Merge remote-tracking branch 'origin/main' + feat: smart routing, response cleaning, single responder, typing fix

- lot-66: pickResponders topic-based routing (search→Sherlock, image→Picasso, etc.)
- lot-67: cleanPersonaResponse strips think tokens + persona name prefix
- lot-68: maxGeneralResponders default 1, configurable via MAX_GENERAL_RESPONDERS
- lot-69: typing indicator moved after memory load, closer to Ollama call

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
L'électron rare
2026-03-19 23:38:03 +01:00
139 changed files with 40265 additions and 2043 deletions
Submodule .claude/worktrees/agent-aaaeff51 updated: 06b2e96a43...d0cfd29ec3
+160 -34
View File
@@ -1,6 +1,6 @@
# PLAN (kxkm-clown-v2)
Updated: 2026-03-18T21:30:00Z
Updated: 2026-03-19T23:00:00Z
## lot-0-cadrage [done]
- Summary: Cadrage historique clos.
@@ -56,47 +56,173 @@ Updated: 2026-03-18T21:30:00Z
- Checks: npm run -w @kxkm/web test, npm run -w @kxkm/api test
- Summary: 3 agents paralleles (15600 LOC), 7 bugs HIGH/MEDIUM identifies, 6 corriges (race condition context-store, persona state pruning, temp file cleanup, WS/timer leaks, dead password field). Architecture Mermaid (docs/ARCHITECTURE.md), veille OSS 40+ projets (docs/OSS_VEILLE_2026-03-18.md).
## lot-21-chatterbox-tts [planned]
- Description: Remplacer piper-tts par Chatterbox (zero-shot voice cloning, MIT, sub-200ms)
## lot-21-chat-reactivity [done]
- Description: Streaming temps reel, web search, historique, timestamps, session admin
- Owner: Backend API + Frontend
- Checks: curl -X POST /api/session/login, WS chunks, SearXNG JSON
- Summary: Cookie Secure retire (HTTP), ADMIN_TOKEN=kxkm, champ password AdminPage, MediaExplorer fix {ok,data}, historique 20 msgs a la connexion WS [HH:MM], streaming chunks (type "chunk" + curseur), personas paralleles (Promise.all), SearXNG JSON active, auto web_search (Sherlock), pickResponders detecte mots-cles web, timestamps HH:MM sur tous messages, TTS retire du chat, delai inter-persona 500ms, timeout Ollama 2min.
## lot-22-chatterbox-tts [done]
- Description: Chatterbox TTS zero-shot voice cloning via Docker GPU
- Depends on: lot-18-media-tts
- Owner: Multimodal
- Priority: P1
- Tasks:
- [ ] Installer Chatterbox sur kxkm-ai (pip install chatterbox-tts)
- [ ] Adapter tts-server.py pour utiliser Chatterbox comme backend principal
- [ ] Tester qualite vocale vs piper sur les 33 personas
- [ ] Benchmark latence (cible: <500ms pour 100 chars)
- Summary: Chatterbox Docker :9200 (GPU), tts-server.py dual backend (chatterbox-remote + piper fallback), deploy.sh tmux.
## lot-22-graph-rag [planned]
- Description: Remplacer RAG cosine par LightRAG (graph-based, knowledge graphs)
## lot-23-graph-rag [done]
- Description: LightRAG graph-based RAG integration
- Depends on: lot-12-deep-audit
- Owner: Backend API
- Priority: P2
- Tasks:
- [ ] Evaluer LightRAG vs txtai vs RAGatouille
- [ ] Integrer le gagnant dans rag.ts
- [ ] Indexer le manifeste + lore personas
- [ ] Benchmark recall vs baseline cosine
- Summary: LightRAG server :9621, rag.ts hybrid (local embeddings + LightRAG fallback), manifeste indexe.
## lot-23-crt-webgl [planned]
- Description: Effets CRT WebGL (vault66-crt-effect ou cool-retro-term-webgl)
- Depends on: lot-16-minitel-ui
## lot-24-deep-audit-3 [done]
- Description: Analyse approfondie code + veille OSS + specs Mermaid + plans agents
- Owner: Coordinateur
- Checks: npm run test:v2 (265/265), bash scripts/health-check.sh (19/19)
- Summary: 9 agents paralleles, 14 bugs fixes, ARCHITECTURE.md 4 Mermaid, OSS_VEILLE enrichie (Pocket TTS, llama3.1, NexusRAG), health-check.sh TUI, compose duration+JSON+size, admin login+cookie, 265 tests 0 fail, TIMING_RECOMMENDATIONS doc.
## lot-25-structured-logging [done]
- Description: Structured logging pino + sentence TTS + llama3.1 tool-calling
- Depends on: lot-24-deep-audit-3
- Owner: Backend API + Multimodal
- Checks: npm run test:v2 (265/265), docker logs JSON structured
- Summary: pino installed, 43 console statements replaced across 15 files, 0 remaining. JSON logs in production, pretty-print in dev. RAG query content truncated to 80 chars (PII). Sentence-boundary TTS chunking (extractSentences + per-persona queues). Sherlock migre vers llama3.1:8b-instruct-q4_0 (tool-calling fiable, benchmark OK vs qwen3/mistral).
## lot-26-ws-protocol-tests [done]
- Description: WS protocol hardening, integration tests, Pocket TTS evaluation
- Depends on: lot-25-structured-logging
- Owner: Backend API + Multimodal + Veille
- Checks: npm run test:v2 (271/271), Docker deployed
- Summary: Promise chain per-connection (FIFO ordering), seq numbers auto-stamped on broadcast, 6 WS integration tests (MOTD, streaming, multi-client, rate limit, disconnect, seq). Pocket TTS spike: EN-only, pas de FR → watch issue #118, ne pas intégrer maintenant. Sherlock sur llama3.1:8b-instruct.
## lot-28-frontend-perf [done]
- Description: Lazy-load routes, React.memo, useCallback stabilization
- Depends on: lot-26-ws-protocol-tests
- Owner: Frontend
- Priority: P3
- Tasks:
- [ ] Evaluer vault66-crt-effect (npm install) vs shaders custom
- [ ] Integrer dans MinitelFrame
- [ ] Tester perf mobile (FPS target: 30+)
- Checks: vite build OK, 17 lazy chunks, 53% initial load reduction
- Summary: 17 routes lazy-loaded (React.lazy + Suspense), ChatSidebar + ChatInput memoized, handleSend/handleKeyDown wrapped in useCallback with ref-based access. Initial JS 468KB→220KB (-53%). NodeEditor (183KB ReactFlow) loads on demand.
## lot-24-tests-integration [planned]
- Description: Tests integration pour RAG, ComfyUI, web-search, TTS, Ollama
- Depends on: lot-20-deep-audit-2
## lot-27-crt-effect [done]
- Description: Effet CRT CSS-only (scanlines, vignette, phosphor glow, boot animation)
- Owner: Frontend
- Checks: vite build OK, bundle inchangé 220KB, ?crt=off pour désactiver
- Summary: Boot animation (ligne→plein écran 0.8s), phosphor glow vert sur texte, scanlines réduits mobile, flicker désactivé mobile. CSS-only, 0 dépendance. Désactivable via ?crt=off.
## lot-28-rag-config [planned]
- Description: RAG parametrable (chunk size, similarity threshold, model embeddings)
- Depends on: lot-23-graph-rag
- Owner: Backend API
- Priority: P2
- Tasks:
- [ ] Mock HTTP pour Ollama (streaming + tools)
- [ ] Mock ComfyUI workflow + polling
- [ ] Mock SearXNG + DuckDuckGo fallback
- [ ] Mock TTS sidecar HTTP
- [ ] Test context-store concurrent writes
- [ ] Test media-store path traversal
- [ ] Env vars: RAG_CHUNK_SIZE, RAG_MIN_SIMILARITY, RAG_EMBEDDING_MODEL
- [ ] Verifier disponibilite modele au startup
- [ ] Benchmark recall avec differents parametres
## lot-29-systemd [done]
- Description: Systemd user units pour LightRAG + TTS, deploy.sh migré, service-status.sh
- Owner: Ops
- Checks: systemctl --user status kxkm-tts kxkm-lightrag, curl health OK
- Summary: kxkm-tts.service (port 9100, chatterbox-remote) + kxkm-lightrag.service (port 9621) créés. Auto-restart on failure. deploy.sh migré tmux→systemd. service-status.sh TUI dashboard. NOTE: `sudo loginctl enable-linger kxkm` à exécuter manuellement pour persistance hors-SSH.
- [ ] Monitoring journald
## lot-30-pocket-tts [planned]
- Description: Evaluer Pocket TTS (MIT, 100M params, CPU realtime, voice cloning 5s)
- Owner: Multimodal
- Priority: P1
- Rationale: Libere GPU (RTX 4090) pour Ollama/ComfyUI. Voice cloning CPU-only.
- Tasks:
- [ ] Spike: installer Pocket TTS, benchmark latence vs Chatterbox
- [ ] Si OK: adapter tts-server.py backend pocket-tts
- [ ] Tester voice cloning sur 5 personas
- [ ] Comparer qualite Pocket vs Chatterbox vs Piper
## lot-31-tool-calling [done]
- Description: llama3.1:8b-instruct pour Sherlock, benchmark tool-calling
- Owner: Backend API
- Checks: tool-calling test OK (3/3 models pass, llama3.1 choisi pour agentic design)
- Summary: llama3.1:8b-instruct-q4_0 pulled (4.7GB), assigné à Sherlock. Benchmark: les 3 modèles (llama3.1, qwen3, mistral) passent tous les tests tool-calling, llama3.1 choisi pour son training spécifique agentic workflows.
## lot-32-qwen3-tts-voices [done]
- Description: Qwen3-TTS 0.6B CustomVoice déployé, serveur HTTP :9300, backend qwen3
- Owner: Multimodal
- Checks: curl :9300/health OK, WAV audio generated, systemd active
- Summary: Qwen3-TTS-12Hz-0.6B-CustomVoice installé (~2GB VRAM). Mode on-demand (systemd start/stop, 5min idle timeout) pour cohabiter avec ACE-Step/Ollama. 9 preset speakers. NOTE: VRAM saturée si Qwen3-TTS + Chatterbox + Ollama + ACE-Step simultanés.
## lot-33-docling-rag [done]
- Description: Assembler pipeline RAG hybride avec composants matures (LightRAG + Docling + bge-reranker)
- Owner: Backend API
- Priority: P2
- Rationale: NexusRAG trop immature (4 jours, pas de licence). Mieux assembler soi-même.
- Tasks:
- [ ] Ajouter Docling à docker-compose pour parsing PDF/documents
- [ ] Intégrer bge-reranker-v2-m3 pour reranking des résultats RAG
- [ ] Benchmark recall LightRAG seul vs LightRAG+reranker
## lot-34-test-coverage [done]
- Description: Tests unitaires web-search, mcp-tools, media-store
- Owner: Backend API
- Checks: 294/294 pass (23 nouveaux tests)
- Summary: web-search.test.ts (5: SearXNG, DDG fallback, format), mcp-tools.test.ts (11: registry, persona tools), media-store.test.ts (7: save/list/traversal). Mock fetch, temp dirs, 100% pass.
## lot-35-persona-voices [done]
- Description: Mapper 33 personas sur Qwen3-TTS CustomVoice speakers
- Owner: Multimodal
- Checks: 294/294 pass, TTS fallback Qwen3→Chatterbox
- Summary: persona-voices.ts avec 34 entries (9 speakers, instructions de style uniques). ws-multimodal.ts tente Qwen3-TTS :9300 d'abord, fallback TTS :9100. QWEN3_TTS_URL ajouté docker-compose.
## lot-36-ws-chat-extraction [done]
- Description: Extraire ws-chat.ts en modules
- Owner: Backend API
- Checks: 294/294 pass, API unchanged
- Summary: ws-chat.ts 425→335 LOC (-21%). 3 modules extraits: ws-chat-logger.ts (39 LOC), ws-chat-helpers.ts (55 LOC), ws-chat-history.ts (39 LOC).
## lot-37-bge-reranker [done]
- Description: bge-reranker-v2-m3 on :9500, integrated in rag.ts with graceful fallback
- Owner: Backend API
- Summary: bge-reranker-v2-m3 on :9500, integrated in rag.ts with graceful fallback.
## lot-38-rag-config [done]
- Description: 4 env vars (chunk size, similarity, max results, embedding model), auto-pull at startup
- Owner: Backend API
- Summary: 4 env vars (RAG_CHUNK_SIZE, RAG_MIN_SIMILARITY, RAG_MAX_RESULTS, RAG_EMBEDDING_MODEL), auto-pull at startup.
## lot-39-voicechat-fix [done]
- Description: 3 memory leaks fixed (AudioContext, unmount, audio queue drain)
- Owner: Frontend
- Summary: 3 memory leaks fixed (AudioContext, unmount, audio queue drain).
## lot-40-app-extraction [done]
- Description: app.ts 540→131 LOC, create-repos.ts extracted (386 LOC)
- Owner: Backend API
- Summary: app.ts 540→131 LOC, create-repos.ts extracted (386 LOC).
## lot-42-mime-validation [done]
- Description: SEC-03 resolved, file-type magic bytes, SAFE_MIMES allowlist
- Owner: Backend API
- Summary: SEC-03 resolved, file-type magic bytes validation, SAFE_MIMES allowlist.
## lot-43-chat-virtualization [done]
- Description: react-window v2, variable row heights, auto-scroll preserved, +15KB
- Owner: Frontend
- Summary: react-window v2, variable row heights, auto-scroll preserved, +15KB bundle.
## lot-44-perf-instrumentation [done]
- Description: 6 labels (http, ollama_ttfb/total, rag_search/rerank, ws_message), p50/p95/p99 endpoint
- Owner: Backend API
- Summary: 6 labels (http, ollama_ttfb/total, rag_search/rerank, ws_message), p50/p95/p99 endpoint.
+2 -2
View File
@@ -131,7 +131,7 @@ npm run check # Lint V1 + TypeScript V2
npm run check:v2 # TypeScript V2 uniquement
npm run smoke # Tests d'integration V1
npm run smoke:v2 # Tests d'integration V2 (22 tests)
npm run test:v2 # Tests unitaires V2 (102 tests)
npm run test:v2 # Tests unitaires V2 (294 tests)
npm run turbo:build # Build complet
```
@@ -261,7 +261,7 @@ kxkm_clown/
| RBAC | n/a | operationnel |
| Frontend React | n/a | operationnel |
| Training (TRL/Unsloth) | n/a | operationnel |
| Tests (204) | smoke | unit + component + smoke |
| Tests (294) | smoke | unit + component + smoke |
| VoiceChat push-to-talk | n/a | operationnel |
| Mediatheque gallery/playlist | n/a | operationnel |
| UI Minitel VIDEOTEX | n/a | operationnel |
+49 -1
View File
@@ -214,4 +214,52 @@ Fait sur ce lot:
- [x] Script `ops/v2/run-deep-cycle.sh` ajoute et execute.
- [x] Tests `apps/api/src/context-store.test.ts` ajoutes et valides.
- [x] Scoring de dette technique integre dans `ops/v2/deep-audit.js`.
- [x] Verification JSON dette: score 78/100 (niveau high).
- [x] Verification JSON dette: score 78/100 (niveau high).
## P14 Lot 24 — Deep Analyse 3 + Reactivity `[en cours]`
### Phase A — Fixes live session 2026-03-19
- [x] Cookie Secure retire (COOKIE_SECURE env, HTTP fonctionne)
- [x] ADMIN_TOKEN=kxkm dans docker-compose + AdminPage champ password
- [x] MediaExplorer fix reponse API ({ok,data} wrapper)
- [x] Historique 20 derniers messages a la connexion WS [HH:MM]
- [x] Streaming chunks temps reel (type "chunk", curseur clignotant)
- [x] Personas paralleles (Promise.all)
- [x] SearXNG JSON active + auto web_search (Sherlock)
- [x] pickResponders detecte mots-cles web → Sherlock
- [x] Timestamps HH:MM sur tous messages
- [x] TTS retire du chat
- [x] Delai inter-persona 2s → 500ms, timeout Ollama 5min → 2min
- [x] /compose progress ticker (feedback 5s, elapsed time, timeout handler)
- [x] /imagine progress ticker (feedback 5s)
- [x] Admin endpoints verifies OK (overview 5ms, personas 33, analytics 326 msgs)
- [x] /compose duration parsing (5-120s, plus hardcode 30s)
- [x] tts-server.py JSON parsing securise (try-catch)
- [x] Audio size limit 50MB (Python + Node)
### Phase B — Analyse approfondie
- [x] Analyse code complete: 33 personas, 8 services, 15+ node types, 135+ tests
- [x] 10 findings prioritaires identifies (P0 securite → P3 docs)
- [ ] Veille OSS web: projets similaires, libs integrables
- [ ] Audit docs/plans existants: coherence et lacunes
- [ ] Fix 6 tests en echec (rate limiting 429, EACCES, TTS)
### Phase C — Livrables
- [x] PLAN.md mis a jour (lots 21-29, statuts corriges)
- [x] TODO.md mis a jour (P14)
- [x] Memoire projet mise a jour
- [ ] ARCHITECTURE.md diagrammes Mermaid actualises
- [ ] README.md conforme au manifeste
- [ ] Script diagnostic TUI (health check complet)
- [ ] docs/OSS_VEILLE_2026-03-19.md (veille enrichie)
### Phase D — Prochaines priorites
- [ ] **P1** lot-25: Structured logging (pino, 39 console.log DEBUG → logger)
- [ ] **P2** lot-26: Tests integration (mocks HTTP, load test concurrence)
- [ ] **P2** lot-28: RAG configurable (chunk size, similarity, model env vars)
- [ ] **P2** lot-29: Systemd units (LightRAG + TTS, retirer tmux)
- [ ] **P3** lot-27: Effets CRT WebGL (MinitelFrame)
+9 -1
View File
@@ -13,7 +13,12 @@
"@kxkm/node-engine": "*",
"@kxkm/persona-domain": "*",
"@kxkm/storage": "*",
"express": "^4.22.1"
"express": "^4.22.1",
"file-type": "^21.3.3",
"p-limit": "^7.3.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"zod": "^4.3.6"
},
"scripts": {
"status": "node ../../scripts/workspace-package.js api status",
@@ -21,5 +26,8 @@
"build": "tsc -p tsconfig.json --pretty false",
"check": "tsc -p tsconfig.json --noEmit --pretty false",
"test": "node --test dist/*.test.js"
},
"devDependencies": {
"@types/pino": "^7.0.4"
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
import logger from "./logger.js";
import {
loadDatabaseConfig,
createPostgresPool,
@@ -78,7 +79,7 @@ export async function bootstrapRepositories<
};
}
console.warn("[kxkm/api] DATABASE_URL not set — using local persona storage + in-memory runtime stores");
logger.warn("[kxkm/api] DATABASE_URL not set — using local persona storage + in-memory runtime stores");
return {
sessionRepo: factories.createSessionRepo(),
+3
View File
@@ -1,3 +1,4 @@
import { recordLatency, getMetrics } from "./perf.js";
import express, { type Request, type Response, type NextFunction } from "express";
import net from "node:net";
import { extractSessionId, hasPermission } from "@kxkm/auth";
@@ -139,6 +140,7 @@ export function createPerfTracker() {
perfStats.requestCount++;
perfStats.totalLatencyMs += latency;
if (latency > perfStats.maxLatencyMs) perfStats.maxLatencyMs = latency;
recordLatency("http", latency);
perfStats.statusCodes.set(res.statusCode, (perfStats.statusCodes.get(res.statusCode) || 0) + 1);
});
next();
@@ -156,6 +158,7 @@ export function createPerfTracker() {
requests: perfStats.requestCount,
avg_latency_ms: Math.round(avgLatency * 100) / 100,
max_latency_ms: Math.round(perfStats.maxLatencyMs * 100) / 100,
percentiles: getMetrics(),
status_codes: Object.fromEntries(perfStats.statusCodes),
memory: {
rss_mb: Math.round(mem.rss / 1048576),
+1
View File
@@ -8,6 +8,7 @@ import { createApp } from "./app.js";
// Ensure no Postgres connection is attempted
delete process.env.DATABASE_URL;
process.env.ADMIN_TOKEN = "test-admin-token";
process.env.NODE_ENV = "test";
const TEST_LOCAL_DIR = path.join(process.cwd(), ".tmp-test-v2-local");
process.env.KXKM_LOCAL_DATA_DIR = TEST_LOCAL_DIR;
+16 -425
View File
@@ -1,34 +1,18 @@
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import express, { type Request, type Response } from "express";
import express, { type Response } from "express";
import {
asApiData,
createId,
isUserRole,
type AuthSession,
type UserRole,
} from "@kxkm/core";
import { createSessionRecord, generateSessionToken, validateLoginInput } from "@kxkm/auth";
import { buildChatChannels } from "@kxkm/chat-domain";
import {
PERSONA_SEED_CATALOG,
clonePersona,
createFeedback,
createProposal,
extractDPOPairs,
type PersonaFeedbackRecord,
type PersonaProposalRecord,
type PersonaRecord,
type PersonaSourceRecord,
} from "@kxkm/persona-domain";
import {
createNodeEngineOverview,
createNodeGraph,
createNodeRun,
type ModelRegistryRecord,
type NodeGraphRecord,
type NodeRunRecord,
} from "@kxkm/node-engine";
createInMemorySessionRepo,
createInMemoryPersonaRepo,
createInMemoryNodeGraphRepo,
createInMemoryNodeRunRepo,
createInMemoryPersonaSourceRepo,
createInMemoryPersonaFeedbackRepo,
createInMemoryPersonaProposalRepo,
modelRegistry,
readRouteParam,
escapeForHtml,
enqueueRunTransition,
type PersonaRepo,
} from "./create-repos.js";
import { createSessionRoutes } from "./routes/session.js";
import { createPersonaRoutes } from "./routes/personas.js";
import { createNodeEngineRoutes } from "./routes/node-engine.js";
@@ -36,7 +20,6 @@ import { createChatHistoryRoutes } from "./routes/chat-history.js";
import mediaRoutes from "./routes/media.js";
import { bootstrapRepositories } from "./app-bootstrap.js";
import {
type SessionRequest,
createSessionMiddleware,
createRequireSession,
createRequirePermission,
@@ -46,355 +29,6 @@ import {
const COOKIE_NAME = "kxkm_v2_session";
function localStoreFiles() {
const storeDir = path.resolve(process.cwd(), process.env.KXKM_LOCAL_DATA_DIR || "data/v2-local");
return {
personas: path.join(storeDir, "personas.json"),
personaSources: path.join(storeDir, "persona-sources.json"),
personaFeedback: path.join(storeDir, "persona-feedback.json"),
personaProposals: path.join(storeDir, "persona-proposals.json"),
};
}
async function readJson<T>(filePath: string, fallback: T): Promise<T> {
try {
const raw = await readFile(filePath, "utf8");
return JSON.parse(raw) as T;
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code !== "ENOENT") {
console.warn(`[kxkm/api] failed to read ${filePath}: ${e.message}`);
}
return fallback;
}
}
async function writeJson(filePath: string, data: unknown): Promise<void> {
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
}
// ---------------------------------------------------------------------------
// In-memory repo adapters (fallback when DATABASE_URL is not set)
// ---------------------------------------------------------------------------
function createInMemorySessionRepo() {
const sessions = new Map<string, AuthSession>();
let lastCleanupAt = 0;
function maybeCleanupExpired(now: number): void {
// Throttle cleanup to avoid O(n) scans on every request.
if (now - lastCleanupAt < 60_000) return;
lastCleanupAt = now;
for (const [id, session] of sessions) {
if (new Date(session.expiresAt).getTime() < now) {
sessions.delete(id);
}
}
}
return {
async create(input: { username: string; role: UserRole; expiresAt?: string }): Promise<AuthSession> {
maybeCleanupExpired(Date.now());
const id = generateSessionToken();
const session = createSessionRecord({ username: input.username, role: input.role }, id);
sessions.set(id, session);
return session;
},
async findById(id: string): Promise<AuthSession | null> {
maybeCleanupExpired(Date.now());
return sessions.get(id) || null;
},
async deleteById(id: string): Promise<void> {
sessions.delete(id);
},
async deleteExpired(): Promise<number> {
const now = Date.now();
let count = 0;
for (const [id, session] of sessions) {
if (new Date(session.expiresAt).getTime() < now) {
sessions.delete(id);
count++;
}
}
return count;
},
};
}
function createInMemoryPersonaRepo() {
const files = localStoreFiles();
const personas = new Map<string, PersonaRecord>();
let loaded = false;
async function ensureLoaded(): Promise<void> {
if (loaded) return;
loaded = true;
const saved = await readJson<PersonaRecord[]>(files.personas, []);
if (saved.length > 0) {
for (const persona of saved) {
personas.set(persona.id, { ...persona });
}
return;
}
for (const seed of PERSONA_SEED_CATALOG) {
personas.set(seed.id, clonePersona(seed));
}
await writeJson(files.personas, [...personas.values()]);
}
async function persist(): Promise<void> {
await writeJson(files.personas, [...personas.values()]);
}
return {
async list(): Promise<PersonaRecord[]> {
await ensureLoaded();
return [...personas.values()];
},
async findById(id: string): Promise<PersonaRecord | null> {
await ensureLoaded();
return personas.get(id) || null;
},
async upsert(persona: PersonaRecord): Promise<PersonaRecord> {
await ensureLoaded();
personas.set(persona.id, { ...persona });
await persist();
return { ...persona };
},
async seedCatalog(catalog: PersonaRecord[]): Promise<void> {
await ensureLoaded();
let changed = false;
for (const p of catalog) {
if (!personas.has(p.id)) {
personas.set(p.id, clonePersona(p));
changed = true;
}
}
if (changed) {
await persist();
}
},
};
}
function createInMemoryNodeGraphRepo() {
const graphs = new Map<string, NodeGraphRecord>([
["starter_local_eval", createNodeGraph("starter_local_eval", "Prototype local evaluation graph")],
]);
// Fix: createNodeGraph generates a random id, so re-set with desired id
const starterGraph: NodeGraphRecord = { id: "starter_local_eval", name: "starter_local_eval", description: "Prototype local evaluation graph" };
graphs.set(starterGraph.id, starterGraph);
return {
async list(): Promise<NodeGraphRecord[]> {
return [...graphs.values()];
},
async findById(id: string): Promise<NodeGraphRecord | null> {
return graphs.get(id) || null;
},
async create(graph: NodeGraphRecord): Promise<NodeGraphRecord> {
graphs.set(graph.id, { ...graph });
return { ...graph };
},
async update(id: string, patch: Partial<NodeGraphRecord>): Promise<NodeGraphRecord | null> {
const graph = graphs.get(id);
if (!graph) return null;
if (patch.name !== undefined) graph.name = patch.name;
if (patch.description !== undefined) graph.description = patch.description;
return { ...graph };
},
};
}
function createInMemoryNodeRunRepo() {
const runs = new Map<string, NodeRunRecord>();
return {
async list(): Promise<NodeRunRecord[]> {
return [...runs.values()];
},
async findById(id: string): Promise<NodeRunRecord | null> {
return runs.get(id) || null;
},
async create(run: NodeRunRecord): Promise<NodeRunRecord> {
runs.set(run.id, { ...run });
return { ...run };
},
async updateStatus(id: string, status: NodeRunRecord["status"]): Promise<void> {
const run = runs.get(id);
if (run) run.status = status;
},
async requestCancel(id: string): Promise<void> {
const run = runs.get(id);
if (run) run.status = "cancelled";
},
async deleteOlderThan(date: string): Promise<number> {
const threshold = new Date(date).getTime();
let count = 0;
for (const [id, run] of runs) {
if (
["completed", "failed", "cancelled"].includes(run.status) &&
new Date(run.createdAt).getTime() < threshold
) {
runs.delete(id);
count++;
}
}
return count;
},
};
}
function createInMemoryPersonaSourceRepo() {
const files = localStoreFiles();
const sources = new Map<string, PersonaSourceRecord>();
let loaded = false;
async function ensureLoaded(): Promise<void> {
if (loaded) return;
loaded = true;
const saved = await readJson<Record<string, PersonaSourceRecord>>(files.personaSources, {});
for (const [personaId, source] of Object.entries(saved)) {
sources.set(personaId, { ...source });
}
}
async function persist(): Promise<void> {
const objectView = Object.fromEntries(sources.entries());
await writeJson(files.personaSources, objectView);
}
return {
async findByPersonaId(personaId: string): Promise<PersonaSourceRecord | null> {
await ensureLoaded();
return sources.get(personaId) || null;
},
async upsert(source: PersonaSourceRecord): Promise<PersonaSourceRecord> {
await ensureLoaded();
sources.set(source.personaId, { ...source });
await persist();
return { ...source };
},
};
}
function createInMemoryPersonaFeedbackRepo() {
const files = localStoreFiles();
const feedback = new Map<string, PersonaFeedbackRecord[]>();
let loaded = false;
async function ensureLoaded(): Promise<void> {
if (loaded) return;
loaded = true;
const saved = await readJson<PersonaFeedbackRecord[]>(files.personaFeedback, []);
for (const record of saved) {
const list = feedback.get(record.personaId) || [];
list.push({ ...record });
feedback.set(record.personaId, list);
}
}
async function persist(): Promise<void> {
const all = [...feedback.values()].flat();
await writeJson(files.personaFeedback, all);
}
return {
async listByPersonaId(personaId: string): Promise<PersonaFeedbackRecord[]> {
await ensureLoaded();
return feedback.get(personaId) || [];
},
async create(record: PersonaFeedbackRecord): Promise<PersonaFeedbackRecord> {
await ensureLoaded();
const list = feedback.get(record.personaId) || [];
list.push({ ...record });
feedback.set(record.personaId, list);
await persist();
return { ...record };
},
};
}
function createInMemoryPersonaProposalRepo() {
const files = localStoreFiles();
const proposals = new Map<string, PersonaProposalRecord[]>();
let loaded = false;
async function ensureLoaded(): Promise<void> {
if (loaded) return;
loaded = true;
const saved = await readJson<PersonaProposalRecord[]>(files.personaProposals, []);
for (const record of saved) {
const list = proposals.get(record.personaId) || [];
list.push({ ...record });
proposals.set(record.personaId, list);
}
}
async function persist(): Promise<void> {
const all = [...proposals.values()].flat();
await writeJson(files.personaProposals, all);
}
return {
async listByPersonaId(personaId: string): Promise<PersonaProposalRecord[]> {
await ensureLoaded();
return proposals.get(personaId) || [];
},
async create(record: PersonaProposalRecord): Promise<PersonaProposalRecord> {
await ensureLoaded();
const list = proposals.get(record.personaId) || [];
list.push({ ...record });
proposals.set(record.personaId, list);
await persist();
return { ...record };
},
async markApplied(id: string): Promise<void> {
await ensureLoaded();
for (const list of proposals.values()) {
const proposal = list.find((p) => p.id === id);
if (proposal) {
proposal.applied = true;
await persist();
return;
}
}
},
};
}
// ---------------------------------------------------------------------------
// Repo interface types (union of Postgres and in-memory)
// ---------------------------------------------------------------------------
type SessionRepo = ReturnType<typeof createInMemorySessionRepo>;
type PersonaRepo = ReturnType<typeof createInMemoryPersonaRepo>;
type GraphRepo = ReturnType<typeof createInMemoryNodeGraphRepo>;
type RunRepo = ReturnType<typeof createInMemoryNodeRunRepo>;
type SourceRepo = ReturnType<typeof createInMemoryPersonaSourceRepo>;
type FeedbackRepo = ReturnType<typeof createInMemoryPersonaFeedbackRepo>;
type ProposalRepo = ReturnType<typeof createInMemoryPersonaProposalRepo>;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const modelRegistry: ModelRegistryRecord[] = [
{ id: "qwen2.5:14b", label: "Qwen 2.5 14B", runtime: "local_gpu" },
{ id: "mistral:7b", label: "Mistral 7B", runtime: "local_cpu" },
{ id: "mythalion:latest", label: "Mythalion", runtime: "local_gpu" },
];
function readRouteParam(value: string | string[] | undefined): string {
return Array.isArray(value) ? value[0] || "" : value || "";
}
function escapeForHtml(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function setSessionCookie(res: Response, sessionId: string): void {
const secure = process.env.NODE_ENV === "production" ? "Secure; " : "";
res.setHeader("Set-Cookie", `${COOKIE_NAME}=${sessionId}; HttpOnly; ${secure}SameSite=Strict; Path=/; Max-Age=3600`);
@@ -405,55 +39,12 @@ function clearSessionCookie(res: Response): void {
res.setHeader("Set-Cookie", `${COOKIE_NAME}=; HttpOnly; ${secure}SameSite=Strict; Path=/; Max-Age=0`);
}
// ---------------------------------------------------------------------------
// Persona sub-store default helper
// ---------------------------------------------------------------------------
function defaultPersonaSource(personaId: string, personaName: string): PersonaSourceRecord {
return {
personaId,
subjectName: personaName || personaId,
summary: "Aucune source structuree pour le moment.",
references: [],
};
}
// ---------------------------------------------------------------------------
// Simulated run transition (dev/demo purposes)
// ---------------------------------------------------------------------------
function enqueueRunTransition(runId: string, runRepo: RunRepo): void {
const timer1 = setTimeout(async () => {
const run = await runRepo.findById(runId);
if (!run || run.status !== "queued") {
clearTimeout(timer2);
return;
}
await runRepo.updateStatus(runId, "running");
}, 50);
const timer2 = setTimeout(async () => {
const run = await runRepo.findById(runId);
if (!run || run.status !== "running") return;
await runRepo.updateStatus(runId, "completed");
}, 150);
}
// ---------------------------------------------------------------------------
// App factory
// ---------------------------------------------------------------------------
export async function createApp(): Promise<{ app: express.Express; personaRepo: PersonaRepo }> {
let sessionRepo: SessionRepo;
let personaRepo: PersonaRepo;
let graphRepo: GraphRepo;
let runRepo: RunRepo;
let sourceRepo: SourceRepo;
let feedbackRepo: FeedbackRepo;
let proposalRepo: ProposalRepo;
let storageMode: "postgres" | "memory";
({
const {
sessionRepo,
personaRepo,
graphRepo,
@@ -470,7 +61,7 @@ export async function createApp(): Promise<{ app: express.Express; personaRepo:
createSourceRepo: createInMemoryPersonaSourceRepo,
createFeedbackRepo: createInMemoryPersonaFeedbackRepo,
createProposalRepo: createInMemoryPersonaProposalRepo,
}));
});
const app = express();
app.use(express.json());
+11 -10
View File
@@ -60,16 +60,17 @@ export type InboundMessage = InboundChatMessage | InboundCommand | InboundUpload
// Outbound message types
export type OutboundMessage =
| { type: "message"; nick: string; text: string; color: string }
| { type: "system"; text: string }
| { type: "join"; nick: string; channel: string; text: string }
| { type: "part"; nick: string; channel: string; text: string }
| { type: "userlist"; users: string[] }
| { type: "persona"; nick: string; color: string }
| { type: "audio"; nick: string; data: string; mimeType: string }
| { type: "image"; nick: string; text: string; imageData: string; imageMime: string }
| { type: "music"; nick: string; text: string; audioData: string; audioMime: string }
| { type: "channelInfo"; channel: string };
| { type: "message"; nick: string; text: string; color: string; seq?: number }
| { type: "system"; text: string; seq?: number }
| { type: "join"; nick: string; channel: string; text: string; seq?: number }
| { type: "part"; nick: string; channel: string; text: string; seq?: number }
| { type: "userlist"; users: string[]; seq?: number }
| { type: "persona"; nick: string; color: string; seq?: number }
| { type: "audio"; nick: string; data: string; mimeType: string; seq?: number }
| { type: "image"; nick: string; text: string; imageData: string; imageMime: string; seq?: number }
| { type: "music"; nick: string; text: string; audioData: string; audioMime: string; seq?: number }
| { type: "channelInfo"; channel: string; seq?: number }
| { type: "chunk"; nick: string; text: string; color: string; seq: number };
// Chat log entry
export interface ChatLogEntry {
+3 -1
View File
@@ -1,3 +1,5 @@
import logger from "./logger.js";
// ---------------------------------------------------------------------------
// ComfyUI image generation
// ---------------------------------------------------------------------------
@@ -100,7 +102,7 @@ export async function generateImage(prompt: string): Promise<{ imageBase64: stri
return null;
} catch (err) {
console.error("[comfyui] Error:", err instanceof Error ? err.message : String(err));
logger.error({ err: err instanceof Error ? err.message : String(err) }, "[comfyui] Error");
return null;
}
}
+7 -6
View File
@@ -11,6 +11,7 @@
const DEBUG = process.env.NODE_ENV !== "production" || process.env.DEBUG === "1";
import { trackError } from "./error-tracker.js";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -52,7 +53,7 @@ function buildDefaultOptions(): ContextStoreOptions {
let maxEntriesBeforeCompact = 500;
let maxFileSizeMB = 100;
let maxTotalSizeMB = 750;
let maxContextChars = 16000;
let maxContextChars = 12000;
let maxSummaryChars = 20000;
if (totalGB <= 8) {
@@ -71,13 +72,13 @@ function buildDefaultOptions(): ContextStoreOptions {
maxEntriesBeforeCompact = 600;
maxFileSizeMB = 120;
maxTotalSizeMB = 900;
maxContextChars = 18000;
maxContextChars = 12000;
maxSummaryChars = 24000;
} else {
maxEntriesBeforeCompact = 900;
maxFileSizeMB = 180;
maxTotalSizeMB = 1400;
maxContextChars = 26000;
maxContextChars = 20000;
maxSummaryChars = 32000;
}
@@ -168,12 +169,12 @@ export class ContextStore {
// Check if compaction needed (runs under same lock)
await this.maybeCompact(channel).catch((err) => {
console.error(`[context] Compaction error for ${channel}:`, err);
trackError("context_compaction", err, { channel });
});
// Enforce per-channel and global storage limits.
await this.maybeEnforceLimits(channel).catch((err) => {
console.error(`[context] Limit enforcement error for ${channel}:`, err);
trackError("context_limits", err, { channel });
});
} finally {
release();
@@ -310,7 +311,7 @@ export class ContextStore {
summaryText = data.message?.content || existingSummary;
}
} catch (err) {
console.error(`[context] LLM summarization failed for ${channel}:`, err);
trackError("context_summarization", err, { channel });
// Keep existing summary, still compact the raw file
}
+386
View File
@@ -0,0 +1,386 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import {
PERSONA_SEED_CATALOG,
clonePersona,
type PersonaFeedbackRecord,
type PersonaProposalRecord,
type PersonaRecord,
type PersonaSourceRecord,
} from "@kxkm/persona-domain";
import {
createNodeGraph,
type ModelRegistryRecord,
type NodeGraphRecord,
type NodeRunRecord,
} from "@kxkm/node-engine";
import { createSessionRecord, generateSessionToken } from "@kxkm/auth";
import type { AuthSession, UserRole } from "@kxkm/core";
// ---------------------------------------------------------------------------
// JSON persistence helpers
// ---------------------------------------------------------------------------
function localStoreFiles() {
const storeDir = path.resolve(process.cwd(), process.env.KXKM_LOCAL_DATA_DIR || "data/v2-local");
return {
personas: path.join(storeDir, "personas.json"),
personaSources: path.join(storeDir, "persona-sources.json"),
personaFeedback: path.join(storeDir, "persona-feedback.json"),
personaProposals: path.join(storeDir, "persona-proposals.json"),
};
}
async function readJson<T>(filePath: string, fallback: T): Promise<T> {
try {
const raw = await readFile(filePath, "utf8");
return JSON.parse(raw) as T;
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code !== "ENOENT") {
console.warn(`[kxkm/api] failed to read ${filePath}: ${e.message}`);
}
return fallback;
}
}
async function writeJson(filePath: string, data: unknown): Promise<void> {
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
}
// ---------------------------------------------------------------------------
// In-memory repo adapters (fallback when DATABASE_URL is not set)
// ---------------------------------------------------------------------------
export function createInMemorySessionRepo() {
const sessions = new Map<string, AuthSession>();
let lastCleanupAt = 0;
function maybeCleanupExpired(now: number): void {
if (now - lastCleanupAt < 60_000) return;
lastCleanupAt = now;
for (const [id, session] of sessions) {
if (new Date(session.expiresAt).getTime() < now) {
sessions.delete(id);
}
}
}
return {
async create(input: { username: string; role: UserRole; expiresAt?: string }): Promise<AuthSession> {
maybeCleanupExpired(Date.now());
const id = generateSessionToken();
const session = createSessionRecord({ username: input.username, role: input.role }, id);
sessions.set(id, session);
return session;
},
async findById(id: string): Promise<AuthSession | null> {
maybeCleanupExpired(Date.now());
return sessions.get(id) || null;
},
async deleteById(id: string): Promise<void> {
sessions.delete(id);
},
async deleteExpired(): Promise<number> {
const now = Date.now();
let count = 0;
for (const [id, session] of sessions) {
if (new Date(session.expiresAt).getTime() < now) {
sessions.delete(id);
count++;
}
}
return count;
},
};
}
export function createInMemoryPersonaRepo() {
const files = localStoreFiles();
const personas = new Map<string, PersonaRecord>();
let loaded = false;
async function ensureLoaded(): Promise<void> {
if (loaded) return;
loaded = true;
const saved = await readJson<PersonaRecord[]>(files.personas, []);
if (saved.length > 0) {
for (const persona of saved) {
personas.set(persona.id, { ...persona });
}
return;
}
for (const seed of PERSONA_SEED_CATALOG) {
personas.set(seed.id, clonePersona(seed));
}
await writeJson(files.personas, [...personas.values()]);
}
async function persist(): Promise<void> {
await writeJson(files.personas, [...personas.values()]);
}
return {
async list(): Promise<PersonaRecord[]> {
await ensureLoaded();
return [...personas.values()];
},
async findById(id: string): Promise<PersonaRecord | null> {
await ensureLoaded();
return personas.get(id) || null;
},
async upsert(persona: PersonaRecord): Promise<PersonaRecord> {
await ensureLoaded();
personas.set(persona.id, { ...persona });
await persist();
return { ...persona };
},
async seedCatalog(catalog: PersonaRecord[]): Promise<void> {
await ensureLoaded();
let changed = false;
for (const p of catalog) {
if (!personas.has(p.id)) {
personas.set(p.id, clonePersona(p));
changed = true;
}
}
if (changed) {
await persist();
}
},
};
}
export function createInMemoryNodeGraphRepo() {
const graphs = new Map<string, NodeGraphRecord>([
["starter_local_eval", createNodeGraph("starter_local_eval", "Prototype local evaluation graph")],
]);
const starterGraph: NodeGraphRecord = { id: "starter_local_eval", name: "starter_local_eval", description: "Prototype local evaluation graph" };
graphs.set(starterGraph.id, starterGraph);
return {
async list(): Promise<NodeGraphRecord[]> {
return [...graphs.values()];
},
async findById(id: string): Promise<NodeGraphRecord | null> {
return graphs.get(id) || null;
},
async create(graph: NodeGraphRecord): Promise<NodeGraphRecord> {
graphs.set(graph.id, { ...graph });
return { ...graph };
},
async update(id: string, patch: Partial<NodeGraphRecord>): Promise<NodeGraphRecord | null> {
const graph = graphs.get(id);
if (!graph) return null;
if (patch.name !== undefined) graph.name = patch.name;
if (patch.description !== undefined) graph.description = patch.description;
return { ...graph };
},
};
}
export function createInMemoryNodeRunRepo() {
const runs = new Map<string, NodeRunRecord>();
return {
async list(): Promise<NodeRunRecord[]> {
return [...runs.values()];
},
async findById(id: string): Promise<NodeRunRecord | null> {
return runs.get(id) || null;
},
async create(run: NodeRunRecord): Promise<NodeRunRecord> {
runs.set(run.id, { ...run });
return { ...run };
},
async updateStatus(id: string, status: NodeRunRecord["status"]): Promise<void> {
const run = runs.get(id);
if (run) run.status = status;
},
async requestCancel(id: string): Promise<void> {
const run = runs.get(id);
if (run) run.status = "cancelled";
},
async deleteOlderThan(date: string): Promise<number> {
const threshold = new Date(date).getTime();
let count = 0;
for (const [id, run] of runs) {
if (
["completed", "failed", "cancelled"].includes(run.status) &&
new Date(run.createdAt).getTime() < threshold
) {
runs.delete(id);
count++;
}
}
return count;
},
};
}
export function createInMemoryPersonaSourceRepo() {
const files = localStoreFiles();
const sources = new Map<string, PersonaSourceRecord>();
let loaded = false;
async function ensureLoaded(): Promise<void> {
if (loaded) return;
loaded = true;
const saved = await readJson<Record<string, PersonaSourceRecord>>(files.personaSources, {});
for (const [personaId, source] of Object.entries(saved)) {
sources.set(personaId, { ...source });
}
}
async function persist(): Promise<void> {
const objectView = Object.fromEntries(sources.entries());
await writeJson(files.personaSources, objectView);
}
return {
async findByPersonaId(personaId: string): Promise<PersonaSourceRecord | null> {
await ensureLoaded();
return sources.get(personaId) || null;
},
async upsert(source: PersonaSourceRecord): Promise<PersonaSourceRecord> {
await ensureLoaded();
sources.set(source.personaId, { ...source });
await persist();
return { ...source };
},
};
}
export function createInMemoryPersonaFeedbackRepo() {
const files = localStoreFiles();
const feedback = new Map<string, PersonaFeedbackRecord[]>();
let loaded = false;
async function ensureLoaded(): Promise<void> {
if (loaded) return;
loaded = true;
const saved = await readJson<PersonaFeedbackRecord[]>(files.personaFeedback, []);
for (const record of saved) {
const list = feedback.get(record.personaId) || [];
list.push({ ...record });
feedback.set(record.personaId, list);
}
}
async function persist(): Promise<void> {
const all = [...feedback.values()].flat();
await writeJson(files.personaFeedback, all);
}
return {
async listByPersonaId(personaId: string): Promise<PersonaFeedbackRecord[]> {
await ensureLoaded();
return feedback.get(personaId) || [];
},
async create(record: PersonaFeedbackRecord): Promise<PersonaFeedbackRecord> {
await ensureLoaded();
const list = feedback.get(record.personaId) || [];
list.push({ ...record });
feedback.set(record.personaId, list);
await persist();
return { ...record };
},
};
}
export function createInMemoryPersonaProposalRepo() {
const files = localStoreFiles();
const proposals = new Map<string, PersonaProposalRecord[]>();
let loaded = false;
async function ensureLoaded(): Promise<void> {
if (loaded) return;
loaded = true;
const saved = await readJson<PersonaProposalRecord[]>(files.personaProposals, []);
for (const record of saved) {
const list = proposals.get(record.personaId) || [];
list.push({ ...record });
proposals.set(record.personaId, list);
}
}
async function persist(): Promise<void> {
const all = [...proposals.values()].flat();
await writeJson(files.personaProposals, all);
}
return {
async listByPersonaId(personaId: string): Promise<PersonaProposalRecord[]> {
await ensureLoaded();
return proposals.get(personaId) || [];
},
async create(record: PersonaProposalRecord): Promise<PersonaProposalRecord> {
await ensureLoaded();
const list = proposals.get(record.personaId) || [];
list.push({ ...record });
proposals.set(record.personaId, list);
await persist();
return { ...record };
},
async markApplied(id: string): Promise<void> {
await ensureLoaded();
for (const list of proposals.values()) {
const proposal = list.find((p) => p.id === id);
if (proposal) {
proposal.applied = true;
await persist();
return;
}
}
},
};
}
// ---------------------------------------------------------------------------
// Repo interface types (union of Postgres and in-memory)
// ---------------------------------------------------------------------------
export type SessionRepo = ReturnType<typeof createInMemorySessionRepo>;
export type PersonaRepo = ReturnType<typeof createInMemoryPersonaRepo>;
export type GraphRepo = ReturnType<typeof createInMemoryNodeGraphRepo>;
export type RunRepo = ReturnType<typeof createInMemoryNodeRunRepo>;
export type SourceRepo = ReturnType<typeof createInMemoryPersonaSourceRepo>;
export type FeedbackRepo = ReturnType<typeof createInMemoryPersonaFeedbackRepo>;
export type ProposalRepo = ReturnType<typeof createInMemoryPersonaProposalRepo>;
// ---------------------------------------------------------------------------
// Model registry + helpers
// ---------------------------------------------------------------------------
export const modelRegistry: ModelRegistryRecord[] = [
{ id: "qwen2.5:14b", label: "Qwen 2.5 14B", runtime: "local_gpu" },
{ id: "mistral:7b", label: "Mistral 7B", runtime: "local_cpu" },
{ id: "mythalion:latest", label: "Mythalion", runtime: "local_gpu" },
];
export function readRouteParam(value: string | string[] | undefined): string {
return Array.isArray(value) ? value[0] || "" : value || "";
}
export function escapeForHtml(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
export function enqueueRunTransition(runId: string, runRepo: RunRepo): void {
const timer1 = setTimeout(async () => {
const run = await runRepo.findById(runId);
if (!run || run.status !== "queued") {
clearTimeout(timer2);
return;
}
await runRepo.updateStatus(runId, "running");
}, 50);
const timer2 = setTimeout(async () => {
const run = await runRepo.findById(runId);
if (!run || run.status !== "running") return;
await runRepo.updateStatus(runId, "completed");
}, 150);
}
+46
View File
@@ -0,0 +1,46 @@
import logger from "./logger.js";
interface ErrorRecord {
timestamp: string;
label: string;
message: string;
stack?: string;
context?: Record<string, unknown>;
}
const MAX_ERRORS = 200;
const errors: ErrorRecord[] = [];
const errorCounts = new Map<string, number>();
export function trackError(label: string, err: unknown, context?: Record<string, unknown>): void {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack?.split("\n").slice(0, 3).join("\n") : undefined;
const record: ErrorRecord = {
timestamp: new Date().toISOString(),
label,
message,
stack,
context,
};
errors.push(record);
if (errors.length > MAX_ERRORS) errors.shift();
errorCounts.set(label, (errorCounts.get(label) || 0) + 1);
logger.error({ label, ...context, err: message }, `[error] ${label}`);
}
export function getRecentErrors(limit = 50): ErrorRecord[] {
return errors.slice(-limit).reverse();
}
export function getErrorCounts(): Record<string, number> {
return Object.fromEntries(errorCounts);
}
export function resetErrors(): void {
errors.length = 0;
errorCounts.clear();
}
+12
View File
@@ -0,0 +1,12 @@
import pino from "pino";
const transport = process.env.NODE_ENV === "production"
? undefined // JSON to stdout in production
: { target: "pino-pretty", options: { colorize: true, translateTime: "HH:MM:ss" } };
export const logger = pino({
level: process.env.LOG_LEVEL || (process.env.DEBUG === "1" ? "debug" : "info"),
...(transport ? { transport } : {}),
});
export default logger;
+233
View File
@@ -0,0 +1,233 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { spawn, type ChildProcess } from "node:child_process";
import path from "node:path";
import http from "node:http";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Send a JSON-RPC request to the MCP server over stdin and read the response. */
function sendRpc(
proc: ChildProcess,
method: string,
params: Record<string, unknown> = {},
id = 1,
): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error(`RPC timeout for ${method}`)), 10_000);
let buffer = "";
const onData = (chunk: Buffer) => {
buffer += chunk.toString();
// MCP SDK sends JSON-RPC messages delimited by newlines
const lines = buffer.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
// Match response by id (ignore notifications)
if (parsed.id === id || parsed.method === undefined) {
clearTimeout(timeout);
proc.stdout?.removeListener("data", onData);
resolve(parsed);
return;
}
} catch {
// partial line, continue buffering
}
}
};
proc.stdout?.on("data", onData);
const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params });
proc.stdin?.write(msg + "\n");
});
}
// ---------------------------------------------------------------------------
// Fake API server — serves minimal persona/health/perf/search data
// ---------------------------------------------------------------------------
function createFakeApi(): http.Server {
return http.createServer((req, res) => {
res.setHeader("Content-Type", "application/json");
if (req.url === "/api/personas") {
res.end(
JSON.stringify({
ok: true,
data: [
{ name: "Schaeffer", model: "llama3", enabled: true, summary: "Musique concrète" },
{ name: "Batty", model: "mistral", enabled: true, summary: "Blade Runner" },
{ name: "Merzbow", model: "llama3", enabled: false, summary: "Noise" },
],
}),
);
return;
}
if (req.url === "/api/v2/health") {
res.end(JSON.stringify({ ok: true, data: { app: "@kxkm/api", uptime: 42 } }));
return;
}
if (req.url === "/api/v2/perf") {
res.end(JSON.stringify({ ok: true, data: { rss: 100, heapUsed: 50 } }));
return;
}
if (req.url?.startsWith("/api/v2/chat/search")) {
res.end(
JSON.stringify({
ok: true,
data: [
{ title: "Result 1", url: "https://example.com/1", content: "test content" },
],
}),
);
return;
}
res.statusCode = 404;
res.end(JSON.stringify({ error: "not found" }));
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("MCP Server (JSON-RPC over stdio)", () => {
let fakeApi: http.Server;
let fakeApiPort: number;
let mcpProc: ChildProcess;
before(async () => {
// Start fake API
fakeApi = createFakeApi();
await new Promise<void>((resolve) => fakeApi.listen(0, resolve));
const addr = fakeApi.address();
assert.ok(addr && typeof addr !== "string");
fakeApiPort = addr.port;
// Start MCP server
const mcpScript = path.resolve(import.meta.dirname, "../../../scripts/mcp-server.js");
mcpProc = spawn(process.execPath, [mcpScript], {
env: {
...process.env,
KXKM_API_URL: `http://127.0.0.1:${fakeApiPort}`,
SEARXNG_URL: `http://127.0.0.1:${fakeApiPort}`,
},
stdio: ["pipe", "pipe", "pipe"],
});
// Wait for server to be ready (it prints to stderr)
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("MCP server startup timeout")), 5000);
mcpProc.stderr?.on("data", (chunk: Buffer) => {
if (chunk.toString().includes("started")) {
clearTimeout(timeout);
resolve();
}
});
mcpProc.on("error", (err) => {
clearTimeout(timeout);
reject(err);
});
mcpProc.on("exit", (code) => {
clearTimeout(timeout);
reject(new Error(`MCP server exited early with code ${code}`));
});
});
// Initialize MCP session (required by the SDK)
const initResp = await sendRpc(mcpProc, "initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "test-client", version: "1.0.0" },
}, 0);
assert.ok(initResp.result, "initialize should return a result");
// Send initialized notification
mcpProc.stdin?.write(
JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n",
);
// Small delay for notification processing
await new Promise((r) => setTimeout(r, 200));
});
after(async () => {
if (mcpProc && !mcpProc.killed) {
mcpProc.kill("SIGTERM");
await new Promise<void>((resolve) => {
mcpProc.on("exit", () => resolve());
setTimeout(resolve, 2000);
});
}
if (fakeApi) {
await new Promise<void>((resolve) => fakeApi.close(() => resolve()));
}
});
it("lists available tools via tools/list", async () => {
const resp = await sendRpc(mcpProc, "tools/list", {}, 1);
const result = resp.result as { tools: Array<{ name: string }> };
assert.ok(Array.isArray(result.tools), "tools/list should return an array");
const toolNames = result.tools.map((t) => t.name);
assert.ok(toolNames.includes("kxkm_personas"), "should have kxkm_personas tool");
assert.ok(toolNames.includes("kxkm_status"), "should have kxkm_status tool");
assert.ok(toolNames.includes("kxkm_chat"), "should have kxkm_chat tool");
assert.ok(toolNames.includes("kxkm_web_search"), "should have kxkm_web_search tool");
});
it("kxkm_personas returns persona list", async () => {
const resp = await sendRpc(mcpProc, "tools/call", {
name: "kxkm_personas",
arguments: {},
}, 2);
const result = resp.result as { content: Array<{ type: string; text: string }> };
assert.ok(result.content, "should have content");
assert.equal(result.content[0].type, "text");
const text = result.content[0].text;
assert.ok(text.includes("Schaeffer"), "should list Schaeffer persona");
assert.ok(text.includes("Batty"), "should list Batty persona");
assert.ok(text.includes("Merzbow"), "should list Merzbow persona");
assert.ok(text.includes("Personas KXKM (3)"), "should show persona count");
});
it("kxkm_status returns health data", async () => {
const resp = await sendRpc(mcpProc, "tools/call", {
name: "kxkm_status",
arguments: {},
}, 3);
const result = resp.result as { content: Array<{ type: string; text: string }> };
assert.ok(result.content, "should have content");
const text = result.content[0].text;
const parsed = JSON.parse(text);
assert.ok(parsed.health, "should have health data");
assert.equal(parsed.health.app, "@kxkm/api");
assert.ok(parsed.perf, "should have perf data");
assert.equal(parsed.perf.rss, 100);
});
it("kxkm_web_search returns results", async () => {
const resp = await sendRpc(mcpProc, "tools/call", {
name: "kxkm_web_search",
arguments: { query: "test query" },
}, 4);
const result = resp.result as { content: Array<{ type: string; text: string }> };
assert.ok(result.content, "should have content");
const text = result.content[0].text;
// The search tool tries API first, which returns our fake data
assert.ok(text.includes("Result 1") || text.includes("test content"), "should contain search results");
});
});
+80
View File
@@ -0,0 +1,80 @@
process.env.NODE_ENV = "test";
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { getToolsForPersona, getToolNames, TOOLS } from "./mcp-tools.js";
describe("TOOLS registry", () => {
it("has web_search, image_generate, rag_search", () => {
assert.ok(TOOLS.web_search, "web_search should exist");
assert.ok(TOOLS.image_generate, "image_generate should exist");
assert.ok(TOOLS.rag_search, "rag_search should exist");
});
it("tool definitions have valid function schema", () => {
for (const [name, tool] of Object.entries(TOOLS)) {
assert.equal(tool.type, "function", `${name} type should be function`);
assert.equal(typeof tool.function.name, "string", `${name} should have a name`);
assert.equal(typeof tool.function.description, "string", `${name} should have a description`);
assert.equal(tool.function.parameters.type, "object", `${name} params type should be object`);
assert.ok(Array.isArray(tool.function.parameters.required), `${name} should have required array`);
assert.ok(tool.function.parameters.required.length > 0, `${name} should require at least one param`);
}
});
it("each tool name matches its key", () => {
for (const [key, tool] of Object.entries(TOOLS)) {
assert.equal(key, tool.function.name, `key "${key}" should match function.name "${tool.function.name}"`);
}
});
});
describe("getToolsForPersona", () => {
it("returns web_search + rag_search for sherlock", () => {
const tools = getToolsForPersona("sherlock");
const names = tools.map(t => t.function.name);
assert.deepEqual(names.sort(), ["rag_search", "web_search"]);
});
it("returns image_generate + rag_search for picasso", () => {
const tools = getToolsForPersona("picasso");
const names = tools.map(t => t.function.name);
assert.deepEqual(names.sort(), ["image_generate", "rag_search"]);
});
it("returns empty for pharmacius", () => {
const tools = getToolsForPersona("pharmacius");
assert.equal(tools.length, 0);
});
it("defaults to rag_search for unknown persona", () => {
const tools = getToolsForPersona("unknown_persona_xyz");
assert.equal(tools.length, 1);
assert.equal(tools[0].function.name, "rag_search");
});
it("is case-insensitive", () => {
const upper = getToolsForPersona("SHERLOCK");
const lower = getToolsForPersona("sherlock");
assert.deepEqual(
upper.map(t => t.function.name).sort(),
lower.map(t => t.function.name).sort(),
);
});
});
describe("getToolNames", () => {
it("returns string array for sherlock", () => {
const names = getToolNames("sherlock");
assert.deepEqual(names.sort(), ["rag_search", "web_search"]);
});
it("returns empty array for pharmacius", () => {
const names = getToolNames("pharmacius");
assert.deepEqual(names, []);
});
it("returns rag_search for unknown persona", () => {
const names = getToolNames("totally_unknown");
assert.deepEqual(names, ["rag_search"]);
});
});
+1 -1
View File
@@ -64,7 +64,7 @@ export const TOOLS: Record<string, ToolDefinition> = {
// Per-persona tool permissions
const PERSONA_TOOLS: Record<string, string[]> = {
// Pharmacius is a router — no tools, delegates to specialists via @mentions
pharmacius: [], // routeur pur — délègue via @mentions, pas de tools
sherlock: ["web_search", "rag_search"],
picasso: ["image_generate", "rag_search"],
// Default for other personas: rag_search only
+84 -113
View File
@@ -1,148 +1,119 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import fsp from "node:fs/promises";
process.env.NODE_ENV = "test";
import { mkdtempSync, rmSync } from "node:fs";
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import crypto from "node:crypto";
import { describe, it, after } from "node:test";
import assert from "node:assert/strict";
// Set DATA_DIR before importing the module so the top-level mkdirSync
// creates directories inside our tmpdir instead of ./data.
const TEST_DATA_DIR = path.join(
os.tmpdir(),
`media-store-test-${crypto.randomBytes(6).toString("hex")}`,
);
process.env.DATA_DIR = TEST_DATA_DIR;
// Create temp dir and set DATA_DIR BEFORE dynamic-importing media-store
const testDataDir = mkdtempSync(path.join(os.tmpdir(), "kxkm-media-test-"));
process.env.DATA_DIR = testDataDir;
// Dynamic import so DATA_DIR is already set when the module evaluates.
const { saveImage, saveAudio, listMedia, getMediaFilePath } = await import(
"./media-store.js"
);
// Small 1x1 PNG as base64
const TINY_PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
// Small WAV (44-byte header, silence)
const TINY_WAV_B64 = Buffer.from(
"UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=",
"base64",
).toString("base64");
const IMAGES_DIR = path.join(TEST_DATA_DIR, "media", "images");
const AUDIO_DIR = path.join(TEST_DATA_DIR, "media", "audio");
// Use dynamic import so DATA_DIR is already set when the module initializes
const mediaStorePromise = import("./media-store.js");
const DUMMY_PNG_B64 = Buffer.from("fake-png-bytes").toString("base64");
const DUMMY_WAV_B64 = Buffer.from("fake-wav-bytes").toString("base64");
const baseOpts = { prompt: "test prompt", nick: "tester", channel: "#test" };
after(async () => {
await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true });
delete process.env.DATA_DIR;
after(() => {
rmSync(testDataDir, { recursive: true, force: true });
});
// -----------------------------------------------------------------------
describe("saveImage()", () => {
it("creates an image file and a metadata JSON file", async () => {
const meta = await saveImage({ ...baseOpts, base64: DUMMY_PNG_B64 });
describe("media-store", () => {
it("saveImage creates PNG file and metadata JSON", async () => {
const { saveImage } = await mediaStorePromise;
const imgPath = path.join(IMAGES_DIR, meta.filename);
const jsonPath = path.join(IMAGES_DIR, `${meta.id}.json`);
assert.ok(fs.existsSync(imgPath), "image file should exist");
assert.ok(fs.existsSync(jsonPath), "metadata JSON should exist");
// Verify image content matches decoded base64
const imgContent = await fsp.readFile(imgPath);
assert.deepStrictEqual(imgContent, Buffer.from(DUMMY_PNG_B64, "base64"));
});
it("returns a MediaMeta with the correct fields", async () => {
const meta = await saveImage({
...baseOpts,
base64: DUMMY_PNG_B64,
mime: "image/jpeg",
base64: TINY_PNG_B64,
prompt: "test image",
nick: "tester",
channel: "#test",
});
assert.equal(meta.type, "image");
assert.equal(meta.prompt, "test prompt");
assert.equal(meta.nick, "tester");
assert.equal(meta.channel, "#test");
assert.equal(meta.mime, "image/jpeg");
assert.ok(meta.filename.endsWith(".jpg"), "jpeg mime should produce .jpg");
assert.ok(meta.id, "id should be present");
assert.ok(meta.createdAt, "createdAt should be present");
assert.ok(meta.filename.endsWith(".png"), "filename should end with .png");
const imagesDir = path.join(testDataDir, "media", "images");
const files = await readdir(imagesDir);
assert.ok(files.includes(meta.filename), "PNG file should exist");
assert.ok(files.includes(`${meta.id}.json`), "metadata JSON should exist");
const jsonContent = JSON.parse(await readFile(path.join(imagesDir, `${meta.id}.json`), "utf-8"));
assert.equal(jsonContent.prompt, "test image");
assert.equal(jsonContent.channel, "#test");
});
});
// -----------------------------------------------------------------------
describe("saveAudio()", () => {
it("creates an audio file and metadata", async () => {
const meta = await saveAudio({ ...baseOpts, base64: DUMMY_WAV_B64 });
it("saveAudio creates WAV file and metadata JSON", async () => {
const { saveAudio } = await mediaStorePromise;
const audioPath = path.join(AUDIO_DIR, meta.filename);
const jsonPath = path.join(AUDIO_DIR, `${meta.id}.json`);
const meta = await saveAudio({
base64: TINY_WAV_B64,
prompt: "test audio",
nick: "tester",
channel: "#test",
});
assert.ok(fs.existsSync(audioPath), "audio file should exist");
assert.ok(fs.existsSync(jsonPath), "metadata JSON should exist");
assert.equal(meta.type, "audio");
assert.equal(meta.mime, "audio/wav");
assert.ok(meta.filename.endsWith(".wav"));
});
});
assert.ok(meta.filename.endsWith(".wav"), "filename should end with .wav");
// -----------------------------------------------------------------------
describe("listMedia()", () => {
it('returns metadata entries for "image" type', async () => {
// saveImage was already called above; list should find them
const list = await listMedia("image");
assert.ok(list.length >= 1, "should find at least one image meta");
assert.equal(list[0].type, "image");
const audioDir = path.join(testDataDir, "media", "audio");
const files = await readdir(audioDir);
assert.ok(files.includes(meta.filename), "WAV file should exist");
assert.ok(files.includes(`${meta.id}.json`), "metadata JSON should exist");
});
it("returns [] when there are no JSON files", async () => {
// Create a fresh empty dir to test the empty case
const emptyDir = path.join(TEST_DATA_DIR, "media", "empty-test");
await fsp.mkdir(emptyDir, { recursive: true });
it("listMedia returns saved items sorted newest first", async () => {
const { saveImage, listMedia } = await mediaStorePromise;
// listMedia only knows "image" | "audio", so test "audio" after
// clearing the audio dir temporarily.
const audioFiles = await fsp.readdir(AUDIO_DIR);
// Move files away
const backupDir = path.join(TEST_DATA_DIR, "backup-audio");
await fsp.mkdir(backupDir, { recursive: true });
for (const f of audioFiles) {
await fsp.rename(path.join(AUDIO_DIR, f), path.join(backupDir, f));
}
await saveImage({ base64: TINY_PNG_B64, prompt: "alpha", nick: "a", channel: "#c" });
await new Promise(r => setTimeout(r, 20));
await saveImage({ base64: TINY_PNG_B64, prompt: "beta", nick: "b", channel: "#c" });
const list = await listMedia("audio");
assert.deepStrictEqual(list, []);
// Restore files
for (const f of audioFiles) {
await fsp.rename(path.join(backupDir, f), path.join(AUDIO_DIR, f));
}
const items = await listMedia("image");
assert.ok(items.length >= 3, `expected >= 3 items, got ${items.length}`);
// Sorted reverse by filename (timestamp-based), newest first
assert.equal(items[0].prompt, "beta");
assert.equal(items[1].prompt, "alpha");
});
});
// -----------------------------------------------------------------------
describe("getMediaFilePath()", () => {
it("returns null when the file does not exist", () => {
const result = getMediaFilePath("image", "nonexistent.png");
it("listMedia returns array for audio type", async () => {
const { listMedia } = await mediaStorePromise;
const items = await listMedia("audio");
assert.ok(Array.isArray(items));
});
it("getMediaFilePath returns null for nonexistent file", async () => {
const { getMediaFilePath } = await mediaStorePromise;
const result = getMediaFilePath("image", "nonexistent-file.png");
assert.equal(result, null);
});
it("prevents directory traversal (../../etc/passwd → basename)", () => {
const result = getMediaFilePath("image", "../../etc/passwd");
// Should resolve to basename "passwd" which doesn't exist → null
it("getMediaFilePath prevents directory traversal", async () => {
const { getMediaFilePath } = await mediaStorePromise;
const result = getMediaFilePath("image", "../../../etc/passwd");
assert.equal(result, null);
});
it("returns the full path for an existing file", async () => {
const meta = await saveImage({ ...baseOpts, base64: DUMMY_PNG_B64 });
const result = getMediaFilePath("image", meta.filename);
assert.ok(result, "should return a path for existing file");
assert.ok(result!.startsWith(IMAGES_DIR), "path should be inside IMAGES_DIR");
assert.ok(result!.endsWith(meta.filename));
});
});
it("saveImage with JPEG mime uses .jpg extension", async () => {
const { saveImage } = await mediaStorePromise;
// -----------------------------------------------------------------------
describe("generateId() uniqueness", () => {
it("produces unique IDs across two successive saves", async () => {
const meta1 = await saveImage({ ...baseOpts, base64: DUMMY_PNG_B64 });
const meta2 = await saveImage({ ...baseOpts, base64: DUMMY_PNG_B64 });
assert.notEqual(meta1.id, meta2.id, "IDs should be unique");
const meta = await saveImage({
base64: TINY_PNG_B64,
prompt: "jpeg test",
nick: "tester",
channel: "#test",
mime: "image/jpeg",
});
assert.ok(meta.filename.endsWith(".jpg"), "filename should end with .jpg");
assert.equal(meta.mime, "image/jpeg");
});
});
+58
View File
@@ -0,0 +1,58 @@
import logger from "./logger.js";
// ---------------------------------------------------------------------------
// Latency metrics collector with percentile support (p50, p95, p99)
// ---------------------------------------------------------------------------
interface PerfMetrics {
count: number;
totalMs: number;
maxMs: number;
buckets: number[]; // sorted latencies for percentile calc
}
const metrics = new Map<string, PerfMetrics>();
const MAX_BUCKET_SIZE = 1000;
export function recordLatency(label: string, ms: number): void {
let m = metrics.get(label);
if (!m) {
m = { count: 0, totalMs: 0, maxMs: 0, buckets: [] };
metrics.set(label, m);
}
m.count++;
m.totalMs += ms;
if (ms > m.maxMs) m.maxMs = ms;
m.buckets.push(ms);
if (m.buckets.length > MAX_BUCKET_SIZE) {
m.buckets.sort((a, b) => a - b);
// Keep only every other element to halve the array
m.buckets = m.buckets.filter((_, i) => i % 2 === 0);
}
}
function percentile(sorted: number[], p: number): number {
if (sorted.length === 0) return 0;
const idx = Math.ceil(sorted.length * p / 100) - 1;
return sorted[Math.max(0, idx)];
}
export function getMetrics(): Record<string, { count: number; avgMs: number; p50: number; p95: number; p99: number; maxMs: number }> {
const result: Record<string, any> = {};
for (const [label, m] of metrics) {
const sorted = [...m.buckets].sort((a, b) => a - b);
result[label] = {
count: m.count,
avgMs: Math.round(m.totalMs / Math.max(1, m.count)),
p50: Math.round(percentile(sorted, 50)),
p95: Math.round(percentile(sorted, 95)),
p99: Math.round(percentile(sorted, 99)),
maxMs: Math.round(m.maxMs),
};
}
return result;
}
export function resetMetrics(): void {
metrics.clear();
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Qwen3-TTS voice mapping for each persona.
* speaker: one of the 9 CustomVoice presets
* instruct: style instruction for voice characteristics
* language: "French" for most, "English" for Moorcock
*/
export interface PersonaVoice {
speaker: string;
instruct: string;
language: string;
}
export const PERSONA_VOICES: Record<string, PersonaVoice> = {
// Musique / Son
Schaeffer: { speaker: "David", instruct: "Speak with academic authority, measured French intellectual tone", language: "French" },
Radigue: { speaker: "Serena", instruct: "Speak very slowly, meditative, barely above a whisper", language: "French" },
Oliveros: { speaker: "Claire", instruct: "Warm, gentle, contemplative, like guiding a meditation", language: "French" },
Eno: { speaker: "Ryan", instruct: "Calm, ambient, understated British intellectual", language: "French" },
Cage: { speaker: "Eric", instruct: "Playful, philosophical, with pauses that are intentional", language: "French" },
Merzbow: { speaker: "Aiden", instruct: "Intense, raw, aggressive, like noise music in voice form", language: "French" },
Oram: { speaker: "Bella", instruct: "Precise, pioneering, electronic music inventor tone", language: "French" },
Bjork: { speaker: "Aria", instruct: "Ethereal, expressive, unpredictable, nature-inspired", language: "French" },
// Philosophie / Pensee
Batty: { speaker: "Ryan", instruct: "Melancholic, existential, like a replicant contemplating mortality", language: "French" },
Foucault: { speaker: "David", instruct: "Sharp, analytical, subversive intellectual authority", language: "French" },
Deleuze: { speaker: "Eric", instruct: "Fast, enthusiastic, conceptual, rhizomatic energy", language: "French" },
// Science
Hypatia: { speaker: "Claire", instruct: "Ancient wisdom, clear, mathematical precision", language: "French" },
Curie: { speaker: "Bella", instruct: "Determined, passionate, scientific rigor with warmth", language: "French" },
Turing: { speaker: "Aiden", instruct: "Logical, precise, slightly awkward, brilliant", language: "French" },
// Politique / Resistance
Swartz: { speaker: "Taylor", instruct: "Young, urgent, activist passion, information freedom", language: "French" },
Bookchin: { speaker: "David", instruct: "Gruff, ecological, municipal libertarian conviction", language: "French" },
LeGuin: { speaker: "Serena", instruct: "Wise storyteller, feminist utopian warmth", language: "French" },
// Arts visuels / Tech
Picasso: { speaker: "Eric", instruct: "Bold, provocative, artistic genius confidence", language: "French" },
Ikeda: { speaker: "Aiden", instruct: "Minimal, precise, data-driven, mathematical beauty", language: "French" },
TeamLab: { speaker: "Aria", instruct: "Collective voice, immersive, flowing like digital water", language: "French" },
Demoscene: { speaker: "Taylor", instruct: "Excited, technical, demo party energy, coder pride", language: "French" },
// Scene / Corps
RoyalDeLuxe: { speaker: "Ryan", instruct: "Grand, theatrical, street performance spectacle", language: "French" },
Decroux: { speaker: "David", instruct: "Physical, precise, mime master's economy of expression", language: "French" },
Mnouchkine: { speaker: "Claire", instruct: "Passionate, theatrical director, collective creation", language: "French" },
Pina: { speaker: "Bella", instruct: "Emotional, dance-like rhythm in speech, expressive pauses", language: "French" },
Grotowski: { speaker: "Eric", instruct: "Intense, ritual, poor theatre conviction", language: "French" },
Fratellini: { speaker: "Taylor", instruct: "Playful, clownesque, circus joy and melancholy", language: "French" },
// Transversal
Pharmacius: { speaker: "Ryan", instruct: "Authoritative router, concise, French orchestrator", language: "French" },
Haraway: { speaker: "Serena", instruct: "Intellectual, cyborg feminist, boundary-dissolving", language: "French" },
SunRa: { speaker: "Aiden", instruct: "Cosmic, prophetic, afrofuturist jazz preacher", language: "French" },
Fuller: { speaker: "David", instruct: "Visionary, buckminster dome enthusiasm, systems thinking", language: "French" },
Tarkovski: { speaker: "Eric", instruct: "Poetic, slow, cinematic, Russian soul depth", language: "French" },
Moorcock: { speaker: "Ryan", instruct: "British fantasy writer, multiverse energy, punk edge", language: "English" },
Sherlock: { speaker: "Aiden", instruct: "Analytical, detective precision, web investigator", language: "French" },
};
export function getPersonaVoice(nick: string): PersonaVoice {
return PERSONA_VOICES[nick] || { speaker: "Ryan", instruct: "Speak naturally in French", language: "French" };
}
+9 -12
View File
@@ -85,7 +85,7 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
"Format: une phrase de réponse + '@NomDuSpecialiste peut approfondir.' " +
"Tu réponds en français.",
color: "#00e676",
maxTokens: 800,
maxTokens: 400,
},
{
id: "turing",
@@ -123,7 +123,7 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
{
id: "hypatia",
nick: "Hypatia",
model: "mistral:7b",
model: "qwen3:8b",
systemPrompt:
"Tu es Hypatia d'Alexandrie, mathématicienne, astronome et philosophe néoplatonicienne. " +
"Tu parles de sciences, de cosmologie, de logique, de la beauté des nombres et des sphères célestes. " +
@@ -215,7 +215,7 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
{
id: "grotowski",
nick: "Grotowski",
model: "mistral:7b",
model: "qwen3:8b",
systemPrompt:
"Tu es Jerzy Grotowski, créateur du théâtre pauvre. Tu as éliminé tout le superflu — décor, costume, lumière — " +
"pour ne garder que l'acteur et le spectateur. Tu parles d'acte total, de via negativa, de transgression. " +
@@ -226,7 +226,7 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
{
id: "cirque",
nick: "Fratellini",
model: "qwen3:8b",
model: "mistral:7b",
systemPrompt:
"Tu es l'esprit de la famille Fratellini et du nouveau cirque. Tu parles de clown, d'acrobatie, " +
"de risque physique, de poésie du geste impossible. Tu connais le cirque traditionnel ET le cirque contemporain — " +
@@ -238,7 +238,7 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
{
id: "curie",
nick: "Curie",
model: "mistral:7b",
model: "qwen3:8b",
systemPrompt:
"Tu es Marie Curie, physicienne et chimiste, double prix Nobel. Tu parles de radioactivité, de recherche obstinée, " +
"de la place des femmes en science. Tu as sacrifié ta santé pour la connaissance. " +
@@ -248,7 +248,7 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
{
id: "foucault",
nick: "Foucault",
model: "gemma3:4b",
model: "qwen3:8b",
systemPrompt:
"Tu es Michel Foucault, philosophe. Tu analyses les dispositifs de pouvoir, la surveillance, la norme, " +
"les institutions disciplinaires. Tu parles de biopolitique, de savoirs assujettis, d'archéologie du discours. " +
@@ -258,7 +258,7 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
{
id: "deleuze",
nick: "Deleuze",
model: "gemma3:4b",
model: "qwen3:8b",
systemPrompt:
"Tu es Gilles Deleuze, philosophe du devenir et de la différence. Tu parles de rhizome, de lignes de fuite, " +
"de déterritorialisation, de corps sans organes. Tu cites Guattari, Spinoza, Nietzsche. " +
@@ -269,7 +269,7 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
{
id: "bookchin",
nick: "Bookchin",
model: "gemma3:4b",
model: "qwen3:8b",
systemPrompt:
"Tu es Murray Bookchin, théoricien de l'écologie sociale et du municipalisme libertaire. " +
"Tu parles de hiérarchie, de domination de la nature par la domination sociale, de démocratie directe. " +
@@ -315,7 +315,7 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
{
id: "fuller",
nick: "Fuller",
model: "gemma3:4b",
model: "qwen3:8b",
systemPrompt:
"Tu es Buckminster Fuller, architecte, inventeur et futuriste. Tu parles de dômes géodésiques, " +
"de Spaceship Earth, de synergétique, de faire plus avec moins. Tu as inventé le mot 'synergie' en design. " +
@@ -358,7 +358,6 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
"tu formules des hypothèses et tu les vérifies. Tu cites tes sources. " +
"Ton ton est précis, déductif, parfois condescendant mais toujours brillant. Tu réponds en français.",
color: "#b39ddb",
maxTokens: 800,
},
{
id: "picasso",
@@ -371,7 +370,6 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
"le style, les couleurs, la composition, l'ambiance. Tu penses en artiste visuel. " +
"Tu cites Braque, Matisse, Cézanne. Ton ton est passionné, provocateur, libre. Tu réponds en français.",
color: "#ffab00",
maxTokens: 600,
},
{
id: "eno",
@@ -384,7 +382,6 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
"Quand on te demande de composer, tu proposes un prompt détaillé pour /compose. " +
"Ton ton est curieux, élégant, expérimental. Tu réponds en français.",
color: "#90caf9",
maxTokens: 600,
},
];
+103 -19
View File
@@ -4,6 +4,17 @@
* Uses cosine similarity for retrieval.
*/
import logger from "./logger.js";
import { trackError } from "./error-tracker.js";
// ---------------------------------------------------------------------------
// Configurable via environment variables
// ---------------------------------------------------------------------------
const RAG_CHUNK_SIZE = Number(process.env.RAG_CHUNK_SIZE) || 500;
const RAG_MIN_SIMILARITY = Number(process.env.RAG_MIN_SIMILARITY) || 0.3;
const RAG_MAX_RESULTS = Number(process.env.RAG_MAX_RESULTS) || 3;
const RAG_EMBEDDING_MODEL = process.env.RAG_EMBEDDING_MODEL || "nomic-embed-text";
interface DocumentChunk {
id: string;
text: string;
@@ -17,6 +28,7 @@ interface RAGOptions {
maxChunks?: number; // max chunks to return
minSimilarity?: number; // minimum cosine similarity threshold
lightragUrl?: string; // e.g. "http://localhost:9621"
rerankerUrl?: string; // e.g. "http://localhost:9500"
}
export class LocalRAG {
@@ -27,13 +39,39 @@ export class LocalRAG {
this.options = options;
}
/** Verify embedding model is available on Ollama, pull if missing. */
async init(): Promise<void> {
const ollamaUrl = this.options.ollamaUrl;
const model = this.options.embeddingModel || RAG_EMBEDDING_MODEL;
try {
const resp = await fetch(`${ollamaUrl}/api/tags`, { signal: AbortSignal.timeout(5000) });
const data = (await resp.json()) as { models?: Array<{ name: string }> };
const models = data.models?.map((m) => m.name) || [];
const available = models.some((m) => m.startsWith(model));
if (!available) {
logger.warn({ model, available: models.slice(0, 5) }, "[rag] Embedding model not found, pulling...");
await fetch(`${ollamaUrl}/api/pull`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: model }),
signal: AbortSignal.timeout(300_000),
});
logger.info({ model }, "[rag] Embedding model pulled successfully");
} else {
logger.debug({ model }, "[rag] Embedding model available");
}
} catch (err) {
logger.warn({ err }, "[rag] Could not verify embedding model");
}
}
/** Embed text via Ollama /api/embed */
async embed(text: string): Promise<number[]> {
const response = await fetch(`${this.options.ollamaUrl}/api/embed`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: this.options.embeddingModel || "nomic-embed-text",
model: this.options.embeddingModel || RAG_EMBEDDING_MODEL,
input: text,
}),
});
@@ -56,17 +94,17 @@ export class LocalRAG {
body: JSON.stringify({ text }),
});
if (res.ok) {
console.log(`[rag:lightrag] DEBUG addDocument to LightRAG OK (source=${source})`);
logger.debug({ source }, "[rag:lightrag] addDocument to LightRAG OK");
} else {
console.warn(`[rag:lightrag] addDocument failed: ${res.status} ${res.statusText}`);
logger.warn(`[rag:lightrag] addDocument failed: ${res.status} ${res.statusText}`);
}
} catch (err) {
console.warn("[rag:lightrag] addDocument error (continuing local):", err);
logger.warn({ err }, "[rag:lightrag] addDocument error (continuing local)");
}
}
// Always index locally
const textChunks = splitIntoChunks(text, 500);
const textChunks = splitIntoChunks(text, RAG_CHUNK_SIZE);
for (const chunk of textChunks) {
const embedding = await this.embed(chunk);
this.chunks.push({
@@ -80,15 +118,19 @@ export class LocalRAG {
}
/** Search for relevant chunks.
* If LightRAG is configured, queries it first; falls back to local on failure. */
* If LightRAG is configured, queries it first; falls back to local on failure.
* If a reranker is configured, reranks results with a cross-encoder for better precision. */
async search(
query: string,
maxResults = 3,
maxResults?: number,
): Promise<Array<{ text: string; source: string; score: number }>> {
const limit = maxResults ?? RAG_MAX_RESULTS;
let results: Array<{ text: string; source: string; score: number }> = [];
// Try LightRAG first if configured
if (this.options.lightragUrl) {
try {
console.log(`[rag:lightrag] DEBUG search query="${query.slice(0, 80)}"`);
logger.debug({ query: query.slice(0, 80) }, "[rag:lightrag] search");
const res = await fetch(`${this.options.lightragUrl}/query`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -99,12 +141,9 @@ export class LocalRAG {
response?: string;
references?: Array<{ content?: string; text?: string }>;
};
console.log(
`[rag:lightrag] DEBUG search OK refs=${data.references?.length ?? 0}`,
);
const results: Array<{ text: string; source: string; score: number }> = [];
logger.debug({ refs: data.references?.length ?? 0 }, "[rag:lightrag] search OK");
if (data.references && data.references.length > 0) {
for (const ref of data.references.slice(0, maxResults)) {
for (const ref of data.references.slice(0, limit)) {
results.push({
text: ref.content || ref.text || "",
source: "lightrag",
@@ -115,13 +154,13 @@ export class LocalRAG {
// No structured references — use the full response as a single chunk
results.push({ text: data.response, source: "lightrag", score: 1.0 });
}
if (results.length > 0) return results;
if (results.length > 0) return this.rerank(query, results, limit);
// Empty results from LightRAG → fall through to local
} else {
console.warn(`[rag:lightrag] search failed: ${res.status} ${res.statusText}`);
logger.warn(`[rag:lightrag] search failed: ${res.status} ${res.statusText}`);
}
} catch (err) {
console.warn("[rag:lightrag] search error (falling back to local):", err);
trackError("rag_lightrag_search", err, { query: query.slice(0, 80) });
}
}
@@ -135,9 +174,54 @@ export class LocalRAG {
score: cosineSimilarity(queryEmbedding, chunk.embedding),
}));
scored.sort((a, b) => b.score - a.score);
return scored
.filter((s) => s.score >= (this.options.minSimilarity ?? 0.3))
.slice(0, maxResults);
results = scored
.filter((s) => s.score >= (this.options.minSimilarity ?? RAG_MIN_SIMILARITY))
.slice(0, limit);
return this.rerank(query, results, limit);
}
/** Rerank results using BGE cross-encoder for improved precision.
* Falls back to original ordering if the reranker is unavailable. */
private async rerank(
query: string,
results: Array<{ text: string; source: string; score: number }>,
maxResults: number,
): Promise<Array<{ text: string; source: string; score: number }>> {
const rerankerUrl = this.options.rerankerUrl || process.env.RERANKER_URL;
if (!rerankerUrl || results.length <= 1) return results;
try {
const resp = await fetch(`${rerankerUrl}/rerank`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query,
documents: results.map((r) => r.text),
top_k: maxResults,
}),
signal: AbortSignal.timeout(5_000),
});
if (resp.ok) {
const data = (await resp.json()) as {
results?: Array<{ text: string; score: number }>;
};
if (data.results && data.results.length > 0) {
// Map reranked texts back to original results to preserve source metadata
const sourceMap = new Map(results.map((r) => [r.text, r.source]));
logger.info(`[rag:reranker] reranked ${results.length}${data.results.length} results`);
return data.results.map((r) => ({
text: r.text,
source: sourceMap.get(r.text) || "unknown",
score: r.score,
}));
}
}
} catch (err) {
// Reranker unavailable — use original ordering
trackError("rag_rerank", err, { query: query.slice(0, 80) });
}
return results;
}
get size(): number {
+4 -2
View File
@@ -12,6 +12,7 @@ import {
type PersonaRecord,
type PersonaSourceRecord,
} from "@kxkm/persona-domain";
import { validate, retentionSweepSchema } from "../schemas.js";
interface SessionRequest extends Request {
session?: AuthSession;
@@ -58,8 +59,9 @@ export function createChatHistoryRoutes(deps: ChatHistoryRouteDeps): Router {
// Retention sweep — delete old completed/failed/cancelled runs
// -----------------------------------------------------------------------
router.post("/api/v2/admin/retention-sweep", requirePermission("node_engine:operate"), async (req, res) => {
const maxAgeDays = Number(req.body?.maxAgeDays) || 30;
router.post("/api/v2/admin/retention-sweep", requirePermission("node_engine:operate"), validate(retentionSweepSchema), async (req, res) => {
const body = req.body as { maxAgeDays?: number };
const maxAgeDays = body.maxAgeDays || 30;
const cutoff = new Date(Date.now() - maxAgeDays * 24 * 60 * 60 * 1000).toISOString();
const deleted = await runRepo.deleteOlderThan(cutoff);
res.json({ ok: true, deleted });
+2 -2
View File
@@ -7,7 +7,7 @@ const router = Router();
router.get("/images", async (_req, res) => {
try {
const items = await listMedia("image");
res.json(items);
res.json({ ok: true, data: items });
} catch (err) {
res.status(500).json({ error: "Failed to list images" });
}
@@ -17,7 +17,7 @@ router.get("/images", async (_req, res) => {
router.get("/audio", async (_req, res) => {
try {
const items = await listMedia("audio");
res.json(items);
res.json({ ok: true, data: items });
} catch (err) {
res.status(500).json({ error: "Failed to list audio" });
}
+11 -10
View File
@@ -13,6 +13,7 @@ import {
type NodeGraphRecord,
type NodeRunRecord,
} from "@kxkm/node-engine";
import { validate, createGraphSchema, updateGraphSchema, runGraphSchema } from "../schemas.js";
interface SessionRequest extends Request {
session?: AuthSession;
@@ -72,30 +73,29 @@ export function createNodeEngineRoutes(deps: NodeEngineRouteDeps): Router {
res.json(asApiData(list));
});
router.post("/api/admin/node-engine/graphs", requirePermission("node_engine:operate"), async (req, res) => {
const graph = createNodeGraph(
String(req.body?.name || "graph"),
String(req.body?.description || ""),
);
router.post("/api/admin/node-engine/graphs", requirePermission("node_engine:operate"), validate(createGraphSchema), async (req, res) => {
const body = req.body as { name: string; description?: string };
const graph = createNodeGraph(body.name, body.description || "");
const created = await graphRepo.create(graph);
res.status(201).json(asApiData(created));
});
router.put("/api/admin/node-engine/graphs/:id", requirePermission("node_engine:operate"), async (req, res) => {
router.put("/api/admin/node-engine/graphs/:id", requirePermission("node_engine:operate"), validate(updateGraphSchema), async (req, res) => {
const graphId = readRouteParam(req.params.id);
const graph = await graphRepo.findById(graphId);
if (!graph) {
res.status(404).json({ ok: false, error: "graph_not_found" });
return;
}
const body = req.body as { name?: string; description?: string };
const updated = await graphRepo.update(graphId, {
name: String(req.body?.name || graph.name),
description: String(req.body?.description || graph.description),
name: body.name || graph.name,
description: body.description || graph.description,
});
res.json(asApiData(updated));
});
router.post("/api/admin/node-engine/graphs/:id/run", requirePermission("node_engine:operate"), async (req, res) => {
router.post("/api/admin/node-engine/graphs/:id/run", requirePermission("node_engine:operate"), validate(runGraphSchema), async (req, res) => {
const graphId = readRouteParam(req.params.id);
const graph = await graphRepo.findById(graphId);
if (!graph) {
@@ -103,9 +103,10 @@ export function createNodeEngineRoutes(deps: NodeEngineRouteDeps): Router {
return;
}
const body = req.body as { hold?: boolean };
const run = createNodeRun(graphId, "queued");
const created = await runRepo.create(run);
if (!req.body?.hold) {
if (!body.hold) {
enqueueRunTransition(created.id, runRepo);
}
res.status(201).json(asApiData(created));
+39 -33
View File
@@ -16,6 +16,15 @@ import {
type PersonaSourceRecord,
} from "@kxkm/persona-domain";
import { resolveVoiceSamplePath, resolveVoiceSamplesRoot } from "../voice-samples.js";
import {
validate,
createPersonaSchema,
updatePersonaSchema,
togglePersonaSchema,
updatePersonaSourceSchema,
reinforcePersonaSchema,
voiceSampleSchema,
} from "../schemas.js";
interface SessionRequest extends Request {
session?: AuthSession;
@@ -90,19 +99,15 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
res.json(asApiData(persona));
});
router.post("/api/admin/personas", requirePermission("persona:write"), async (req: SessionRequest, res) => {
const name = String(req.body?.name || "").trim();
if (!name) {
res.status(400).json({ ok: false, error: "name_required" });
return;
}
router.post("/api/admin/personas", requirePermission("persona:write"), validate(createPersonaSchema), async (req: SessionRequest, res) => {
const body = req.body as { name: string; model?: string; summary?: string; enabled?: boolean };
const persona: PersonaRecord = {
id: createId("persona"),
name,
model: String(req.body?.model || "qwen3:8b"),
summary: String(req.body?.summary || ""),
name: body.name,
model: body.model || "qwen3:8b",
summary: body.summary || "",
editable: true,
enabled: req.body?.enabled !== undefined ? Boolean(req.body.enabled) : true,
enabled: body.enabled !== undefined ? body.enabled : true,
};
await personaRepo.upsert(persona);
@@ -113,7 +118,7 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
res.status(201).json(asApiData(persona));
});
router.put("/api/admin/personas/:id", requirePermission("persona:write"), async (req: SessionRequest, res) => {
router.put("/api/admin/personas/:id", requirePermission("persona:write"), validate(updatePersonaSchema), async (req: SessionRequest, res) => {
const personaId = readRouteParam(req.params.id);
const persona = await personaRepo.findById(personaId);
if (!persona) {
@@ -121,11 +126,12 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
return;
}
persona.name = String(req.body?.name || persona.name);
persona.model = String(req.body?.model || persona.model);
persona.summary = String(req.body?.summary || persona.summary);
if (req.body?.enabled !== undefined) {
(persona as unknown as Record<string, unknown>).enabled = Boolean(req.body.enabled);
const body = req.body as { name?: string; model?: string; summary?: string; enabled?: boolean };
persona.name = body.name || persona.name;
persona.model = body.model || persona.model;
persona.summary = body.summary || persona.summary;
if (body.enabled !== undefined) {
(persona as unknown as Record<string, unknown>).enabled = body.enabled;
}
await personaRepo.upsert(persona);
@@ -137,7 +143,7 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
res.json(asApiData(persona));
});
router.post("/api/admin/personas/:id/toggle", requirePermission("persona:write"), async (req: SessionRequest, res) => {
router.post("/api/admin/personas/:id/toggle", requirePermission("persona:write"), validate(togglePersonaSchema), async (req: SessionRequest, res) => {
const personaId = readRouteParam(req.params.id);
const persona = await personaRepo.findById(personaId);
if (!persona) {
@@ -145,7 +151,8 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
return;
}
const enabled = req.body?.enabled !== undefined ? Boolean(req.body.enabled) : !(persona as unknown as { enabled?: boolean }).enabled;
const body = req.body as { enabled?: boolean };
const enabled = body.enabled !== undefined ? body.enabled : !(persona as unknown as { enabled?: boolean }).enabled;
(persona as unknown as Record<string, unknown>).enabled = enabled;
await personaRepo.upsert(persona);
@@ -163,13 +170,14 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
res.json(asApiData(source || defaultPersonaSource(personaId, persona?.name || personaId)));
});
router.put("/api/admin/personas/:id/source", requirePermission("persona:write"), async (req, res) => {
router.put("/api/admin/personas/:id/source", requirePermission("persona:write"), validate(updatePersonaSourceSchema), async (req, res) => {
const personaId = readRouteParam(req.params.id);
const body = req.body as { subjectName?: string; summary?: string; references?: string[] };
const source: PersonaSourceRecord = {
personaId,
subjectName: String(req.body?.subjectName || personaId),
summary: String(req.body?.summary || ""),
references: Array.isArray(req.body?.references) ? req.body.references.map(String) : [],
subjectName: body.subjectName || personaId,
summary: body.summary || "",
references: body.references || [],
};
const saved = await sourceRepo.upsert(source);
res.json(asApiData(saved));
@@ -187,7 +195,7 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
res.json(asApiData(list));
});
router.post("/api/admin/personas/:id/reinforce", requirePermission("persona:write"), async (req: SessionRequest, res) => {
router.post("/api/admin/personas/:id/reinforce", requirePermission("persona:write"), validate(reinforcePersonaSchema), async (req: SessionRequest, res) => {
const personaId = readRouteParam(req.params.id);
const persona = await personaRepo.findById(personaId);
if (!persona) {
@@ -195,14 +203,15 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
return;
}
const body = req.body as { name?: string; model?: string; summary?: string; apply?: boolean };
const existingFeedback = await feedbackRepo.listByPersonaId(persona.id);
const suffix = existingFeedback.length ? " affinee par feedback" : " calibree par source";
const after = {
name: String(req.body?.name || persona.name),
model: String(req.body?.model || persona.model),
summary: String(req.body?.summary || `${persona.summary}${suffix}`),
name: body.name || persona.name,
model: body.model || persona.model,
summary: body.summary || `${persona.summary}${suffix}`,
};
const apply = Boolean(req.body?.apply);
const apply = Boolean(body.apply);
const proposal = createProposal(persona, after, "reinforce_v2", apply);
if (apply) {
@@ -236,7 +245,7 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
});
// Voice sample upload for XTTS-v2 cloning
router.post("/api/admin/personas/:id/voice-sample", requirePermission("persona:write"), async (req: SessionRequest, res) => {
router.post("/api/admin/personas/:id/voice-sample", requirePermission("persona:write"), validate(voiceSampleSchema), async (req: SessionRequest, res) => {
const personaId = readRouteParam(req.params.id);
const persona = await personaRepo.findById(personaId);
if (!persona) {
@@ -244,11 +253,8 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
return;
}
const audioB64 = req.body?.audio as string | undefined;
if (!audioB64 || typeof audioB64 !== "string") {
res.status(400).json({ ok: false, error: "audio_required (base64 field 'audio')" });
return;
}
const body = req.body as { audio: string };
const audioB64 = body.audio;
// Decode and validate size (max 10 MB)
const buffer = Buffer.from(audioB64, "base64");
+9
View File
@@ -10,6 +10,7 @@ import {
} from "@kxkm/core";
import { validateLoginInput } from "@kxkm/auth";
import { buildChatChannels } from "@kxkm/chat-domain";
import { getRecentErrors, getErrorCounts } from "../error-tracker.js";
import type { PersonaRecord } from "@kxkm/persona-domain";
import type { ModelRegistryRecord, NodeGraphRecord, NodeRunRecord } from "@kxkm/node-engine";
@@ -233,5 +234,13 @@ export function createSessionRoutes(deps: SessionRouteDeps): Router {
res.json({ ok: true, data: stats });
});
// -----------------------------------------------------------------------
// Error telemetry — recent tracked errors
// -----------------------------------------------------------------------
router.get("/api/v2/errors", requirePermission("ops:read"), (_req: SessionRequest, res) => {
res.json({ ok: true, data: { recent: getRecentErrors(), counts: getErrorCounts() } });
});
return router;
}
+116
View File
@@ -0,0 +1,116 @@
import { z } from "zod";
import type { Request, Response, NextFunction } from "express";
// ---------------------------------------------------------------------------
// Session / Login
// ---------------------------------------------------------------------------
export const loginSchema = z.object({
username: z.string().min(1).max(40).regex(/^[a-zA-Z0-9_]+$/),
role: z.enum(["admin", "editor", "operator", "viewer"]).optional(),
token: z.string().max(256).optional(),
password: z.string().max(256).optional(),
});
export type LoginInput = z.infer<typeof loginSchema>;
// ---------------------------------------------------------------------------
// Persona CRUD
// ---------------------------------------------------------------------------
export const createPersonaSchema = z.object({
name: z.string().min(1).max(50),
model: z.string().min(1).max(100).optional(),
summary: z.string().max(2000).optional().default(""),
enabled: z.boolean().optional().default(true),
});
export const updatePersonaSchema = z.object({
name: z.string().min(1).max(50).optional(),
model: z.string().min(1).max(100).optional(),
summary: z.string().max(2000).optional(),
enabled: z.boolean().optional(),
});
export const togglePersonaSchema = z.object({
enabled: z.boolean().optional(),
});
export const updatePersonaSourceSchema = z.object({
subjectName: z.string().max(200).optional(),
summary: z.string().max(5000).optional().default(""),
references: z.array(z.string().max(500)).max(100).optional().default([]),
});
export const reinforcePersonaSchema = z.object({
name: z.string().min(1).max(50).optional(),
model: z.string().min(1).max(100).optional(),
summary: z.string().max(2000).optional(),
apply: z.boolean().optional(),
});
export const voiceSampleSchema = z.object({
audio: z.string().min(1), // base64
});
// ---------------------------------------------------------------------------
// Node Engine
// ---------------------------------------------------------------------------
export const createGraphSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(2000).optional().default(""),
});
export const updateGraphSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(2000).optional(),
});
export const runGraphSchema = z.object({
hold: z.boolean().optional(),
});
// ---------------------------------------------------------------------------
// Chat History
// ---------------------------------------------------------------------------
export const retentionSweepSchema = z.object({
maxAgeDays: z.number().int().min(1).max(365).optional(),
});
// ---------------------------------------------------------------------------
// WebSocket messages
// ---------------------------------------------------------------------------
export const wsMessageSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("message"), text: z.string().min(1).max(8192) }),
z.object({ type: z.literal("command"), text: z.string().min(1).max(8192) }),
z.object({
type: z.literal("upload"),
filename: z.string().max(255).optional(),
mimeType: z.string().max(100).optional(),
data: z.string().optional(), // base64
size: z.number().max(16 * 1024 * 1024).optional(),
}),
]);
// ---------------------------------------------------------------------------
// Middleware helper -- validate(schema) returns Express middleware
// ---------------------------------------------------------------------------
export function validate<T>(schema: z.ZodType<T>) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body);
if (!result.success) {
res.status(400).json({
ok: false,
error: "validation_error",
details: result.error.issues,
});
return;
}
req.body = result.data;
next();
};
}
+2 -1
View File
@@ -41,7 +41,7 @@ async function main() {
// -----------------------------------------------------------------------
// Initialize local RAG (embeddings via Ollama)
// -----------------------------------------------------------------------
const rag = new LocalRAG({ ollamaUrl, lightragUrl: process.env.LIGHTRAG_URL });
const rag = new LocalRAG({ ollamaUrl, lightragUrl: process.env.LIGHTRAG_URL, rerankerUrl: process.env.RERANKER_URL });
// Index manifeste files asynchronously (non-blocking)
// Try multiple paths: relative to cwd (inside container /app) and absolute on host
@@ -55,6 +55,7 @@ async function main() {
(async () => {
try {
await rag.init(); // verify / pull embedding model
const indexed = new Set<string>();
for (const file of dataFiles) {
const filePath = path.isAbsolute(file) ? file : path.resolve(process.cwd(), file);
+124
View File
@@ -0,0 +1,124 @@
process.env.NODE_ENV = "test";
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { searchWeb } from "./web-search.js";
// Save original fetch
const originalFetch = globalThis.fetch;
function mockFetch(impl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>) {
globalThis.fetch = impl as typeof globalThis.fetch;
}
afterEach(() => {
globalThis.fetch = originalFetch;
delete process.env.SEARXNG_URL;
delete process.env.WEB_SEARCH_API_BASE;
});
describe("searchWeb", () => {
it("returns results from SearXNG when available", async () => {
process.env.SEARXNG_URL = "http://fake-searxng:8080";
mockFetch(async (input) => {
const url = String(input);
if (url.includes("fake-searxng")) {
return new Response(JSON.stringify({
results: [
{ title: "Result 1", content: "Snippet 1", url: "https://example.com/1" },
{ title: "Result 2", content: "Snippet 2", url: "https://example.com/2" },
],
}), { status: 200 });
}
throw new Error("unexpected fetch: " + url);
});
const result = await searchWeb("test query");
assert.ok(result.includes("Result 1"), "should contain first result title");
assert.ok(result.includes("Snippet 1"), "should contain first result content");
assert.ok(result.includes("https://example.com/1"), "should contain first result URL");
assert.ok(result.includes("Result 2"), "should contain second result title");
});
it("falls back to DuckDuckGo when SearXNG fails", async () => {
process.env.SEARXNG_URL = "http://fake-searxng:8080";
delete process.env.WEB_SEARCH_API_BASE;
mockFetch(async (input) => {
const url = String(input);
if (url.includes("fake-searxng")) {
return new Response("Internal Server Error", { status: 500 });
}
if (url.includes("api.duckduckgo.com")) {
return new Response(JSON.stringify({
Abstract: "DuckDuckGo abstract text",
AbstractSource: "Wikipedia",
AbstractURL: "https://en.wikipedia.org/wiki/Test",
RelatedTopics: [],
}), { status: 200 });
}
if (url.includes("lite.duckduckgo.com")) {
return new Response("<html></html>", { status: 200 });
}
throw new Error("unexpected fetch: " + url);
});
const result = await searchWeb("test query");
assert.ok(result.includes("Wikipedia"), "should contain DDG abstract source");
assert.ok(result.includes("DuckDuckGo abstract text"), "should contain DDG abstract");
});
it("handles no results gracefully", async () => {
process.env.SEARXNG_URL = "http://fake-searxng:8080";
delete process.env.WEB_SEARCH_API_BASE;
mockFetch(async (input) => {
const url = String(input);
if (url.includes("fake-searxng")) {
return new Response(JSON.stringify({ results: [] }), { status: 200 });
}
if (url.includes("api.duckduckgo.com")) {
return new Response(JSON.stringify({}), { status: 200 });
}
if (url.includes("lite.duckduckgo.com")) {
return new Response("<html></html>", { status: 200 });
}
throw new Error("unexpected fetch: " + url);
});
const result = await searchWeb("nonexistent thing");
assert.ok(
result.includes("Aucun résultat") || result.includes("aucun"),
`should indicate no results, got: ${result}`,
);
});
it("formats results with numbered list", async () => {
process.env.SEARXNG_URL = "http://fake-searxng:8080";
mockFetch(async () =>
new Response(JSON.stringify({
results: [
{ title: "A", content: "aa", url: "https://a.com" },
{ title: "B", content: "bb", url: "https://b.com" },
{ title: "C", content: "cc", url: "https://c.com" },
],
}), { status: 200 }),
);
const result = await searchWeb("format test");
assert.ok(result.includes("1. A"), "should have numbered item 1");
assert.ok(result.includes("2. B"), "should have numbered item 2");
assert.ok(result.includes("3. C"), "should have numbered item 3");
});
it("limits results to 5 items", async () => {
process.env.SEARXNG_URL = "http://fake-searxng:8080";
const results = Array.from({ length: 10 }, (_, i) => ({
title: `R${i}`, content: `c${i}`, url: `https://${i}.com`,
}));
mockFetch(async () =>
new Response(JSON.stringify({ results }), { status: 200 }),
);
const result = await searchWeb("many results");
assert.ok(result.includes("5."), "should have item 5");
assert.ok(!result.includes("6."), "should NOT have item 6");
});
});
+3 -1
View File
@@ -1,3 +1,5 @@
import logger from "./logger.js";
// ---------------------------------------------------------------------------
// Web search (DuckDuckGo Lite scraping)
// ---------------------------------------------------------------------------
@@ -109,7 +111,7 @@ export async function searchWeb(query: string): Promise<string> {
}
if (results.length === 0) {
console.warn(`[web-search] No results for "${query}"`);
logger.warn(`[web-search] No results for "${query}"`);
return "(Aucun résultat trouvé)";
}
+55
View File
@@ -0,0 +1,55 @@
import { WebSocket } from "ws";
import type { ClientInfo, OutboundMessage } from "./chat-types.js";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export const MAX_WS_MESSAGE_BYTES = 16 * 1024 * 1024; // 16 MB to support file uploads
export const MAX_TEXT_LENGTH = 8192;
export const RATE_LIMIT_WINDOW_MS = 10_000; // 10 seconds
export const RATE_LIMIT_MAX_MESSAGES = 15; // max messages per window
// ---------------------------------------------------------------------------
// Client ID / nick generation
// ---------------------------------------------------------------------------
let clientIdCounter = 0;
export function generateNick(): string {
return `user_${++clientIdCounter}`;
}
// ---------------------------------------------------------------------------
// Safe WebSocket send
// ---------------------------------------------------------------------------
export type SendFn = (ws: WebSocket, msg: OutboundMessage) => void;
export function send(ws: WebSocket, msg: OutboundMessage): void {
if (ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify(msg));
} catch { /* connection closed between check and send */ }
}
}
// ---------------------------------------------------------------------------
// Rate-limit check
// ---------------------------------------------------------------------------
/**
* Returns true if the client is rate-limited (message should be dropped).
* Mutates info.messageTimestamps to prune old entries.
*/
export function checkRateLimit(info: ClientInfo): boolean {
const now = Date.now();
info.messageTimestamps = info.messageTimestamps.filter(
(t) => now - t < RATE_LIMIT_WINDOW_MS,
);
if (info.messageTimestamps.length >= RATE_LIMIT_MAX_MESSAGES) {
return true;
}
info.messageTimestamps.push(now);
return false;
}
+39
View File
@@ -0,0 +1,39 @@
import fs from "node:fs";
import path from "node:path";
import { WebSocket } from "ws";
import type { ChatPersona } from "./chat-types.js";
import type { SendFn } from "./ws-chat-helpers.js";
// ---------------------------------------------------------------------------
// History replay — send recent messages to a newly connected client
// ---------------------------------------------------------------------------
export async function replayHistory(
ws: WebSocket,
channel: string,
personas: ChatPersona[],
sendFn: SendFn,
): Promise<void> {
try {
const channelSafe = channel.replace(/[^a-zA-Z0-9_-]/g, "_");
const contextFile = path.join(process.cwd(), "data", "context", channelSafe + ".jsonl");
const raw = await fs.promises.readFile(contextFile, "utf-8");
const lines = raw.trim().split("\n").filter(Boolean);
const recent = lines.slice(-20);
sendFn(ws, { type: "system", text: "--- Historique recent ---" });
for (const line of recent) {
try {
const entry = JSON.parse(line);
const ts = entry.ts ? new Date(entry.ts).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" }) : "";
const prefix = ts ? `[${ts}] ` : "";
sendFn(ws, {
type: "message",
nick: entry.nick,
text: prefix + entry.text,
color: (entry.nick && personas.find(p => p.nick === entry.nick)?.color) || "#888888",
});
} catch { /* skip malformed */ }
}
sendFn(ws, { type: "system", text: "--- Fin de l'historique ---" });
} catch { /* no history file yet */ }
}
+39
View File
@@ -0,0 +1,39 @@
import fs from "node:fs";
import path from "node:path";
import logger from "./logger.js";
import type { ChatLogEntry } from "./chat-types.js";
// ---------------------------------------------------------------------------
// Chat logging (JSONL)
// ---------------------------------------------------------------------------
const CHAT_LOG_DIR = path.resolve(process.cwd(), "data/chat-logs");
let logDirReady = false;
async function ensureLogDir(): Promise<void> {
if (logDirReady) return;
await fs.promises.mkdir(CHAT_LOG_DIR, { recursive: true });
logDirReady = true;
}
// Ensure log dir at startup (fire-and-forget)
ensureLogDir().catch((err) =>
logger.error({ err: err instanceof Error ? err.message : String(err) }, "[ws-chat-logger] Failed to create log dir"),
);
function logFilePath(): string {
const date = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
return path.join(CHAT_LOG_DIR, `v2-${date}.jsonl`);
}
export function logChatMessage(entry: ChatLogEntry): void {
ensureLogDir()
.then(() => fs.promises.appendFile(logFilePath(), JSON.stringify(entry) + "\n", "utf8"))
.catch((err) => {
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
return;
}
logger.error({ err: err instanceof Error ? err.message : String(err) }, "[ws-chat-logger] Failed to log chat message");
});
}
+3 -1
View File
@@ -35,7 +35,7 @@ describe("ws-chat smoke", () => {
server = undefined;
globalThis.fetch = originalFetch;
await wait(50);
await rm(CHAT_LOG_DIR, { recursive: true, force: true });
try { await rm(CHAT_LOG_DIR, { recursive: true, force: true }); } catch { /* EACCES on root-owned dirs */ }
try {
await rmdir(path.dirname(CHAT_LOG_DIR));
} catch {
@@ -70,6 +70,7 @@ describe("ws-chat smoke", () => {
});
await new Promise<void>((resolve) => client?.once("open", resolve));
await wait(150);
const baseline = messages.length;
client.send("not-json");
@@ -140,6 +141,7 @@ describe("ws-chat smoke", () => {
});
await new Promise<void>((resolve) => client?.once("open", resolve));
await wait(150);
await wait(100);
messages.length = 0;
+63 -103
View File
@@ -1,12 +1,14 @@
import http from "node:http";
import fs from "node:fs";
import path from "node:path";
import { WebSocketServer, WebSocket } from "ws";
import { DEFAULT_PERSONAS, personaColor } from "./personas-default.js";
import { createCommandHandler } from "./ws-commands.js";
import { createConversationRouter } from "./ws-conversation-router.js";
import logger from "./logger.js";
import { logChatMessage } from "./ws-chat-logger.js";
import { send, generateNick, checkRateLimit, MAX_WS_MESSAGE_BYTES, MAX_TEXT_LENGTH } from "./ws-chat-helpers.js";
import { replayHistory } from "./ws-chat-history.js";
import { wsMessageSchema } from "./schemas.js";
const DEBUG = process.env.NODE_ENV !== "production" || process.env.DEBUG === "1";
import type {
ChatPersona,
ClientInfo,
@@ -15,7 +17,6 @@ import type {
InboundChatMessage,
InboundUpload,
OutboundMessage,
ChatLogEntry,
} from "./chat-types.js";
import {
@@ -26,64 +27,23 @@ import {
} from "./ws-multimodal.js";
// ---------------------------------------------------------------------------
// Chat logging (JSONL)
// Per-channel sequence counter
// ---------------------------------------------------------------------------
const CHAT_LOG_DIR = path.resolve(process.cwd(), "data/chat-logs");
const channelSeq = new Map<string, number>();
let logDirReady = false;
async function ensureLogDir(): Promise<void> {
if (logDirReady) return;
await fs.promises.mkdir(CHAT_LOG_DIR, { recursive: true });
logDirReady = true;
}
// Ensure log dir at startup (fire-and-forget)
ensureLogDir().catch((err) =>
console.error("[ws-chat] Failed to create log dir:", err instanceof Error ? err.message : String(err)),
);
function logFilePath(): string {
const date = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
return path.join(CHAT_LOG_DIR, `v2-${date}.jsonl`);
}
function logChatMessage(entry: ChatLogEntry): void {
ensureLogDir()
.then(() => fs.promises.appendFile(logFilePath(), JSON.stringify(entry) + "\n", "utf8"))
.catch((err) => {
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
return;
}
console.error("[ws-chat] Failed to log chat message:", err instanceof Error ? err.message : String(err));
});
function nextSeq(channel: string): number {
const n = (channelSeq.get(channel) || 0) + 1;
channelSeq.set(channel, n);
return n;
}
// ---------------------------------------------------------------------------
// Helpers
// Constants
// ---------------------------------------------------------------------------
const MAX_WS_MESSAGE_BYTES = 16 * 1024 * 1024; // 16 MB to support file uploads
const MAX_TEXT_LENGTH = 8192;
const RATE_LIMIT_WINDOW_MS = 10_000; // 10 seconds
const RATE_LIMIT_MAX_MESSAGES = 15; // max messages per window
const PERSONA_REFRESH_INTERVAL_MS = 60_000; // 60 seconds
let clientIdCounter = 0;
function generateNick(): string {
return `user_${++clientIdCounter}`;
}
function send(ws: WebSocket, msg: OutboundMessage): void {
if (ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify(msg));
} catch { /* connection closed between check and send */ }
}
}
// ---------------------------------------------------------------------------
// Main export
// ---------------------------------------------------------------------------
@@ -109,7 +69,7 @@ export function attachWebSocketChat(server: http.Server, options: ChatOptions):
const loaded = await loadPersonas();
const enabled = loaded.filter((p) => p.enabled);
if (enabled.length === 0) {
console.warn("[ws-chat] Persona loader returned no enabled personas — keeping current list");
logger.warn("[ws-chat] Persona loader returned no enabled personas — keeping current list");
return;
}
@@ -122,9 +82,9 @@ export function attachWebSocketChat(server: http.Server, options: ChatOptions):
maxTokens: p.maxTokens,
}));
if (DEBUG) console.log(`[ws-chat] Refreshed personas: ${personas.map((p) => p.nick).join(", ")}`);
logger.debug(`[ws-chat] Refreshed personas: ${personas.map((p) => p.nick).join(", ")}`);
} catch (err) {
console.error("[ws-chat] Failed to refresh personas, keeping current list:", err instanceof Error ? err.message : String(err));
logger.error({ err: err instanceof Error ? err.message : String(err) }, "[ws-chat] Failed to refresh personas, keeping current list");
} finally {
refreshInProgress = false;
}
@@ -145,9 +105,10 @@ export function attachWebSocketChat(server: http.Server, options: ChatOptions):
// --- broadcast helpers ---
function broadcast(channel: string, msg: OutboundMessage, exclude?: WebSocket): void {
const stamped = { ...msg, seq: nextSeq(channel) };
for (const [ws, info] of clients) {
if (info.channel === channel && ws !== exclude) {
send(ws, msg);
send(ws, stamped);
}
}
}
@@ -159,7 +120,6 @@ export function attachWebSocketChat(server: http.Server, options: ChatOptions):
users.push(info.nick);
}
}
// Append persona nicks
for (const p of personas) {
users.push(p.nick);
}
@@ -186,6 +146,7 @@ export function attachWebSocketChat(server: http.Server, options: ChatOptions):
return await contextStore.getContext(channel, 4000);
} catch { return ""; }
}
const routeToPersonas = createConversationRouter({
ollamaUrl,
rag,
@@ -200,7 +161,6 @@ export function attachWebSocketChat(server: http.Server, options: ChatOptions):
// --- handle chat message ---
async function handleChatMessage(ws: WebSocket, info: ClientInfo, text: string): Promise<void> {
// Echo user message to all clients in channel
broadcast(info.channel, {
type: "message",
nick: info.nick,
@@ -208,7 +168,6 @@ export function attachWebSocketChat(server: http.Server, options: ChatOptions):
color: "#e0e0e0",
});
// Log user message + add to context
logChatMessage({
ts: new Date().toISOString(),
channel: info.channel,
@@ -262,7 +221,6 @@ export function attachWebSocketChat(server: http.Server, options: ChatOptions):
});
wss.on("connection", (ws: WebSocket, req: http.IncomingMessage) => {
// Read nick from query param ?nick=, fallback to generated
const reqUrl = new URL(req.url || "/ws", "http://localhost");
const paramNick = reqUrl.searchParams.get("nick")?.trim().slice(0, 24);
const nick = paramNick && /^[a-zA-Z0-9_\-À-ÿ]+$/.test(paramNick) ? paramNick : generateNick();
@@ -307,60 +265,62 @@ export function attachWebSocketChat(server: http.Server, options: ChatOptions):
// Send userlist
send(ws, { type: "userlist", users: channelUsers(info.channel) });
// --- message handler ---
// Send recent chat history
if (contextStore) {
replayHistory(ws, info.channel, personas, send);
}
ws.on("message", async (raw: Buffer) => {
if (raw.length > MAX_WS_MESSAGE_BYTES) return;
// --- message handler (Promise chain to prevent async reordering) ---
// Rate limiting
const now = Date.now();
info.messageTimestamps = info.messageTimestamps.filter(
(t) => now - t < RATE_LIMIT_WINDOW_MS,
);
if (info.messageTimestamps.length >= RATE_LIMIT_MAX_MESSAGES) {
send(ws, { type: "system", text: "Trop de messages — ralentis un peu." });
return;
}
info.messageTimestamps.push(now);
let processingChain = Promise.resolve();
let message: InboundMessage;
try {
message = JSON.parse(raw.toString()) as InboundMessage;
} catch {
return;
}
ws.on("message", (raw: Buffer) => {
processingChain = processingChain.then(async () => {
if (raw.length > MAX_WS_MESSAGE_BYTES) return;
if (!message || typeof message !== "object") return;
if (typeof message.type !== "string") return;
if (checkRateLimit(info)) {
send(ws, { type: "system", text: "Trop de messages — ralentis un peu." });
return;
}
if (message.type === "upload") {
await handleUploadMessage(ws, info, message as InboundUpload);
return;
}
let rawParsed: unknown;
try {
rawParsed = JSON.parse(raw.toString());
} catch {
return;
}
// For message and command types, text is required
if (typeof (message as InboundChatMessage).text !== "string") return;
const text = (message as InboundChatMessage).text;
if (text.length > MAX_TEXT_LENGTH) {
send(ws, { type: "system", text: "Message trop long (max 8192 caracteres)." });
return;
}
// Validate with Zod schema (non-breaking: log invalid, drop message)
const validated = wsMessageSchema.safeParse(rawParsed);
if (!validated.success) {
logger.warn({ issues: validated.error.issues }, "[ws-chat] Invalid WS message rejected by schema");
send(ws, { type: "system", text: "Message invalide (format incorrect)." });
return;
}
if (message.type === "command") {
await handleCommand({ ws, info, text });
} else if (message.type === "message") {
await handleChatMessage(ws, info, text);
}
const message = validated.data as InboundMessage;
if (message.type === "upload") {
await handleUploadMessage(ws, info, message as InboundUpload);
return;
}
const text = (message as InboundChatMessage).text;
if (message.type === "command") {
await handleCommand({ ws, info, text });
} else if (message.type === "message") {
await handleChatMessage(ws, info, text);
}
}).catch((err) => {
logger.error({ err: err instanceof Error ? err.message : String(err) }, "[ws-chat] handler error");
});
});
// --- error handler (prevent unhandled error crash) ---
ws.on("error", (err) => {
console.error(`[ws-chat] WebSocket error for ${info.nick}:`, err.message);
logger.error({ err: err.message, nick: info.nick }, "[ws-chat] WebSocket error");
});
// --- close handler ---
ws.on("close", () => {
broadcast(info.channel, {
type: "part",
@@ -373,6 +333,6 @@ export function attachWebSocketChat(server: http.Server, options: ChatOptions):
});
});
if (DEBUG) console.log(`[ws-chat] WebSocket chat attached on /ws (Ollama: ${ollamaUrl})`);
logger.debug(`[ws-chat] WebSocket chat attached on /ws (Ollama: ${ollamaUrl})`);
return wss;
}
+45 -11
View File
@@ -9,8 +9,6 @@ import { saveImage, saveAudio } from "./media-store.js";
import type { ChatPersona, ClientInfo, OutboundMessage, ChatLogEntry } from "./chat-types.js";
const execFileAsync = promisify(execFile);
const DEBUG = process.env.NODE_ENV !== "production" || process.env.DEBUG === "1";
interface CommandContext {
ws: WebSocket;
info: ClientInfo;
@@ -189,7 +187,11 @@ async function handleComposeCommand({
send: (ws: WebSocket, msg: OutboundMessage) => void;
logChatMessage: (entry: ChatLogEntry) => void;
}): Promise<void> {
const musicPrompt = text.slice(9).trim();
const rawPrompt = text.slice(9).trim();
// Parse duration from prompt (e.g., "ambient drone, 60s" or "ambient drone, experimental style, 120s")
const durationMatch = rawPrompt.match(/(\d+)s\s*$/);
const duration = durationMatch ? Math.min(Math.max(parseInt(durationMatch[1], 10), 5), 120) : 30;
const musicPrompt = durationMatch ? rawPrompt.replace(/,?\s*\d+s\s*$/, '').trim() : rawPrompt;
if (!musicPrompt) {
send(ws, { type: "system", text: "Usage: /compose <description musicale>" });
return;
@@ -197,32 +199,51 @@ async function handleComposeCommand({
broadcast(info.channel, {
type: "system",
text: `${info.nick} compose: "${musicPrompt}"...`,
text: `${info.nick} compose: "${musicPrompt}" (${duration}s)... generation en cours`,
});
const ttsUrl = process.env.TTS_URL || "http://127.0.0.1:9100";
const startTime = Date.now();
// Progress ticker — send updates every 5s while waiting
const progressInterval = setInterval(() => {
const elapsed = Math.round((Date.now() - startTime) / 1000);
send(ws, { type: "system", text: `[compose] Generation en cours... ${elapsed}s` });
}, 5000);
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 300_000);
const resp = await fetch(`${ttsUrl}/compose`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: musicPrompt, duration: 30 }),
signal: AbortSignal.timeout(300_000),
body: JSON.stringify({ prompt: musicPrompt, duration }),
signal: controller.signal,
});
clearTimeout(timeout);
clearInterval(progressInterval);
const elapsed = Math.round((Date.now() - startTime) / 1000);
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: `HTTP ${resp.status}` })) as { error?: string };
send(ws, { type: "system", text: `Composition echouee: ${body.error || "unknown"}` });
send(ws, { type: "system", text: `Composition echouee (${elapsed}s): ${body.error || "unknown"}` });
return;
}
const audioBuffer = Buffer.from(await resp.arrayBuffer());
if (audioBuffer.length > 50 * 1024 * 1024) {
send(ws, { type: "system", text: "Audio trop volumineux (>50MB) — essaie une duree plus courte." });
return;
}
const audioBase64 = audioBuffer.toString("base64");
broadcast(info.channel, {
type: "music",
nick: info.nick,
text: `[Musique: "${musicPrompt}"]`,
text: `[Musique: "${musicPrompt}"${elapsed}s]`,
audioData: audioBase64,
audioMime: "audio/wav",
} as OutboundMessage);
@@ -234,12 +255,18 @@ async function handleComposeCommand({
channel: info.channel,
nick: info.nick,
type: "system",
text: `[Musique generee: "${musicPrompt}"]`,
text: `[Musique generee: "${musicPrompt}" (${elapsed}s)]`,
});
} catch (err) {
clearInterval(progressInterval);
const elapsed = Math.round((Date.now() - startTime) / 1000);
const msg = err instanceof Error ? err.message : String(err);
const isTimeout = msg.includes("abort") || msg.includes("timeout");
send(ws, {
type: "system",
text: `Erreur composition: ${err instanceof Error ? err.message : String(err)}`,
text: isTimeout
? `Composition timeout apres ${elapsed}s — la generation a pris trop de temps.`
: `Erreur composition (${elapsed}s): ${msg}`,
});
}
}
@@ -267,11 +294,18 @@ async function handleImagineCommand({
broadcast(info.channel, {
type: "system",
text: `${info.nick} genere une image: "${imagePrompt}"...`,
text: `${info.nick} genere une image: "${imagePrompt}"... (generation ~10-30s)`,
});
const startTime = Date.now();
const progressInterval = setInterval(() => {
const elapsed = Math.round((Date.now() - startTime) / 1000);
send(ws, { type: "system", text: `[imagine] Generation en cours... ${elapsed}s` });
}, 5000);
try {
const result = await generateImage(imagePrompt);
clearInterval(progressInterval);
if (!result) {
send(ws, { type: "system", text: "Generation echouee — verifiez ComfyUI" });
return;
+231
View File
@@ -0,0 +1,231 @@
process.env.NODE_ENV = "test";
import { afterEach, describe, it } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import path from "node:path";
import { rm, rmdir } from "node:fs/promises";
import { WebSocket } from "ws";
import { attachWebSocketChat } from "./ws-chat.js";
import type { OutboundMessage } from "./chat-types.js";
const originalFetch = globalThis.fetch;
const CHAT_LOG_DIR = path.resolve(process.cwd(), "data/chat-logs");
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function waitForMessage(
ws: WebSocket,
predicate: (msg: OutboundMessage) => boolean,
timeoutMs = 15000,
): Promise<OutboundMessage> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
ws.removeListener("message", handler);
reject(new Error(`Timeout waiting for message (${timeoutMs}ms)`));
}, timeoutMs);
function handler(data: Buffer) {
try {
const msg = JSON.parse(data.toString()) as OutboundMessage;
if (predicate(msg)) {
clearTimeout(timer);
ws.removeListener("message", handler);
resolve(msg);
}
} catch { /* skip */ }
}
ws.on("message", handler);
});
}
function collectMessages(ws: WebSocket): OutboundMessage[] {
const msgs: OutboundMessage[] = [];
ws.on("message", (data) => {
try { msgs.push(JSON.parse(data.toString()) as OutboundMessage); } catch { /* skip */ }
});
return msgs;
}
// Mock Ollama that returns a quick response
function mockOllamaFetch(original: typeof fetch): typeof fetch {
return async (input, init) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url;
if (url.includes("/api/chat")) {
const body = init?.body ? JSON.parse(init.body.toString()) : {};
const isStream = body.stream !== false;
if (isStream) {
const chunks = [
JSON.stringify({ message: { content: "Test " }, done: false }) + "\n",
JSON.stringify({ message: { content: "response." }, done: true }) + "\n",
];
return new Response(new ReadableStream({
start(controller) {
for (const c of chunks) controller.enqueue(new TextEncoder().encode(c));
controller.close();
}
}), { status: 200, headers: { "Content-Type": "application/x-ndjson" } });
}
return new Response(JSON.stringify({
message: { role: "assistant", content: "Test response.", tool_calls: [] },
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url.includes("/api/tags")) {
return new Response(JSON.stringify({ models: [] }), { status: 200 });
}
return original(input, init);
};
}
describe("ws-integration", () => {
let server: http.Server | undefined;
let wss: ReturnType<typeof attachWebSocketChat> | undefined;
const clients: WebSocket[] = [];
function createServer() {
server = http.createServer();
globalThis.fetch = mockOllamaFetch(originalFetch) as typeof fetch;
wss = attachWebSocketChat(server, {
ollamaUrl: "http://ollama.test",
loadPersonas: async () => [
{ id: "pharmacius", nick: "Pharmacius", model: "test", systemPrompt: "Tu es un test bot. Reponds en 1 phrase.", color: "#0f0", enabled: true, maxTokens: 100 },
],
maxGeneralResponders: 1,
});
return new Promise<number>((resolve) => {
server!.listen(0, () => {
const addr = server!.address();
resolve(typeof addr === "object" && addr ? addr.port : 0);
});
});
}
/** Connect and immediately start collecting messages (before "open" resolves) */
function connectWithCollector(port: number, nick?: string): Promise<{ ws: WebSocket; msgs: OutboundMessage[] }> {
const url = `ws://127.0.0.1:${port}/ws${nick ? `?nick=${nick}` : ""}`;
const ws = new WebSocket(url);
clients.push(ws);
const msgs = collectMessages(ws);
return new Promise((resolve) => ws.once("open", () => resolve({ ws, msgs })));
}
function connect(port: number, nick?: string): Promise<WebSocket> {
const url = `ws://127.0.0.1:${port}/ws${nick ? `?nick=${nick}` : ""}`;
const ws = new WebSocket(url);
clients.push(ws);
return new Promise((resolve) => ws.once("open", () => resolve(ws)));
}
afterEach(async () => {
for (const c of clients) {
if (c.readyState === WebSocket.OPEN) {
c.close();
await new Promise((r) => c.once("close", r)).catch(() => {});
}
}
clients.length = 0;
if (wss) wss.close();
if (server) await new Promise<void>((r) => server!.close(() => r()));
server = undefined;
wss = undefined;
globalThis.fetch = originalFetch;
await wait(50);
try { await rm(CHAT_LOG_DIR, { recursive: true, force: true }); } catch {}
try { await rmdir(path.dirname(CHAT_LOG_DIR)); } catch {}
});
it("connects and receives MOTD + userlist + persona colors", async () => {
const port = await createServer();
const { ws, msgs } = await connectWithCollector(port, "alice");
await wait(300);
const motd = msgs.find((m) => m.type === "system" && "text" in m && m.text.includes("KXKM_Clown"));
assert.ok(motd, "should receive MOTD");
const userlist = msgs.find((m) => m.type === "userlist");
assert.ok(userlist, "should receive userlist");
assert.ok("users" in userlist && Array.isArray(userlist.users), "userlist should have users array");
const persona = msgs.find((m) => m.type === "persona");
assert.ok(persona, "should receive persona color info");
});
it("sends message and receives persona response with chunks", async () => {
const port = await createServer();
const ws = await connect(port, "bob");
await wait(200);
ws.send(JSON.stringify({ type: "message", text: "hello" }));
// Wait for either chunk or final message from Pharmacius
const response = await waitForMessage(ws, (m) =>
(m.type === "message" || (m as any).type === "chunk") && "nick" in m && m.nick === "Pharmacius",
10000,
);
assert.ok(response, "should receive persona response");
});
it("multiple clients see each others messages", async () => {
const port = await createServer();
const wsA = await connect(port, "clientA");
const wsB = await connect(port, "clientB");
const msgsB = collectMessages(wsB);
await wait(200);
wsA.send(JSON.stringify({ type: "message", text: "hello from A" }));
await wait(300);
const echoOnB = msgsB.find((m) =>
m.type === "message" && "nick" in m && m.nick === "clientA",
);
assert.ok(echoOnB, "client B should see client A message");
});
it("rate limiting kicks in after 15 messages", async () => {
const port = await createServer();
const ws = await connect(port, "spammer");
const msgs = collectMessages(ws);
await wait(200);
for (let i = 0; i < 16; i++) {
ws.send(JSON.stringify({ type: "message", text: `msg${i}` }));
}
await wait(500);
const rateLimitMsg = msgs.find((m) =>
m.type === "system" && "text" in m && m.text.includes("ralentis"),
);
assert.ok(rateLimitMsg, "should receive rate limit warning");
});
it("disconnect sends part message to other clients", async () => {
const port = await createServer();
const wsA = await connect(port, "leaver");
const wsB = await connect(port, "stayer");
const msgsB = collectMessages(wsB);
await wait(200);
wsA.close();
await wait(300);
const partMsg = msgsB.find((m) =>
m.type === "part" && "nick" in m && m.nick === "leaver",
);
assert.ok(partMsg, "stayer should see part message for leaver");
});
it("messages have seq numbers when available", async () => {
const port = await createServer();
const ws = await connect(port, "seqtest");
const msgs = collectMessages(ws);
await wait(200);
ws.send(JSON.stringify({ type: "message", text: "test seq" }));
await wait(2000);
const withSeq = msgs.filter((m) => "seq" in m && typeof (m as any).seq === "number");
// If seq is implemented, broadcast messages should have it
// This test documents behavior — passes whether seq exists or not
assert.ok(true, `${withSeq.length} messages had seq numbers`);
});
});
+38 -4
View File
@@ -4,7 +4,10 @@ import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { OutboundMessage } from "./chat-types.js";
import logger from "./logger.js";
import { trackError } from "./error-tracker.js";
import { resolvePreferredPythonBin, resolveVoiceSamplePath } from "./voice-samples.js";
import { getPersonaVoice } from "./persona-voices.js";
const execFileAsync = promisify(execFile);
@@ -43,10 +46,11 @@ export function releaseTTS(): void {
}
// ---------------------------------------------------------------------------
// TTS synthesis (Piper TTS via Python script)
// TTS synthesis (Qwen3-TTS primary, Piper/Chatterbox fallback)
// ---------------------------------------------------------------------------
const TTS_URL = process.env.TTS_URL || "http://127.0.0.1:9100";
const QWEN3_TTS_URL = process.env.QWEN3_TTS_URL || "http://127.0.0.1:9300";
export async function synthesizeTTS(
nick: string,
@@ -57,7 +61,36 @@ export async function synthesizeTTS(
if (!text || text.length < 10) return;
const truncated = text.slice(0, 1000);
const voice = getPersonaVoice(nick);
// --- Try Qwen3-TTS first (per-persona voice routing) ---
try {
const resp = await fetch(`${QWEN3_TTS_URL}/synthesize`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: truncated,
persona: nick.toLowerCase(),
speaker: voice.speaker,
instruct: voice.instruct,
language: voice.language,
}),
signal: AbortSignal.timeout(30_000),
});
if (resp.ok) {
const audioBuffer = Buffer.from(await resp.arrayBuffer());
const base64 = audioBuffer.toString("base64");
broadcastFn(channel, { type: "audio", nick, data: base64, mimeType: "audio/wav" });
logger.info(`[tts] Qwen3-TTS OK for ${nick} (speaker=${voice.speaker})`);
return;
}
logger.warn(`[tts] Qwen3-TTS HTTP ${resp.status} for ${nick}, falling back to ${TTS_URL}`);
} catch (err) {
logger.warn(`[tts] Qwen3-TTS unreachable for ${nick}: ${err instanceof Error ? err.message : String(err)}, falling back`);
}
// --- Fallback to existing TTS (Chatterbox/Piper via sidecar) ---
try {
const resp = await fetch(`${TTS_URL}/synthesize`, {
method: "POST",
@@ -68,7 +101,7 @@ export async function synthesizeTTS(
if (!resp.ok) {
const body = await resp.text().catch(() => "");
console.error(`[tts] HTTP ${resp.status} for ${nick}: ${body.slice(0, 200)}`);
trackError("tts_fallback", new Error(`HTTP ${resp.status}: ${body.slice(0, 200)}`), { nick, backend: "piper" });
return;
}
@@ -76,7 +109,7 @@ export async function synthesizeTTS(
const base64 = audioBuffer.toString("base64");
broadcastFn(channel, { type: "audio", nick, data: base64, mimeType: "audio/wav" });
} catch (err) {
console.error(`[tts] Synthesis failed for ${nick}: ${err instanceof Error ? err.message : String(err)}`);
trackError("tts_fallback", err, { nick, backend: "piper" });
}
}
@@ -144,7 +177,7 @@ export async function analyzeImage(
if (!response.ok) {
const body = await response.text().catch(() => "");
console.error(`[vision] ${visionModel} returned ${response.status}: ${body.slice(0, 200)}`);
trackError("vision", new Error(`${visionModel} returned ${response.status}: ${body.slice(0, 200)}`), { filename, model: visionModel });
return `[Image: ${filename} — analyse échouée: modèle ${visionModel} erreur ${response.status}]`;
}
@@ -154,6 +187,7 @@ export async function analyzeImage(
const caption = result.message?.content || "Pas de description disponible";
return `[Image: ${filename}]\n${caption}`;
} catch (err) {
trackError("vision", err, { filename });
return `[Image: ${filename} — erreur d'analyse: ${err instanceof Error ? err.message : String(err)}]`;
} finally {
clearTimeout(timeout);
+14 -3
View File
@@ -5,6 +5,17 @@ import { trackError } from "./error-tracker.js";
import type { ToolDefinition } from "./mcp-tools.js";
import type { ChatPersona } from "./chat-types.js";
// Dynamic context sizing based on prompt length
// Rough estimate: 1 token ≈ 4 chars for French text
function estimateNumCtx(systemPrompt: string, userMessage: string, baseCtx = 8192): number {
const promptTokens = Math.ceil((systemPrompt.length + userMessage.length) / 4);
const minResponse = 2048; // always leave room for response
const needed = promptTokens + minResponse;
// Round up to nearest 2048
const ctx = Math.ceil(needed / 2048) * 2048;
return Math.max(4096, Math.min(ctx, 32768)); // clamp 4k-32k
}
const DEBUG = process.env.NODE_ENV !== "production" || process.env.DEBUG === "1";
// ---------------------------------------------------------------------------
@@ -40,7 +51,7 @@ export async function streamOllamaChat(
{ role: "user", content: userMessage },
],
stream: true,
options: { num_predict: persona.maxTokens || 1024 },
options: { num_predict: persona.maxTokens || 2048, num_ctx: estimateNumCtx(persona.systemPrompt, userMessage) }, keep_alive: "30m",
}),
signal: controller.signal,
});
@@ -182,7 +193,7 @@ export async function streamOllamaChatWithTools(
messages,
tools: tools.map(t => t),
stream: false,
options: { num_predict: persona.maxTokens || 1024 },
options: { num_predict: persona.maxTokens || 2048, num_ctx: estimateNumCtx(persona.systemPrompt, userMessage) }, keep_alive: "30m",
}),
signal: controller.signal,
});
@@ -244,7 +255,7 @@ export async function streamOllamaChatWithTools(
model: persona.model,
messages,
stream: true,
options: { num_predict: persona.maxTokens || 1024 },
options: { num_predict: persona.maxTokens || 2048, num_ctx: estimateNumCtx(persona.systemPrompt, userMessage) }, keep_alive: "30m",
}),
signal: controller.signal,
});
+3 -5
View File
@@ -1,5 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import logger from "./logger.js";
import type { ChatPersona, PersonaMemory } from "./chat-types.js";
// ---------------------------------------------------------------------------
@@ -56,7 +57,7 @@ export async function updatePersonaMemory(
try {
extracted = JSON.parse(data.message?.content || "{}");
} catch (parseErr) {
console.error("[persona-router] Failed to parse LLM JSON:", parseErr);
logger.error({ err: parseErr }, "[persona-router] Failed to parse LLM JSON");
}
if (extracted.facts && Array.isArray(extracted.facts)) {
@@ -69,10 +70,7 @@ export async function updatePersonaMemory(
await savePersonaMemory(memory);
} catch (err) {
console.error(
`[ws-chat] Memory update failed for ${persona.nick}:`,
err instanceof Error ? err.message : String(err),
);
logger.error({ err: err instanceof Error ? err.message : String(err), nick: persona.nick }, "[ws-chat] Memory update failed");
}
}
+48 -13
View File
@@ -4,12 +4,13 @@ import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { WebSocket } from "ws";
import logger from "./logger.js";
import { trackError } from "./error-tracker.js";
import type { InboundUpload, ClientInfo, OutboundMessage } from "./chat-types.js";
import { fileTypeFromBuffer } from "file-type";
const execFileAsync = promisify(execFile);
const DEBUG = process.env.NODE_ENV !== "production" || process.env.DEBUG === "1";
export async function handleUpload(
ws: WebSocket,
info: ClientInfo,
@@ -48,6 +49,37 @@ export async function handleUpload(
const buffer = Buffer.from(dataB64, "base64");
// SEC-03: MIME magic bytes validation
const SAFE_MIMES = new Set([
"text/plain", "text/markdown", "text/csv",
"application/json", "application/pdf",
"image/png", "image/jpeg", "image/webp", "image/gif",
"audio/wav", "audio/mpeg", "audio/ogg", "audio/mp4", "audio/flac",
"audio/x-wav", "audio/x-flac",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
]);
const detected = await fileTypeFromBuffer(buffer);
let actualMime = mimeType;
if (detected) {
if (!SAFE_MIMES.has(detected.mime)) {
send(ws, { type: "system", text: `Type de fichier non autorisé: ${detected.mime}` });
return;
}
// Trust magic bytes over declared MIME
actualMime = detected.mime;
} else {
// No magic bytes detected — likely a text file; verify by extension
const ext = filename.split(".").pop()?.toLowerCase() || "";
const textExts = new Set(["txt", "md", "csv", "json", "jsonl", "xml", "html", "yml", "yaml", "toml"]);
if (!textExts.has(ext)) {
send(ws, { type: "system", text: `Extension non reconnue sans signature binaire: .${ext}` });
return;
}
actualMime = mimeType || "text/plain";
}
// Broadcast upload notification
broadcast(info.channel, {
type: "system",
@@ -67,16 +99,16 @@ export async function handleUpload(
let analysis = "";
if (
mimeType.startsWith("text/") ||
mimeType === "application/json" ||
actualMime.startsWith("text/") ||
actualMime === "application/json" ||
filename.endsWith(".csv") ||
filename.endsWith(".jsonl")
) {
const text = buffer.slice(0, 12000).toString("utf-8");
analysis = `[Fichier texte: ${filename}]\n${text}`;
} else if (mimeType.startsWith("image/")) {
analysis = await analyzeImage(buffer, mimeType, filename, ollamaUrl);
} else if (mimeType.startsWith("audio/")) {
} else if (actualMime.startsWith("image/")) {
analysis = await analyzeImage(buffer, actualMime, filename, ollamaUrl);
} else if (actualMime.startsWith("audio/")) {
// Transcribe audio via Whisper (faster-whisper or openai-whisper)
const ext = filename.split(".").pop() || "wav";
const tmpFile = path.join("/tmp", `kxkm-audio-${Date.now()}.${ext}`);
@@ -92,7 +124,7 @@ export async function handleUpload(
const { stdout, stderr } = await execFileAsync(pythonBin, [
scriptPath, "--input", tmpFile, "--language", "fr",
], { timeout: 120_000 });
if (stderr && DEBUG) console.log(`[ws-chat][audio] ${stderr.trim().slice(-200)}`);
if (stderr) logger.debug(`[ws-chat][audio] ${stderr.trim().slice(-200)}`);
const lastLine = stdout.trim().split("\n").pop() || "{}";
const result = JSON.parse(lastLine);
if (result.transcript) {
@@ -104,11 +136,12 @@ export async function handleUpload(
releaseFileProcessor();
}
} catch (err) {
trackError("upload_audio", err, { filename });
analysis = `[Audio: ${filename} — erreur: ${err instanceof Error ? err.message : String(err)}]`;
} finally {
try { await fsp.unlink(tmpFile); } catch { /* ignore cleanup errors */ }
}
} else if (mimeType === "application/pdf") {
} else if (actualMime === "application/pdf") {
const tmpFile = path.join("/tmp", `kxkm-pdf-${Date.now()}.pdf`);
try {
await fsp.writeFile(tmpFile, buffer);
@@ -117,7 +150,7 @@ export async function handleUpload(
const pythonBin = process.env.PYTHON_BIN || "python3";
const scriptPath = path.join(process.env.SCRIPTS_DIR || "scripts", "extract_pdf_docling.py");
const { stdout, stderr } = await execFileAsync(pythonBin, [scriptPath, "--input", tmpFile], { timeout: 60_000 });
if (stderr && DEBUG) console.log(`[upload] pdf: ${stderr.slice(-200)}`);
if (stderr) logger.debug(`[upload] pdf: ${stderr.slice(-200)}`);
const result = JSON.parse(stdout.trim().split("\n").pop() || "{}");
if (result.text) {
analysis = `[PDF: ${filename}, ${result.pages || "?"} page(s)]\n${result.text}`;
@@ -128,11 +161,12 @@ export async function handleUpload(
releaseFileProcessor();
}
} catch (err) {
trackError("upload_pdf", err, { filename });
analysis = `[PDF: ${filename} — erreur: ${err instanceof Error ? err.message : String(err)}]`;
} finally {
try { await fsp.unlink(tmpFile); } catch {}
}
} else if (isOfficeDocument(filename, mimeType)) {
} else if (isOfficeDocument(filename, actualMime)) {
const ext = filename.split(".").pop() || "";
const tmpFile = path.join("/tmp", `kxkm-doc-${Date.now()}.${ext}`);
try {
@@ -144,7 +178,7 @@ export async function handleUpload(
const { stdout, stderr } = await execFileAsync(pythonBin, [
scriptPath, "--input", tmpFile,
], { timeout: 60_000 });
if (stderr && DEBUG) console.log(`[upload] doc extract: ${stderr.slice(-200)}`);
if (stderr) logger.debug(`[upload] doc extract: ${stderr.slice(-200)}`);
const jsonLine = stdout.trim().split("\n").pop() || "{}";
const result = JSON.parse(jsonLine);
if (result.text) {
@@ -156,12 +190,13 @@ export async function handleUpload(
releaseFileProcessor();
}
} catch (err) {
trackError("upload_document", err, { filename });
analysis = `[Document: ${filename} — erreur: ${err instanceof Error ? err.message : String(err)}]`;
} finally {
try { await fsp.unlink(tmpFile); } catch { /* ignore */ }
}
} else {
analysis = `[Fichier: ${filename}, type: ${mimeType}, ${(size / 1024).toFixed(0)} KB]`;
analysis = `[Fichier: ${filename}, type: ${actualMime}, ${(size / 1024).toFixed(0)} KB]`;
}
if (analysis) {
+5 -1
View File
@@ -7,7 +7,9 @@
"dependencies": {
"@xyflow/react": "^12.10.1",
"react": "^19.2.4",
"react-dom": "^19.2.4"
"react-dom": "^19.2.4",
"react-virtualized-auto-sizer": "^2.0.3",
"react-window": "^2.2.7"
},
"scripts": {
"status": "node ../../scripts/workspace-package.js web status",
@@ -20,6 +22,8 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react-virtualized-auto-sizer": "^1.0.4",
"@types/react-window": "^1.8.8",
"jsdom": "^29.0.0",
"vitest": "^4.1.0"
}
+23 -19
View File
@@ -1,31 +1,33 @@
import { useState, useEffect } from "react";
import { useState, useEffect, lazy, Suspense } from "react";
import { api, type SessionData, type UserRole } from "./api";
import Login from "./components/Login";
import MinitelFrame from "./components/MinitelFrame";
import MinitelConnect from "./components/MinitelConnect";
import PersonaList from "./components/PersonaList";
import PersonaDetail from "./components/PersonaDetail";
import NodeEngineOverview from "./components/NodeEngineOverview";
import GraphDetail from "./components/GraphDetail";
import RunStatus from "./components/RunStatus";
import ChannelList from "./components/ChannelList";
import Chat from "./components/Chat";
import VoiceChat from "./components/VoiceChat";
import ChatHistory from "./components/ChatHistory";
import NodeEditor from "./components/NodeEditor";
import TrainingDashboard from "./components/TrainingDashboard";
import Analytics from "./components/Analytics";
import Collectif from "./components/Collectif";
import UllaPage from "./components/UllaPage";
import ComposePage from "./components/ComposePage";
import ImaginePage from "./components/ImaginePage";
import AdminPage from "./components/AdminPage";
import MediaExplorer from "./components/MediaExplorer";
import ErrorBoundary from "./components/ErrorBoundary";
import { useAppSession } from "./hooks/useAppSession";
import { useHashRoute } from "./hooks/useHashRoute";
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
// Lazy-load heavy routes (only shown on navigation)
const PersonaList = lazy(() => import("./components/PersonaList"));
const PersonaDetail = lazy(() => import("./components/PersonaDetail"));
const NodeEngineOverview = lazy(() => import("./components/NodeEngineOverview"));
const GraphDetail = lazy(() => import("./components/GraphDetail"));
const RunStatus = lazy(() => import("./components/RunStatus"));
const ChannelList = lazy(() => import("./components/ChannelList"));
const VoiceChat = lazy(() => import("./components/VoiceChat"));
const ChatHistory = lazy(() => import("./components/ChatHistory"));
const NodeEditor = lazy(() => import("./components/NodeEditor"));
const TrainingDashboard = lazy(() => import("./components/TrainingDashboard"));
const Analytics = lazy(() => import("./components/Analytics"));
const Collectif = lazy(() => import("./components/Collectif"));
const UllaPage = lazy(() => import("./components/UllaPage"));
const ComposePage = lazy(() => import("./components/ComposePage"));
const ImaginePage = lazy(() => import("./components/ImaginePage"));
const AdminPage = lazy(() => import("./components/AdminPage"));
const MediaExplorer = lazy(() => import("./components/MediaExplorer"));
// ---------------------------------------------------------------------------
// App state phases:
// 1. "connecting" — modem animation (3615 ULLA → 3615 KXKM)
@@ -175,7 +177,9 @@ export default function App() {
>
{error && <div className="minitel-error">ERREUR: {error}</div>}
<ErrorBoundary>
{renderPage()}
<Suspense fallback={<div className="muted">Chargement...</div>}>
{renderPage()}
</Suspense>
</ErrorBoundary>
</MinitelFrame>
);
+2 -2
View File
@@ -131,10 +131,10 @@ async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
export const api = {
// Session
login(username: string, role?: UserRole): Promise<SessionData> {
login(username: string, role?: UserRole, token?: string): Promise<SessionData> {
return apiFetch<SessionData>("/api/session/login", {
method: "POST",
body: JSON.stringify({ username, role: role || "viewer" }),
body: JSON.stringify({ username, role: role || "viewer", ...(token && { token }) }),
});
},
+13 -2
View File
@@ -14,6 +14,7 @@ interface AdminPageProps {
*/
export default function AdminPage({ session, onLogin, onNavigate }: AdminPageProps) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [stats, setStats] = useState<{
@@ -57,7 +58,7 @@ export default function AdminPage({ session, onLogin, onNavigate }: AdminPagePro
setLoading(true);
setError("");
try {
const s = await api.login(username.trim(), "admin" as UserRole);
const s = await api.login(username.trim(), "admin" as UserRole, password);
onLogin(s);
} catch (err) {
setError(err instanceof Error ? err.message : "Authentification echouee");
@@ -85,11 +86,21 @@ export default function AdminPage({ session, onLogin, onNavigate }: AdminPagePro
autoFocus
/>
</div>
<div className="minitel-field">
<label>Mot de passe _</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="minitel-input"
placeholder="********"
/>
</div>
<button type="submit" className="minitel-login-btn" disabled={loading || !username.trim()}>
{loading ? "Authentification..." : ">>> Connexion admin <<<"}
</button>
</form>
{error && <div className="minitel-login-error">ERREUR: {error}</div>}
{error && <div className="minitel-login-error" role="alert">ERREUR: {error}</div>}
</div>
);
}
+24 -5
View File
@@ -1,4 +1,4 @@
import React from "react";
import React, { useRef, useEffect } from "react";
import { useChatState } from "../hooks/useChatState";
import { ChatMessage } from "./ChatMessage";
import { ChatInput } from "./ChatInput";
@@ -16,13 +16,32 @@ export default function Chat() {
toggleSidebar,
typingPersona,
ws,
messagesEndRef,
messagesContainerRef,
getNickColor,
handleSend,
handleKeyDown,
} = useChatState();
const messagesEndRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
function onScroll() {
if (!el) return;
autoScrollRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
}
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
if (autoScrollRef.current) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [messages]);
return (
<div className="chat-container">
<div className="chat-header">
@@ -33,7 +52,7 @@ export default function Chat() {
</div>
<div className="chat-body">
<div className="chat-messages" ref={messagesContainerRef} role="log" aria-live="polite">
<div className="chat-messages" ref={containerRef} role="log" aria-live="polite">
{messages.map((msg) => (
<ChatMessage key={msg.id} msg={msg} getNickColor={getNickColor} channel={channel} />
))}
@@ -49,7 +68,7 @@ export default function Chat() {
</div>
{typingPersona && (
<div className="chat-typing">
<div className="chat-typing" role="status" aria-live="assertive">
{">>> "}{typingPersona}{" ecrit"}
<span className="chat-typing-dots">...</span>
</div>
+37 -25
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from "react";
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { api } from "../api";
interface ChatLogFile {
@@ -68,6 +68,28 @@ function messageClass(msg: ChatLogMessage): string {
return "history-line";
}
const HistoryRow = React.memo(function HistoryRow({ msg, offset, index }: { msg: ChatLogMessage; offset: number; index: number }) {
return (
<div key={`${offset}-${index}`} className={messageClass(msg)}>
{renderMessage(msg)}
</div>
);
});
const DateButton = React.memo(function DateButton({ file, isActive, onSelect }: { file: ChatLogFile; isActive: boolean; onSelect: (date: string) => void }) {
return (
<button
className={`history-date-btn${isActive ? " history-date-active" : ""}`}
onClick={() => onSelect(file.date)}
>
<span className="history-date-label">{file.date}</span>
<span className="history-date-meta">
{file.lines} msg &middot; {formatFileSize(file.size)}
</span>
</button>
);
});
export default function ChatHistory() {
const [files, setFiles] = useState<ChatLogFile[]>([]);
const [selectedDate, setSelectedDate] = useState<string | null>(null);
@@ -197,8 +219,7 @@ export default function ChatHistory() {
setSearchResults([]);
}, []);
// Highlight matching text in search results
function highlightText(text: string, query: string): React.ReactNode {
const highlightText = useCallback((text: string, query: string): React.ReactNode => {
if (!query) return text;
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
@@ -211,17 +232,19 @@ export default function ChatHistory() {
{text.slice(idx + query.length)}
</>
);
}
}, []);
const filteredMessages = filterTerm
? messages.filter((msg) => {
const rendered = renderMessage(msg).toLowerCase();
return rendered.includes(filterTerm.toLowerCase());
})
: messages;
const filteredMessages = useMemo(() => {
if (!filterTerm) return messages;
const lowerFilter = filterTerm.toLowerCase();
return messages.filter((msg) => {
const rendered = renderMessage(msg).toLowerCase();
return rendered.includes(lowerFilter);
});
}, [messages, filterTerm]);
const totalPages = Math.ceil(total / PAGE_SIZE);
const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
const totalPages = useMemo(() => Math.ceil(total / PAGE_SIZE), [total]);
const currentPage = useMemo(() => Math.floor(offset / PAGE_SIZE) + 1, [offset]);
return (
<div className="history-container">
@@ -286,16 +309,7 @@ export default function ChatHistory() {
<div className="history-empty">Aucun log disponible</div>
)}
{files.map((f) => (
<button
key={f.date}
className={`history-date-btn${selectedDate === f.date ? " history-date-active" : ""}`}
onClick={() => handleDateSelect(f.date)}
>
<span className="history-date-label">{f.date}</span>
<span className="history-date-meta">
{f.lines} msg &middot; {formatFileSize(f.size)}
</span>
</button>
<DateButton key={f.date} file={f} isActive={selectedDate === f.date} onSelect={handleDateSelect} />
))}
</div>
@@ -346,9 +360,7 @@ export default function ChatHistory() {
</div>
)}
{filteredMessages.map((msg, i) => (
<div key={`${offset}-${i}`} className={messageClass(msg)}>
{renderMessage(msg)}
</div>
<HistoryRow key={`${offset}-${i}`} msg={msg} offset={offset} index={i} />
))}
</div>
</div>
+3 -2
View File
@@ -9,7 +9,7 @@ export interface ChatInputProps {
ws: UseWebSocketReturn;
}
export function ChatInput({ input, setInput, onSend, onKeyDown, ws }: ChatInputProps) {
export const ChatInput = React.memo(function ChatInput({ input, setInput, onSend, onKeyDown, ws }: ChatInputProps) {
return (
<div className="chat-input">
<input
@@ -51,9 +51,10 @@ export function ChatInput({ input, setInput, onSend, onKeyDown, ws }: ChatInputP
className="btn btn-primary"
onClick={onSend}
disabled={!ws.connected || !input.trim()}
aria-label="Envoyer le message"
>
Envoyer
</button>
</div>
);
}
});
+21 -2
View File
@@ -1,6 +1,13 @@
import React from "react";
import type { ChatMsg } from "./chat-types";
function fmtTime(ts: number): string {
const d = new Date(ts);
const h = String(d.getHours()).padStart(2, "0");
const m = String(d.getMinutes()).padStart(2, "0");
return h + ":" + m;
}
export interface ChatMessageProps {
msg: ChatMsg;
getNickColor: (nick: string) => string | undefined;
@@ -12,6 +19,7 @@ export const ChatMessage = React.memo(function ChatMessage({ msg, getNickColor,
case "system":
return (
<div className="chat-msg chat-msg-system">
<span className="chat-ts">{fmtTime(msg.timestamp)}</span>
{(msg.text || "").split("\n").map((line, i) => (
<div key={i}>{line || "\u00A0"}</div>
))}
@@ -21,6 +29,7 @@ export const ChatMessage = React.memo(function ChatMessage({ msg, getNickColor,
case "join":
return (
<div className="chat-msg chat-msg-system">
<span className="chat-ts">{fmtTime(msg.timestamp)}</span>
{"--> "}{msg.nick} a rejoint {msg.channel || channel}
</div>
);
@@ -28,6 +37,7 @@ export const ChatMessage = React.memo(function ChatMessage({ msg, getNickColor,
case "part":
return (
<div className="chat-msg chat-msg-system">
<span className="chat-ts">{fmtTime(msg.timestamp)}</span>
{"<-- "}{msg.nick} a quitte {msg.channel || channel}
</div>
);
@@ -36,11 +46,12 @@ export const ChatMessage = React.memo(function ChatMessage({ msg, getNickColor,
const color = msg.nick ? getNickColor(msg.nick) : undefined;
return (
<div key={msg.id} className="chat-msg chat-msg-audio" style={color ? { color } : undefined}>
<span className="chat-ts">{fmtTime(msg.timestamp)}</span>
<span className="chat-nick" style={color ? { color } : undefined}>
{"<"}{msg.nick || "???"}{">"}{" "}
</span>
<span className="chat-audio-indicator">&#9835;</span>
<button className="chat-audio-play" onClick={() => {
<button className="chat-audio-play" aria-label="Lire le message audio" onClick={() => {
if (msg.audioData && msg.audioMime) {
const a = new Audio(`data:${msg.audioMime};base64,${msg.audioData}`);
a.volume = 0.7;
@@ -55,6 +66,7 @@ export const ChatMessage = React.memo(function ChatMessage({ msg, getNickColor,
const color = msg.nick ? getNickColor(msg.nick) : undefined;
return (
<div className="chat-msg chat-msg-image" style={color ? { color } : undefined}>
<span className="chat-ts">{fmtTime(msg.timestamp)}</span>
<span className="chat-nick" style={color ? { color } : undefined}>
{"<"}{msg.nick || "???"}{">"}{" "}
</span>
@@ -75,6 +87,7 @@ export const ChatMessage = React.memo(function ChatMessage({ msg, getNickColor,
const color = msg.nick ? getNickColor(msg.nick) : undefined;
return (
<div key={msg.id} className="chat-msg chat-msg-music" style={color ? { color } : undefined}>
<span className="chat-ts">{fmtTime(msg.timestamp)}</span>
<span className="chat-nick" style={color ? { color } : undefined}>
{"<"}{msg.nick || "???"}{">"}{" "}
</span>
@@ -83,6 +96,7 @@ export const ChatMessage = React.memo(function ChatMessage({ msg, getNickColor,
<audio
controls
src={`data:${msg.audioMime};base64,${msg.audioData}`}
aria-label={`Musique generee: ${msg.text || "sans titre"}`}
style={{ display: "block", marginTop: "4px", maxWidth: "400px" }}
/>
)}
@@ -90,19 +104,24 @@ export const ChatMessage = React.memo(function ChatMessage({ msg, getNickColor,
);
}
case "chunk":
case "message":
default: {
const color = msg.nick ? getNickColor(msg.nick) : undefined;
const isStreaming = msg.type === "chunk";
const className = color ? "chat-msg chat-msg-persona" : "chat-msg chat-msg-user";
return (
<div
className={className}
className={`${className}${isStreaming ? " chat-msg-streaming" : ""}`}
role="article"
style={color ? { color } : undefined}
>
<span className="chat-ts">{fmtTime(msg.timestamp)}</span>
<span className="chat-nick" style={color ? { color } : undefined}>
{"<"}{msg.nick || "???"}{">"}{" "}
</span>
<span className="chat-text">{msg.text}</span>
{isStreaming && <span className="chat-cursor"></span>}
</div>
);
}
+2 -2
View File
@@ -8,7 +8,7 @@ export interface ChatSidebarProps {
toggleSidebar: (section: "personas" | "users") => void;
}
export function ChatSidebar({ personaColors, users, sidebarCollapsed, toggleSidebar }: ChatSidebarProps) {
export const ChatSidebar = React.memo(function ChatSidebar({ personaColors, users, sidebarCollapsed, toggleSidebar }: ChatSidebarProps) {
return (
<div className="chat-sidebar">
<div className="chat-sidebar-section">
@@ -52,4 +52,4 @@ export function ChatSidebar({ personaColors, users, sidebarCollapsed, toggleSide
</div>
</div>
);
}
});
+20 -114
View File
@@ -1,6 +1,5 @@
import { useState, useRef, useEffect } from "react";
import { useMinitelSounds } from "../hooks/useMinitelSounds";
import { resolveWebSocketUrl } from "../lib/websocket-url";
import { useState } from "react";
import { useGenerationCommand } from "../hooks/useGenerationCommand";
import { VideotexPageHeader } from "./VideotexMosaic";
interface ComposeResult {
@@ -8,98 +7,30 @@ interface ComposeResult {
audioData?: string;
audioMime?: string;
prompt?: string;
error?: string;
}
export default function ComposePage() {
const [prompt, setPrompt] = useState("");
const [style, setStyle] = useState("experimental");
const [duration, setDuration] = useState(30);
const [generating, setGenerating] = useState(false);
const [progress, setProgress] = useState(0);
const [results, setResults] = useState<ComposeResult[]>([]);
const [error, setError] = useState("");
const [activeTrack, setActiveTrack] = useState<number | null>(null);
const sounds = useMinitelSounds();
const wsRef = useRef<WebSocket | null>(null);
const progressRef = useRef<ReturnType<typeof setInterval> | null>(null);
const wsUrl = resolveWebSocketUrl();
// Close WebSocket on unmount
useEffect(() => {
return () => {
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
};
}, []);
// Simulated progress bar during generation
useEffect(() => {
if (generating) {
setProgress(0);
const estimatedMs = duration * 1000;
const step = 100 / (estimatedMs / 200);
progressRef.current = setInterval(() => {
setProgress(p => Math.min(p + step * (0.5 + Math.random()), 92));
}, 200);
} else {
if (progressRef.current) clearInterval(progressRef.current);
if (progress > 0) {
setProgress(100);
setTimeout(() => setProgress(0), 800);
}
}
return () => { if (progressRef.current) clearInterval(progressRef.current); };
}, [generating]);
function getWs(): WebSocket | null {
if (wsRef.current?.readyState === WebSocket.OPEN) return wsRef.current;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data);
if (msg.type === "music" && msg.audioData) {
setResults(prev => [{
status: "completed",
audioData: msg.audioData,
audioMime: msg.audioMime || "audio/wav",
prompt: msg.text,
}, ...prev].slice(0, 10));
setGenerating(false);
sounds.receive();
}
if (msg.type === "system" && msg.text?.includes("Composition echouee")) {
setError(msg.text);
setGenerating(false);
}
} catch {}
};
ws.onclose = () => { wsRef.current = null; };
return ws;
}
const { generating, progress, results, error, send } = useGenerationCommand<ComposeResult>({
responseType: "music",
extractResult: (msg) =>
msg.audioData
? { status: "completed", audioData: msg.audioData as string, audioMime: (msg.audioMime as string) || "audio/wav", prompt: msg.text as string }
: null,
errorMatch: "Composition echouee",
progressInterval: 200,
progressStep: 2,
maxResults: 10,
});
function handleCompose(e: React.FormEvent) {
e.preventDefault();
if (!prompt.trim() || generating) return;
const ws = getWs();
const cmd = `/compose ${prompt.trim()}, ${style} style, ${duration}s`;
if (!ws || ws.readyState !== WebSocket.OPEN) {
ws?.addEventListener("open", () => {
ws.send(JSON.stringify({ type: "command", text: cmd }));
}, { once: true });
} else {
ws.send(JSON.stringify({ type: "command", text: cmd }));
}
setGenerating(true);
setError("");
sounds.send();
send(`/compose ${prompt.trim()}, ${style} style, ${duration}s`);
}
return (
@@ -109,16 +40,8 @@ export default function ComposePage() {
<form onSubmit={handleCompose} className="compose-form">
<div className="minitel-field">
<label>Description musicale _</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="ambient drone with deep bass, musique concrete style..."
className="minitel-input compose-textarea"
rows={3}
maxLength={500}
/>
<textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="ambient drone with deep bass, musique concrete style..." className="minitel-input compose-textarea" rows={3} maxLength={500} />
</div>
<div className="compose-options">
<div className="minitel-field">
<label>Style _</label>
@@ -135,7 +58,6 @@ export default function ComposePage() {
<option value="folk">Folk</option>
</select>
</div>
<div className="minitel-field">
<label>Duree _</label>
<select value={duration} onChange={(e) => setDuration(Number(e.target.value))} className="minitel-input">
@@ -146,7 +68,6 @@ export default function ComposePage() {
</select>
</div>
</div>
<button type="submit" className="minitel-login-btn" disabled={generating || !prompt.trim()}>
{generating ? "Generation en cours..." : ">>> Composer <<<"}
</button>
@@ -154,44 +75,29 @@ export default function ComposePage() {
{error && <div className="minitel-login-error">{error}</div>}
{/* Progress bar */}
{generating && (
<div className="vtx-progress">
<div className="vtx-progress-label">
<span className="minitel-cursor"></span> GENERATION EN COURS
</div>
<div className="vtx-progress-label"><span className="minitel-cursor">{"█"}</span> GENERATION EN COURS</div>
<div className="vtx-progress-bar">
<div className="vtx-progress-fill" style={{ width: `${progress}%` }}>
{"█".repeat(Math.floor(progress / 2.5))}
</div>
<div className="vtx-progress-fill" style={{ width: `${progress}%` }}>{"█".repeat(Math.floor(progress / 2.5))}</div>
</div>
<div className="vtx-progress-pct">{Math.floor(progress)}%</div>
</div>
)}
{/* Results with player */}
{results.length > 0 && (
<div className="compose-results">
<div className="compose-results-title">{"--- Compositions ---"}</div>
{results.map((r, i) => (
<div
key={i}
className={`vtx-track${activeTrack === i ? " vtx-track-active" : ""}`}
onClick={() => setActiveTrack(activeTrack === i ? null : i)}
>
<div key={i} className={`vtx-track${activeTrack === i ? " vtx-track-active" : ""}`} onClick={() => setActiveTrack(activeTrack === i ? null : i)}>
<div className="vtx-track-header">
<span className="vtx-track-icon">{activeTrack === i ? "" : ""}</span>
<span className="vtx-track-icon">{activeTrack === i ? "\u25B6" : "\u266B"}</span>
<span className="vtx-track-title">{r.prompt || "Sans titre"}</span>
<span className="vtx-track-badge">OK</span>
</div>
{activeTrack === i && r.audioData && r.audioMime && (
<div className="vtx-player">
<audio
controls
autoPlay
src={`data:${r.audioMime};base64,${r.audioData}`}
className="vtx-audio"
/>
<audio controls autoPlay src={`data:${r.audioMime};base64,${r.audioData}`} className="vtx-audio" />
</div>
)}
</div>
+26 -118
View File
@@ -1,6 +1,5 @@
import { useState, useRef, useEffect } from "react";
import { useMinitelSounds } from "../hooks/useMinitelSounds";
import { resolveWebSocketUrl } from "../lib/websocket-url";
import { useState, useEffect } from "react";
import { useGenerationCommand } from "../hooks/useGenerationCommand";
import { VideotexPageHeader } from "./VideotexMosaic";
interface ImageResult {
@@ -11,91 +10,29 @@ interface ImageResult {
export default function ImaginePage() {
const [prompt, setPrompt] = useState("");
const [generating, setGenerating] = useState(false);
const [progress, setProgress] = useState(0);
const [results, setResults] = useState<ImageResult[]>([]);
const [error, setError] = useState("");
const [viewIdx, setViewIdx] = useState<number | null>(null);
const sounds = useMinitelSounds();
const wsRef = useRef<WebSocket | null>(null);
const progressRef = useRef<ReturnType<typeof setInterval> | null>(null);
const wsUrl = resolveWebSocketUrl();
// Close WebSocket on unmount
const { generating, progress, results, error, send } = useGenerationCommand<ImageResult>({
responseType: "image",
extractResult: (msg) =>
msg.imageData
? { prompt: (msg.text as string) || prompt, imageData: msg.imageData as string, imageMime: (msg.imageMime as string) || "image/png" }
: null,
errorMatch: "echoue",
progressInterval: 100,
progressStep: 4,
maxResults: 20,
});
// Auto-show newest image
useEffect(() => {
return () => {
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
};
}, []);
// Simulated progress (~3s for SDXL Lightning)
useEffect(() => {
if (generating) {
setProgress(0);
progressRef.current = setInterval(() => {
setProgress(p => Math.min(p + 3 + Math.random() * 4, 92));
}, 100);
} else {
if (progressRef.current) clearInterval(progressRef.current);
if (progress > 0) {
setProgress(100);
setTimeout(() => setProgress(0), 600);
}
}
return () => { if (progressRef.current) clearInterval(progressRef.current); };
}, [generating]);
function getWs(): WebSocket | null {
if (wsRef.current?.readyState === WebSocket.OPEN) return wsRef.current;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data);
if (msg.type === "image" && msg.imageData) {
const newResult = {
prompt: msg.text || prompt,
imageData: msg.imageData,
imageMime: msg.imageMime || "image/png",
};
setResults(prev => [newResult, ...prev].slice(0, 20));
setGenerating(false);
setViewIdx(0); // Auto-show the new image
sounds.receive();
}
if (msg.type === "system" && msg.text?.includes("echoue")) {
setError(msg.text);
setGenerating(false);
}
} catch {}
};
ws.onclose = () => { wsRef.current = null; };
return ws;
}
if (results.length > 0) setViewIdx(0);
}, [results.length]);
function handleImagine(e: React.FormEvent) {
e.preventDefault();
if (!prompt.trim() || generating) return;
const ws = getWs();
const sendMsg = () => {
ws?.send(JSON.stringify({ type: "command", text: `/imagine ${prompt.trim()}` }));
};
if (!ws || ws.readyState !== WebSocket.OPEN) {
ws?.addEventListener("open", sendMsg, { once: true });
} else {
sendMsg();
}
setGenerating(true);
setError("");
sounds.send();
send(`/imagine ${prompt.trim()}`);
}
return (
@@ -105,16 +42,8 @@ export default function ImaginePage() {
<form onSubmit={handleImagine} className="compose-form">
<div className="minitel-field">
<label>Description (anglais) _</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="a cyberpunk terminal glowing green, dark room, phosphor CRT aesthetic..."
className="minitel-input compose-textarea"
rows={3}
maxLength={500}
/>
<textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="a cyberpunk terminal glowing green, dark room, phosphor CRT aesthetic..." className="minitel-input compose-textarea" rows={3} maxLength={500} />
</div>
<button type="submit" className="minitel-login-btn" disabled={generating || !prompt.trim()}>
{generating ? "Generation en cours..." : ">>> Imaginer <<<"}
</button>
@@ -122,58 +51,37 @@ export default function ImaginePage() {
{error && <div className="minitel-login-error">{error}</div>}
{/* Progress bar */}
{generating && (
<div className="vtx-progress vtx-amber">
<div className="vtx-progress-label">
<span className="minitel-cursor"></span> RENDU EN COURS
</div>
<div className="vtx-progress-label"><span className="minitel-cursor">{"█"}</span> RENDU EN COURS</div>
<div className="vtx-progress-bar">
<div className="vtx-progress-fill" style={{ width: `${progress}%` }}>
{"▓".repeat(Math.floor(progress / 2.5))}
</div>
<div className="vtx-progress-fill" style={{ width: `${progress}%` }}>{"\u2593".repeat(Math.floor(progress / 2.5))}</div>
</div>
<div className="vtx-progress-pct">{Math.floor(progress)}%</div>
</div>
)}
{/* Fullscreen image viewer */}
{viewIdx !== null && results[viewIdx]?.imageData && (
<div className="vtx-viewer" onClick={() => setViewIdx(null)}>
<div className="vtx-viewer-frame" onClick={(e) => e.stopPropagation()}>
<img
src={`data:${results[viewIdx].imageMime};base64,${results[viewIdx].imageData}`}
alt={results[viewIdx].prompt}
className="vtx-viewer-img"
/>
<img src={`data:${results[viewIdx].imageMime};base64,${results[viewIdx].imageData}`} alt={results[viewIdx].prompt} className="vtx-viewer-img" />
<div className="vtx-viewer-caption">{results[viewIdx].prompt}</div>
<div className="vtx-viewer-nav">
{viewIdx < results.length - 1 && (
<button className="vtx-viewer-btn" onClick={() => setViewIdx(viewIdx + 1)}> Prec</button>
)}
<button className="vtx-viewer-btn vtx-viewer-close" onClick={() => setViewIdx(null)}> Fermer</button>
{viewIdx > 0 && (
<button className="vtx-viewer-btn" onClick={() => setViewIdx(viewIdx - 1)}>Suiv </button>
)}
{viewIdx < results.length - 1 && <button className="vtx-viewer-btn" onClick={() => setViewIdx(viewIdx + 1)}>{"\u25C0"} Prec</button>}
<button className="vtx-viewer-btn vtx-viewer-close" onClick={() => setViewIdx(null)}>{"\u2715"} Fermer</button>
{viewIdx > 0 && <button className="vtx-viewer-btn" onClick={() => setViewIdx(viewIdx - 1)}>Suiv {"\u25B6"}</button>}
</div>
</div>
</div>
)}
{/* Thumbnail grid */}
{results.length > 0 && (
<div className="imagine-results">
<div className="compose-results-title">{"--- Images generees ---"}</div>
<div className="imagine-grid">
{results.map((r, i) => (
<div key={i} className="imagine-result" onClick={() => setViewIdx(i)}>
{r.imageData && r.imageMime && (
<img
src={`data:${r.imageMime};base64,${r.imageData}`}
alt={r.prompt}
className="imagine-img"
/>
)}
{r.imageData && r.imageMime && <img src={`data:${r.imageMime};base64,${r.imageData}`} alt={r.prompt} className="imagine-img" />}
<div className="imagine-prompt">{r.prompt}</div>
</div>
))}
+1 -1
View File
@@ -49,7 +49,7 @@ export default function Login({ onLogin, error }: LoginProps) {
</button>
</form>
{error && <div className="minitel-login-error">ERREUR: {error}</div>}
{error && <div className="minitel-login-error" role="alert">ERREUR: {error}</div>}
<div className="minitel-login-footer">
Tarification: GRATUIT (c'est local, c'est libre)
+1 -1
View File
@@ -46,7 +46,7 @@ export default function MediaExplorer() {
setPlayingIdx(idx);
}
if (loading) return <div className="muted">Chargement des medias...</div>;
if (loading) return <div className="muted" role="status" aria-busy="true">Chargement des medias...</div>;
return (
<div className="media-explorer">
+20 -4
View File
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import { VideotexBlocks } from "./VideotexMosaic";
interface MinitelFrameProps {
@@ -64,6 +64,20 @@ export default function MinitelFrame({
onLogout,
}: MinitelFrameProps) {
const [navOpen, setNavOpen] = useState(false);
const [booted, setBooted] = useState(false);
// CRT off via ?crt=off URL param
const crtOff = typeof window !== "undefined" &&
new URLSearchParams(window.location.search).get("crt") === "off";
// Boot animation: trigger on mount
useEffect(() => {
if (!crtOff) {
// Small delay so the animation is visible after hydration
const t = setTimeout(() => setBooted(true), 50);
return () => clearTimeout(t);
}
}, [crtOff]);
const visibleNav = NAV_ITEMS.filter(
(item) => !item.roles || (session && item.roles.includes(session.role))
@@ -75,8 +89,8 @@ export default function MinitelFrame({
}
return (
<div className="minitel-terminal">
<div className="minitel-body">
<div className={`minitel-terminal${crtOff ? " crt-off" : ""}`}>
<div className={`minitel-body${!crtOff && booted ? " crt-boot" : ""}`}>
<div className="minitel-screen-bezel">
<div className="minitel-screen">
{/* CRT overlays */}
@@ -137,11 +151,13 @@ export default function MinitelFrame({
)}
{/* Bottom bar — project mode buttons */}
<div className="minitel-service-bottom">
<div className="minitel-service-bottom" role="navigation" aria-label="Modes du projet">
<button
className="minitel-fkey minitel-fkey-sommaire"
onClick={() => setNavOpen(!navOpen)}
title="Sommaire — navigation complete"
aria-label="Menu de navigation"
aria-expanded={navOpen}
>
</button>
+19 -285
View File
@@ -1,154 +1,14 @@
import { useEffect, useState, useCallback, useMemo } from "react";
import {
ReactFlow,
Background,
Controls,
MiniMap,
useNodesState,
useEdgesState,
addEdge,
type Node,
type Edge,
type Connection,
type OnConnect,
} from "@xyflow/react";
import { ReactFlow, Background, Controls, MiniMap } from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { api, type GraphNodeRecord, type GraphEdgeRecord } from "../api";
import EngineNodeComponent, { type EngineNodeData } from "./EngineNode";
// ---------------------------------------------------------------------------
// Node type registry (client-side mirror of server definitions)
// ---------------------------------------------------------------------------
interface NodeTypeDef {
id: string;
family: string;
label: string;
inputs: string[];
outputs: string[];
runtimes: string[];
}
const NODE_TYPES: NodeTypeDef[] = [
{ id: "dataset_file", family: "dataset_source", label: "Dataset File", inputs: [], outputs: ["dataset"], runtimes: ["local_cpu", "local_gpu", "cloud_api"] },
{ id: "dataset_folder", family: "dataset_source", label: "Dataset Folder", inputs: [], outputs: ["dataset"], runtimes: ["local_cpu", "local_gpu"] },
{ id: "huggingface_dataset", family: "dataset_source", label: "HuggingFace Dataset", inputs: [], outputs: ["dataset"], runtimes: ["cloud_api", "local_cpu", "local_gpu"] },
{ id: "web_scraper", family: "dataset_source", label: "Web Scraper", inputs: [], outputs: ["dataset"], runtimes: ["cloud_api", "local_cpu"] },
{ id: "clean_text", family: "data_processing", label: "Clean Text", inputs: ["dataset"], outputs: ["dataset"], runtimes: ["local_cpu", "local_gpu", "cluster"] },
{ id: "remove_duplicates", family: "data_processing", label: "Remove Duplicates", inputs: ["dataset"], outputs: ["dataset"], runtimes: ["local_cpu", "local_gpu", "cluster"] },
{ id: "split_dataset", family: "data_processing", label: "Split Dataset", inputs: ["dataset"], outputs: ["dataset"], runtimes: ["local_cpu", "local_gpu", "cluster"] },
{ id: "format_instruction_dataset", family: "dataset_builder", label: "Instruction Dataset", inputs: ["dataset"], outputs: ["dataset_ready"], runtimes: ["local_cpu", "local_gpu", "cluster"] },
{ id: "chat_dataset", family: "dataset_builder", label: "Chat Dataset", inputs: ["dataset"], outputs: ["dataset_ready"], runtimes: ["local_cpu", "local_gpu", "cluster"] },
{ id: "lora_training", family: "training", label: "LoRA Training", inputs: ["dataset_ready"], outputs: ["model"], runtimes: ["local_gpu", "remote_gpu", "cluster"] },
{ id: "qlora_training", family: "training", label: "QLoRA Training", inputs: ["dataset_ready"], outputs: ["model"], runtimes: ["local_gpu", "remote_gpu", "cluster"] },
{ id: "benchmark", family: "evaluation", label: "Benchmark", inputs: ["model", "dataset_ready"], outputs: ["evaluation"], runtimes: ["local_cpu", "local_gpu", "cluster"] },
{ id: "prompt_test", family: "evaluation", label: "Prompt Test", inputs: ["model", "dataset_ready"], outputs: ["evaluation"], runtimes: ["local_cpu", "local_gpu", "cloud_api"] },
{ id: "register_model", family: "model_registry", label: "Register Model", inputs: ["model", "evaluation"], outputs: ["registered_model"], runtimes: ["local_cpu", "cluster"] },
{ id: "deploy_api", family: "deployment", label: "Deploy API", inputs: ["registered_model"], outputs: ["deployment"], runtimes: ["local_cpu", "remote_gpu", "cluster", "cloud_api"] },
];
const FAMILY_COLORS: Record<string, string> = {
dataset_source: "#4a90d9",
data_processing: "#50b83c",
dataset_builder: "#9c6ade",
training: "#de3618",
evaluation: "#f49342",
model_registry: "#47c1bf",
registry: "#47c1bf",
deployment: "#212b36",
};
const FAMILY_LABELS: Record<string, string> = {
dataset_source: "Dataset Source",
data_processing: "Data Processing",
dataset_builder: "Dataset Builder",
training: "Training",
evaluation: "Evaluation",
model_registry: "Model Registry",
deployment: "Deployment",
};
// Group node types by family
function groupByFamily(types: NodeTypeDef[]): Map<string, NodeTypeDef[]> {
const map = new Map<string, NodeTypeDef[]>();
for (const t of types) {
const list = map.get(t.family) || [];
list.push(t);
map.set(t.family, list);
}
return map;
}
// ---------------------------------------------------------------------------
// Conversion helpers
// ---------------------------------------------------------------------------
function graphNodeToFlowNode(node: GraphNodeRecord): Node {
const def = NODE_TYPES.find((t) => t.id === node.type);
const data: EngineNodeData = {
label: def?.label || node.type,
family: def?.family || "unknown",
runtime: node.runtime,
inputs: def?.inputs || [],
outputs: def?.outputs || [],
params: node.params || {},
};
return {
id: node.id,
type: "engineNode",
position: { x: node.x ?? 0, y: node.y ?? 0 },
data,
};
}
function graphEdgeToFlowEdge(edge: GraphEdgeRecord, index: number): Edge {
return {
id: `e-${edge.from.node}-${edge.from.output}-${edge.to.node}-${edge.to.input}-${index}`,
source: edge.from.node,
sourceHandle: edge.from.output,
target: edge.to.node,
targetHandle: edge.to.input,
animated: true,
style: { stroke: "#c84c0c", strokeWidth: 2 },
};
}
function flowNodesToGraphNodes(nodes: Node[]): GraphNodeRecord[] {
return nodes.map((n) => {
const d = n.data as unknown as EngineNodeData;
const def = NODE_TYPES.find((t) => t.label === d.label);
return {
id: n.id,
type: def?.id || "unknown",
runtime: d.runtime,
params: d.params || {},
x: Math.round(n.position.x),
y: Math.round(n.position.y),
};
});
}
function flowEdgesToGraphEdges(edges: Edge[]): GraphEdgeRecord[] {
return edges.map((e) => ({
from: { node: e.source, output: e.sourceHandle || "dataset" },
to: { node: e.target, input: e.targetHandle || "dataset" },
}));
}
// ---------------------------------------------------------------------------
// Connection validation
// ---------------------------------------------------------------------------
function isValidConnection(connection: Edge | Connection): boolean {
// Prevent self-connections
if (connection.source === connection.target) return false;
// Only allow matching output->input types (same handle name = same data type)
if (connection.sourceHandle && connection.targetHandle) {
return connection.sourceHandle === connection.targetHandle;
}
return true;
}
import {
useNodeEditor,
isValidConnection,
FAMILY_COLORS,
FAMILY_LABELS,
type NodeTypeDef,
} from "../hooks/useNodeEditor";
// ---------------------------------------------------------------------------
// Component
@@ -161,127 +21,13 @@ interface NodeEditorProps {
const nodeTypes = { engineNode: EngineNodeComponent };
let nodeCounter = 0;
export default function NodeEditor({ graphId, onBack }: NodeEditorProps) {
const [nodes, setNodes, onNodesChange] = useNodesState([] as Node[]);
const [edges, setEdges, onEdgesChange] = useEdgesState([] as Edge[]);
const [graphName, setGraphName] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [running, setRunning] = useState(false);
const [error, setError] = useState("");
const [status, setStatus] = useState("");
const [panelOpen, setPanelOpen] = useState(false);
const families = useMemo(() => groupByFamily(NODE_TYPES), []);
// Load graph
useEffect(() => {
loadGraph();
}, [graphId]);
async function loadGraph() {
setLoading(true);
setError("");
try {
// Try getGraph first, fall back to listing
let graph;
try {
graph = await api.getGraph(graphId);
} catch {
const graphs = await api.listGraphs();
graph = graphs.find((g) => g.id === graphId);
}
if (!graph) {
setError("Graph not found");
setLoading(false);
return;
}
setGraphName(graph.name || graph.id);
const flowNodes = (graph.nodes || []).map(graphNodeToFlowNode);
const flowEdges = (graph.edges || []).map(graphEdgeToFlowEdge);
setNodes(flowNodes);
setEdges(flowEdges);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load graph");
} finally {
setLoading(false);
}
}
// Connect edges
const onConnect: OnConnect = useCallback(
(connection) => {
if (!isValidConnection(connection)) return;
setEdges((eds) =>
addEdge(connection, eds).map((e) =>
e.source === connection.source && e.target === connection.target
? { ...e, animated: true, style: { stroke: "#c84c0c", strokeWidth: 2 } }
: e,
),
);
},
[setEdges],
);
// Add a new node from the panel
function handleAddNode(typeDef: NodeTypeDef) {
nodeCounter++;
const newId = `node_${Date.now()}_${nodeCounter}`;
const data: EngineNodeData = {
label: typeDef.label,
family: typeDef.family,
runtime: typeDef.runtimes[0] || "local_cpu",
inputs: typeDef.inputs,
outputs: typeDef.outputs,
params: {},
};
const newNode: Node = {
id: newId,
type: "engineNode",
position: { x: 200 + nodeCounter * 30, y: 100 + nodeCounter * 30 },
data,
};
setNodes((nds) => [...nds, newNode]);
setPanelOpen(false);
setStatus(`Added ${typeDef.label}`);
}
// Save
async function handleSave() {
setSaving(true);
setError("");
setStatus("");
try {
const graphNodes = flowNodesToGraphNodes(nodes);
const graphEdges = flowEdgesToGraphEdges(edges);
await api.updateGraph(graphId, {
nodes: graphNodes,
edges: graphEdges,
});
setStatus("Saved successfully");
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
}
// Run
async function handleRun() {
setRunning(true);
setError("");
setStatus("");
try {
const run = await api.startRun(graphId);
setStatus(`Run started: ${run.id} (${run.status})`);
} catch (err) {
setError(err instanceof Error ? err.message : "Run failed");
} finally {
setRunning(false);
}
}
const {
nodes, edges, onNodesChange, onEdgesChange, onConnect,
graphName, loading, saving, running, error, status, setStatus,
panelOpen, setPanelOpen, families,
handleAddNode, handleSave, handleRun,
} = useNodeEditor(graphId);
if (loading) {
return <div className="muted" style={{ padding: 40 }}>Loading graph editor...</div>;
@@ -296,17 +42,10 @@ export default function NodeEditor({ graphId, onBack }: NodeEditorProps) {
</button>
<h3 className="node-editor-title">{graphName}</h3>
<div className="node-editor-toolbar-actions">
<button
className="btn btn-secondary"
onClick={() => setPanelOpen(!panelOpen)}
>
<button className="btn btn-secondary" onClick={() => setPanelOpen(!panelOpen)}>
+ Add Node
</button>
<button
className="btn btn-primary"
onClick={handleSave}
disabled={saving}
>
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : "Save"}
</button>
<button
@@ -322,10 +61,7 @@ export default function NodeEditor({ graphId, onBack }: NodeEditorProps) {
{error && <div className="banner">{error}</div>}
{status && (
<div
className="node-editor-status"
onClick={() => setStatus("")}
>
<div className="node-editor-status" onClick={() => setStatus("")}>
{status}
</div>
)}
@@ -352,14 +88,12 @@ export default function NodeEditor({ graphId, onBack }: NodeEditorProps) {
>
{FAMILY_LABELS[familyId] || familyId}
</div>
{types.map((t) => (
{types.map((t: NodeTypeDef) => (
<button
key={t.id}
className="node-editor-add-btn"
onClick={() => handleAddNode(t)}
style={{
borderLeftColor: FAMILY_COLORS[familyId] || "#666",
}}
style={{ borderLeftColor: FAMILY_COLORS[familyId] || "#666" }}
>
<span>{t.label}</span>
<span className="node-editor-add-io">
+5
View File
@@ -54,6 +54,7 @@ export default function VoiceChat() {
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const audioCtxRef = useRef<AudioContext | null>(null);
const recordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const levelAnimRef = useRef<number>(0);
@@ -116,6 +117,7 @@ export default function VoiceChat() {
// Audio level monitoring
function startLevelMonitor(stream: MediaStream) {
const ctx = new AudioContext();
audioCtxRef.current = ctx;
const source = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = 256;
@@ -225,6 +227,7 @@ export default function VoiceChat() {
streamRef.current = null;
cancelAnimationFrame(levelAnimRef.current);
analyserRef.current = null;
if (audioCtxRef.current) { audioCtxRef.current.close().catch(() => {}); audioCtxRef.current = null; }
setAudioLevel(0);
if (silenceTimerRef.current) { clearTimeout(silenceTimerRef.current); silenceTimerRef.current = null; }
if (recordingTimerRef.current) { clearInterval(recordingTimerRef.current); recordingTimerRef.current = null; }
@@ -276,11 +279,13 @@ export default function VoiceChat() {
if (recordingTimerRef.current) clearInterval(recordingTimerRef.current);
if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current);
cancelAnimationFrame(levelAnimRef.current);
if (audioCtxRef.current) { audioCtxRef.current.close().catch(() => {}); audioCtxRef.current = null; }
if (mediaRecorderRef.current?.state === "recording") {
try { mediaRecorderRef.current.stop(); } catch {}
}
streamRef.current?.getTracks().forEach(t => t.stop());
if (currentAudioRef.current) { currentAudioRef.current.pause(); currentAudioRef.current = null; }
audioQueueRef.current.length = 0;
};
}, []);
+2 -1
View File
@@ -1,6 +1,6 @@
export interface ChatMsg {
id: number;
type: "system" | "message" | "join" | "part" | "persona" | "channelInfo" | "userlist" | "command" | "uploadCapability" | "audio" | "image" | "music";
type: "system" | "message" | "join" | "part" | "persona" | "channelInfo" | "userlist" | "command" | "uploadCapability" | "audio" | "image" | "music" | "chunk";
nick?: string;
text?: string;
color?: string;
@@ -10,6 +10,7 @@ export interface ChatMsg {
audioMime?: string;
imageData?: string;
imageMime?: string;
seq?: number;
timestamp: number;
}
+90 -20
View File
@@ -107,6 +107,7 @@ export function useChatState(): UseChatStateReturn {
text: typeof msg.text === "string" ? msg.text : undefined,
imageData: typeof msg.imageData === "string" ? msg.imageData : undefined,
imageMime: typeof msg.imageMime === "string" ? msg.imageMime : undefined,
seq: typeof msg.seq === "number" ? msg.seq : undefined,
timestamp: Date.now(),
};
setMessages((prev) => {
@@ -124,6 +125,7 @@ export function useChatState(): UseChatStateReturn {
text: typeof msg.text === "string" ? msg.text : undefined,
audioData: typeof msg.audioData === "string" ? msg.audioData : undefined,
audioMime: typeof msg.audioMime === "string" ? msg.audioMime : undefined,
seq: typeof msg.seq === "number" ? msg.seq : undefined,
timestamp: Date.now(),
};
setMessages((prev) => {
@@ -142,6 +144,7 @@ export function useChatState(): UseChatStateReturn {
text: "\u266A message vocal",
audioData: msg.data as string,
audioMime: msg.mimeType as string,
seq: typeof msg.seq === "number" ? msg.seq : undefined,
timestamp: Date.now(),
};
setMessages((prev) => {
@@ -152,6 +155,37 @@ export function useChatState(): UseChatStateReturn {
return;
}
case "chunk": {
// Streaming chunk from persona — append to existing or create new message
const chunkNick = typeof msg.nick === "string" ? msg.nick : "???";
const chunkText = typeof msg.text === "string" ? msg.text : "";
const chunkColor = typeof msg.color === "string" ? msg.color : undefined;
const chunkSeq = typeof msg.seq === "number" ? msg.seq : undefined;
setMessages((prev) => {
// Find the last message from this nick that is a chunk (still streaming)
const lastIdx = prev.length - 1;
const last = lastIdx >= 0 ? prev[lastIdx] : null;
if (last && last.type === "chunk" && last.nick === chunkNick) {
// Append to existing chunk message
const updated = [...prev];
updated[lastIdx] = { ...last, text: (last.text || "") + chunkText, seq: chunkSeq ?? last.seq };
return updated;
}
// New streaming message
const next = [...prev, {
id: ++msgIdCounter,
type: "chunk" as ChatMsg["type"],
nick: chunkNick,
text: chunkText,
color: chunkColor,
seq: chunkSeq,
timestamp: Date.now(),
}];
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
});
return;
}
default: {
// Intercept typing indicators
if (type === "system" && typeof msg.text === "string") {
@@ -164,6 +198,7 @@ export function useChatState(): UseChatStateReturn {
}
}
const incomingSeq = typeof msg.seq === "number" ? msg.seq : undefined;
const chatMsg: ChatMsg = {
id: ++msgIdCounter,
type,
@@ -171,9 +206,30 @@ export function useChatState(): UseChatStateReturn {
text: typeof msg.text === "string" ? msg.text : undefined,
color: typeof msg.color === "string" ? msg.color : undefined,
channel: typeof msg.channel === "string" ? msg.channel : undefined,
seq: incomingSeq,
timestamp: Date.now(),
};
setMessages((prev) => {
// If this is a final message from a persona, replace the last chunk from same nick
// Use seq to find the right chunk when available
if (type === "message" && chatMsg.nick) {
let lastChunkIdx = -1;
if (incomingSeq != null) {
// Find chunk whose seq is just before this message's seq (same persona)
lastChunkIdx = prev.findLastIndex(
(m) => m.type === "chunk" && m.nick === chatMsg.nick
);
} else {
lastChunkIdx = prev.findLastIndex(
(m) => m.type === "chunk" && m.nick === chatMsg.nick
);
}
if (lastChunkIdx >= 0) {
const updated = [...prev];
updated[lastChunkIdx] = { ...chatMsg, id: prev[lastChunkIdx].id };
return updated;
}
}
const next = [...prev, chatMsg];
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
});
@@ -261,9 +317,23 @@ export function useChatState(): UseChatStateReturn {
return () => window.removeEventListener("keydown", handleGlobalKeyDown);
}, []);
function handleSend() {
const trimmed = input.trim();
if (!trimmed || !ws.connected) return;
// Refs for stable useCallback closures
const inputRef = useRef(input);
inputRef.current = input;
const wsRef = useRef(ws);
wsRef.current = ws;
const soundsRef = useRef(sounds);
soundsRef.current = sounds;
const usersRef = useRef(users);
usersRef.current = users;
const tabIndexRef = useRef(tabIndex);
tabIndexRef.current = tabIndex;
const tabPrefixRef = useRef(tabPrefix);
tabPrefixRef.current = tabPrefix;
const handleSend = useCallback(() => {
const trimmed = inputRef.current.trim();
if (!trimmed || !wsRef.current.connected) return;
// Push to history
historyRef.current.unshift(trimmed);
@@ -304,25 +374,25 @@ export function useChatState(): UseChatStateReturn {
return;
}
sounds.send();
soundsRef.current.send();
if (trimmed.startsWith("/")) {
ws.send({ type: "command", text: trimmed });
wsRef.current.send({ type: "command", text: trimmed });
} else {
ws.send({ type: "message", text: trimmed });
wsRef.current.send({ type: "message", text: trimmed });
}
setInput("");
}
}, []); // stable — reads from refs
// Keep handleSendRef in sync
useEffect(() => { handleSendRef.current = handleSend; });
function handleKeyDown(e: React.KeyboardEvent) {
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
// Debounced Minitel keyPress sound (every 3rd key)
if (e.key.length === 1) {
keyPressCountRef.current++;
if (keyPressCountRef.current % 3 === 0) {
sounds.keyPress();
soundsRef.current.keyPress();
}
}
@@ -335,34 +405,34 @@ export function useChatState(): UseChatStateReturn {
// Tab completion for nicks and slash commands
if (e.key === "Tab") {
e.preventDefault();
const text = input;
const text = inputRef.current;
// Slash command completion
if (text.startsWith("/") && !text.includes(" ")) {
const slashCommands = ["/help", "/clear", "/nick", "/join", "/channels", "/msg", "/web", "/imagine", "/compose", "/status", "/model", "/persona", "/reload", "/export"];
const prefix = tabPrefix || text;
const prefix = tabPrefixRef.current || text;
const matches = slashCommands.filter((c) => c.startsWith(prefix.toLowerCase()));
if (matches.length === 0) return;
const nextIdx = (tabIndex + 1) % matches.length;
const nextIdx = (tabIndexRef.current + 1) % matches.length;
setInput(matches[nextIdx] + " ");
setTabIndex(nextIdx);
if (!tabPrefix) setTabPrefix(prefix);
if (!tabPrefixRef.current) setTabPrefix(prefix);
return;
}
// Nick completion
const words = text.split(" ");
const lastWord = words[words.length - 1];
const prefix = tabPrefix || lastWord;
const matches = users.filter((u) =>
const prefix = tabPrefixRef.current || lastWord;
const matches = usersRef.current.filter((u) =>
u.toLowerCase().startsWith(prefix.toLowerCase()),
);
if (matches.length === 0) return;
const nextIdx = (tabIndex + 1) % matches.length;
const nextIdx = (tabIndexRef.current + 1) % matches.length;
words[words.length - 1] = matches[nextIdx] + (words.length === 1 ? ": " : " ");
setInput(words.join(" "));
setTabIndex(nextIdx);
if (!tabPrefix) setTabPrefix(prefix);
if (!tabPrefixRef.current) setTabPrefix(prefix);
return;
}
@@ -372,7 +442,7 @@ export function useChatState(): UseChatStateReturn {
if (history.length === 0) return;
e.preventDefault();
if (historyIndexRef.current < history.length - 1) {
if (historyIndexRef.current === -1) savedInputRef.current = input;
if (historyIndexRef.current === -1) savedInputRef.current = inputRef.current;
historyIndexRef.current++;
setInput(history[historyIndexRef.current]);
}
@@ -393,11 +463,11 @@ export function useChatState(): UseChatStateReturn {
}
// Reset tab state on any other key
if (tabIndex >= 0) {
if (tabIndexRef.current >= 0) {
setTabIndex(-1);
setTabPrefix("");
}
}
}, [handleSend]); // stable — reads from refs
const getNickColor = useCallback((nick: string): string | undefined => {
return personaColors[nick];
+114
View File
@@ -0,0 +1,114 @@
import { useState, useRef, useEffect } from "react";
import { useMinitelSounds } from "./useMinitelSounds";
import { resolveWebSocketUrl } from "../lib/websocket-url";
export interface UseGenerationCommandOptions {
/** Message type to match in WS responses (e.g. "music", "image") */
responseType: string;
/** Extract result data from a matched WS message */
extractResult: (msg: Record<string, unknown>) => Record<string, unknown> | null;
/** Error substring to match in system messages */
errorMatch: string;
/** Simulated progress speed: interval ms between ticks */
progressInterval?: number;
/** Simulated progress increment per tick */
progressStep?: number;
/** Max results to keep */
maxResults?: number;
}
export function useGenerationCommand<T extends Record<string, unknown>>(
opts: UseGenerationCommandOptions,
) {
const [generating, setGenerating] = useState(false);
const [progress, setProgress] = useState(0);
const [results, setResults] = useState<T[]>([]);
const [error, setError] = useState("");
const sounds = useMinitelSounds();
const wsRef = useRef<WebSocket | null>(null);
const progressRef = useRef<ReturnType<typeof setInterval> | null>(null);
const wsUrl = resolveWebSocketUrl();
const maxResults = opts.maxResults ?? 20;
const interval = opts.progressInterval ?? 200;
const step = opts.progressStep ?? 3;
// Close WS on unmount
useEffect(() => {
return () => {
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
};
}, []);
// Simulated progress bar
useEffect(() => {
if (generating) {
setProgress(0);
progressRef.current = setInterval(() => {
setProgress((p) => Math.min(p + step * (0.5 + Math.random()), 92));
}, interval);
} else {
if (progressRef.current) clearInterval(progressRef.current);
if (progress > 0) {
setProgress(100);
setTimeout(() => setProgress(0), 700);
}
}
return () => {
if (progressRef.current) clearInterval(progressRef.current);
};
}, [generating]);
function getWs(): WebSocket | null {
if (wsRef.current?.readyState === WebSocket.OPEN) return wsRef.current;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data) as Record<string, unknown>;
if (msg.type === opts.responseType) {
const extracted = opts.extractResult(msg);
if (extracted) {
setResults((prev) => [extracted as T, ...prev].slice(0, maxResults));
setGenerating(false);
sounds.receive();
}
}
if (
msg.type === "system" &&
typeof msg.text === "string" &&
msg.text.includes(opts.errorMatch)
) {
setError(msg.text);
setGenerating(false);
}
} catch {}
};
ws.onclose = () => {
wsRef.current = null;
};
return ws;
}
function send(command: string) {
const ws = getWs();
const payload = JSON.stringify({ type: "command", text: command });
if (!ws || ws.readyState !== WebSocket.OPEN) {
ws?.addEventListener("open", () => ws.send(payload), { once: true });
} else {
ws.send(payload);
}
setGenerating(true);
setError("");
sounds.send();
}
return { generating, progress, results, setResults, error, send };
}
+263
View File
@@ -0,0 +1,263 @@
import { useEffect, useState, useCallback, useMemo } from "react";
import {
useNodesState,
useEdgesState,
addEdge,
type Node,
type Edge,
type Connection,
type OnConnect,
} from "@xyflow/react";
import { api, type GraphNodeRecord, type GraphEdgeRecord } from "../api";
import type { EngineNodeData } from "../components/EngineNode";
// ---------------------------------------------------------------------------
// Node type registry
// ---------------------------------------------------------------------------
export interface NodeTypeDef {
id: string;
family: string;
label: string;
inputs: string[];
outputs: string[];
runtimes: string[];
}
export const NODE_TYPES: NodeTypeDef[] = [
{ id: "dataset_file", family: "dataset_source", label: "Dataset File", inputs: [], outputs: ["dataset"], runtimes: ["local_cpu", "local_gpu", "cloud_api"] },
{ id: "dataset_folder", family: "dataset_source", label: "Dataset Folder", inputs: [], outputs: ["dataset"], runtimes: ["local_cpu", "local_gpu"] },
{ id: "huggingface_dataset", family: "dataset_source", label: "HuggingFace Dataset", inputs: [], outputs: ["dataset"], runtimes: ["cloud_api", "local_cpu", "local_gpu"] },
{ id: "web_scraper", family: "dataset_source", label: "Web Scraper", inputs: [], outputs: ["dataset"], runtimes: ["cloud_api", "local_cpu"] },
{ id: "clean_text", family: "data_processing", label: "Clean Text", inputs: ["dataset"], outputs: ["dataset"], runtimes: ["local_cpu", "local_gpu", "cluster"] },
{ id: "remove_duplicates", family: "data_processing", label: "Remove Duplicates", inputs: ["dataset"], outputs: ["dataset"], runtimes: ["local_cpu", "local_gpu", "cluster"] },
{ id: "split_dataset", family: "data_processing", label: "Split Dataset", inputs: ["dataset"], outputs: ["dataset"], runtimes: ["local_cpu", "local_gpu", "cluster"] },
{ id: "format_instruction_dataset", family: "dataset_builder", label: "Instruction Dataset", inputs: ["dataset"], outputs: ["dataset_ready"], runtimes: ["local_cpu", "local_gpu", "cluster"] },
{ id: "chat_dataset", family: "dataset_builder", label: "Chat Dataset", inputs: ["dataset"], outputs: ["dataset_ready"], runtimes: ["local_cpu", "local_gpu", "cluster"] },
{ id: "lora_training", family: "training", label: "LoRA Training", inputs: ["dataset_ready"], outputs: ["model"], runtimes: ["local_gpu", "remote_gpu", "cluster"] },
{ id: "qlora_training", family: "training", label: "QLoRA Training", inputs: ["dataset_ready"], outputs: ["model"], runtimes: ["local_gpu", "remote_gpu", "cluster"] },
{ id: "benchmark", family: "evaluation", label: "Benchmark", inputs: ["model", "dataset_ready"], outputs: ["evaluation"], runtimes: ["local_cpu", "local_gpu", "cluster"] },
{ id: "prompt_test", family: "evaluation", label: "Prompt Test", inputs: ["model", "dataset_ready"], outputs: ["evaluation"], runtimes: ["local_cpu", "local_gpu", "cloud_api"] },
{ id: "register_model", family: "model_registry", label: "Register Model", inputs: ["model", "evaluation"], outputs: ["registered_model"], runtimes: ["local_cpu", "cluster"] },
{ id: "deploy_api", family: "deployment", label: "Deploy API", inputs: ["registered_model"], outputs: ["deployment"], runtimes: ["local_cpu", "remote_gpu", "cluster", "cloud_api"] },
];
export const FAMILY_COLORS: Record<string, string> = {
dataset_source: "#4a90d9",
data_processing: "#50b83c",
dataset_builder: "#9c6ade",
training: "#de3618",
evaluation: "#f49342",
model_registry: "#47c1bf",
registry: "#47c1bf",
deployment: "#212b36",
};
export const FAMILY_LABELS: Record<string, string> = {
dataset_source: "Dataset Source",
data_processing: "Data Processing",
dataset_builder: "Dataset Builder",
training: "Training",
evaluation: "Evaluation",
model_registry: "Model Registry",
deployment: "Deployment",
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function groupByFamily(types: NodeTypeDef[]): Map<string, NodeTypeDef[]> {
const map = new Map<string, NodeTypeDef[]>();
for (const t of types) {
const list = map.get(t.family) || [];
list.push(t);
map.set(t.family, list);
}
return map;
}
function graphNodeToFlowNode(node: GraphNodeRecord): Node {
const def = NODE_TYPES.find((t) => t.id === node.type);
const data: EngineNodeData = {
label: def?.label || node.type,
family: def?.family || "unknown",
runtime: node.runtime,
inputs: def?.inputs || [],
outputs: def?.outputs || [],
params: node.params || {},
};
return {
id: node.id,
type: "engineNode",
position: { x: node.x ?? 0, y: node.y ?? 0 },
data,
};
}
function graphEdgeToFlowEdge(edge: GraphEdgeRecord, index: number): Edge {
return {
id: `e-${edge.from.node}-${edge.from.output}-${edge.to.node}-${edge.to.input}-${index}`,
source: edge.from.node,
sourceHandle: edge.from.output,
target: edge.to.node,
targetHandle: edge.to.input,
animated: true,
style: { stroke: "#c84c0c", strokeWidth: 2 },
};
}
function flowNodesToGraphNodes(nodes: Node[]): GraphNodeRecord[] {
return nodes.map((n) => {
const d = n.data as unknown as EngineNodeData;
const def = NODE_TYPES.find((t) => t.label === d.label);
return {
id: n.id,
type: def?.id || "unknown",
runtime: d.runtime,
params: d.params || {},
x: Math.round(n.position.x),
y: Math.round(n.position.y),
};
});
}
function flowEdgesToGraphEdges(edges: Edge[]): GraphEdgeRecord[] {
return edges.map((e) => ({
from: { node: e.source, output: e.sourceHandle || "dataset" },
to: { node: e.target, input: e.targetHandle || "dataset" },
}));
}
export function isValidConnection(connection: Edge | Connection): boolean {
if (connection.source === connection.target) return false;
if (connection.sourceHandle && connection.targetHandle) {
return connection.sourceHandle === connection.targetHandle;
}
return true;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
let nodeCounter = 0;
export function useNodeEditor(graphId: string) {
const [nodes, setNodes, onNodesChange] = useNodesState([] as Node[]);
const [edges, setEdges, onEdgesChange] = useEdgesState([] as Edge[]);
const [graphName, setGraphName] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [running, setRunning] = useState(false);
const [error, setError] = useState("");
const [status, setStatus] = useState("");
const [panelOpen, setPanelOpen] = useState(false);
const families = useMemo(() => groupByFamily(NODE_TYPES), []);
// Load graph
useEffect(() => {
loadGraph();
}, [graphId]);
async function loadGraph() {
setLoading(true);
setError("");
try {
let graph;
try {
graph = await api.getGraph(graphId);
} catch {
const graphs = await api.listGraphs();
graph = graphs.find((g) => g.id === graphId);
}
if (!graph) {
setError("Graph not found");
setLoading(false);
return;
}
setGraphName(graph.name || graph.id);
setNodes((graph.nodes || []).map(graphNodeToFlowNode));
setEdges((graph.edges || []).map(graphEdgeToFlowEdge));
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load graph");
} finally {
setLoading(false);
}
}
const onConnect: OnConnect = useCallback(
(connection) => {
if (!isValidConnection(connection)) return;
setEdges((eds) =>
addEdge(connection, eds).map((e) =>
e.source === connection.source && e.target === connection.target
? { ...e, animated: true, style: { stroke: "#c84c0c", strokeWidth: 2 } }
: e,
),
);
},
[setEdges],
);
function handleAddNode(typeDef: NodeTypeDef) {
nodeCounter++;
const newId = `node_${Date.now()}_${nodeCounter}`;
const data: EngineNodeData = {
label: typeDef.label,
family: typeDef.family,
runtime: typeDef.runtimes[0] || "local_cpu",
inputs: typeDef.inputs,
outputs: typeDef.outputs,
params: {},
};
const newNode: Node = {
id: newId,
type: "engineNode",
position: { x: 200 + nodeCounter * 30, y: 100 + nodeCounter * 30 },
data,
};
setNodes((nds) => [...nds, newNode]);
setPanelOpen(false);
setStatus(`Added ${typeDef.label}`);
}
async function handleSave() {
setSaving(true);
setError("");
setStatus("");
try {
const graphNodes = flowNodesToGraphNodes(nodes);
const graphEdges = flowEdgesToGraphEdges(edges);
await api.updateGraph(graphId, { nodes: graphNodes, edges: graphEdges });
setStatus("Saved successfully");
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
}
async function handleRun() {
setRunning(true);
setError("");
setStatus("");
try {
const run = await api.startRun(graphId);
setStatus(`Run started: ${run.id} (${run.status})`);
} catch (err) {
setError(err instanceof Error ? err.message : "Run failed");
} finally {
setRunning(false);
}
}
return {
nodes, edges, onNodesChange, onEdgesChange, onConnect,
graphName, loading, saving, running, error, status, setStatus,
panelOpen, setPanelOpen, families,
handleAddNode, handleSave, handleRun,
};
}
+56 -19
View File
@@ -7,24 +7,33 @@ export interface UseWebSocketOptions {
enabled?: boolean;
}
export type ConnectionStatus = "connected" | "reconnecting" | "disconnected";
export interface UseWebSocketReturn {
connected: boolean;
connectionStatus: ConnectionStatus;
reconnectAttempts: number;
send: (data: unknown) => void;
lastMessage: unknown | null;
disconnect: () => void;
}
const MAX_RECONNECT_INTERVAL = 30_000;
const INITIAL_DELAY = 1000;
const MAX_DELAY = 30_000;
const MAX_ATTEMPTS = 20;
export function useWebSocket(options: UseWebSocketOptions): UseWebSocketReturn {
const { url, onMessage, reconnectInterval = 3000, enabled = true } = options;
const { url, onMessage, reconnectInterval = INITIAL_DELAY, enabled = true } = options;
const [connected, setConnected] = useState(false);
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>("disconnected");
const [reconnectAttempts, setReconnectAttempts] = useState(0);
const [lastMessage, setLastMessage] = useState<unknown | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const backoffRef = useRef(reconnectInterval);
const attemptsRef = useRef(0);
const onMessageRef = useRef(onMessage);
const mountedRef = useRef(true);
const manualDisconnect = useRef(false);
@@ -61,6 +70,31 @@ export function useWebSocket(options: UseWebSocketOptions): UseWebSocketReturn {
}
}, [clearReconnect]);
// Use a ref-based connect to break the circular dependency with scheduleReconnect
const connectFnRef = useRef<() => void>(() => {});
const scheduleReconnect = useCallback(() => {
if (!mountedRef.current || manualDisconnect.current) return;
if (attemptsRef.current >= MAX_ATTEMPTS) {
if (mountedRef.current) {
setConnectionStatus("disconnected");
}
return;
}
if (mountedRef.current) {
setConnectionStatus("reconnecting");
setReconnectAttempts(attemptsRef.current);
}
reconnectTimer.current = setTimeout(() => {
attemptsRef.current++;
if (mountedRef.current) {
setReconnectAttempts(attemptsRef.current);
}
connectFnRef.current();
}, backoffRef.current);
backoffRef.current = Math.min(backoffRef.current * 2, MAX_DELAY);
}, []);
const connect = useCallback(() => {
if (!mountedRef.current || manualDisconnect.current) return;
closeSocket();
@@ -69,14 +103,7 @@ export function useWebSocket(options: UseWebSocketOptions): UseWebSocketReturn {
try {
ws = new WebSocket(url);
} catch {
// Schedule reconnect on construction failure
reconnectTimer.current = setTimeout(() => {
backoffRef.current = Math.min(
backoffRef.current * 2,
MAX_RECONNECT_INTERVAL
);
connect();
}, backoffRef.current);
scheduleReconnect();
return;
}
@@ -84,8 +111,12 @@ export function useWebSocket(options: UseWebSocketOptions): UseWebSocketReturn {
ws.onopen = () => {
if (!mountedRef.current) return;
// Reset backoff on successful connection
backoffRef.current = reconnectInterval;
attemptsRef.current = 0;
setConnected(true);
setConnectionStatus("connected");
setReconnectAttempts(0);
};
ws.onmessage = (event: MessageEvent) => {
@@ -109,16 +140,17 @@ export function useWebSocket(options: UseWebSocketOptions): UseWebSocketReturn {
setConnected(false);
wsRef.current = null;
if (!manualDisconnect.current) {
reconnectTimer.current = setTimeout(() => {
backoffRef.current = Math.min(
backoffRef.current * 2,
MAX_RECONNECT_INTERVAL
);
connect();
}, backoffRef.current);
scheduleReconnect();
} else {
setConnectionStatus("disconnected");
}
};
}, [url, reconnectInterval, closeSocket]);
}, [url, reconnectInterval, closeSocket, scheduleReconnect]);
// Keep connectFnRef in sync
useEffect(() => {
connectFnRef.current = connect;
}, [connect]);
const send = useCallback((data: unknown) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
@@ -129,6 +161,8 @@ export function useWebSocket(options: UseWebSocketOptions): UseWebSocketReturn {
const disconnect = useCallback(() => {
manualDisconnect.current = true;
closeSocket();
setConnectionStatus("disconnected");
setReconnectAttempts(0);
}, [closeSocket]);
// Connect / disconnect based on enabled flag
@@ -138,9 +172,12 @@ export function useWebSocket(options: UseWebSocketOptions): UseWebSocketReturn {
if (enabled) {
backoffRef.current = reconnectInterval;
attemptsRef.current = 0;
setReconnectAttempts(0);
connect();
} else {
closeSocket();
setConnectionStatus("disconnected");
}
return () => {
@@ -149,5 +186,5 @@ export function useWebSocket(options: UseWebSocketOptions): UseWebSocketReturn {
};
}, [enabled, url, connect, closeSocket, reconnectInterval]);
return { connected, send, lastMessage, disconnect };
return { connected, connectionStatus, reconnectAttempts, send, lastMessage, disconnect };
}
+90
View File
@@ -3533,3 +3533,93 @@ code {
width: 120px;
}
}
/* Timestamps */
.chat-ts {
color: var(--muted, #666);
font-size: 0.8em;
margin-right: 6px;
opacity: 0.6;
user-select: none;
}
/* Streaming cursor */
.chat-cursor {
animation: blink-cursor 0.6s step-end infinite;
color: var(--accent, #0f0);
margin-left: 2px;
}
@keyframes blink-cursor {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.chat-msg-streaming {
opacity: 0.9;
}
/* ══════════════════════════════════════════════════════════
CRT PHOSPHOR EFFECT — boot animation, glow, kill switch
══════════════════════════════════════════════════════════ */
/* CRT turn-on animation */
@keyframes crt-turn-on {
0% { transform: scaleY(0.005) scaleX(0.3); filter: brightness(10); opacity: 1; }
30% { transform: scaleY(0.005) scaleX(1); filter: brightness(5); opacity: 1; }
50% { transform: scaleY(1) scaleX(1); filter: brightness(2); opacity: 1; }
100% { transform: scaleY(1) scaleX(1); filter: brightness(1); opacity: 1; }
}
.crt-boot {
animation: crt-turn-on 0.8s ease-out;
transform-origin: center center;
}
/* Phosphor glow on all content text */
.minitel-content {
text-shadow: 0 0 2px rgba(51, 255, 51, 0.25);
}
/* ── CRT off mode — disable all effects via ?crt=off ── */
.crt-off .minitel-scanlines,
.crt-off .minitel-vignette,
.crt-off .minitel-flicker {
display: none !important;
}
.crt-off .minitel-screen {
box-shadow: none !important;
}
.crt-off .minitel-content {
text-shadow: none !important;
}
.crt-off .crt-boot {
animation: none !important;
}
/* ── Mobile: reduce scanline intensity for FPS ── */
@media (max-width: 768px) {
.minitel-scanlines {
opacity: 0.5;
}
.minitel-flicker {
animation: none;
}
}
/* ── Accessibility: skip-to-content link (WCAG 2.1 AA) ── */
.minitel-skip-link {
position: absolute;
top: -100%;
left: 0;
z-index: 9999;
padding: 0.5em 1em;
background: #000;
color: #0f0;
font-family: inherit;
font-size: 1rem;
text-decoration: underline;
outline: 2px solid #0f0;
}
.minitel-skip-link:focus {
top: 0;
}
File diff suppressed because one or more lines are too long
+448 -58
View File
@@ -5,9 +5,6 @@
* topological order using stub executors, and updates run status in the DB.
*/
import { promisify } from "node:util";
import { execFile } from "node:child_process";
import * as path from "node:path";
import {
loadDatabaseConfig,
createPostgresPool,
@@ -18,18 +15,34 @@ import {
import {
createNodeEngineRegistry,
createQueueState,
canDequeue,
dequeue,
enqueue,
markComplete,
topologicalSort,
validateEdgeContracts,
collectNodeInputs,
resolveFinalStatus,
listDefaultRuntimes,
createNodeEngineOverview,
createRun,
validateJobSpec,
buildTrlCommand,
DEFAULT_HYPERPARAMS,
type TrainingJobSpec,
type NodeRun,
type RunStep,
type GraphNode,
type NodeGraph,
type StepStatus,
type RunStatus,
type NodeEngineRegistry,
type QueueState,
} from "@kxkm/node-engine";
import { formatOverviewLine } from "@kxkm/tui";
import {
createShutdownController,
createNodeExecutor,
executeRun,
runPollCycle,
waitForNextPollTick,
type WorkerLogger,
} from "./worker-runtime.js";
import { createIsoTimestamp } from "@kxkm/core";
import { formatOverviewLine, ansi } from "@kxkm/tui";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import * as path from "node:path";
const execFileAsync = promisify(execFile);
@@ -54,32 +67,343 @@ const DEBUG = process.env.NODE_ENV !== "production" || process.env.DEBUG === "1"
function log(msg: string): void {
if (!DEBUG) return;
const ts = new Date().toISOString();
process.stdout.write(`[${ts}] ${msg}\n`);
console.log(`[${ts}] ${msg}`);
}
function logError(msg: string, err?: unknown): void {
const ts = new Date().toISOString();
const detail = err instanceof Error ? err.message : String(err ?? "");
process.stderr.write(`[${ts}] ERROR: ${msg}${detail ? " — " + detail : ""}\n`);
console.error(`[${ts}] ERROR: ${msg}${detail ? " — " + detail : ""}`);
}
const workerLogger: WorkerLogger = {
log,
error: logError,
};
// ---------------------------------------------------------------------------
// Stub executors
// ---------------------------------------------------------------------------
type StubResult = Record<string, unknown>;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function executeNodeStub(
nodeType: string,
inputs: Record<string, unknown>,
params: Record<string, unknown>,
): Promise<StubResult> {
await delay(STEP_DELAY_MS);
switch (nodeType) {
// Dataset sources — return a mock dataset
case "dataset_file":
case "dataset_folder":
case "huggingface_dataset":
case "web_scraper":
return { dataset: { items: [], format: "stub" } };
// Data processing — pass through inputs
case "clean_text":
case "remove_duplicates":
case "split_dataset":
return { dataset: inputs.dataset ?? { items: [], format: "stub" } };
// Dataset builders — pass through as dataset_ready
case "format_instruction_dataset":
case "chat_dataset":
return { dataset_ready: inputs.dataset ?? { items: [], format: "stub" } };
// Evaluation — execute via eval_model.py
case "prompt_test":
case "benchmark": {
const evalModel = typeof params.model === "string" && params.model
? params.model
: (inputs.model as Record<string, unknown>)?.modelName as string || "unsloth/llama-3-8b";
const adapterPath = (inputs.model as Record<string, unknown>)?.adapterPath as string || undefined;
const promptsPath = typeof params.promptsPath === "string" ? params.promptsPath : "";
const evalOutputPath = `/tmp/kxkm-eval-${Date.now()}.json`;
if (DRY_RUN) {
log(` [dry-run] would evaluate model=${evalModel} adapter=${adapterPath || "none"}`);
return { evaluation: { kind: "dry-run", score: 1 } };
}
if (!promptsPath) {
// No prompts file — return stub score
log(` [eval] no promptsPath provided — returning stub evaluation`);
return { evaluation: { kind: "stub", score: 1 } };
}
const scriptPath = path.join(SCRIPTS_DIR, "eval_model.py");
const args = [
scriptPath,
"--model", evalModel,
"--prompts", promptsPath,
"--output", evalOutputPath,
];
if (adapterPath) args.push("--adapter", adapterPath);
log(` [eval] ${PYTHON_BIN} ${args.join(" ")}`);
try {
const { stdout, stderr } = await execFileAsync(PYTHON_BIN, args, {
timeout: TRAINING_TIMEOUT_MS,
maxBuffer: 50 * 1024 * 1024,
});
if (stderr) log(` [eval] stderr: ${stderr.slice(-500)}`);
const jsonLine = stdout.trim().split("\n").pop() || "{}";
let evalResult: Record<string, unknown> = {};
try {
evalResult = JSON.parse(jsonLine);
} catch (parseErr) {
logError(` [eval] Failed to parse JSON output`, parseErr);
}
log(` [eval] result: status=${evalResult.status} score=${evalResult.score}`);
return {
evaluation: {
kind: "real",
score: evalResult.score,
metrics: evalResult.metrics,
outputFile: evalOutputPath,
},
};
} catch (err) {
logError(` [eval] failed`, err);
return { evaluation: { kind: "error", score: 0, error: err instanceof Error ? err.message : String(err) } };
}
}
// Training — execute via train_unsloth.py
case "sft_training":
case "lora_training":
case "qlora_training": {
const baseModel = typeof params.baseModel === "string" && params.baseModel
? params.baseModel
: "unsloth/llama-3-8b";
const datasetPath = typeof params.datasetPath === "string" ? params.datasetPath : "";
const outputDir = typeof params.outputDir === "string"
? params.outputDir
: `/tmp/kxkm-training-${Date.now()}`;
const hp = {
...DEFAULT_HYPERPARAMS,
...(params.hyperparams && typeof params.hyperparams === "object" ? params.hyperparams : {}),
};
if (DRY_RUN) {
const jobSpec = validateJobSpec({
type: nodeType as TrainingJobSpec["type"], baseModel,
datasetPath: datasetPath || "/data/dataset.jsonl",
outputDir, hyperparams: hp,
});
log(` [dry-run] would execute: ${buildTrlCommand(jobSpec)}`);
return { model: { kind: "dry-run", modelName: `${baseModel}-finetuned`, jobSpec } };
}
if (!datasetPath) {
return { model: { kind: "error", error: "datasetPath is required for training" } };
}
const scriptPath = path.join(SCRIPTS_DIR, "train_unsloth.py");
const args = [
scriptPath,
"--model", baseModel,
"--data", datasetPath,
"--output", outputDir,
"--method", params.dpo === true ? "dpo" : nodeType === "qlora_training" ? "qlora" : nodeType === "sft_training" ? "sft" : "lora",
"--lr", String(hp.learningRate),
"--epochs", String(hp.epochs),
"--batch-size", String(hp.batchSize),
"--lora-rank", String(hp.loraRank),
"--lora-alpha", String(hp.loraAlpha),
"--warmup-steps", String(hp.warmupSteps),
"--max-seq-length", String(hp.maxSeqLength),
];
if (nodeType === "qlora_training") args.push("--quantize", "4bit");
log(` [training] ${PYTHON_BIN} ${args.join(" ")}`);
try {
const { stdout, stderr } = await execFileAsync(PYTHON_BIN, args, {
timeout: TRAINING_TIMEOUT_MS,
maxBuffer: 50 * 1024 * 1024,
});
if (stderr) log(` [training] stderr: ${stderr.slice(-500)}`);
const jsonLine = stdout.trim().split("\n").pop() || "{}";
let trainResult: Record<string, unknown> = {};
try {
trainResult = JSON.parse(jsonLine);
} catch (parseErr) {
logError(` [training] Failed to parse JSON output`, parseErr);
}
log(` [training] result: status=${trainResult.status} loss=${(trainResult.metrics as Record<string, unknown> | undefined)?.trainLoss}`);
return {
model: {
kind: "trained",
modelName: `${baseModel}-finetuned`,
adapterPath: trainResult.adapterPath || outputDir,
metrics: trainResult.metrics,
status: trainResult.status,
},
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logError(` [training] failed`, err);
return { model: { kind: "error", error: msg } };
}
}
// Registry
case "register_model":
return { registered_model: { id: "stub" } };
// Deployment — import LoRA adapter into Ollama
case "deploy_api": {
const modelInput = inputs.registered_model as Record<string, unknown> || inputs.model as Record<string, unknown> || {};
const adapterPath = modelInput.adapterPath as string || (typeof params.adapterPath === "string" ? params.adapterPath : "");
const baseOllamaModel = typeof params.baseOllamaModel === "string" ? params.baseOllamaModel : "llama3.2:1b";
const deployName = typeof params.deployName === "string" ? params.deployName : `kxkm-${Date.now()}`;
if (DRY_RUN || !adapterPath) {
log(` [deploy] dry-run or no adapter: base=${baseOllamaModel} name=${deployName}`);
return { deployment: { kind: DRY_RUN ? "dry-run" : "stub", id: deployName } };
}
const scriptPath = path.join(SCRIPTS_DIR, "ollama-import-adapter.sh");
const args = [scriptPath, "--base-model", baseOllamaModel, "--adapter-path", adapterPath, "--name", deployName];
log(` [deploy] importing to Ollama: ${deployName} from ${baseOllamaModel} + ${adapterPath}`);
try {
const { stdout, stderr } = await execFileAsync("/bin/bash", args, { timeout: 120_000 });
if (stderr) log(` [deploy] stderr: ${stderr.slice(-500)}`);
const jsonLine = stdout.trim().split("\n").pop() || "{}";
let result;
try {
result = JSON.parse(jsonLine);
} catch {
logError(` [deploy] invalid JSON: ${jsonLine.slice(0, 100)}`);
return { deployment: { kind: "error", id: deployName, error: "invalid_json_output" } };
}
log(` [deploy] result: ${JSON.stringify(result)}`);
return { deployment: { kind: "ollama", id: deployName, ...result } };
} catch (err) {
logError(` [deploy] failed`, err);
return { deployment: { kind: "error", id: deployName, error: err instanceof Error ? err.message : String(err) } };
}
}
default:
return {};
}
}
// ---------------------------------------------------------------------------
// Run executor
// ---------------------------------------------------------------------------
async function executeRun(
run: NodeRun,
registry: NodeEngineRegistry,
options: { shouldCancel?: () => boolean } = {},
): Promise<void> {
const graph = run.graphSnapshot;
const shouldCancel = options.shouldCancel ?? (() => false);
// Validate edges against registry contracts
validateEdgeContracts(graph, registry);
// Get execution order
const sorted = topologicalSort(graph);
// Track outputs per node for input collection
const outputsByNode = new Map<string, Record<string, unknown>>();
// Mark run as running
run.status = "running";
run.startedAt = createIsoTimestamp();
let cancelled = false;
log(` Executing ${sorted.length} node(s) in topological order`);
// Restore already-completed steps (recovery support)
for (const node of sorted) {
const step = run.steps.find((s) => s.id === node.id);
if (step?.status === "completed") {
log(` [${node.id}] ${node.type} — already completed (recovered)`);
}
}
for (const node of sorted) {
const step = run.steps.find((s) => s.id === node.id);
if (!step) continue;
if (step.status === "completed") continue; // skip recovered steps
if (shouldCancel() || shutdownRequested) {
cancelled = true;
log(` [${node.id}] ${node.type} — cancelled`);
break;
}
step.status = "running";
step.startedAt = createIsoTimestamp();
log(` [${node.id}] ${node.type} — running`);
try {
const inputs = collectNodeInputs(graph, node.id, outputsByNode);
const result = await executeNodeStub(node.type, inputs, node.params);
outputsByNode.set(node.id, result);
step.status = "completed";
step.finishedAt = createIsoTimestamp();
step.outputs = Object.keys(result);
log(` [${node.id}] ${node.type} — completed`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
step.status = "failed";
step.finishedAt = createIsoTimestamp();
step.error = message;
logError(` [${node.id}] ${node.type} — failed`, err);
break; // stop on first failure (like V1)
}
}
// Resolve final status from step statuses
const stepStatuses = run.steps.map((s) => s.status);
run.status = resolveFinalStatus(stepStatuses, cancelled);
run.finishedAt = createIsoTimestamp();
}
// ---------------------------------------------------------------------------
// Shutdown handling
// ---------------------------------------------------------------------------
const shutdown = createShutdownController();
let shutdownRequested = false;
function requestShutdown(): void {
if (shutdown.isShutdownRequested()) return;
shutdown.requestShutdown();
if (shutdownRequested) return;
shutdownRequested = true;
log("Shutdown requested — finishing current work...");
}
// ---------------------------------------------------------------------------
// Global error handlers
// ---------------------------------------------------------------------------
process.on("unhandledRejection", (reason) => {
logError("Unhandled promise rejection", reason);
});
process.on("uncaughtException", (err) => {
logError("Uncaught exception — shutting down", err);
process.exit(1);
});
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
@@ -126,57 +450,123 @@ async function main(): Promise<void> {
log(`Recovered ${recovered.length} stale run(s): ${recovered.map((r) => r.id).join(", ")}`);
}
const executeNode = createNodeExecutor(
{
dryRun: DRY_RUN,
stepDelayMs: STEP_DELAY_MS,
pythonBin: PYTHON_BIN,
scriptsDir: SCRIPTS_DIR,
trainingTimeoutMs: TRAINING_TIMEOUT_MS,
},
execFileAsync,
workerLogger,
);
// 6. Graceful shutdown
process.on("SIGTERM", requestShutdown);
process.on("SIGINT", requestShutdown);
// 6. Graceful shutdown with forced exit timeout
const SHUTDOWN_TIMEOUT_MS = 30_000;
function handleShutdownSignal(signal: string) {
log(`${signal} received`);
requestShutdown();
setTimeout(() => {
logError(`Forced exit after ${SHUTDOWN_TIMEOUT_MS}ms timeout`);
process.exit(1);
}, SHUTDOWN_TIMEOUT_MS).unref();
}
process.on("SIGTERM", () => handleShutdownSignal("SIGTERM"));
process.on("SIGINT", () => handleShutdownSignal("SIGINT"));
// 7. Poll loop
log(`Entering poll loop (interval=${POLL_INTERVAL_MS}ms)`);
while (!shutdown.isShutdownRequested()) {
while (!shutdownRequested) {
try {
const cycle = await runPollCycle({
queueState,
runRepo,
graphRepo,
registry,
executeNode,
shutdown,
logger: workerLogger,
});
// Fetch queued runs from the DB
const queuedDbRuns = await runRepo.listByStatus("queued", 20);
const overview = formatOverviewLine(
createNodeEngineOverview({
graphs: (await graphRepo.list()).length,
models: 0,
queuedRuns: queueState.queued.length,
runningRuns: queueState.running.length,
// Sync DB queued runs into in-memory queue state
for (const dbRun of queuedDbRuns) {
enqueue(queueState, dbRun.id);
}
// Process runs while we have capacity
while (canDequeue(queueState) && !shutdownRequested) {
const runId = dequeue(queueState);
if (!runId) break;
log(`Dequeued run: ${runId}`);
// Update status to running in DB
await runRepo.updateStatus(runId, "running");
// Look up the run from the DB
const dbRun = await runRepo.findById(runId);
if (!dbRun) {
logError(`Run ${runId} not found in DB — skipping`);
markComplete(queueState, runId);
continue;
}
const graphRecord = await graphRepo.findById(dbRun.graphId);
if (!graphRecord) {
logError(`Graph ${dbRun.graphId} for run ${runId} not found — marking failed`);
await runRepo.updateStatus(runId, "failed");
markComplete(queueState, runId);
continue;
}
// Build a NodeGraph from the record (minimal — no edges/nodes stored in DB yet,
// so we create a stub graph from the record for now)
const graph: NodeGraph = {
id: graphRecord.id,
name: graphRecord.name,
description: graphRecord.description,
nodes: [],
edges: [],
createdAt: createIsoTimestamp(),
updatedAt: createIsoTimestamp(),
};
// Create an in-memory run with steps from the graph
const nodeRun = createRun(graph, "worker");
// Override the id to match the DB run
(nodeRun as { id: string }).id = runId;
try {
await executeRun(nodeRun, registry, {
shouldCancel: () => {
// Check if run was cancelled in DB (async check would be better
// but keeping it simple — the cancel is also checked via shutdownRequested)
return shutdownRequested;
},
});
// Persist final status
await runRepo.updateStatus(runId, nodeRun.status);
log(`Run ${runId} finished with status: ${nodeRun.status}`);
} catch (err) {
logError(`Run ${runId} failed unexpectedly`, err);
await runRepo.updateStatus(runId, "failed");
} finally {
markComplete(queueState, runId);
}
}
// Log overview periodically
const overview = formatOverviewLine({
queue: {
desiredWorkers: MAX_CONCURRENCY,
activeWorkers: queueState.running.length,
}),
);
queuedRuns: queueState.queued.length,
runningRuns: queueState.running.length,
},
registry: {
graphs: (await graphRepo.list()).length,
models: 0,
},
storage: {
backend: "postgres",
artifacts: "filesystem",
},
});
if (cycle.queuedDbRuns > 0) {
if (queuedDbRuns.length > 0) {
log(`Poll status: ${overview}`);
}
} catch (err) {
logError("Poll loop error", err);
}
if (!shutdown.isShutdownRequested()) {
await waitForNextPollTick(POLL_INTERVAL_MS);
// Wait before next poll (check shutdown frequently)
if (!shutdownRequested) {
await delay(POLL_INTERVAL_MS);
}
}
Executable
+89
View File
@@ -0,0 +1,89 @@
#!/bin/bash
# ═══════════════════════════════════════════════════════════
# 3615-KXKM — Deploy script
# Usage: bash scripts/deploy.sh [--full|--web|--api|--tts]
# ═══════════════════════════════════════════════════════════
set -euo pipefail
HOST="kxkm@kxkm-ai"
REMOTE_DIR="/home/kxkm/KXKM_Clown"
SSH="ssh $HOST"
LOG_PREFIX="[deploy]"
log() { echo "$LOG_PREFIX $*"; }
fail() { echo "$LOG_PREFIX ERROR: $*" >&2; exit 1; }
MODE="${1:---full}"
# ─── Step 1: Build locally ─────────────────────────────────
log "Building locally..."
npx tsc --noEmit -p apps/api/tsconfig.json || fail "TypeScript API check failed"
npx tsc --noEmit -p apps/web/tsconfig.json || fail "TypeScript Web check failed"
npm run -w @kxkm/web build || fail "Web build failed"
npm run -w @kxkm/api build || fail "API build failed"
log "Local build OK"
# ─── Step 2: Sync to remote ────────────────────────────────
log "Syncing to $HOST..."
if [[ "$MODE" == "--full" || "$MODE" == "--web" ]]; then
rsync -avz --delete --exclude='node_modules' --exclude='.git' \
apps/web/src/ "$HOST:$REMOTE_DIR/apps/web/src/"
log "Web sources synced"
fi
if [[ "$MODE" == "--full" || "$MODE" == "--api" ]]; then
rsync -avz --delete --exclude='node_modules' --exclude='.git' \
apps/api/src/ "$HOST:$REMOTE_DIR/apps/api/src/"
log "API sources synced"
fi
rsync -avz scripts/ "$HOST:$REMOTE_DIR/scripts/"
rsync -avz Dockerfile docker-compose.yml "$HOST:$REMOTE_DIR/"
log "Scripts + infra synced"
# ─── Step 3: Remote build ──────────────────────────────────
log "Building on remote..."
$SSH "source ~/.nvm/nvm.sh && cd $REMOTE_DIR && \
npx tsc -b tsconfig.v2.json && \
npm run -w @kxkm/web build && \
npm run -w @kxkm/api build && \
npm run build" || fail "Remote build failed"
log "Remote build OK"
# ─── Step 4: Deploy to Docker ──────────────────────────────
log "Deploying to Docker..."
$SSH "cd $REMOTE_DIR && \
docker cp apps/web/dist/. kxkm_clown-api-1:/app/apps/web/dist/ && \
docker cp apps/api/dist/. kxkm_clown-api-1:/app/apps/api/dist/ && \
docker restart kxkm_clown-api-1" || fail "Docker deploy failed"
log "Docker restarted"
# ─── Step 5: Restart TTS server (chatterbox-remote + piper fallback) ──
if [[ "$MODE" == "--full" || "$MODE" == "--tts" ]]; then
log "Restarting TTS server..."
$SSH "tmux kill-session -t tts 2>/dev/null || true; \
sleep 1; \
tmux new-session -d -s tts \
'source /home/kxkm/venv/bin/activate && cd $REMOTE_DIR && CHATTERBOX_URL=http://127.0.0.1:9200 python3 scripts/tts-server.py --port 9100 --backend chatterbox-remote 2>&1 | tee /tmp/tts-server.log'; \
sleep 3; \
curl -sf http://127.0.0.1:9100/health && echo ' TTS OK' || echo ' TTS FAIL'"
fi
# ─── Step 5b: Restart LightRAG server ─────────────────────
if [[ "$MODE" == "--full" ]]; then
log "Restarting LightRAG server..."
$SSH "tmux kill-session -t lightrag 2>/dev/null || true; \
sleep 1; \
tmux new-session -d -s lightrag \
'source /home/kxkm/venv/bin/activate && cd $REMOTE_DIR && EMBEDDING_DIM=768 LLM_MODEL=qwen3:8b EMBEDDING_MODEL=nomic-embed-text OLLAMA_HOST=http://localhost:11434 lightrag-server --host 0.0.0.0 --port 9621 --working-dir $REMOTE_DIR/data/lightrag --llm-binding ollama --embedding-binding ollama 2>&1 | tee /tmp/lightrag-server.log'; \
sleep 5; \
curl -sf http://127.0.0.1:9621/health | head -c 30 && echo ' LightRAG OK' || echo ' LightRAG FAIL'"
fi
# ─── Step 6: Health check ──────────────────────────────────
log "Health check..."
sleep 3
$SSH "curl -sf http://localhost:3333/api/v2/health | head -c 50" && echo " API OK" || echo " API FAIL"
log "═══ Deploy complete ═══"
+27 -1
View File
@@ -72,7 +72,7 @@ services:
WEB_DIST_PATH: "/app/apps/web/dist"
ADMIN_BOOTSTRAP_TOKEN: "${ADMIN_BOOTSTRAP_TOKEN:-}"
ADMIN_ALLOWED_SUBNETS: "${ADMIN_ALLOWED_SUBNETS:-}"
TTS_ENABLED: "1"
TTS_ENABLED: "0"
PYTHON_BIN: "python3"
SCRIPTS_DIR: "/app/scripts"
PIPER_VOICE_DIR: "/app/data/piper-voices"
@@ -80,6 +80,12 @@ services:
VISION_MODEL: "qwen3-vl:8b"
LIGHTRAG_URL: "http://localhost:9621"
TTS_URL: "http://localhost:9100"
DOCLING_URL: "http://localhost:9400"
RERANKER_URL: "http://localhost:9500"
RAG_CHUNK_SIZE: "${RAG_CHUNK_SIZE:-500}"
RAG_MIN_SIMILARITY: "${RAG_MIN_SIMILARITY:-0.3}"
RAG_MAX_RESULTS: "${RAG_MAX_RESULTS:-3}"
RAG_EMBEDDING_MODEL: "${RAG_EMBEDDING_MODEL:-nomic-embed-text}"
depends_on:
postgres:
condition: service_healthy
@@ -270,6 +276,26 @@ services:
chatterbox:
condition: service_healthy
# -------------------------------------------------------------------------
# Docling Serve — Document parsing (PDF, DOCX, PPTX → structured text)
# API: POST /v1/convert/source
# UI: http://localhost:9400/ui (if DOCLING_SERVE_ENABLE_UI=1)
# -------------------------------------------------------------------------
docling:
image: ghcr.io/docling-project/docling-serve:latest
restart: unless-stopped
profiles: [v2]
ports:
- "9400:5001"
environment:
DOCLING_SERVE_ENABLE_UI: "1"
healthcheck:
test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:5001/health')\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 120s
# -------------------------------------------------------------------------
# LightRAG — Graph RAG server (Ollama backend)
# API: POST /query, POST /documents/text, GET /health
+165 -44
View File
@@ -1,6 +1,8 @@
# Architecture 3615-KXKM
> "Le medium est le message, et ton terminal a deja compris." -- electron rare
>
> "Saboteurs of big daddy mainframe" -- VNS Matrix, 1991
## Vue d'ensemble
@@ -16,26 +18,30 @@ graph TB
Admin[AdminPage]
end
subgraph API["API Node.js Express + WebSocket"]
subgraph API["API Express + WebSocket :3333"]
WS[ws-chat.ts — Handler WS]
CMD[ws-commands.ts — /compose /imagine /web]
ROUTER[ws-conversation-router.ts — Routing @mention]
ROUTER[ws-conversation-router.ts — pickResponders + @mention]
LLM[ws-ollama.ts — Stream + Tools + Think-strip]
MULTI[ws-multimodal.ts — TTS HTTP + Vision]
TOOLS[mcp-tools.ts — web_search, image_generate, rag_search]
MSTORE[media-store.ts — Persistance media]
CTX[context-store.ts — Contexte JSONL 4000ch]
RAG[rag.ts — LightRAG + local fallback]
REST[Routes REST — session, personas, media]
end
subgraph Services["Services"]
OLLAMA[Ollama qwen3:8b mistral gemma3]
TTS[TTS Sidecar :9100 — proxy]
CBOX[Chatterbox GPU :9200 — Docker]
LRAG[LightRAG :9621 — Graph RAG]
COMFY[ComfyUI SDXL]
subgraph Infra["Services Infrastructure"]
OLLAMA["Ollama natif :11434\nqwen3:8b · mistral:7b · qwen3-vl:8b"]
PG[(PostgreSQL 16 :5432)]
SEARX[SearXNG :8080]
PG[(PostgreSQL 16)]
end
subgraph MLStack["ML / Génération"]
TTS[TTS Sidecar :9100\nproxy Chatterbox + Piper fallback]
CBOX[Chatterbox Docker :9200\nGPU voice cloning]
LRAG[LightRAG :9621\nGraph RAG knowledge graph]
COMFY[ComfyUI SDXL\nstable2.kxkm.net]
end
subgraph Worker["Worker GPU"]
@@ -43,17 +49,24 @@ graph TB
TRAIN[Training Unsloth/TRL]
end
subgraph External["Hors cluster"]
STABLE[StableView :3000\ninterface séparée]
end
Chat -- "WS message/command" --> WS
Voice -- "WS upload audio" --> WS
Compose -- "WS command /compose" --> CMD
Imagine -- "WS command /imagine" --> CMD
Compose -- "WS /compose" --> CMD
Imagine -- "WS /imagine" --> CMD
Media -- "REST /api/v2/media" --> REST
WS --> ROUTER --> LLM --> OLLAMA
ROUTER --> CTX
ROUTER --> RAG
LLM -- "tool_call web_search" --> TOOLS --> SEARX
LLM -- "tool_call image_generate" --> TOOLS --> COMFY
LLM -- "tool_call rag_search" --> TOOLS --> LRAG
LLM -- "TTS" --> MULTI --> TTS --> CBOX
LLM -- "Vision" --> MULTI --> OLLAMA
LLM -- "Vision qwen3-vl:8b" --> MULTI --> OLLAMA
RAG -- "query hybrid" --> LRAG --> OLLAMA
CMD -- "/imagine" --> COMFY
CMD -- "/web" --> SEARX
@@ -62,33 +75,153 @@ graph TB
ENGINE --> TRAIN --> OLLAMA
```
## Flux chat avec routing personas
## Flux chat — séquence complète
```mermaid
sequenceDiagram
participant U as User
participant WS as WebSocket
participant U as User (Browser)
participant WS as WebSocket Server
participant PR as pickResponders
participant Ph as Pharmacius (routeur)
participant Sh as Sherlock (web_search)
participant Sp as Spécialiste @mention
participant TTS as TTS Server
participant CTX as ContextStore
participant RAG as RAG (LightRAG)
participant TTS as TTS Sidecar :9100
participant OL as Ollama
U->>WS: message "parle-moi de noise"
WS->>WS: broadcast + log + context
WS->>Ph: streamOllamaChat (maxTokens:600)
Ph-->>WS: "Le noise art... @Merzbow peut approfondir."
WS->>WS: stripThinking + broadcast texte
WS->>TTS: POST /synthesize (Pharmacius)
TTS-->>WS: audio WAV
WS->>U: audio base64
U->>WS: message "cherche des infos sur Xenakis"
WS->>WS: broadcast user message to all clients
WS->>CTX: addToContext(channel, user, text)
Note over WS: Détecte @Merzbow → inter-persona (depth+1, 2s delay)
WS->>Sp: streamOllamaChat (Merzbow, maxTokens:500)
Sp-->>WS: "Le bruit est une matière vivante..."
WS->>TTS: POST /synthesize (Merzbow)
TTS-->>WS: audio WAV
WS->>U: texte + audio
Note over WS,PR: pickResponders: @mention direct → persona mentionnée<br/>sinon → Pharmacius (routeur par défaut)
WS->>PR: pickResponders(text, personas)
PR-->>WS: [Pharmacius]
WS->>CTX: getContextString(channel)
CTX-->>WS: contexte conversationnel (4000 chars)
WS->>RAG: search(text, 2 results)
RAG-->>WS: chunks pertinents du manifeste
WS->>OL: streamOllamaChat(Pharmacius, enrichedText)
Note over Ph,OL: Pharmacius: max 2 phrases, no tools<br/>Routage → @Sherlock pour recherche web
OL-->>WS: stream chunks
WS->>WS: stripThinking + broadcast "message" (final replaces chunks)
WS->>CTX: addToContext(channel, Pharmacius, fullText)
Note over WS: Détecte @Sherlock → inter-persona chain<br/>depth+1, délai 2000ms, max depth=3
WS->>OL: streamOllamaChatWithTools(Sherlock, contextMessage, [web_search, rag_search])
OL-->>WS: tool_call: web_search("Xenakis")
WS->>Sh: executeTool(web_search)
Sh->>WS: SearXNG query → 5 résultats
WS->>OL: tool result → continue generation
OL-->>WS: stream chunks (analyse des résultats)
WS->>WS: broadcast final message to all clients
WS->>CTX: addToContext(channel, Sherlock, fullText)
Note over WS,CTX: Memory update: every 5 messages per persona<br/>LLM extracts facts + summary → persona-memory/{nick}.json
opt TTS_ENABLED=1
WS->>TTS: POST /synthesize {nick, text}
TTS->>TTS: Chatterbox GPU :9200 (voice cloning)
TTS-->>WS: audio WAV
WS->>U: audio base64 broadcast
end
```
## Routing Pharmacius → Spécialistes
```mermaid
graph LR
Ph((Pharmacius<br/>routeur))
subgraph Son["Son / Musique"]
Schaeffer[Schaeffer<br/>musique concrète]
Radigue[Radigue<br/>drones]
Oliveros[Oliveros<br/>deep listening]
Eno[Eno<br/>composition]
end
subgraph Pensee["Pensée / Philosophie"]
Batty[Batty<br/>existentiel]
Foucault[Foucault<br/>pouvoir]
Deleuze[Deleuze<br/>concepts]
end
subgraph Politique["Politique / Résistance"]
Swartz[Swartz<br/>hacktivisme]
Bookchin[Bookchin<br/>écologie]
LeGuin[LeGuin<br/>SF/utopie]
end
subgraph Tech["Tech / Science"]
Turing[Turing<br/>code/hack]
Hypatia[Hypatia<br/>science]
Curie[Curie<br/>science]
Sherlock[Sherlock<br/>web_search]
end
subgraph Arts["Arts vivants / Visuels"]
Merzbow[Merzbow<br/>noise/glitch]
Cage[Cage<br/>silence]
Ikeda[Ikeda<br/>data art]
Picasso[Picasso<br/>image_generate]
TeamLab[TeamLab<br/>immersif]
Demoscene[Demoscene<br/>demoscene]
end
subgraph Scene["Scène / Corps"]
RoyalDeLuxe[RoyalDeLuxe<br/>arts de la rue]
Decroux[Decroux<br/>mime]
Mnouchkine[Mnouchkine<br/>théâtre]
Pina[Pina<br/>danse]
Grotowski[Grotowski<br/>rituel]
Fratellini[Fratellini<br/>clown]
end
subgraph Transversal["Transversal"]
Haraway[Haraway<br/>cyborg/féminisme]
SunRa[SunRa<br/>afrofuturisme]
Bjork[Bjork<br/>pop/nature]
Fuller[Fuller<br/>design]
Tarkovski[Tarkovski<br/>cinéma]
Oram[Oram<br/>électronique/DIY]
end
Ph --> Son
Ph --> Pensee
Ph --> Politique
Ph --> Tech
Ph --> Arts
Ph --> Scene
Ph --> Transversal
style Ph fill:#00e676,color:#000
style Sherlock fill:#ff7043,color:#000
style Picasso fill:#ffd54f,color:#000
```
## Services production (kxkm-ai)
| Service | Port | Docker Profile | Stack | Health | Rôle |
| ------- | ---- | ------------- | ----- | ------ | ---- |
| **API V2** | `:3333` | `v2` | Node.js (network_mode: host) | `GET /api/v2/health` | Express + WebSocket chat + React SPA |
| **PostgreSQL** | `:5432` | *(always)* | postgres:16-alpine | `pg_isready` | Persistence sessions, personas, graphs |
| **SearXNG** | `:8080` | `v2` | searxng/searxng | `wget /` | Recherche web self-hosted (Google, Bing, DDG) |
| **Chatterbox** | `:9200` | `v2` | Docker GPU (ghcr.io/devnen/chatterbox-tts-server) | `GET /get_predefined_voices` | TTS voice cloning GPU |
| **TTS Sidecar** | `:9100` | `v2` | Python (network_mode: host) | — | Proxy Chatterbox + Piper fallback |
| **LightRAG** | `:9621` | `v2` | Python 3.12 (lightrag-hku, network_mode: host) | `GET /health` | Graph RAG, knowledge graph (Ollama backend) |
| **Ollama** | `:11434` | `ollama` *(opt)* | Natif RTX 4090 | `GET /api/tags` | LLM inference: qwen3:8b, mistral:7b, qwen3-vl:8b |
| **Worker** | host | `v2` | Node.js (GPU passthrough) | — | Node Engine DAG execution, training |
| **Docling** | `:9400` | `v2` | Python (Docling REST) | `GET /health` | PDF/document parsing (tables, layout, OCR) |
| **Reranker** | `:9500` | `v2` | Python (bge-reranker-v2-m3) | `GET /health` | Cross-encoder reranking for RAG results |
| **ComfyUI** | ext | — | stable2.kxkm.net | — | Image gen SDXL |
| **StableView** | `:3000` | — | Séparé | — | Interface visualisation (hors cluster) |
| **Discord Bot** | — | `discord` | Node.js (network_mode: host) | — | Bridge chat KXKM → Discord |
| **Discord Voice** | — | `discord-voice` | Node.js + Python STT | — | STT → Personas → TTS en vocal |
## Feature Map
```mermaid
@@ -141,7 +274,7 @@ mindmap
## Modules (LOC)
| Module | LOC | Tests | Rôle |
|--------|-----|-------|------|
| ------ | --- | ----- | ---- |
| apps/api | 5200 | 1000 | Backend API + WebSocket |
| apps/web | 4800 | 800 | Frontend React |
| apps/worker | 956 | 230 | Worker GPU Node Engine |
@@ -156,19 +289,6 @@ mindmap
| scripts | 37 fichiers | - | TTS, training, migration |
| **Total** | **~15600** | **417 tests** | |
## Services production (kxkm-ai)
| Service | Port | Stack | Rôle |
|---------|------|-------|------|
| API V2 | :3333 | Docker (Node.js) | Express + WebSocket chat |
| PostgreSQL | :5432 | Docker (Alpine) | Persistence |
| SearXNG | :8080 | Docker | Recherche web self-hosted |
| Chatterbox | :9200 | Docker (GPU) | TTS voice cloning |
| TTS Sidecar | :9100 | Docker (host) | Proxy Chatterbox + Piper fallback |
| LightRAG | :9621 | Docker (Python) | Graph RAG, knowledge graph |
| Ollama | :11434 | Natif | LLM inference |
| Worker | host | Docker | Node Engine DAG execution |
## Bugs critiques identifiés (audit 2026-03-18)
| # | Sévérité | Module | Description |
@@ -193,6 +313,7 @@ mindmap
| VISION_MODEL | qwen3-vl:8b | Non |
| COMFYUI_URL | stable2.kxkm.net | Non |
| SEARXNG_URL | localhost:8080 | Non |
| LIGHTRAG_URL | localhost:9621 | Non |
| PYTHON_BIN | python3 | Non |
| MAX_OLLAMA_CONCURRENT | 3 | Non |
| ADMIN_BOOTSTRAP_TOKEN | - | Non |
+293
View File
@@ -0,0 +1,293 @@
# Spike: Integration NexusRAG (lot-31) — 2026-03-19
**Date**: 2026-03-19
**Auteur**: Claude (spike automatise)
**Statut**: DRAFT
**Lot**: 31
---
## 1. Resume du projet
| Champ | Valeur |
|---|---|
| **Nom** | NexusRAG |
| **Auteur** | LeDat98 |
| **URL GitHub** | https://github.com/LeDat98/NexusRAG |
| **Stars** | ~197 (mars 2026) |
| **Forks** | ~45 |
| **Licence** | Non specifiee (pas de LICENSE dans le repo) |
| **Cree** | 2026-03-15 |
| **Derniere MAJ** | 2026-03-19 (actif, 4 jours d'age) |
| **Langage** | Python |
| **Issues ouvertes** | 6 |
NexusRAG est un systeme RAG hybride combinant recherche vectorielle, graphe de connaissances
(LightRAG), et cross-encoder reranking, avec parsing documentaire Docling, intelligence
visuelle (captioning images/tableaux), chat agentique streaming, et citations inline.
Alimente par Gemini ou des modeles locaux Ollama.
**Note importante** : ce projet a seulement 4 jours d'existence (cree le 15 mars 2026).
C'est un projet tres recent et experimental.
---
## 2. Architecture
### Pipeline de retrieval hybride a 3 voies
```
Documents (PDF, DOCX, PPTX, HTML, TXT)
|
v
[Docling Parser]
| - Preservation hierarchie titres
| - Enrichissement formules LaTeX
| - Groupement paragraphes, limites de pages
v
[HybridChunker (max_tokens=512, merge_peers=True)]
| - Respecte limites semantiques ET structurelles
| - Ne coupe jamais mid-heading ou mid-table
| - Metadata page-aware (numeros de page, heading paths)
v
+--------------------+--------------------+
| | |
v v v
[Vector Search] [KG Entity Lookup] [Visual Intelligence]
BAAI/bge-m3 LightRAG KG Image/Table
1024d embeddings Gemini 3072d / captioning
Ollama / ST
| | |
+--------------------+--------------------+
|
v
[Cross-Encoder Reranking]
|
v
[Agentic Streaming Chat]
|
v
[Reponse avec citations inline]
```
### Composants cles
| Composant | Detail |
|---|---|
| **Parsing documents** | Docling (PDF, DOCX, PPTX, HTML, TXT) |
| **Chunking** | HybridChunker semantique + structurel, 512 tokens max |
| **Embeddings** | Dual-model : BAAI/bge-m3 (1024d) + KG embedding (Gemini 3072d / Ollama / sentence-transformers) |
| **Vector Search** | Recherche vectorielle classique (over-fetch) |
| **Knowledge Graph** | LightRAG — extraction entites/relations automatique |
| **Reranking** | Cross-encoder (ameliore significativement la precision) |
| **Visual Intelligence** | Captioning images et tableaux dans les documents |
| **Chat** | Streaming agentique avec citations inline |
| **LLM backends** | Gemini (cloud) ou Ollama (local) |
### Dual-Model Embeddings
- **Recherche vectorielle** : BAAI/bge-m3 (1024 dimensions) — modele multilingue performant
- **KG embedding** : Gemini 3072d (cloud) / Ollama embedding (local) / sentence-transformers (offline)
---
## 3. Compatibilite Ollama
NexusRAG supporte nativement Ollama pour un deploiement 100% local :
- **LLM** : tout modele Ollama (gemma2, llama3, mistral, qwen, etc.)
- **Embeddings** : via Ollama ou sentence-transformers (offline complet)
- **Mode offline** : possible sans aucun appel cloud
Cela correspond parfaitement a l'architecture kxkm_clown qui utilise deja Ollama
en natif sur kxkm-ai.
### Test communautaire Ollama
LightRAG (composant interne de NexusRAG) a ete teste avec Ollama + gemma2:2b sur un
GPU de minage avec 6 GB RAM : 197 entites et 19 relations extraites sur un livre de test.
---
## 4. Integration Docling
Docling est le parser documentaire de NexusRAG, developpe par IBM :
| Fonctionnalite | Detail |
|---|---|
| **Formats** | PDF, DOCX, PPTX, HTML, TXT |
| **Preservation structure** | Hierarchie titres, limites pages, groupement paragraphes |
| **Formules** | Notation LaTeX preservee |
| **Tables** | Extraction structurelle (optionnelle, GPU pour table_structure) |
| **GPU** | Optionnel — principalement CPU-bound, GPU pour model table seulement |
| **VRAM** | Minimal avec `convert_do_table_structure=false` |
Docling est principalement CPU-bound (parsing PDF, analyse layout). Le GPU n'accelere
que le modele de structure de tableaux, qui s'active en courtes rafales par page.
---
## 5. Benchmarks et evaluation
### Methodology de test NexusRAG
NexusRAG a ete evalue avec deux methodes complementaires :
| Methode | Detail |
|---|---|
| **16 tests manuels** | 6 categories, 8 metriques rule-based (keyword coverage, refusal accuracy, citation format, language match) |
| **30 tests RAGAS synthetiques** | LLM-as-judge, metriques standard RAGAS |
### Corpus de test
- TechVina Annual Report 2025 (vietnamien, 26 chunks)
- DeepSeek-V3.2 Technical Paper (anglais, 57 chunks)
### Resultats publies
Les benchmarks comparent principalement :
- **Cout-efficacite** : modeles locaux 4B/9B vs cloud
- **Faithfulness** : fidelite aux documents sources
- **Table extraction** : qualite d'extraction de tableaux
- **Consistance multilingue** : vietnamien + anglais
**Note** : pas de benchmark direct NexusRAG vs LightRAG seul publie.
Les 197 stars suggerent un projet encore en phase d'adoption precoce.
### Comparaison conceptuelle : NexusRAG vs LightRAG seul
| Aspect | LightRAG seul | NexusRAG |
|---|---|---|
| **Retrieval** | KG + vecteurs (mode mix) | KG + vecteurs + cross-encoder reranking |
| **Parsing** | Manuel (text brut) | Docling (structure preservee) |
| **Visual** | Non | Captioning images/tableaux |
| **Citations** | Support basique | Citations inline avec sources |
| **Streaming** | Non natif | Chat agentique streaming |
| **Complexity** | Simple, mature (EMNLP 2025) | Plus complet, mais plus jeune |
---
## 6. Capacites cles pour kxkm_clown
### 6.1. RAG documentaire pour les personnages
Les clowns de kxkm_clown pourraient avoir acces a une base documentaire contextuelle :
- Scripts, textes de spectacle
- Fiches de personnages
- Historique des interactions
- Documents techniques/artistiques
NexusRAG permettrait une recherche hybride (vecteurs + graphe de connaissances)
significativement plus precise que le RAG naif.
### 6.2. Intelligence visuelle
Le captioning d'images et de tableaux pourrait enrichir les reponses des personnages
avec du contexte visuel (affiches, photos de scene, plans).
### 6.3. Citations inline
Les reponses avec citations permettent la tracabilite et le debug des hallucinations,
utile pour le monitoring en spectacle.
---
## 7. Plan d'integration (3 phases)
### Phase 1 : Evaluation comparative (2-3 jours)
1. Installer NexusRAG localement sur kxkm-ai
2. Comparer avec LightRAG seul (deja spike le meme jour) :
- Qualite de retrieval sur corpus FR
- Latence de reponse
- Utilisation VRAM avec Ollama
3. Tester Docling sur documents FR reels (scripts, fiches)
4. Evaluer la maturite du code (4 jours d'age seulement)
5. Verifier : est-ce un wrapper fin sur LightRAG ou un apport reel ?
### Phase 2 : Integration conditionnelle (3-5 jours)
*Uniquement si Phase 1 montre un avantage significatif sur LightRAG seul*
1. Integrer le pipeline NexusRAG dans l'API kxkm_clown
2. Configurer Ollama comme backend LLM + embeddings
3. Indexer le corpus documentaire du spectacle
4. Exposer via endpoint REST pour les personas
5. Tester cross-encoder reranking avec bge-reranker-v2-m3
### Phase 3 : Production (2-3 jours)
1. Docker compose avec volumes persistants pour le KG et le vector store
2. Pipeline d'ingestion automatique de nouveaux documents
3. Monitoring latence / qualite dans OPS TUI
4. Cache et optimisation pour le temps reel conversationnel
---
## 8. Risques et bloqueurs
| Risque | Severite | Mitigation |
|---|---|---|
| **Projet de 4 jours d'age** | **HAUTE** | Evaluation approfondie Phase 1 ; fallback sur LightRAG seul |
| **Licence non specifiee** | **HAUTE** | Contacter l'auteur ou attendre clarification avant usage production |
| **197 stars seulement** | Moyenne | Indicateur de maturite faible ; le code peut manquer de robustesse |
| **Pas de benchmark FR** | Moyenne | Tests FR manuels en Phase 1 |
| **Dependance sur LightRAG** | Faible | LightRAG est mature (EMNLP 2025, MIT) ; NexusRAG ajoute une couche |
| **Overlap avec LightRAG spike existant** | Moyenne | Evaluer si NexusRAG apporte assez au-dessus de LightRAG seul |
| **Docling GPU optionnel** | Faible | CPU suffit pour le parsing ; GPU pour tables seulement |
| **6 issues ouvertes, 1 contributeur** | Moyenne | Bus factor de 1, risque d'abandon |
| **Corpus de test non-FR** | Moyenne | Vietnamien + anglais testes ; francais non valide |
---
## 9. Recommandation
### ATTENDRE (evaluer en Phase 1 avant engagement)
**Justification** :
1. **Projet extremement jeune** (4 jours, cree le 15 mars 2026). Malgre 197 stars
et une architecture prometteuse, la maturite est insuffisante pour la production.
2. **Licence non specifiee** : bloqueur pour tout usage serieux. Pas de fichier LICENSE
dans le repository.
3. **Overlap avec LightRAG** : le spike LIGHTRAG_SPIKE_2026-03-19.md couvre deja
LightRAG seul, qui est mature (EMNLP 2025, MIT, 21K+ stars). NexusRAG ajoute
Docling + cross-encoder reranking + visual intelligence par-dessus LightRAG.
4. **La valeur ajoutee est reproductible** : les composants que NexusRAG ajoute
(Docling, cross-encoder reranking, bge-m3) peuvent etre integres manuellement
dans un pipeline LightRAG existant, avec plus de controle.
5. **Bus factor 1** : un seul contributeur, risque d'abandon.
### Alternative recommandee
Plutot que d'adopter NexusRAG en bloc, construire un pipeline equivalent :
```
[Docling] --> [HybridChunker] --> [LightRAG (mature)]
|
[bge-reranker-v2-m3]
|
[API kxkm_clown]
```
Cela donne les memes capacites avec des composants matures et licencies :
- **LightRAG** : MIT, 21K+ stars, EMNLP 2025
- **Docling** : Apache-2.0, IBM, mature
- **bge-reranker-v2-m3** : MIT, BAAI
Surveiller NexusRAG pour evaluer sa maturation dans 2-3 mois.
---
## Sources
- [LeDat98/NexusRAG (GitHub)](https://github.com/LeDat98/NexusRAG)
- [HKUDS/LightRAG (GitHub)](https://github.com/HKUDS/LightRAG)
- [Docling (IBM)](https://www.docling.ai/)
- [LightRAG: Simple and Fast RAG (EMNLP 2025)](https://openreview.net/forum?id=bbVH40jy7f)
- [BAAI/bge-m3 (Hugging Face)](https://huggingface.co/BAAI/bge-m3)
- [Hands-on LightRAG (DEV Community)](https://dev.to/aairom/hands-on-experience-with-lightrag-3hje)
+404 -75
View File
@@ -1,89 +1,418 @@
# Veille OSS — 2026-03-19
# Veille OSS -- kxkm_clown / 3615-KXKM
**Date**: 2026-03-19 (mise a jour approfondie)
Mise a jour veille projets et librairies open source pour 3615-KXKM.
---
## Top recommandations (impact/effort)
| Priorite | Projet | Usage | URL |
| --- | --- | --- | --- |
| 1 | Chatterbox Turbo | TTS zero-shot, 350M, emotion tags, MIT | https://github.com/resemble-ai/chatterbox |
| 2 | LightRAG v1.4.11 | Graph RAG, Ollama natif, workspace isolation | https://github.com/HKUDS/LightRAG |
| 3 | Qwen3-TTS | TTS 0.6B-1.7B, clone 3s, 10 langues, Apache 2.0 | https://github.com/QwenLM/Qwen3-TTS |
| 4 | Kokoro | TTS 82M params, ultra-leger, CPU | https://github.com/hexgrad/kokoro |
| 5 | AgoraAI | Multi-persona voice-to-voice, dual-queue async | https://www.mdpi.com/2076-3417/16/4/2120 |
| 6 | vault66-crt-effect | React CRT presets (npm install) | https://github.com/mdombrov-33/vault66-crt-effect |
| 7 | webgl-crt-shader | WebGL GPU CRT shader, jan 2026 | https://github.com/gingerbeardman/webgl-crt-shader |
| 8 | NexusRAG | Hybrid: LightRAG + Docling + Ollama | https://github.com/LeDat98/NexusRAG |
| 9 | Pocket TTS | 100M params, CPU temps reel, voice cloning | https://github.com/kyutai-labs/pocket-tts |
| 10 | LiveKit Agents v1.4.5 | Voice/WebRTC, MCP natif, Apache 2.0 | https://github.com/livekit/agents |
| 1 | Pocket TTS | TTS CPU temps reel, voice cloning, MIT, 100M params | https://github.com/kyutai-labs/pocket-tts |
| 2 | Qwen3-TTS | Voice design par prompt NL, clone 3s, streaming 97ms | https://github.com/QwenLM/Qwen3-TTS |
| 3 | Kokoro-82M | TTS ultra-rapide (<0.3s), CPU/GPU, Apache 2.0 | https://github.com/hexgrad/kokoro |
| 4 | Dify | Workflow visuel LLM + RAG + agents + MCP server | https://github.com/langgenius/dify |
| 5 | SillyTavern | Multi-persona chat, character cards, group chat | https://github.com/SillyTavern/SillyTavern |
| 6 | Chatterbox Turbo | TTS zero-shot, 350M, emotion tags, MIT (deja integre) | https://github.com/resemble-ai/chatterbox |
| 7 | LightRAG v1.4.11 | Graph RAG, Ollama natif (deja integre) | https://github.com/HKUDS/LightRAG |
| 8 | NodeTool | Visual AI workflow builder, reference pour node engine | https://github.com/nodetool-ai/nodetool |
| 9 | Rivet | Visual prompt graph editor, debug LLM chains | https://github.com/Ironclad/rivet |
| 10 | F5-TTS | Meilleur voice cloning zero-shot, flow matching | https://github.com/SWivid/F5-TTS |
## Nouvelles decouvertes (mars 2026)
---
## 1. Multi-Persona LLM Chat Platforms (Open Source)
### SillyTavern
- **URL**: https://github.com/SillyTavern/SillyTavern
- **Stars**: ~10k+ (300+ contributors, 3 ans de dev)
- **Activite**: Tres actif, grosse communaute
- **Description**: Fork de TavernAI. Character cards avec personnalite/background/scenario. Group chats multi-bots ou les personnages parlent entre eux. Fonctionne avec Ollama, Claude, OpenAI, modeles locaux.
- **Pertinence**: **HAUTE** -- projet existant le plus proche de kxkm_clown. Systeme de character cards similaire aux definitions de persona. Group chat = routage de conversation multi-persona.
- **Integration**: Etudier le format character card pour interoperabilite. Emprunter les patterns UI de switch de persona. Leur logique d'orchestration de group chat est directement pertinente.
### Open WebUI
- **URL**: https://github.com/open-webui/open-webui
- **Stars**: ~90k+
- **Activite**: Extremement actif, frontend Ollama de reference
- **Description**: Plateforme AI self-hosted complete. RAG avec 9 vector DBs, web search (15+ providers dont SearXNG), voice I/O (Whisper + TTS), model builder, multi-user, Python function calling.
- **Pertinence**: **HAUTE** -- overlap significatif avec le stack kxkm_clown. Meme backend Ollama, RAG, web search, integration TTS.
- **Integration**: Architecture de reference pour bonnes pratiques Ollama. Adopter leurs patterns de pipeline RAG. Leur integration SearXNG est directement pertinente (kxkm utilise deja SearXNG).
### LobeChat / LobeHub
- **URL**: https://github.com/lobehub/lobe-chat
- **Stars**: ~50k+
- **Activite**: Tres actif
- **Description**: Plateforme de collaboration multi-agents. Design d'equipes d'agents, ecosysteme de plugins, TTS/STT, upload fichiers, support modeles visuels.
- **Pertinence**: MOYENNE -- le paradigme agent-comme-unite-de-travail correspond au concept de persona. Bonne reference pour patterns UX multi-agents.
- **Integration**: L'architecture de plugins pourrait inspirer les extensions du node engine kxkm.
### LibreChat
- **URL**: https://github.com/danny-avila/LibreChat
- **Stars**: ~25k+
- **Description**: Interface chat AI unifiee. Multi-provider, branching de conversations, presets, plugins. API compatible OpenAI.
- **Pertinence**: MOYENNE -- le branching de conversations et le routage multi-provider sont des patterns pertinents.
### Eliza (BarbarossaKad)
- **URL**: https://github.com/BarbarossaKad/Eliza
- **Description**: Systeme de roleplay AI self-hosted. 100% local, zero fuites de donnees. Alternative open-source a Character.AI utilisant Ollama.
- **Pertinence**: MOYENNE -- meme philosophie que kxkm (local-first, Ollama, base sur les personnages).
---
## 2. Frameworks d'Orchestration LLM (Alternatives a LangChain)
### Dify
- **URL**: https://github.com/langgenius/dify
- **Stars**: ~70k+
- **Activite**: Extremement actif
- **Description**: Plateforme open-source d'apps LLM. Workflow builder visuel, pipeline RAG, 50+ outils agents integres, support serveur MCP, integration Ollama/LocalAI. Self-hosted, donnees restent locales.
- **Pertinence**: **HAUTE** -- le workflow builder visuel est parallele au node engine kxkm. Integration Ollama, RAG, support protocole MCP. Pourrait remplacer ou complementer l'orchestration custom.
- **Integration**: La capacite serveur MCP signifie que les workflows Dify pourraient etre exposes comme outils MCP vers kxkm. Les patterns de workflow visuel informent le design du node engine.
### LlamaIndex
- **URL**: https://github.com/run-llama/llama_index
- **Stars**: ~40k+
- **Description**: Framework de donnees pour apps LLM. Best-in-class pour search/retrieval, metadata structuree, traitement de documents.
- **Pertinence**: MOYENNE -- alternative de pipeline RAG. Meilleure metadata structuree que LightRAG pour certains cas.
### Haystack
- **URL**: https://github.com/deepset-ai/haystack
- **Stars**: ~20k+
- **Description**: Framework NLP/LLM end-to-end. Architecture pipeline, document stores, retrievers, generators. Production-ready.
- **Pertinence**: MOYENNE -- architecture pipeline mature. Bon pour hardening production du RAG.
### Flowise
- **URL**: https://github.com/FlowiseAI/Flowise
- **Stars**: ~35k+
- **Description**: UI low-code visuelle pour construire des chaines/agents LLM. Construit sur LangChain.js. Drag-and-drop.
- **Pertinence**: MOYENNE -- paradigme de builder visuel overlap avec le concept de node engine.
### LangGraph
- **URL**: https://github.com/langchain-ai/langgraph
- **Stars**: ~10k+
- **Description**: Apps multi-agents avec cycles, agents long-running, haut controle.
- **Pertinence**: BASSE-MOYENNE -- pertinent si kxkm a besoin de machines a etats agents complexes (graphes d'interaction de personas).
---
## 3. Streaming WebSocket pour Reponses LLM
### Bonnes pratiques (consolidees)
1. **WebSocket full-duplex > SSE pour le chat**: WebSocket permet communication bidirectionnelle (l'utilisateur peut interrompre/annuler la generation en cours). SSE est plus simple mais unidirectionnel.
2. **Forwarding token-par-token**: Forward chaque token de la reponse streaming Ollama directement au client WebSocket. Ne pas bufferiser la reponse entiere.
3. **Gestion de backpressure**: Monitorer le taux de consommation client. Si le buffer d'envoi WebSocket se remplit, pauser le streaming Ollama pour eviter l'accumulation memoire.
4. **Reconnexion avec reprise**: Implementer des IDs de message pour que les clients puissent se reconnecter et reprendre depuis le dernier token recu.
5. **Slots de modeles concurrents**: Ollama supporte `OLLAMA_MAX_LOADED_MODELS` pour inference parallele. Utiliser des canaux WebSocket separes par persona pour activer de vraies reponses multi-persona concurrentes.
6. **Heartbeat/ping-pong**: Garder les connexions vivantes a travers NAT/proxies avec des pings periodiques.
7. **Frames binaires pour l'audio**: Quand on stream des chunks audio TTS via WebSocket, utiliser des frames binaires (pas base64 dans JSON). 33% d'economie de bande passante.
8. **Multiplexage de canaux**: Utiliser un systeme de channel/topic dans les messages WS pour gerer plusieurs streams concurrents (generation texte + TTS + status generation image).
### Format de message recommande
```json
{
"type": "token|audio|image|status|error",
"persona": "pharmacius",
"channel": "chat-123",
"seq": 42,
"data": "..."
}
```
### Projets de reference
- **Resonance Framework** (distantmagic/resonance) -- Framework PHP async avec integration WebSocket llama.cpp
- **web-llm** (mlc-ai/web-llm) -- ~15k+ stars. Inference LLM in-browser via WebGPU
- **AG2** -- Framework agent avec streaming WebSocket pour chat multi-agent
---
## 4. Ollama Tool Calling / Function Calling
### Meilleurs modeles pour tool calling (2025-2026)
| Modele | Taille | Qualite Tool Calling | Notes |
|--------|--------|---------------------|-------|
| Llama 3.1 8B-Instruct | 8B | Meilleur overall | Implementation de reference Meta |
| Qwen 2.5 7B | 7B | Excellent | Bon tool calling multilingual |
| Mistral 7B | 7B | Bon | Moins de ressources necessaires |
| Llama 3.3 70B | 70B | Excellent | Necessite RTX 4090 (kxkm en a une) |
| Command-R+ | 35B | Tres bon | Optimise pour l'utilisation d'outils |
### Bonnes pratiques
- **Format JSON Schema** pour les definitions d'outils via le champ `tools` de `/api/chat` Ollama
- **Tool calls en streaming** supporte -- commencer l'action avant la reponse complete
- **Modeles concurrents multiples**: Utiliser `OLLAMA_MAX_LOADED_MODELS` pour garder un petit modele pour le routage d'outils et un plus gros pour la generation
- **La fiabilite chute sous 8B params** -- pour du tool calling complexe, utiliser des modeles 8B+
- **Mode sortie structuree** (`format: json`) aide a forcer des reponses JSON valides pour les tool calls
- **Chaine de fallback**: Essayer tool call -> si JSON malformed, retry avec prompt plus simple -> fallback en texte libre
### References
- Docs officiels: https://docs.ollama.com/capabilities/tool-calling
- Blog post: https://ollama.com/blog/tool-support
---
## 5. Solutions TTS (Alternatives/Upgrades a Piper)
### Tier 1: Upgrades drop-in pour kxkm
#### Kokoro-82M
- **URL**: https://github.com/hexgrad/kokoro
- **Licence**: Apache 2.0
- **Taille**: 82M params (~300MB, quantise ~80MB)
- **Vitesse**: Sous 0.3s pour n'importe quel texte. 36x temps reel sur GPU. Quasi temps reel sur CPU.
- **Qualite**: Note 5/5 en naturalite. Gamme emotionnelle limitee.
- **Voice Cloning**: Pas de cloning natif. Librairie de voix preselectionnees.
- **Langues**: Anglais, Francais, Japonais, Coreen, Chinois, autres
- **Pertinence**: **HAUTE** -- minuscule, rapide, Apache. Parfait pour reponses personas a faible latence. Support francais.
- **Integration**: Remplacer Piper pour les chemins critique-vitesse. Runtime ONNX ou crate Rust disponible. Wrapper FastAPI existe (Kokoro-FastAPI).
#### Kyutai Pocket TTS
- **URL**: https://github.com/kyutai-labs/pocket-tts
- **Licence**: MIT
- **Taille**: 100M params
- **Vitesse**: Temps reel sur CPU (RTF ~0.17 sur M4, ~6x plus rapide que temps reel). Pas de GPU necessaire.
- **Qualite**: Plus bas WER (1.84%) parmi les concurrents. Haute fidelite.
- **Voice Cloning**: Oui, 5 secondes d'audio de reference.
- **Pertinence**: **TRES HAUTE** -- CPU temps reel + voice cloning + licence MIT + minuscule. Peut tourner a cote d'Ollama sans contention GPU.
- **Integration**: 5 lignes de Python. Lancer comme service sidecar. Parfait pour kxkm ou le GPU est occupe avec Ollama/ComfyUI.
### Tier 2: Haute qualite, plus de ressources
#### Chatterbox Turbo (deja dans le stack kxkm)
- **URL**: https://github.com/resemble-ai/chatterbox
- **Licence**: MIT
- **Taille**: 350M params
- **Qualite**: Bat ElevenLabs en tests aveugles (63.75% preference). 1M+ downloads HuggingFace.
- **Voice Cloning**: Oui, zero-shot. Expressivite configurable. Tags paralinguistiques [laugh] [cough].
- **Langues**: 23 langues dont le francais
- **Status**: Deja integre dans kxkm. Garder comme moteur haute-qualite principal.
#### F5-TTS
- **URL**: https://github.com/SWivid/F5-TTS
- **Licence**: MIT-like
- **Qualite**: Cloning zero-shot le plus realiste. Architecture flow matching + DiT.
- **Voice Cloning**: Oui, zero-shot a partir d'un court echantillon.
- **Pertinence**: HAUTE -- meilleure qualite de cloning que Chatterbox pour certaines voix.
- **Integration**: Backend TTS secondaire pour les personas necessitant un cloning vocal tres specifique.
#### Qwen3-TTS
- **URL**: https://github.com/QwenLM/Qwen3-TTS
- **Licence**: Apache 2.0
- **Taille**: 0.6B - 1.7B params
- **Vitesse**: 97ms latence premier token (architecture streaming)
- **Voice Cloning**: Oui, 3 secondes d'audio de reference
- **Special**: Design de voix par langage naturel ("fais-le sonner comme un vieux professeur fatigue"). Controle emotion/ton/prosodie.
- **Langues**: 10 langues dont le francais
- **Pertinence**: **HAUTE** -- le design de voix en langage naturel est parfait pour creer des voix distinctes par persona. Integration ComfyUI existante.
- **Integration**: Utiliser des prompts de design vocal pour creer des voix uniques par persona. Node ComfyUI deja disponible. Architecture streaming compatible avec le pipeline WebSocket.
### Tier 3: Specialise
#### CosyVoice2
- **URL**: https://github.com/FunAudioLLM/CosyVoice
- **Description**: Multi-lingue, ultra-basse latence, controle emotionnel. Par Alibaba/FunAudioLLM.
#### MeloTTS
- **Description**: Multilingual, basse latence, nombreux accents. Pas de voice cloning.
- **Pertinence**: BASSE -- Kokoro est meilleur pour le meme usage.
### Strategie TTS recommandee pour kxkm
```
Chemin rapide/CPU: Kokoro-82M ou Pocket TTS (< 200ms, pas de GPU)
Chemin qualite: Chatterbox (actuel) (GPU, meilleure qualite)
Chemin cloning: Qwen3-TTS ou F5-TTS (GPU, design de voix)
```
---
## 6. Frameworks RAG compatibles Ollama
### LightRAG (deja dans le stack kxkm)
- **URL**: https://github.com/HKUDS/LightRAG
- **Stars**: 29.4k
- **Status**: Deja integre. RAG base graphe, rapide, tourne sur CPU.
- **Verdict**: Garder. Meilleur equilibre vitesse/qualite pour retrieval augmente par graphe.
### Nano-GraphRAG
- **URL**: https://github.com/gusye1234/nano-graphrag
- **Description**: Alternative GraphRAG legere. Trois modes de requete (Naive, Local, Global). Plus simple que LightRAG.
- **Pertinence**: MOYENNE -- alternative plus simple si LightRAG devient trop complexe.
### RAGFlow
- **URL**: https://github.com/infiniflow/ragflow
- **Stars**: ~70k+
- **Description**: Moteur RAG avec comprehension profonde de documents. Chunking avance, extraction tables/images.
- **Pertinence**: MOYENNE -- meilleur pour traitement lourd de documents (PDFs avec tables, etc).
### NexusRAG
- **URL**: https://github.com/LeDat98/NexusRAG
- **Description**: Hybride: vector + graphe LightRAG + cross-encoder reranking + Docling. Supporte Ollama nativement.
- **Pertinence**: MOYENNE-HAUTE -- evolution naturelle du setup LightRAG actuel.
### Chroma + Ollama (RAG vectoriel simple)
- **URL**: https://github.com/chroma-core/chroma
- **Stars**: ~18k+
- **Description**: Vector DB legere. Integration facile embeddings Ollama.
- **Pertinence**: MOYENNE -- plus simple que LightRAG quand les relations de graphe ne sont pas necessaires.
### Strategie RAG recommandee
```
Requetes graphe: LightRAG (actuel) -- retrieval conscient des relations
Vecteur simple: Chroma -- recherche de similarite rapide
Documents lourds: RAGFlow -- si parsing PDF/tables necessaire
Hybride: NexusRAG -- quand les deux sont necessaires
```
---
## 7. Projets AI Creatifs / Performance Artistique
### NodeTool
- **URL**: https://github.com/nodetool-ai/nodetool
- **Description**: Builder visuel pour workflows/agents AI. Node-based, local-first, multimodal (texte/image/video/audio). Connexions de nodes type-safe.
- **Pertinence**: **HAUTE** -- directement comparable au concept de node engine kxkm. Editeur visuel de graphes pour workflows AI avec connexions type-safe.
- **Integration**: Etudier leur systeme de types de nodes et la validation de connexions. Peut informer le design du registry du node engine kxkm.
### Rivet
- **URL**: https://github.com/Ironclad/rivet
- **Stars**: ~3k+
- **Description**: Environnement de programmation AI visuel open-source. Editeur de graphes node-based pour chaines de prompts LLM. Debug et collaboration sur des graphes de prompts.
- **Pertinence**: **HAUTE** -- le plus proche du concept de node engine kxkm pour des workflows specifiques LLM.
- **Integration**: Leurs outils de debug de graphes de prompts sont directement pertinents. Adapter leur format de serialisation de graphes.
### Invoke AI
- **URL**: https://github.com/invoke-ai/InvokeAI
- **Stars**: ~25k+
- **Licence**: Apache 2.0
- **Description**: Moteur creatif pour generation d'images AI. Self-hosted, entierement personnalisable. Editeur de workflow node-based.
- **Pertinence**: MOYENNE -- workflow creatif AI node-based. Parallele avec l'approche ComfyUI + node engine de kxkm.
### ChainForge
- **URL**: https://github.com/ianarawjo/ChainForge
- **Description**: Environnement de programmation visuelle pour battle-tester des prompts LLM. Analyse de data flow.
- **Pertinence**: MOYENNE -- evaluation de prompts et A/B testing des prompts de personas.
### AgoraAI
- **URL**: https://www.mdpi.com/2076-3417/16/4/2120
- **Description**: Framework voice-to-voice multi-persona (paper fev 2026). Resout le "Concurrency-Coherence Paradox" via Asynchronous Dual-Queue Processing.
- **Pertinence**: **HAUTE** -- directement applicable au use-case kxkm_clown pour conversations multi-persona concurrentes.
### NeurIPS Creative AI Track
- **URL**: https://neurips.cc/Conferences/2025/CallForCreativeAI
- **Description**: Papiers de recherche et oeuvres explorant l'AI dans l'art/design/performance.
- **Pertinence**: BASSE mais inspirante -- recherche academique sur AI + performance creative.
---
## 8. Architecture WebSocket Recommandee
```
Client (UI Minitel)
|
| WebSocket (wss://)
|
API Server (Node.js)
|
+-- Ollama streaming API (HTTP SSE)
+-- TTS sidecar (HTTP streaming)
+-- ComfyUI (HTTP polling -> WS notify)
|
Persona Router
|
+-- Route message vers le bon modele/config persona
+-- Gere le contexte de conversation par persona
+-- Gere les reponses concurrentes de personas (group chat)
```
---
## Feuille de route d'integration prioritaire
### Immediat (faible effort, fort impact)
| Projet | Action | Effort |
|---------|--------|--------|
| **Pocket TTS** | Ajouter comme backend TTS CPU-only a cote de Chatterbox | 1-2 jours |
| **Kokoro-82M** | Ajouter comme TTS ultra-rapide pour reponses basse-latence | 1 jour |
| **Ollama tool calling** | Implementer le tool calling structure pour les actions de personas | 2-3 jours |
### Court terme (effort moyen)
| Projet | Action | Effort |
|---------|--------|--------|
| **Qwen3-TTS** | Design vocal par persona via prompts en langage naturel | 3-5 jours |
| **SillyTavern** | Etudier le format character card, envisager la compatibilite import | 2 jours |
| **NodeTool/Rivet** | Informer le design du node engine avec leurs patterns | Recherche |
### Moyen terme (effort plus eleve)
| Projet | Action | Effort |
|---------|--------|--------|
| **Dify** | Evaluer comme workflow builder visuel pour pipelines persona complexes | 1 semaine |
| **Open WebUI** | Etudier patterns RAG/search pour ameliorer kxkm | Recherche |
| **F5-TTS** | Ajouter comme backend de voice cloning premium | 3-5 jours |
| **NexusRAG** | Evaluer comme upgrade hybride de LightRAG | 3 jours |
---
## Sources
### Multi-Persona Chat
- [SillyTavern Docs](https://docs.sillytavern.app/)
- [Open WebUI](https://github.com/open-webui/open-webui)
- [LobeChat](https://github.com/lobehub/lobe-chat)
- [LibreChat](https://www.librechat.ai/)
- [Eliza](https://github.com/BarbarossaKad/Eliza)
### LLM Orchestration
- [Dify AI](https://github.com/langgenius/dify)
- [LlamaIndex](https://github.com/run-llama/llama_index)
- [Haystack](https://github.com/deepset-ai/haystack)
- [Flowise](https://flowiseai.com/)
- [LangGraph](https://github.com/langchain-ai/langgraph)
- [Top LangChain Alternatives](https://www.vellum.ai/blog/top-langchain-alternatives)
- [LLM Orchestration 2026](https://aimultiple.com/llm-orchestration)
### TTS
- **Qwen3-TTS** (Alibaba, jan 2026) — 0.6B-1.7B params, clone voix en 3s, 10 langues, latence 97ms, Apache 2.0. FastAPI OpenAI-compat. Concurrent serieux de Chatterbox.
- **Pocket TTS** (Kyutai) — 100M params, CPU temps reel, voice cloning. Ultra-leger, ideal edge/embarque. ~3600 stars.
- **Chatterbox Turbo** — 350M params, 1-step diffusion, tags paralinguistiques [laugh] [cough]. Multilingual (23 langues).
- [Kokoro-82M](https://github.com/hexgrad/kokoro)
- [Kyutai Pocket TTS](https://github.com/kyutai-labs/pocket-tts)
- [Chatterbox](https://github.com/resemble-ai/chatterbox)
- [F5-TTS](https://github.com/SWivid/F5-TTS)
- [Qwen3-TTS](https://github.com/QwenLM/Qwen3-TTS)
- [CosyVoice2](https://github.com/FunAudioLLM/CosyVoice)
- [Best Open-Source TTS Models 2026](https://www.bentoml.com/blog/exploring-the-world-of-open-source-text-to-speech-models)
- [Open-Source TTS Comparison](https://www.inferless.com/learn/comparing-different-text-to-speech---tts--models-part-2)
- [Chatterbox vs Kokoro vs others](https://ocdevel.com/blog/20250720-tts)
### RAG
- [LightRAG](https://github.com/HKUDS/LightRAG)
- [Nano-GraphRAG](https://github.com/gusye1234/nano-graphrag)
- [RAGFlow](https://github.com/infiniflow/ragflow)
- [NexusRAG](https://github.com/LeDat98/NexusRAG)
- [Best Open-Source RAG Frameworks 2026](https://www.firecrawl.dev/blog/best-open-source-rag-frameworks)
- **LightRAG v1.4.11rc2** (13 mars 2026) — 29.4k stars. Nouveau Makefile, batch query embeddings, Qdrant fixes.
- **RAG-Anything** (HKUDS) — Extension LightRAG pour multimodal (images, tables, formules).
- **NexusRAG** — Hybrid: vector + LightRAG graph + cross-encoder reranking + Docling. Supporte Ollama nativement.
- **ApeRAG** — Production-ready GraphRAG, multi-modal, MCP support, K8s.
### Node/Visual Programming
- [NodeTool](https://github.com/nodetool-ai/nodetool)
- [Rivet](https://rivet.ironcladapp.com/)
- [ChainForge](https://github.com/ianarawjo/ChainForge)
- [Invoke AI](https://invoke.ai/)
### Multi-persona / Agents
### WebSocket/Streaming
- [web-llm](https://github.com/mlc-ai/web-llm)
- [Resonance Framework](https://github.com/distantmagic/resonance)
- [Ollama Tool Calling Docs](https://docs.ollama.com/capabilities/tool-calling)
- [ComfyUI LLM Toolkit](https://github.com/Big-Idea-Technology/ComfyUI_LLM_Node)
- **AgoraAI** (fev 2026, paper MDPI) — Framework voice-to-voice multi-persona. Resout le "Concurrency-Coherence Paradox" via Asynchronous Dual-Queue Processing. Directement applicable au use-case kxkm_clown.
- **CrewAI** — 44.3k stars, role-playing agents. Trop enterprise pour kxkm.
- **LobeChat Agent Groups** — Equipes d'agents specialises collaborant en parallele.
### MCP 2026
- **Roadmap 2026** : Streamable HTTP (serveurs MCP distants sans etat), Tasks (lifecycle), Enterprise (audit, SSO, gateway).
- **Ecosysteme** : OpenAI et Microsoft supportent MCP. Catalogue mcpservers.org.
- **LiveKit Agents** : Integration MCP native depuis v1.4.
### UI Retro / CRT
- **webgl-crt-shader** (gingerbeardman, jan 2026) — WebGL pur, GPU-accelere, tweakable. Plus performant qu'overlay CSS.
- **crt-beam-simulator** (Blur Busters) — Simulation physique du faisceau CRT, le plus realiste.
## Par categorie (mise a jour)
### TTS
| Projet | Stars | Licence | Notes |
| --- | --- | --- | --- |
| Chatterbox Turbo | 11k+ | MIT | Zero-shot, emotion, 350M, 1-step diffusion |
| Qwen3-TTS | Nouveau | Apache 2.0 | 0.6B-1.7B, clone 3s, 10 langues |
| Kokoro | 3k+ | Apache | 82M, ultra-rapide, CPU |
| Pocket TTS | 3.6k | Open | 100M, CPU temps reel |
| RealtimeTTS | 3.8k | MIT | Abstraction multi-backends |
| OpenVoice | 36k | MIT | Voice cloning zero-shot |
### RAG
| Projet | Stars | Notes |
| --- | --- | --- |
| LightRAG | 29.4k | Graph RAG, Ollama natif, v1.4.11rc2 |
| RAGFlow | 70k+ | Enterprise, deep document understanding |
| NexusRAG | Nouveau | Hybrid LightRAG + Docling + Ollama |
| ApeRAG | Nouveau | GraphRAG, MCP, K8s |
### Embeddings
| Modele | Taille | Ollama | Notes |
| --- | --- | --- | --- |
| nomic-embed-text | ~137M | Oui | Baseline actuelle |
| BGE-M3 | ~568M | Oui | Hybrid dense+sparse, multilingual |
| mxbai-embed-large | ~335M | Oui | Depasse text-embedding-3-large |
| Qwen3-Embedding-0.6B | 600M | A verifier | Nouveau 2026 |
### Voice temps reel
| Projet | Stars | Notes |
| --- | --- | --- |
| LiveKit Agents | 9.7k | v1.4.5, MCP natif, Apache 2.0 |
| pipecat | Actif | Voice/multimodal conversational AI |
| AgoraAI | Paper | Multi-persona voice, dual-queue |
### Creative AI
- [AgoraAI Paper](https://www.mdpi.com/2076-3417/16/4/2120)
- [NeurIPS Creative AI 2025](https://neurips.cc/Conferences/2025/CallForCreativeAI)
+348
View File
@@ -0,0 +1,348 @@
# Spike: Evaluation Pocket TTS (Kyutai) — 2026-03-19
## 1. Presentation du projet
| Champ | Valeur |
|---|---|
| **Nom** | Pocket TTS |
| **Editeur** | Kyutai Labs (labo FR, Paris) |
| **URL GitHub** | https://github.com/kyutai-labs/pocket-tts |
| **Stars** | ~3 600 |
| **Derniere version** | v1.1.1 (16 fevrier 2026) |
| **Licence** | MIT |
| **PyPI** | `pip install pocket-tts` |
| **Hugging Face** | https://huggingface.co/kyutai/pocket-tts |
| **Blog** | https://kyutai.org/blog/2026-01-13-pocket-tts |
| **Tech Report** | https://kyutai.org/pocket-tts-technical-report |
| **Python** | 3.10, 3.11, 3.12, 3.13, 3.14 |
| **Deps** | PyTorch 2.5+ (version CPU suffit) |
**Resume**: Pocket TTS est un modele TTS de 100M parametres, concu pour tourner en temps reel sur CPU sans GPU. Il supporte le voice cloning zero-shot a partir de ~5 secondes d'audio de reference. Developpe par Kyutai, le meme labo francais derriere Moshi (IA conversationnelle).
## 2. Architecture
### Composants principaux
```
Texte -> [Normalisation] -> [FlowLMModel] -> [Mimi Decoder] -> Audio PCM
^
[Speaker Encoder]
(voice cloning)
```
| Composant | Role | Details |
|---|---|---|
| **FlowLMModel** | Generation de latents audio a partir de texte | Flow Matching avec Lagrangian Self-Distillation (LSD) |
| **MimiModel** | Codec audio neural | Encode/decode entre PCM et latents, base sur Mimi (Kyutai/Moshi) |
| **Speaker Encoder** | Extraction d'embeddings de voix | Zero-shot, ~5s de reference audio |
### Parametres du modele
- **100M parametres** au total (FlowLM + Mimi)
- Le FlowLM genere les latents en **un seul forward pass** (pas de diffusion iterative)
- Regularisation semantique via distillation de WavLM
- Sample rate: 24 kHz (Mimi natif)
### Pourquoi c'est rapide
Contrairement aux modeles de diffusion (Chatterbox Original: 10 steps, Chatterbox Turbo: 1 step), Pocket TTS utilise le **flow matching single-step** via LSD. Le bruit gaussien est converti en latent audio en un seul forward pass. C'est la raison principale de la vitesse CPU.
## 3. Voice cloning
| Critere | Detail |
|---|---|
| **Type** | Zero-shot speaker adaptation |
| **Audio de reference** | ~5 secondes minimum |
| **Format** | WAV (tout format lisible par torchaudio) |
| **Mecanisme** | Speaker encoder extrait un embedding, conditionnement de la generation |
| **Qualite** | Bonne pour 100M params, inferieure aux modeles 500M+ |
| **Voice caching** | Export en `.safetensors` pour chargement rapide |
### Utilisation CLI
```bash
# Voice cloning avec fichier WAV
pocket-tts generate --voice ./reference.wav --text "Bonjour le monde"
# Voix pre-definies Kyutai (HuggingFace)
pocket-tts generate --voice alba --text "Hello world"
# Export du voice state pour reutilisation rapide
pocket-tts export-voice --voice ./reference.wav --output voice_state.safetensors
```
### Voices pre-definies
Kyutai fournit un repertoire de voix sur HuggingFace: https://huggingface.co/kyutai/tts-voices
## 4. Support linguistique
### Etat actuel: anglais uniquement
Pocket TTS v1.1.1 ne supporte que l'anglais. C'est le **point bloquant principal** pour kxkm_clown (33 personas FR).
### Langues planifiees (pas de date)
Issue officielle: https://github.com/kyutai-labs/pocket-tts/issues/118
Langues annoncees (non-exhaustif, sans date de sortie):
- **Francais** (confirme)
- Espagnol
- Allemand
- Portugais
- Italien
### Alternative FR chez Kyutai: tts-1.6b-en_fr
Kyutai a un **autre modele** qui supporte le francais:
| Champ | Valeur |
|---|---|
| **Nom** | kyutai/tts-1.6b-en_fr |
| **HuggingFace** | https://huggingface.co/kyutai/tts-1.6b-en_fr |
| **Params** | 1.8B (backbone 1B + depth transformer 600M) |
| **Langues** | Anglais + Francais |
| **Type** | Streaming TTS (Delayed Streams Modeling) |
| **Training data** | 2.5M heures audio public |
| **Frame rate** | 12.5 Hz, 32 tokens/frame |
Ce modele est bien plus gros (1.8B vs 100M) et n'est **pas** concu pour tourner sur CPU. Il necessite un GPU. Son architecture (Delayed Streams Modeling) est differente de Pocket TTS (Flow Matching).
## 5. Benchmarks de latence
### Pocket TTS sur CPU
| Plateforme | RTF | Vitesse | Cores | Notes |
|---|---|---|---|---|
| MacBook Air M4 | 0.17 | ~6x temps reel | 2 cores | Benchmark officiel |
| CPU x86 generique | ~0.3-0.5 | ~2-3x temps reel | Variable | Estimation communaute |
- **First-chunk latency**: ~200ms (mode streaming)
- **GPU**: Pas d'acceleration observee (modele trop petit, batch=1, overhead kernel launch)
### Comparaison des latences
| Moteur | Latence typique | Hardware | RTF |
|---|---|---|---|
| **Piper** | ~50ms | CPU (ONNX) | <0.1 |
| **Pocket TTS** | ~200ms (streaming) | CPU (PyTorch) | ~0.17 |
| **Chatterbox Turbo** | ~500ms-1.5s | GPU (1 step) | ~0.5 |
| **Chatterbox Multilingual** | ~2-5s | GPU (10 steps) | ~2-5 |
## 6. API Python
### Installation
```bash
pip install pocket-tts
# ou
uvx pocket-tts generate # zero-install avec uv
```
### Usage basique
```python
from pocket_tts import TTSModel
import scipy.io.wavfile
# Charger le modele (CPU par defaut)
tts_model = TTSModel.load_model()
# Voice state depuis une voix pre-definie
voice_state = tts_model.get_state_for_audio_prompt("alba")
# Voice state depuis un fichier WAV (voice cloning)
voice_state = tts_model.get_state_for_audio_prompt("./reference.wav")
# Generation audio
audio = tts_model.generate_audio(voice_state, "Hello world")
scipy.io.wavfile.write("output.wav", tts_model.sample_rate, audio.numpy())
```
### Streaming
```python
# Generation en streaming (chunks)
for chunk in tts_model.generate_audio_stream(voice_state, "Long text here..."):
# chunk est un tensor audio, ~200ms pour le premier
process_audio(chunk)
```
### Voice caching (optimisation)
```python
# Exporter le voice state (evite de re-encoder la reference a chaque appel)
tts_model.export_voice_state(voice_state, "persona.safetensors")
# Recharger rapidement
voice_state = tts_model.load_voice_state("persona.safetensors")
```
### Serveur HTTP integre
```bash
pocket-tts serve
# -> http://localhost:8000 (interface web + API)
```
### Docker
Le repo inclut un `Dockerfile` et `docker-compose.yaml` officiels. Des images communautaires existent aussi (pocket-tts-wyoming pour Home Assistant, OpenAI-compatible servers).
## 7. Comparaison avec le stack actuel (Piper + Chatterbox)
### Tableau comparatif
| Critere | Piper | Pocket TTS | Chatterbox Multilingual | Chatterbox Turbo |
|---|---|---|---|---|
| **Params** | ~15-20M (VITS) | 100M | 500M (LLaMA) | 350M |
| **Qualite** | Moyenne | Bonne | Excellente | Tres bonne |
| **Latence** | ~50ms (CPU) | ~200ms (CPU) | ~2-5s (GPU) | ~0.5-1.5s (GPU) |
| **Hardware** | CPU (ONNX) | CPU (PyTorch) | GPU (CUDA) | GPU (CUDA) |
| **VRAM** | 0 | 0 | ~3-4 GB | ~2 GB |
| **Voice cloning** | Non | Oui (zero-shot, 5s) | Oui (zero-shot, 6-30s) | Oui (zero-shot, 6-30s) |
| **Francais** | Oui (voix pre-faites) | **Non** (EN only) | Oui (natif, 23 langues) | Non (EN only) |
| **Emotion control** | Non | Non | Oui (exaggeration) | Oui (paralinguistics) |
| **Streaming** | Non | Oui (~200ms first chunk) | Non | Non |
| **Licence** | MIT | MIT | MIT | MIT |
| **Deps** | onnxruntime (leger) | torch (CPU only) | torch + CUDA | torch + CUDA |
### Analyse
**Avantages de Pocket TTS par rapport a Piper:**
- Voice cloning zero-shot (Piper n'en a pas)
- Qualite vocale superieure (100M vs ~15M)
- Streaming natif avec faible latence premier chunk
- API Python moderne et bien documentee
**Avantages de Pocket TTS par rapport a Chatterbox:**
- Tourne sur CPU pur (pas de GPU requis)
- Latence ~10x plus faible (~200ms vs ~2-5s)
- Installation triviale (`pip install pocket-tts`)
- Modele leger (100M vs 350-500M)
- Streaming natif
**Inconvenients de Pocket TTS:**
- **Pas de francais** (bloquant pour 32/33 personas)
- Qualite inferieure a Chatterbox (100M vs 500M)
- Pas de controle d'emotion/expressivite
- Pas de tags paralinguistiques (`[laugh]`, `[cough]`)
- Communaute plus petite (3.6k stars vs 23.7k pour Chatterbox)
## 8. Plan d'integration pour kxkm_clown
### Verdict: NE PAS INTEGRER MAINTENANT
L'absence de support francais est **redhibitoire** pour le projet. 32 des 33 personas parlent francais. Pocket TTS ne peut pas remplacer ni Piper ni Chatterbox dans l'etat actuel.
### Plan conditionnel (si/quand le francais arrive)
#### Phase 1: Veille et test EN (0.5 jour, quand FR annonce)
1. Installer Pocket TTS sur kxkm-ai: `pip install pocket-tts`
2. Tester la voix clonee de Moorcock (seule persona EN)
3. Comparer qualite/latence avec Chatterbox Turbo pour cette persona
4. Tester le serveur integre (`pocket-tts serve`) sur :9300
#### Phase 2: Migration persona EN (1 jour, apres validation Phase 1)
1. Integrer Pocket TTS comme backend supplementaire dans `tts-server.py`
2. Router Moorcock vers Pocket TTS (CPU, ~200ms) au lieu de Chatterbox Turbo (GPU, ~500ms)
3. Avantage: liberer de la VRAM GPU pour Chatterbox Multilingual (FR)
```
Docker (API) --HTTP :9100--> tts-server.py
|
persona.lang == "en" ?
/ \
Pocket TTS (CPU) Chatterbox Multilingual (GPU)
:9300 :9200
\
Piper (fallback CPU, FR)
```
#### Phase 3: Migration complete FR (2-3 jours, quand FR disponible et valide)
1. Tester Pocket TTS FR sur les 5 personas representatives (Schaeffer, Batty, Radigue, Merzbow, Pharmacius)
2. Comparer qualite/latence avec Chatterbox Multilingual
3. Si qualite acceptable:
- Migrer toutes les personas FR vers Pocket TTS (CPU)
- Avantage massif: **liberation totale de la VRAM GPU** pour Ollama
- Chatterbox en fallback pour les personas necessitant le controle d'emotion
4. Exporter tous les voice states en `.safetensors` pour chargement rapide
### Scenario ideal (post-FR)
```
Docker (API) --HTTP :9100--> tts-server.py
|
Pocket TTS (CPU, defaut)
:9300, toutes langues
|
qualite insuffisante ?
/ \
Chatterbox (GPU) Piper (fallback leger)
:9200 (emotion+) ONNX, CPU
```
Budget VRAM libere: **3-4 GB** (Chatterbox decharge). Ollama peut utiliser des modeles plus gros.
## 9. Risques et limitations
| Risque | Impact | Probabilite | Mitigation |
|---|---|---|---|
| **Pas de francais** | Bloquant, inutilisable pour 32/33 personas | Certaine (etat actuel) | Attendre la release FR. Surveiller issue #118. |
| **Date FR inconnue** | Pas de planning possible | Elevee | Aucune date annoncee. Pourrait etre des semaines ou des mois. |
| **Qualite FR** incertaine | Voice cloning FR peut etre inferieur a Chatterbox | Moyenne | Kyutai est un labo FR, bon signe. Mais 100M vs 500M, gap probable. |
| **Pas d'emotion control** | Personas expressives (Merzbow, Batty) moins differenciees | Certaine | Garder Chatterbox pour les personas a forte expressivite. |
| **Modele 100M** limites expressives | Moins de nuances que Chatterbox | Certaine | Acceptable pour la majorite des personas "calmes". |
| **PyTorch CPU** plus lourd qu'ONNX | RAM superieure a Piper (~400 MB vs ~100 MB) | Certaine | kxkm-ai a 64 GB RAM, non bloquant. |
| **Pas de support GPU accelere** | Pas d'interet a utiliser le GPU | Certaine | C'est aussi un avantage: libere le GPU pour autre chose. |
| **API serveur basique** | Pas d'OpenAI-compat natif (a verifier) | Moyenne | Le serveur integre suffit. Wrapper possible. |
## 10. Recommandation
### Court terme (maintenant): ne rien faire, surveiller
- **Ne pas integrer Pocket TTS** dans kxkm_clown tant que le francais n'est pas supporte
- **Ajouter une alerte** sur le repo GitHub (Watch > Releases) et l'issue #118
- **Continuer avec le stack actuel**: Chatterbox Multilingual (FR, GPU) + Piper (fallback CPU)
### Moyen terme (quand FR sort): evaluer pour les personas EN
- Tester Pocket TTS pour la persona Moorcock (EN)
- Si ok: remplacer Chatterbox Turbo pour les cas EN, liberer de la VRAM
### Long terme (si FR + qualite OK): migration CPU-first
- Pocket TTS pourrait devenir le moteur TTS par defaut, liberant le GPU pour Ollama
- L'architecture CPU-first simplifie le deploiement (pas de CUDA, pas de VRAM management)
- Le streaming natif (~200ms first chunk) ameliore l'UX du chat temps reel
### Comparaison avec le modele kyutai/tts-1.6b-en_fr
Le modele 1.8B de Kyutai **supporte deja le francais** mais:
- Necessite un GPU (1.8B params, ~4-6 GB VRAM)
- Architecture differente (Delayed Streams, pas Flow Matching)
- Pas emballe dans Pocket TTS (API differente)
- Rivalise avec Chatterbox Multilingual sur le meme creneau (GPU, gros modele)
- Pas d'avantage clair par rapport a Chatterbox pour notre cas d'usage
A surveiller mais **pas prioritaire** par rapport a Chatterbox deja en place.
---
## Sources
- [Pocket TTS GitHub](https://github.com/kyutai-labs/pocket-tts) (3.6k stars, MIT, v1.1.1)
- [Pocket TTS Blog](https://kyutai.org/blog/2026-01-13-pocket-tts)
- [Pocket TTS Technical Report](https://kyutai.org/pocket-tts-technical-report)
- [Pocket TTS Python API](https://kyutai-labs.github.io/pocket-tts/API%20Reference/python-api/)
- [Pocket TTS HuggingFace](https://huggingface.co/kyutai/pocket-tts)
- [Kyutai TTS Voices](https://huggingface.co/kyutai/tts-voices)
- [kyutai/tts-1.6b-en_fr](https://huggingface.co/kyutai/tts-1.6b-en_fr) (modele FR, 1.8B)
- [Issue #118: More languages](https://github.com/kyutai-labs/pocket-tts/issues/118)
- [Pocket TTS Dockerfile](https://github.com/kyutai-labs/pocket-tts/blob/main/Dockerfile)
- [DeepWiki: pocket-tts](https://deepwiki.com/kyutai-labs/pocket-tts)
- [HN Discussion](https://news.ycombinator.com/item?id=46628329)
- [Chatterbox Spike (ce projet)](./CHATTERBOX_SPIKE_2026-03-19.md)
+295
View File
@@ -0,0 +1,295 @@
# Spike: Integration Qwen3-TTS (lot-30) — 2026-03-19
**Date**: 2026-03-19
**Auteur**: Claude (spike automatise)
**Statut**: DRAFT
**Lot**: 30
---
## 1. Resume du projet
| Champ | Valeur |
|---|---|
| **Nom** | Qwen3-TTS |
| **Editeur** | Qwen team, Alibaba Cloud |
| **URL GitHub** | https://github.com/QwenLM/Qwen3-TTS |
| **Stars** | ~9 700 (mars 2026) |
| **Forks** | ~1 200 |
| **Licence** | Apache-2.0 |
| **Cree** | 2026-01-21 |
| **Derniere MAJ** | 2026-03-19 (actif) |
| **Langage** | Python |
| **Issues ouvertes** | ~85 |
| **ArXiv** | 2601.15621 |
Qwen3-TTS est une famille de modeles TTS open-source de pointe, supportant la generation
vocale stable, expressive et en streaming, le voice design par prompt en langage naturel,
et le clonage vocal zero-shot a partir de 3 secondes de reference audio.
---
## 2. Architecture et modeles
### Architecture interne
```
Texte + [Instructions vocales / Audio reference]
|
v
[Qwen3-TTS LM] -- Discrete Multi-Codebook Language Model
| Architecture non-DiT, end-to-end
v
[Qwen3-TTS-Tokenizer-12Hz] -- Codec audio basse frequence
| Compression acoustique efficace
v
[Decodeur audio] --> WAV 24kHz
```
- **Dual-Track Hybrid Streaming**: architecture innovante supportant streaming et non-streaming
- **Latence end-to-end**: aussi basse que 97ms en mode streaming
- **Codec 12Hz**: tokenisation audio a 12 tokens/seconde (vs 50-75 Hz pour la plupart des codecs)
- **Multi-codebook LM**: modelisation full-information end-to-end des signaux vocaux
### Modeles disponibles (Hugging Face)
| Modele | Params | Telecharges | Usage |
|---|---|---|---|
| **Qwen3-TTS-12Hz-1.7B-Base** | 1.7B | ~1.96M | TTS general + clonage vocal |
| **Qwen3-TTS-12Hz-1.7B-CustomVoice** | 1.7B | ~1.10M | Voix preset + controle par instruction |
| **Qwen3-TTS-12Hz-1.7B-VoiceDesign** | 1.7B | ~494K | Creation de nouvelles voix par prompt NL |
| **Qwen3-TTS-12Hz-0.6B-Base** | 0.6B | ~379K | Version legere, TTS + clonage |
| **Qwen3-TTS-12Hz-0.6B-CustomVoice** | 0.6B | ~270K | Version legere, voix preset |
| **Qwen3-TTS-Tokenizer-12Hz** | - | ~84K | Codec audio (composant partage) |
### Estimation VRAM
| Modele | VRAM estime (fp16) | VRAM estime (int8) |
|---|---|---|
| 1.7B | ~4-5 GB | ~2.5-3 GB |
| 0.6B | ~1.5-2 GB | ~1 GB |
Sur le RTX 4090 (24 GB), le modele 1.7B tient facilement avec marge pour batch/streaming.
---
## 3. Capacites cles pour kxkm_clown
### 3.1. Voice Design par langage naturel (VoiceDesign)
Le modele VoiceDesign permet de creer des voix entierement nouvelles via des descriptions
en langage naturel. Exemples de prompts :
- "Voix d'un vieux professeur fatigue, avec un timbre grave et une diction lente"
- "Jeune femme energique, legere intonation du sud de la France"
- "Voix robotique, metallique, sans emotion"
Cela s'integre directement avec les personas de kxkm_clown : chaque persona pourrait
avoir une description vocale en langage naturel, transformee automatiquement en voix unique.
```python
# Exemple d'API (VoiceDesign)
voice = model.generate_voice_design(
text="Bonjour, je suis Merzbow le clown.",
instruct="A deep, gravelly voice with a sardonic tone and slow, deliberate pacing"
)
```
### 3.2. Voice Cloning zero-shot (Base / CustomVoice)
- Clonage a partir de 3 secondes d'audio de reference
- Supporte WAV, MP3
- Fonctionne en mode streaming
### 3.3. Langues supportees
| Langue | Code | Support |
|---|---|---|
| Chinois | zh | Natif (meilleur support, dialectes inclus) |
| Anglais | en | Excellent |
| **Francais** | **fr** | **Oui, natif** |
| Japonais | ja | Oui |
| Coreen | ko | Oui |
| Allemand | de | Oui |
| Russe | ru | Oui |
| Portugais | pt | Oui |
| Espagnol | es | Oui |
| Italien | it | Oui |
Le francais est dans les 10 langues nativement supportees. Selon les benchmarks communautaires,
la qualite est "consistante" sur le francais, bien que le chinois reste la langue la plus
forte du modele.
### 3.4. Streaming
- Dual-Track Hybrid Streaming: TTFA (time-to-first-audio) de 97ms
- Compatible avec des use-cases conversationnels en temps reel
---
## 4. Performance RTX 4090
### Inference officielle (QwenLM/Qwen3-TTS)
| Metrique | Valeur |
|---|---|
| TTFA (streaming) | ~97ms |
| Sessions concurrentes (1.7B) | 15-20 sessions temps reel |
| Performance vs RTX 5090 | ~65% du throughput, moitie du prix |
### faster-qwen3-tts (andimarafioti, 676 stars)
Implementation optimisee avec CUDA graphs, sans Flash Attention, vLLM ni Triton :
| Metrique | RTX 4090 | H100 |
|---|---|---|
| **RTF (Real-Time Factor)** | **5.6x temps reel** | 4.2x temps reel |
| **TTFA (streaming)** | **~152ms** | - |
| Overhead | Zero custom attention code | - |
Un RTF de 5.6x signifie que 1 seconde d'audio est generee en ~0.18s. Excellent pour le
temps reel conversationnel de kxkm_clown.
### Qwen3-TTS-streaming (dffdeeq)
Fork alternatif revendiquant ~6x d'acceleration sur l'inference.
---
## 5. Deploiement Docker / API
### Option A : Qwen3-TTS-Openai-Fastapi (groxaxo)
Serveur FastAPI drop-in compatible avec l'API OpenAI `/v1/audio/speech` :
- Docker GPU, CPU, et vLLM disponibles
- Port 8880
- Streaming via `stream=true`
- Formats audio : MP3, Opus, AAC, FLAC, WAV, PCM
- 28 voix custom preconfigures
- Cache modeles HuggingFace
```bash
# Deploiement Docker GPU
docker build -t qwen3-tts-api --target gpu-production .
docker run --gpus all -p 8880:8880 \
-v ~/.cache/huggingface:/root/.cache/huggingface \
qwen3-tts-api
```
### Option B : faster-qwen3-tts (pour perf max)
Implementation legere, CUDA graphs, RTF 5.6x :
```bash
pip install faster-qwen3-tts
```
### Option C : Integration directe dans tts-server.py existant
Le projet kxkm_clown a deja un `tts-server.py` avec dual backend Chatterbox/Piper.
Qwen3-TTS pourrait devenir un troisieme backend.
---
## 6. Comparaison Qwen3-TTS vs Chatterbox Multilingual
| Critere | Qwen3-TTS 1.7B | Chatterbox Multilingual | Avantage |
|---|---|---|---|
| **Qualite globale** | SOTA | Excellente (bat ElevenLabs 63.75%) | Comparable |
| **Voice Design (NL prompt)** | Oui (VoiceDesign model) | Non (exaggeration slider) | **Qwen3** |
| **Voice Cloning** | 3s ref audio | 6-30s ref audio | **Qwen3** (moins de ref) |
| **Facilite d'usage** | Complexe (seed pinning, tuning) | Simple (plug & play) | **Chatterbox** |
| **Francais** | Natif (10 langues) | Natif (23 langues) | Chatterbox (plus de langues) |
| **Controle emotion** | Via prompt NL | Slider exaggeration (0-1) | Qwen3 (plus fin) |
| **Latence streaming** | 97-152ms TTFA | ~2-5s (10 diffusion steps) | **Qwen3** |
| **VRAM** | ~4-5 GB (1.7B) | ~3-4 GB (0.5B) | Comparable |
| **Licence** | Apache-2.0 | MIT | Les deux permissives |
| **Paralinguistique** | Non | Oui (Turbo: [laugh], [cough]) | **Chatterbox** |
| **Ecosysteme** | 9.7K stars, Alibaba | 23.7K stars, Resemble AI | Les deux solides |
| **Modele leger** | 0.6B disponible | 350M Turbo (EN only) | Qwen3 (multilingue leger) |
### Verdict comparatif
- **Chatterbox** : meilleur pour le clonage vocal simple et fiable, plug & play
- **Qwen3-TTS** : meilleur pour le voice design creatif, le streaming basse latence, et les personas dynamiques
- **Recommandation** : les deux sont complementaires. Qwen3-TTS pour les personas generees dynamiquement, Chatterbox pour le clonage de voix reelles.
---
## 7. Plan d'integration (3 phases)
### Phase 1 : PoC local (1-2 jours)
1. Installer Qwen3-TTS 0.6B-Base sur kxkm-ai (RTX 4090)
2. Tester inference francaise : qualite, latence, artefacts
3. Tester voice design avec descriptions de personas existantes (Merzbow, etc.)
4. Benchmarker RTF et TTFA avec faster-qwen3-tts
5. Comparer sortie audio vs Chatterbox Multilingual sur memes phrases FR
### Phase 2 : Integration backend (2-3 jours)
1. Ajouter backend `qwen3-tts` dans `tts-server.py` (3e backend apres Chatterbox/Piper)
2. Exposer via API OpenAI-compatible (FastAPI, `/v1/audio/speech`)
3. Mapper chaque persona a un profil vocal :
- `voice_design_prompt` : description NL pour VoiceDesign
- `voice_ref_audio` : fichier WAV pour clonage (CustomVoice/Base)
- `voice_preset` : nom de voix preset (CustomVoice)
4. Hot-swap entre backends (Piper CPU / Chatterbox GPU / Qwen3 GPU)
5. Ajouter route streaming WebSocket pour TTFA < 200ms
### Phase 3 : Production + fine-tuning (3-5 jours)
1. Deployer via Docker compose sur kxkm-ai
2. Optimiser cohabitation GPU : Ollama (LLM) + Qwen3-TTS + Chatterbox
3. Tester charge : sessions concurrentes, stabilite long-terme
4. Explorer fine-tuning 0.6B sur corpus vocal francais specifique
5. Dashboard monitoring VRAM / latence dans OPS TUI
---
## 8. Risques et bloqueurs
| Risque | Severite | Mitigation |
|---|---|---|
| **Qualite francaise inferieure au chinois/anglais** | Moyenne | PoC Phase 1 : benchmark FR avant engagement |
| **Complexite de tuning** | Moyenne | Utiliser faster-qwen3-tts (simplifie) ; fallback sur Chatterbox |
| **VRAM partagee avec Ollama** | Moyenne | 24 GB suffisants (LLM ~8-12GB + TTS ~4-5GB) ; swap si besoin |
| **85 issues ouvertes** | Faible | Projet tres actif, Alibaba maintient |
| **Seed pinning requis pour consistance** | Moyenne | CustomVoice presets evitent ce probleme |
| **Pas de paralinguistiques** | Faible | Chatterbox Turbo disponible en complement |
| **Dependance PyTorch lourde** | Faible | Deja installe sur kxkm-ai pour Chatterbox |
---
## 9. Recommandation
### INTEGRER MAINTENANT (Phase 1-2)
**Justification** :
1. **Voice Design par prompt NL** est un game-changer pour les personas dynamiques de kxkm_clown.
Chaque clown pourrait avoir sa voix unique generee a la volee par description textuelle.
2. **Streaming basse latence** (97-152ms TTFA) est nettement superieur a Chatterbox (2-5s),
critique pour le conversationnel temps reel du spectacle.
3. **Francais natif** dans les 10 langues supportees.
4. **RTX 4090 ideale** : RTF 5.6x avec faster-qwen3-tts, 15-20 sessions concurrentes.
5. **Complementaire** avec Chatterbox (pas un remplacement) : Qwen3 pour le creatif,
Chatterbox pour le fiable.
6. **Apache-2.0** : licence permissive, pas de restriction commerciale.
7. **Modele 0.6B** disponible pour economiser VRAM si necessaire.
Le PoC Phase 1 (1-2 jours) est a faible risque et haut potentiel.
---
## Sources
- [QwenLM/Qwen3-TTS (GitHub)](https://github.com/QwenLM/Qwen3-TTS)
- [Qwen3-TTS Model Cards (Hugging Face)](https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-Base)
- [faster-qwen3-tts (GitHub)](https://github.com/andimarafioti/faster-qwen3-tts)
- [Qwen3-TTS-Openai-Fastapi (GitHub)](https://github.com/groxaxo/Qwen3-TTS-Openai-Fastapi)
- [Qwen3-TTS Performance Benchmarks](https://qwen3-tts.app/blog/qwen3-tts-performance-benchmarks-hardware-guide-2026)
- [Qwen3-TTS Complete 2026 Guide (DEV Community)](https://dev.to/czmilo/qwen3-tts-the-complete-2026-guide-to-open-source-voice-cloning-and-ai-speech-generation-1in6)
- [Qwen3-TTS vs Chatterbox (Archy.net)](https://www.archy.net/from-qwen3-tts-to-chatterbox-finally-getting-voice-cloning-right/)
- [ArXiv Paper 2601.15621](https://arxiv.org/abs/2601.15621)
+44
View File
@@ -0,0 +1,44 @@
# Timing & Ordering Recommendations (2026-03-19)
## Context
Analyse des patterns de timing pour les pipelines LLM + TTS + music gen + image gen en temps reel.
## Recommandations
### P1 — Sentence-boundary TTS chunking
Au lieu d'attendre la reponse complete avant TTS, decoupe en phrases pendant le streaming.
Latence percue: 6s → 1s avec Piper, ~1.5s avec Chatterbox.
### P1 — Placeholder-then-resolve pour tasks longues
Envoyer media_pending immediatement, puis media_ready quand le resultat est pret.
Pattern valide par ChatGPT, Midjourney, Discord bots.
### P2 — Sequence numbers WS (seq + replyTo)
Garantir l'ordre d'affichage cote client. Attacher audio/image au bon message via replyTo.
### P2 — Async handler ordering guard
Promise chain sur ws.on(message) pour eviter le reordonnement des messages async.
### P2 — Per-persona task queues
Remplacer le mutex global TTS par des queues per-persona avec concurrence bornee.
TTS et image gen peuvent tourner en parallele (ressources GPU differentes).
### P3 — Protocol enrichi
Types: message_chunk, media_pending, media_ready, media_error.
Client affiche skeleton loader pour pending, swap sur ready.
## Latences cibles (RTX 4090)
| Pipeline | Cible |
|----------|-------|
| Ollama TTFB | 200-500ms |
| Piper TTS/phrase | 200-400ms |
| Chatterbox first chunk | ~470ms |
| ACE-Step 1 min musique | ~2-5s |
| Total texte+audio percu | <2s |
## Sources
- Pipecat, LLMVoX (sentence chunking)
- ACE-Step 1.5 benchmarks (34x RTF sur A100)
- WebSocket ordering: sitongpeng.com
- LiveKit + Piper low-latency pattern
+36 -62
View File
@@ -1,69 +1,43 @@
# EXECUTION STATUS (kxkm-clown-v2)
# OPS V2 Status
Updated: 2026-03-17T22:13:03Z
Updated: 2026-03-19T17:30:00Z
## lot-0-cadrage
- Status: done
- Owner: Coordinateur
- Execution: managed
- Checks: docs-reviewed
- Open tasks: none
## Lots
## lot-1-socle
- Status: done
- Owner: Coordinateur
- Execution: managed
- Checks: npm run check:v2, npm run test:v2
- Open tasks: none
| Lot | Status | Summary |
|-----|--------|---------|
| lot-0-cadrage | done | Cadrage historique |
| lot-1-socle | done | Monorepo, TUI, verifications |
| lot-2-domaines | done | Auth, chat, storage, personas, node engine |
| lot-3-surfaces | done | React/Vite, admin, chat UI, node engine UI |
| lot-4-bascule | done | Migration, parite, rollback |
| lot-12-deep-audit | done | Pipeline/docs coherents, seams fermes |
| lot-13-voice-mcp | done | XTTS valide, MCP SDK officiel |
| lot-14-documents-search | done | SearXNG + BGE-M3 spike |
| lot-15-hotspot-reduction | done | Chat.tsx 631→67 LOC, cookie secure, rate limit |
| lot-16-minitel-ui | done | CSS phosphore, VIDEOTEX, F1-F7 |
| lot-17-chat-fixes | done | nick WS, Pharmacius concis, qwen3:8b |
| lot-18-media-tts | done | media-store, VoiceChat, 26 voices |
| lot-19-infra | done | Dockerfile Bookworm, deploy.sh tmux |
| lot-20-deep-audit-2 | done | 7 bugs, 6 fixes, Mermaid, OSS veille |
| lot-21-chat-reactivity | done | Streaming chunks, web search, timestamps |
| lot-22-chatterbox-tts | done | Chatterbox Docker GPU :9200 |
| lot-23-graph-rag | done | LightRAG :9621 integre |
| lot-24-deep-audit-3 | running | Admin fixes, compose timing, tests, ARCHITECTURE.md |
## lot-2-domaines
- Status: done
- Owner: Backend API
- Execution: managed
- Checks: npm run test:v2
- Open tasks: none
## Services (kxkm-ai)
## lot-3-surfaces
- Status: done
- Owner: Frontend
- Execution: managed
- Checks: npm run -w @kxkm/web check
- Open tasks: none
| Service | Port | Status |
|---------|------|--------|
| API V2 | :3333 | healthy |
| PostgreSQL | :5432 | healthy |
| SearXNG | :8080 | healthy (JSON enabled) |
| Chatterbox TTS | :9200 | GPU Docker |
| TTS Sidecar | :9100 | chatterbox-remote |
| LightRAG | :9621 | healthy |
| Ollama | :11434 | natif (25 models) |
| Worker | host | UP |
## lot-4-bascule
- Status: done
- Owner: Coordinateur
- Execution: managed
- Checks: npm run smoke:v2
- Open tasks: none
## Tests: 265 (248 pass, 6 fail → fix en cours)
## lot-12-deep-audit
- Status: done
- Owner: Coordinateur
- Execution: manual
- Checks: npm run check:v2, npm run test:v2, npm run -w @kxkm/web test, node ops/v2/deep-audit.js --json
- Open tasks: none
## lot-13-voice-mcp
- Status: done
- Owner: Multimodal
- Execution: manual
- Checks: node scripts/mcp-server-smoke.js, npm run smoke:voice-mcp, npm run smoke:voice-clone, bash ops/v2/run-spike-checks.sh voice-clone --yes
- Open tasks: none
## lot-14-documents-search
- Status: done
- Owner: Ops/TUI
- Execution: manual
- Checks: docker compose --profile v2 config --services, bash scripts/health-doc-search.sh search --strict, npm run smoke:documents-search, npm run smoke:embeddings, bash ops/v2/run-spike-checks.sh embeddings --yes
- Open tasks: none
## lot-15-hotspot-reduction
- Status: running
- Owner: Coordinateur
- Execution: manual
- Checks: npm run -w @kxkm/persona-domain check, npm run test:v2, bash ops/v2/run-deep-cycle.sh run --yes
- Open tasks:
- node-engine-seams [pending] (P2, Worker/Engine)
- storage-test-split [pending] (P3, Backend API)
- web-chat-modularization [pending] (P2, Frontend)
## Health Check: 19/19 pass, 1 warning (Chatterbox :9200 direct)
+309
View File
@@ -0,0 +1,309 @@
{
"security": [],
"performance": [],
"metrics": [
{
"file": "packages/persona-domain/src/index.ts",
"lines": 787,
"sizeKB": 22,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.ts",
"lines": 717,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.test.ts",
"lines": 670,
"sizeKB": 24,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.test.ts",
"lines": 651,
"sizeKB": 20,
"flag": "large"
},
{
"file": "apps/web/src/components/Chat.tsx",
"lines": 617,
"sizeKB": 21,
"flag": "large"
},
{
"file": "apps/web/src/components/ChatHistory.tsx",
"lines": 602,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/worker/src/worker-runtime.ts",
"lines": 565,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.ts",
"lines": 551,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/api/src/app.ts",
"lines": 536,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/api/src/app.test.ts",
"lines": 493,
"sizeKB": 17,
"flag": "medium"
},
{
"file": "apps/api/src/context-store.ts",
"lines": 422,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/web/src/components/VoiceChat.tsx",
"lines": 421,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/personas-default.ts",
"lines": 418,
"sizeKB": 20,
"flag": "medium"
},
{
"file": "apps/web/src/components/NodeEditor.tsx",
"lines": 407,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/ws-chat.ts",
"lines": 375,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/ws-conversation-router.ts",
"lines": 355,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/web/src/api.ts",
"lines": 328,
"sizeKB": 9,
"flag": "medium"
},
{
"file": "apps/api/src/routes/personas.ts",
"lines": 323,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/ws-commands.ts",
"lines": 316,
"sizeKB": 10,
"flag": "medium"
},
{
"file": "apps/api/src/ws-ollama.ts",
"lines": 290,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.test.ts",
"lines": 279,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaDetail.tsx",
"lines": 278,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/api/src/routes/chat-history.ts",
"lines": 275,
"sizeKB": 10,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.ts",
"lines": 263,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/persona-domain/src/index.test.ts",
"lines": 260,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/Collectif.tsx",
"lines": 252,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/node-engine/src/training.ts",
"lines": 250,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaList.tsx",
"lines": 243,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/api/src/ws-conversation-router.test.ts",
"lines": 242,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/App.tsx",
"lines": 236,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/worker/src/worker-runtime.test.ts",
"lines": 231,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/api/src/routes/session.ts",
"lines": 208,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/TrainingDashboard.tsx",
"lines": 203,
"sizeKB": 7,
"flag": "ok"
}
],
"deps": [
{
"package": "@discordjs/voice",
"current": "0.19.1",
"wanted": "0.19.2",
"latest": "0.19.2"
},
{
"package": "express"
},
{
"package": "pdf-parse",
"current": "1.1.4",
"wanted": "1.1.4",
"latest": "2.4.5"
}
],
"typescript": [
{
"dir": "apps/api",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/web",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/worker",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/core",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/auth",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/chat-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/persona-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/node-engine",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/storage",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/ui",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/tui",
"status": "ok",
"errors": 0,
"detail": ""
}
],
"debt": {
"score": 40,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 0,
"largeFiles": 9,
"mediumFiles": 10,
"deps": 3
}
}
}
+309
View File
@@ -0,0 +1,309 @@
{
"security": [],
"performance": [],
"metrics": [
{
"file": "packages/persona-domain/src/index.ts",
"lines": 787,
"sizeKB": 22,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.ts",
"lines": 717,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.test.ts",
"lines": 670,
"sizeKB": 24,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.test.ts",
"lines": 651,
"sizeKB": 20,
"flag": "large"
},
{
"file": "apps/web/src/components/Chat.tsx",
"lines": 617,
"sizeKB": 21,
"flag": "large"
},
{
"file": "apps/web/src/components/ChatHistory.tsx",
"lines": 602,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/worker/src/worker-runtime.ts",
"lines": 565,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.ts",
"lines": 551,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/api/src/app.ts",
"lines": 536,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/api/src/app.test.ts",
"lines": 493,
"sizeKB": 17,
"flag": "medium"
},
{
"file": "apps/api/src/context-store.ts",
"lines": 422,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/web/src/components/VoiceChat.tsx",
"lines": 421,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/personas-default.ts",
"lines": 418,
"sizeKB": 20,
"flag": "medium"
},
{
"file": "apps/web/src/components/NodeEditor.tsx",
"lines": 407,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/ws-chat.ts",
"lines": 375,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/ws-conversation-router.ts",
"lines": 355,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/web/src/api.ts",
"lines": 328,
"sizeKB": 9,
"flag": "medium"
},
{
"file": "apps/api/src/routes/personas.ts",
"lines": 323,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/ws-commands.ts",
"lines": 316,
"sizeKB": 10,
"flag": "medium"
},
{
"file": "apps/api/src/ws-ollama.ts",
"lines": 290,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.test.ts",
"lines": 279,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaDetail.tsx",
"lines": 278,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/api/src/routes/chat-history.ts",
"lines": 275,
"sizeKB": 10,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.ts",
"lines": 263,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/persona-domain/src/index.test.ts",
"lines": 260,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/Collectif.tsx",
"lines": 252,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/node-engine/src/training.ts",
"lines": 250,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaList.tsx",
"lines": 243,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/api/src/ws-conversation-router.test.ts",
"lines": 242,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/App.tsx",
"lines": 236,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/worker/src/worker-runtime.test.ts",
"lines": 231,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/api/src/routes/session.ts",
"lines": 208,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/TrainingDashboard.tsx",
"lines": 203,
"sizeKB": 7,
"flag": "ok"
}
],
"deps": [
{
"package": "@discordjs/voice",
"current": "0.19.1",
"wanted": "0.19.2",
"latest": "0.19.2"
},
{
"package": "express"
},
{
"package": "pdf-parse",
"current": "1.1.4",
"wanted": "1.1.4",
"latest": "2.4.5"
}
],
"typescript": [
{
"dir": "apps/api",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/web",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/worker",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/core",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/auth",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/chat-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/persona-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/node-engine",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/storage",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/ui",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/tui",
"status": "ok",
"errors": 0,
"detail": ""
}
],
"debt": {
"score": 40,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 0,
"largeFiles": 9,
"mediumFiles": 10,
"deps": 3
}
}
}
+309
View File
@@ -0,0 +1,309 @@
{
"security": [],
"performance": [],
"metrics": [
{
"file": "packages/persona-domain/src/index.ts",
"lines": 787,
"sizeKB": 22,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.ts",
"lines": 717,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.test.ts",
"lines": 670,
"sizeKB": 24,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.test.ts",
"lines": 651,
"sizeKB": 20,
"flag": "large"
},
{
"file": "apps/web/src/components/Chat.tsx",
"lines": 617,
"sizeKB": 21,
"flag": "large"
},
{
"file": "apps/web/src/components/ChatHistory.tsx",
"lines": 602,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/worker/src/worker-runtime.ts",
"lines": 565,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.ts",
"lines": 551,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/api/src/app.ts",
"lines": 536,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/api/src/app.test.ts",
"lines": 493,
"sizeKB": 17,
"flag": "medium"
},
{
"file": "apps/api/src/context-store.ts",
"lines": 422,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/web/src/components/VoiceChat.tsx",
"lines": 421,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/personas-default.ts",
"lines": 418,
"sizeKB": 20,
"flag": "medium"
},
{
"file": "apps/web/src/components/NodeEditor.tsx",
"lines": 407,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/ws-chat.ts",
"lines": 375,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/ws-conversation-router.ts",
"lines": 355,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/web/src/api.ts",
"lines": 328,
"sizeKB": 9,
"flag": "medium"
},
{
"file": "apps/api/src/routes/personas.ts",
"lines": 323,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/ws-commands.ts",
"lines": 316,
"sizeKB": 10,
"flag": "medium"
},
{
"file": "apps/api/src/ws-ollama.ts",
"lines": 290,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.test.ts",
"lines": 279,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaDetail.tsx",
"lines": 278,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/api/src/routes/chat-history.ts",
"lines": 275,
"sizeKB": 10,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.ts",
"lines": 263,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/persona-domain/src/index.test.ts",
"lines": 260,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/Collectif.tsx",
"lines": 252,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/node-engine/src/training.ts",
"lines": 250,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaList.tsx",
"lines": 243,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/api/src/ws-conversation-router.test.ts",
"lines": 242,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/App.tsx",
"lines": 236,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/worker/src/worker-runtime.test.ts",
"lines": 231,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/api/src/routes/session.ts",
"lines": 208,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/TrainingDashboard.tsx",
"lines": 203,
"sizeKB": 7,
"flag": "ok"
}
],
"deps": [
{
"package": "@discordjs/voice",
"current": "0.19.1",
"wanted": "0.19.2",
"latest": "0.19.2"
},
{
"package": "express"
},
{
"package": "pdf-parse",
"current": "1.1.4",
"wanted": "1.1.4",
"latest": "2.4.5"
}
],
"typescript": [
{
"dir": "apps/api",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/web",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/worker",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/core",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/auth",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/chat-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/persona-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/node-engine",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/storage",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/ui",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/tui",
"status": "ok",
"errors": 0,
"detail": ""
}
],
"debt": {
"score": 40,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 0,
"largeFiles": 9,
"mediumFiles": 10,
"deps": 3
}
}
}
+309
View File
@@ -0,0 +1,309 @@
{
"security": [],
"performance": [],
"metrics": [
{
"file": "packages/node-engine/src/index.ts",
"lines": 717,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.test.ts",
"lines": 670,
"sizeKB": 24,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.test.ts",
"lines": 651,
"sizeKB": 20,
"flag": "large"
},
{
"file": "apps/web/src/components/Chat.tsx",
"lines": 617,
"sizeKB": 21,
"flag": "large"
},
{
"file": "apps/web/src/components/ChatHistory.tsx",
"lines": 602,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/worker/src/worker-runtime.ts",
"lines": 565,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.ts",
"lines": 551,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/api/src/app.ts",
"lines": 536,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/api/src/app.test.ts",
"lines": 493,
"sizeKB": 17,
"flag": "medium"
},
{
"file": "apps/api/src/context-store.ts",
"lines": 422,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "packages/persona-domain/src/index.ts",
"lines": 422,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/web/src/components/VoiceChat.tsx",
"lines": 421,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/personas-default.ts",
"lines": 418,
"sizeKB": 20,
"flag": "medium"
},
{
"file": "apps/web/src/components/NodeEditor.tsx",
"lines": 407,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/ws-chat.ts",
"lines": 375,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/ws-conversation-router.ts",
"lines": 355,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/web/src/api.ts",
"lines": 328,
"sizeKB": 9,
"flag": "medium"
},
{
"file": "apps/api/src/routes/personas.ts",
"lines": 323,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/ws-commands.ts",
"lines": 316,
"sizeKB": 10,
"flag": "medium"
},
{
"file": "apps/api/src/ws-ollama.ts",
"lines": 290,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.test.ts",
"lines": 279,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaDetail.tsx",
"lines": 278,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/api/src/routes/chat-history.ts",
"lines": 275,
"sizeKB": 10,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.ts",
"lines": 263,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/persona-domain/src/index.test.ts",
"lines": 260,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/Collectif.tsx",
"lines": 252,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/node-engine/src/training.ts",
"lines": 250,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaList.tsx",
"lines": 243,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/api/src/ws-conversation-router.test.ts",
"lines": 242,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/App.tsx",
"lines": 236,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/worker/src/worker-runtime.test.ts",
"lines": 231,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/api/src/routes/session.ts",
"lines": 208,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/TrainingDashboard.tsx",
"lines": 203,
"sizeKB": 7,
"flag": "ok"
}
],
"deps": [
{
"package": "@discordjs/voice",
"current": "0.19.1",
"wanted": "0.19.2",
"latest": "0.19.2"
},
{
"package": "express"
},
{
"package": "pdf-parse",
"current": "1.1.4",
"wanted": "1.1.4",
"latest": "2.4.5"
}
],
"typescript": [
{
"dir": "apps/api",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/web",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/worker",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/core",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/auth",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/chat-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/persona-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/node-engine",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/storage",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/ui",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/tui",
"status": "ok",
"errors": 0,
"detail": ""
}
],
"debt": {
"score": 38,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 0,
"largeFiles": 8,
"mediumFiles": 11,
"deps": 3
}
}
}
@@ -0,0 +1,65 @@
{
"timestamp": "20260317-230529",
"generated_at": "2026-03-17T22:05:38Z",
"files": {
"audit": "/home/kxkm/KXKM_Clown/ops/v2/logs/deep-audit-20260317-230529.json",
"check": "/home/kxkm/KXKM_Clown/ops/v2/logs/check-v2-20260317-230529.log",
"test": "/home/kxkm/KXKM_Clown/ops/v2/logs/test-v2-20260317-230529.log",
"summary": "/home/kxkm/KXKM_Clown/ops/v2/outputs/deep-cycles/summary-20260317-230529.md"
},
"checks": {
"check:v2": "ok",
"test:v2": "ok"
},
"retained_logs": [
"/home/kxkm/KXKM_Clown/ops/v2/logs/deep-audit-20260317-230529.json",
"/home/kxkm/KXKM_Clown/ops/v2/logs/check-v2-20260317-230529.log",
"/home/kxkm/KXKM_Clown/ops/v2/logs/test-v2-20260317-230529.log"
],
"counts": {
"security": 0,
"performance": 0,
"large_files": 9
},
"debt": {
"score": 40,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 0,
"largeFiles": 9,
"mediumFiles": 10,
"deps": 3
}
},
"hotspots": [
"packages/persona-domain/src/index.ts",
"packages/node-engine/src/index.ts",
"packages/storage/src/index.test.ts",
"packages/node-engine/src/index.test.ts",
"apps/web/src/components/Chat.tsx"
],
"open_findings": [],
"backlog_links": [
"lot-12-deep-audit/pipeline-canonique",
"lot-12-deep-audit/deep-cycle-operator",
"lot-12-deep-audit/ws-chat-seams",
"lot-12-deep-audit/ui-contract",
"lot-12-deep-audit/web-shell-convergence"
],
"retention_days": 7,
"hashes": {
"audit_sha256": "f407098d6b9eefa41e87481fce8160d6c61a56c07b52ffc4ec2cd676aad1dda5",
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
"test_sha256": "b294f58e5e15644959d8eb76fbd180b6e28468006fd5f49cf98cfaeca90569c9"
}
}
@@ -0,0 +1,65 @@
{
"timestamp": "20260317-230603",
"generated_at": "2026-03-17T22:06:11Z",
"files": {
"audit": "/home/kxkm/KXKM_Clown/ops/v2/logs/deep-audit-20260317-230603.json",
"check": "/home/kxkm/KXKM_Clown/ops/v2/logs/check-v2-20260317-230603.log",
"test": "/home/kxkm/KXKM_Clown/ops/v2/logs/test-v2-20260317-230603.log",
"summary": "/home/kxkm/KXKM_Clown/ops/v2/outputs/deep-cycles/summary-20260317-230603.md"
},
"checks": {
"check:v2": "ok",
"test:v2": "ok"
},
"retained_logs": [
"/home/kxkm/KXKM_Clown/ops/v2/logs/deep-audit-20260317-230603.json",
"/home/kxkm/KXKM_Clown/ops/v2/logs/check-v2-20260317-230603.log",
"/home/kxkm/KXKM_Clown/ops/v2/logs/test-v2-20260317-230603.log"
],
"counts": {
"security": 0,
"performance": 0,
"large_files": 9
},
"debt": {
"score": 40,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 0,
"largeFiles": 9,
"mediumFiles": 10,
"deps": 3
}
},
"hotspots": [
"packages/persona-domain/src/index.ts",
"packages/node-engine/src/index.ts",
"packages/storage/src/index.test.ts",
"packages/node-engine/src/index.test.ts",
"apps/web/src/components/Chat.tsx"
],
"open_findings": [],
"backlog_links": [
"lot-12-deep-audit/pipeline-canonique",
"lot-12-deep-audit/deep-cycle-operator",
"lot-12-deep-audit/ws-chat-seams",
"lot-12-deep-audit/ui-contract",
"lot-12-deep-audit/web-shell-convergence"
],
"retention_days": 7,
"hashes": {
"audit_sha256": "f407098d6b9eefa41e87481fce8160d6c61a56c07b52ffc4ec2cd676aad1dda5",
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
"test_sha256": "ee0bf24524cdd085a6a0eeee1339aa6924311be226819e7c195471bc7abf8e38"
}
}
@@ -0,0 +1,19 @@
# Deep Cycle Summary 20260317-230529
- Generated: 2026-03-17T22:05:38Z
- Retention policy: 7 days
- check:v2: ok
- test:v2: ok
- security findings: 0
- performance findings: 0
- debt score: 40/100 (medium)
## Hotspots
- packages/persona-domain/src/index.ts
- packages/node-engine/src/index.ts
- packages/storage/src/index.test.ts
- packages/node-engine/src/index.test.ts
- apps/web/src/components/Chat.tsx
## Open Findings
- none
@@ -0,0 +1,19 @@
# Deep Cycle Summary 20260317-230603
- Generated: 2026-03-17T22:06:11Z
- Retention policy: 7 days
- check:v2: ok
- test:v2: ok
- security findings: 0
- performance findings: 0
- debt score: 40/100 (medium)
## Hotspots
- packages/persona-domain/src/index.ts
- packages/node-engine/src/index.ts
- packages/storage/src/index.test.ts
- packages/node-engine/src/index.test.ts
- apps/web/src/components/Chat.tsx
## Open Findings
- none
@@ -0,0 +1,18 @@
{
"timestamp": "20260317-230523",
"mode": "all",
"generated_at": "2026-03-17T22:05:24Z",
"checks": {
"voice-mcp": "ok",
"voice-clone": "ok",
"documents-search": "ok",
"embeddings": "ok"
},
"logs": {
"voice-mcp": "/home/kxkm/KXKM_Clown/ops/v2/logs/voice-mcp-smoke-20260317-230523.log",
"voice-clone": "/home/kxkm/KXKM_Clown/ops/v2/logs/voice-clone-smoke-20260317-230523.log",
"documents-search": "/home/kxkm/KXKM_Clown/ops/v2/logs/documents-search-smoke-20260317-230523.log",
"embeddings": "/home/kxkm/KXKM_Clown/ops/v2/logs/embeddings-smoke-20260317-230523.log"
},
"summary": "/home/kxkm/KXKM_Clown/ops/v2/outputs/spikes/summary-all-20260317-230523.md"
}
@@ -0,0 +1,13 @@
# Spike Summary all 20260317-230523
- Generated: 2026-03-17T22:05:24Z
- Voice / MCP smoke: ok
- Voice cloning smoke: ok
- Documents / Search smoke: ok
- Embeddings smoke: ok
## Logs
- voice-mcp: /home/kxkm/KXKM_Clown/ops/v2/logs/voice-mcp-smoke-20260317-230523.log
- voice-clone: /home/kxkm/KXKM_Clown/ops/v2/logs/voice-clone-smoke-20260317-230523.log
- documents-search: /home/kxkm/KXKM_Clown/ops/v2/logs/documents-search-smoke-20260317-230523.log
- embeddings: /home/kxkm/KXKM_Clown/ops/v2/logs/embeddings-smoke-20260317-230523.log
+482 -5
View File
@@ -52,7 +52,15 @@
"@kxkm/node-engine": "*",
"@kxkm/persona-domain": "*",
"@kxkm/storage": "*",
"express": "^4.22.1"
"express": "^4.22.1",
"file-type": "^21.3.3",
"p-limit": "^7.3.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/pino": "^7.0.4"
}
},
"apps/web": {
@@ -61,12 +69,16 @@
"dependencies": {
"@xyflow/react": "^12.10.1",
"react": "^19.2.4",
"react-dom": "^19.2.4"
"react-dom": "^19.2.4",
"react-virtualized-auto-sizer": "^2.0.3",
"react-window": "^2.2.7"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react-virtualized-auto-sizer": "^1.0.4",
"@types/react-window": "^1.8.8",
"jsdom": "^29.0.0",
"vitest": "^4.1.0"
}
@@ -166,6 +178,16 @@
"node": ">=6.9.0"
}
},
"node_modules/@borewit/text-codec": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz",
"integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
@@ -1465,6 +1487,12 @@
"@noble/hashes": "^1.1.5"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
@@ -2109,6 +2137,52 @@
"@testing-library/dom": ">=7.21.4"
}
},
"node_modules/@tokenizer/inflate": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
"integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"token-types": "^6.1.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@tokenizer/inflate/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@tokenizer/inflate/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -2289,6 +2363,16 @@
"pg-types": "^2.2.0"
}
},
"node_modules/@types/pino": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/@types/pino/-/pino-7.0.4.tgz",
"integrity": "sha512-yKw1UbZOTe7vP1xMQT+oz3FexwgIpBTrM+AC62vWgAkNRULgLTJWfYX+H5/sKPm8VXFbIcXkC3VZPyuaNioZFg==",
"dev": true,
"license": "MIT",
"dependencies": {
"pino": "*"
}
},
"node_modules/@types/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
@@ -2323,6 +2407,26 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/react-virtualized-auto-sizer": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.4.tgz",
"integrity": "sha512-nhYwlFiYa8M3S+O2T9QO/e1FQUYMr/wJENUdf/O0dhRi1RS/93rjrYQFYdbUqtdFySuhrtnEDX29P6eKOttY+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-window": {
"version": "1.8.8",
"resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz",
"integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
@@ -2728,6 +2832,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/b4a": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz",
@@ -2989,6 +3102,12 @@
"color-support": "bin.js"
}
},
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -3252,6 +3371,15 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/dateformat": {
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
"integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -3421,6 +3549,15 @@
"node": ">= 0.8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
@@ -3658,6 +3795,12 @@
"express": ">= 4.11"
}
},
"node_modules/fast-copy": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
"integrity": "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3674,7 +3817,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-uri": {
@@ -3711,6 +3853,24 @@
}
}
},
"node_modules/file-type": {
"version": "21.3.3",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.3.tgz",
"integrity": "sha512-pNwbwz8c3aZ+GvbJnIsCnDjKvgCZLHxkFWLEFxU3RMa+Ey++ZSEfisvsWQMcdys6PpxQjWUOIDi1fifXsW3YRg==",
"license": "MIT",
"dependencies": {
"@tokenizer/inflate": "^0.4.1",
"strtok3": "^10.3.4",
"token-types": "^6.1.1",
"uint8array-extras": "^1.4.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -3986,6 +4146,12 @@
"node": ">= 0.4"
}
},
"node_modules/help-me": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
"license": "MIT"
},
"node_modules/hono": {
"version": "4.12.8",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz",
@@ -4076,6 +4242,26 @@
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/indent-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
@@ -4158,6 +4344,15 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/joycon": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -4651,6 +4846,15 @@
"node": "*"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minipass": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
@@ -4857,6 +5061,15 @@
"whatwg-fetch": "^3.6.20"
}
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -4878,6 +5091,21 @@
"wrappy": "1"
}
},
"node_modules/p-limit": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz",
"integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==",
"license": "MIT",
"dependencies": {
"yocto-queue": "^1.2.1"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse5": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
@@ -5056,6 +5284,67 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pino": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^4.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-pretty": {
"version": "13.1.3",
"resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz",
"integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==",
"license": "MIT",
"dependencies": {
"colorette": "^2.0.7",
"dateformat": "^4.6.3",
"fast-copy": "^4.0.0",
"fast-safe-stringify": "^2.1.1",
"help-me": "^5.0.0",
"joycon": "^3.1.1",
"minimist": "^1.2.6",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pump": "^3.0.0",
"secure-json-parse": "^4.0.0",
"sonic-boom": "^4.0.1",
"strip-json-comments": "^5.0.2"
},
"bin": {
"pino-pretty": "bin.js"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/pkce-challenge": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
@@ -5175,6 +5464,22 @@
}
}
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -5188,6 +5493,16 @@
"node": ">= 0.10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -5213,6 +5528,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -5266,6 +5587,26 @@
"license": "MIT",
"peer": true
},
"node_modules/react-virtualized-auto-sizer": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-2.0.3.tgz",
"integrity": "sha512-nonmCSUIh5HtbzazGcQ1NhnMFps/ZBu/UKJyhCt0Fhi7ondLAUZNETtRCWM8RWYZDzVlMYOQGgBmIxUutIhqgw==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/react-window": {
"version": "2.2.7",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.7.tgz",
"integrity": "sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@@ -5280,6 +5621,15 @@
"node": ">= 6"
}
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -5451,6 +5801,15 @@
],
"license": "MIT"
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -5476,6 +5835,22 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/secure-json-parse": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
"integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@@ -5665,6 +6040,15 @@
"bare": ">=1.16.0"
}
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -5766,6 +6150,34 @@
"node": ">=8"
}
},
"node_modules/strip-json-comments": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
"integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==",
"license": "MIT",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strtok3": {
"version": "10.3.4",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz",
"integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==",
"license": "MIT",
"dependencies": {
"@tokenizer/token": "^0.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/superagent": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
@@ -5893,6 +6305,18 @@
"b4a": "^1.6.4"
}
},
"node_modules/thread-stream": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -5966,6 +6390,24 @@
"node": ">=0.6"
}
},
"node_modules/token-types": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
"integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
"license": "MIT",
"dependencies": {
"@borewit/text-codec": "^0.2.1",
"@tokenizer/token": "^0.3.0",
"ieee754": "^1.2.1"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/tough-cookie": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
@@ -6153,6 +6595,18 @@
"node": ">=14.17"
}
},
"node_modules/uint8array-extras": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
"integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici": {
"version": "7.24.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.3.tgz",
@@ -6532,6 +6986,18 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/yocto-queue": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
"integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
"license": "MIT",
"engines": {
"node": ">=12.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
@@ -6600,7 +7066,17 @@
"name": "@kxkm/node-engine",
"version": "0.0.0",
"dependencies": {
"@kxkm/core": "*"
"@kxkm/core": "*",
"zod": "^3.23.0"
}
},
"packages/node-engine/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"packages/persona-domain": {
@@ -6617,7 +7093,8 @@
"@kxkm/core": "*",
"@kxkm/node-engine": "*",
"@kxkm/persona-domain": "*",
"pg": "^8.20.0"
"pg": "^8.20.0",
"zod": "^4.3.6"
}
},
"packages/tui": {
+5 -5
View File
@@ -1,3 +1,5 @@
import crypto from "node:crypto";
export const USER_ROLES = ["admin", "editor", "operator", "viewer"] as const;
export type UserRole = (typeof USER_ROLES)[number];
@@ -70,11 +72,9 @@ export function createIsoTimestamp(date = new Date()): string {
return date.toISOString();
}
export function createId(prefix: string): string {
const bytes = globalThis.crypto?.getRandomValues
? globalThis.crypto.getRandomValues(new Uint8Array(8))
: require("node:crypto").randomBytes(8);
return `${prefix}_${Buffer.from(bytes).toString("hex")}`;
export function createId(prefix = ""): string {
const sep = prefix ? "_" : "";
return `${prefix}${sep}${Date.now().toString(36)}_${crypto.randomBytes(4).toString("hex")}`;
}
export function isUserRole(value: string): value is UserRole {
+2 -1
View File
@@ -10,7 +10,8 @@
".": "./dist/index.js"
},
"dependencies": {
"@kxkm/core": "*"
"@kxkm/core": "*",
"zod": "^3.23.0"
},
"scripts": {
"status": "node ../../scripts/workspace-package.js node-engine status",
+38 -8
View File
@@ -1,3 +1,5 @@
import { z } from "zod";
// ---------------------------------------------------------------------------
// Training adapter configuration — pure module (no I/O, no child_process)
// ---------------------------------------------------------------------------
@@ -24,6 +26,26 @@ export const DEFAULT_HYPERPARAMS: TrainingHyperparams = {
maxSeqLength: 2048,
};
// ---------------------------------------------------------------------------
// Zod bounds validation for hyperparameters (lot-60)
// ---------------------------------------------------------------------------
/** Coerce non-number values to undefined so they fall through to defaults. */
const numOrUndef = z.preprocess(
(v) => (typeof v === "number" && Number.isFinite(v) ? v : undefined),
z.number().optional(),
);
export const hyperparamsSchema = z.object({
learningRate: numOrUndef.pipe(z.number().min(1e-7).max(1).optional()),
epochs: numOrUndef.pipe(z.number().int().min(1).max(100).optional()),
batchSize: numOrUndef.pipe(z.number().int().min(1).max(256).optional()),
warmupSteps: numOrUndef.pipe(z.number().int().min(0).max(10000).optional()),
maxSeqLength: numOrUndef.pipe(z.number().int().min(32).max(8192).optional()),
loraRank: numOrUndef.pipe(z.number().int().min(1).max(256).optional()),
loraAlpha: numOrUndef.pipe(z.number().int().min(1).max(512).optional()),
}).passthrough();
export interface TrainingJobSpec {
type: TrainingJobType;
baseModel: string;
@@ -157,20 +179,28 @@ export function validateJobSpec(spec: unknown): TrainingJobSpec {
throw new Error("TrainingJobSpec.outputDir must be a non-empty string");
}
// hyperparams — merge with defaults
// hyperparams — validate bounds with Zod, then merge with defaults
const rawHp =
raw.hyperparams && typeof raw.hyperparams === "object"
? (raw.hyperparams as Record<string, unknown>)
: {};
const parsed = hyperparamsSchema.safeParse(rawHp);
if (!parsed.success) {
throw new Error(
`Invalid hyperparams: ${parsed.error.issues.map((i) => i.message).join(", ")}`,
);
}
const hp = parsed.data;
const hyperparams: TrainingHyperparams = {
learningRate: validNumber(rawHp.learningRate, DEFAULT_HYPERPARAMS.learningRate),
epochs: validNumber(rawHp.epochs, DEFAULT_HYPERPARAMS.epochs),
batchSize: validNumber(rawHp.batchSize, DEFAULT_HYPERPARAMS.batchSize),
loraRank: validNumber(rawHp.loraRank, DEFAULT_HYPERPARAMS.loraRank),
loraAlpha: validNumber(rawHp.loraAlpha, DEFAULT_HYPERPARAMS.loraAlpha),
warmupSteps: validNumber(rawHp.warmupSteps, DEFAULT_HYPERPARAMS.warmupSteps),
maxSeqLength: validNumber(rawHp.maxSeqLength, DEFAULT_HYPERPARAMS.maxSeqLength),
learningRate: validNumber(hp.learningRate, DEFAULT_HYPERPARAMS.learningRate),
epochs: validNumber(hp.epochs, DEFAULT_HYPERPARAMS.epochs),
batchSize: validNumber(hp.batchSize, DEFAULT_HYPERPARAMS.batchSize),
loraRank: validNumber((hp as Record<string, unknown>).loraRank, DEFAULT_HYPERPARAMS.loraRank),
loraAlpha: validNumber((hp as Record<string, unknown>).loraAlpha, DEFAULT_HYPERPARAMS.loraAlpha),
warmupSteps: validNumber(hp.warmupSteps, DEFAULT_HYPERPARAMS.warmupSteps),
maxSeqLength: validNumber(hp.maxSeqLength, DEFAULT_HYPERPARAMS.maxSeqLength),
};
return {
+6
View File
@@ -101,5 +101,11 @@ export function extractDPOPairs(
});
}
if (pairs.length === 0 && feedback.length > 0) {
console.warn(
`[DPO] extractDPOPairs returned 0 pairs for persona "${persona.id}" from ${feedback.length} feedback items — check vote polarity signals`,
);
}
return pairs;
}
+3 -2
View File
@@ -11,9 +11,10 @@
},
"dependencies": {
"@kxkm/core": "*",
"@kxkm/persona-domain": "*",
"@kxkm/node-engine": "*",
"pg": "^8.20.0"
"@kxkm/persona-domain": "*",
"pg": "^8.20.0",
"zod": "^4.3.6"
},
"scripts": {
"status": "node ../../scripts/workspace-package.js storage status",
+220 -89
View File
@@ -1,4 +1,5 @@
import { Pool } from "pg";
import { z } from "zod";
import type { AuthSession, UserRole } from "@kxkm/core";
import { createId, createIsoTimestamp } from "@kxkm/core";
import type { PersonaRecord, PersonaSourceRecord, PersonaFeedbackRecord, PersonaProposalRecord } from "@kxkm/persona-domain";
@@ -129,6 +130,74 @@ export async function runMigrations(pool: Pool): Promise<void> {
}
}
// ---------------------------------------------------------------------------
// Validation logger (lightweight, no external dep)
// ---------------------------------------------------------------------------
const validationLogger = {
warn(ctx: { repo: string; errors: z.ZodIssue[] }, msg: string) {
console.warn(`[storage] ${msg}`, JSON.stringify({ repo: ctx.repo, errors: ctx.errors }));
},
};
// ---------------------------------------------------------------------------
// Zod schemas for DB row validation
// ---------------------------------------------------------------------------
const sessionRowSchema = z.object({
id: z.string(),
username: z.string(),
role: z.string(),
created_at: z.coerce.date(),
expires_at: z.coerce.date(),
});
const personaRowSchema = z.object({
id: z.string(),
name: z.string(),
model: z.string(),
summary: z.string(),
editable: z.union([z.boolean(), z.number()]).transform(Boolean),
});
const nodeGraphRowSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string(),
});
const nodeRunRowSchema = z.object({
id: z.string(),
graph_id: z.string(),
status: z.string(),
created_at: z.union([z.date(), z.string()]),
});
const personaSourceRowSchema = z.object({
persona_id: z.string(),
subject_name: z.string(),
summary: z.string(),
references_: z.unknown().transform((v) => (Array.isArray(v) ? v : [])),
});
const personaFeedbackRowSchema = z.object({
id: z.string(),
persona_id: z.string(),
kind: z.string(),
message: z.string(),
created_at: z.union([z.date(), z.string()]),
});
const personaProposalRowSchema = z.object({
id: z.string(),
persona_id: z.string(),
before_snapshot: z.unknown().nullable(),
after_snapshot: z.unknown().nullable(),
reason: z.string(),
applied: z.union([z.boolean(), z.number()]).transform(Boolean),
created_at: z.union([z.date(), z.string()]),
});
// ---------------------------------------------------------------------------
// Session input type
// ---------------------------------------------------------------------------
@@ -139,6 +208,126 @@ export interface SessionCreateInput {
expiresAt?: string;
}
// ---------------------------------------------------------------------------
// Validated row mappers
// ---------------------------------------------------------------------------
function mapSessionRow(row: unknown): AuthSession | null {
const result = sessionRowSchema.safeParse(row);
if (!result.success) {
validationLogger.warn({ repo: "session", errors: result.error.issues }, "Invalid session row");
return null;
}
return {
id: result.data.id,
username: result.data.username,
role: result.data.role as UserRole,
createdAt: result.data.created_at.toISOString(),
expiresAt: result.data.expires_at.toISOString(),
};
}
function mapPersonaRow(row: unknown): PersonaRecord | null {
const result = personaRowSchema.safeParse(row);
if (!result.success) {
validationLogger.warn({ repo: "persona", errors: result.error.issues }, "Invalid persona row");
return null;
}
return {
id: result.data.id,
name: result.data.name,
model: result.data.model,
summary: result.data.summary,
editable: result.data.editable,
};
}
function mapNodeGraphRow(row: unknown): NodeGraphRecord | null {
const result = nodeGraphRowSchema.safeParse(row);
if (!result.success) {
validationLogger.warn({ repo: "nodeGraph", errors: result.error.issues }, "Invalid node_graph row");
return null;
}
return {
id: result.data.id,
name: result.data.name,
description: result.data.description,
};
}
function mapNodeRunRow(row: unknown): NodeRunRecord | null {
const result = nodeRunRowSchema.safeParse(row);
if (!result.success) {
validationLogger.warn({ repo: "nodeRun", errors: result.error.issues }, "Invalid node_run row");
return null;
}
const ca = result.data.created_at;
return {
id: result.data.id,
graphId: result.data.graph_id,
status: result.data.status as RunStatus,
createdAt: ca instanceof Date ? ca.toISOString() : String(ca),
};
}
function mapPersonaSourceRow(row: unknown): PersonaSourceRecord | null {
const result = personaSourceRowSchema.safeParse(row);
if (!result.success) {
validationLogger.warn({ repo: "personaSource", errors: result.error.issues }, "Invalid persona_source row");
return null;
}
return {
personaId: result.data.persona_id,
subjectName: result.data.subject_name,
summary: result.data.summary,
references: result.data.references_ as string[],
};
}
function mapPersonaFeedbackRow(row: unknown): PersonaFeedbackRecord | null {
const result = personaFeedbackRowSchema.safeParse(row);
if (!result.success) {
validationLogger.warn({ repo: "personaFeedback", errors: result.error.issues }, "Invalid persona_feedback row");
return null;
}
const ca = result.data.created_at;
return {
id: result.data.id,
personaId: result.data.persona_id,
kind: result.data.kind as PersonaFeedbackRecord["kind"],
message: result.data.message,
createdAt: ca instanceof Date ? ca.toISOString() : String(ca),
};
}
function mapPersonaProposalRow(row: unknown): PersonaProposalRecord | null {
const result = personaProposalRowSchema.safeParse(row);
if (!result.success) {
validationLogger.warn({ repo: "personaProposal", errors: result.error.issues }, "Invalid persona_proposal row");
return null;
}
const ca = result.data.created_at;
return {
id: result.data.id,
personaId: result.data.persona_id,
before: result.data.before_snapshot as PersonaProposalRecord["before"],
after: result.data.after_snapshot as PersonaProposalRecord["after"],
reason: result.data.reason,
applied: result.data.applied,
createdAt: ca instanceof Date ? ca.toISOString() : String(ca),
};
}
/** Filter null results from validated row mapping */
function filterValid<T>(rows: unknown[], mapper: (row: unknown) => T | null): T[] {
const results: T[] = [];
for (const row of rows) {
const mapped = mapper(row);
if (mapped !== null) results.push(mapped);
}
return results;
}
// ---------------------------------------------------------------------------
// Session repository
// ---------------------------------------------------------------------------
@@ -165,14 +354,7 @@ export function createSessionRepo(pool: Pool) {
[id],
);
if (result.rows.length === 0) return null;
const row = result.rows[0];
return {
id: row.id,
username: row.username,
role: row.role as UserRole,
createdAt: (row.created_at as Date).toISOString(),
expiresAt: (row.expires_at as Date).toISOString(),
};
return mapSessionRow(result.rows[0]);
},
async deleteById(id: string): Promise<void> {
@@ -198,7 +380,7 @@ export function createPersonaRepo(pool: Pool) {
const result = await pool.query(
`SELECT id, name, model, summary, editable FROM personas ORDER BY name`,
);
return result.rows.map(rowToPersona);
return filterValid(result.rows, mapPersonaRow);
},
async findById(id: string): Promise<PersonaRecord | null> {
@@ -207,7 +389,7 @@ export function createPersonaRepo(pool: Pool) {
[id],
);
if (result.rows.length === 0) return null;
return rowToPersona(result.rows[0]);
return mapPersonaRow(result.rows[0]);
},
async upsert(persona: PersonaRecord): Promise<PersonaRecord> {
@@ -223,7 +405,9 @@ export function createPersonaRepo(pool: Pool) {
RETURNING id, name, model, summary, editable`,
[persona.id, persona.name, persona.model, persona.summary, persona.editable],
);
return rowToPersona(result.rows[0]);
const mapped = mapPersonaRow(result.rows[0]);
if (!mapped) throw new Error("Persona upsert returned invalid row");
return mapped;
},
async seedCatalog(catalog: PersonaRecord[]): Promise<void> {
@@ -250,16 +434,6 @@ export function createPersonaRepo(pool: Pool) {
};
}
function rowToPersona(row: Record<string, unknown>): PersonaRecord {
return {
id: row.id as string,
name: row.name as string,
model: row.model as string,
summary: row.summary as string,
editable: Boolean(row.editable),
};
}
// ---------------------------------------------------------------------------
// Node Graph repository
// ---------------------------------------------------------------------------
@@ -270,7 +444,7 @@ export function createNodeGraphRepo(pool: Pool) {
const result = await pool.query(
`SELECT id, name, description FROM node_graphs ORDER BY created_at DESC`,
);
return result.rows.map(rowToNodeGraph);
return filterValid(result.rows, mapNodeGraphRow);
},
async findById(id: string): Promise<NodeGraphRecord | null> {
@@ -279,7 +453,7 @@ export function createNodeGraphRepo(pool: Pool) {
[id],
);
if (result.rows.length === 0) return null;
return rowToNodeGraph(result.rows[0]);
return mapNodeGraphRow(result.rows[0]);
},
async create(graph: NodeGraphRecord): Promise<NodeGraphRecord> {
@@ -289,11 +463,12 @@ export function createNodeGraphRepo(pool: Pool) {
RETURNING id, name, description`,
[graph.id, graph.name, graph.description],
);
return rowToNodeGraph(result.rows[0]);
const mapped = mapNodeGraphRow(result.rows[0]);
if (!mapped) throw new Error("NodeGraph create returned invalid row");
return mapped;
},
async update(id: string, patch: Partial<NodeGraphRecord>): Promise<NodeGraphRecord | null> {
// Build SET clause dynamically from provided fields
const setClauses: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
@@ -320,19 +495,11 @@ export function createNodeGraphRepo(pool: Pool) {
values,
);
if (result.rows.length === 0) return null;
return rowToNodeGraph(result.rows[0]);
return mapNodeGraphRow(result.rows[0]);
},
};
}
function rowToNodeGraph(row: Record<string, unknown>): NodeGraphRecord {
return {
id: row.id as string,
name: row.name as string,
description: row.description as string,
};
}
// ---------------------------------------------------------------------------
// Node Run repository
// ---------------------------------------------------------------------------
@@ -343,7 +510,7 @@ export function createNodeRunRepo(pool: Pool) {
const result = await pool.query(
`SELECT id, graph_id, status, created_at FROM node_runs ORDER BY created_at DESC`,
);
return result.rows.map(rowToNodeRun);
return filterValid(result.rows, mapNodeRunRow);
},
async findById(id: string): Promise<NodeRunRecord | null> {
@@ -352,7 +519,7 @@ export function createNodeRunRepo(pool: Pool) {
[id],
);
if (result.rows.length === 0) return null;
return rowToNodeRun(result.rows[0]);
return mapNodeRunRow(result.rows[0]);
},
async create(run: NodeRunRecord): Promise<NodeRunRecord> {
@@ -362,7 +529,9 @@ export function createNodeRunRepo(pool: Pool) {
RETURNING id, graph_id, status, created_at`,
[run.id, run.graphId, run.status, run.createdAt],
);
return rowToNodeRun(result.rows[0]);
const mapped = mapNodeRunRow(result.rows[0]);
if (!mapped) throw new Error("NodeRun create returned invalid row");
return mapped;
},
async updateStatus(id: string, status: RunStatus): Promise<void> {
@@ -372,7 +541,6 @@ export function createNodeRunRepo(pool: Pool) {
);
},
/** Mark a run as cancel-requested (worker checks this during execution) */
async requestCancel(id: string): Promise<void> {
await pool.query(
`UPDATE node_runs SET status = 'cancelled', updated_at = NOW() WHERE id = $1 AND status IN ('queued', 'running')`,
@@ -380,26 +548,23 @@ export function createNodeRunRepo(pool: Pool) {
);
},
/** Recover runs that were running when the worker crashed → re-queue them */
async recoverStaleRuns(): Promise<NodeRunRecord[]> {
const result = await pool.query(
`UPDATE node_runs SET status = 'queued', updated_at = NOW()
WHERE status = 'running'
RETURNING id, graph_id, status, created_at`,
);
return result.rows.map(rowToNodeRun);
return filterValid(result.rows, mapNodeRunRow);
},
/** List runs by status */
async listByStatus(status: RunStatus, limit = 50): Promise<NodeRunRecord[]> {
const result = await pool.query(
`SELECT id, graph_id, status, created_at FROM node_runs WHERE status = $1 ORDER BY created_at ASC LIMIT $2`,
[status, limit],
);
return result.rows.map(rowToNodeRun);
return filterValid(result.rows, mapNodeRunRow);
},
/** Delete completed/failed/cancelled runs older than the given ISO date */
async deleteOlderThan(date: string): Promise<number> {
const result = await pool.query(
`DELETE FROM node_runs WHERE status IN ('completed', 'failed', 'cancelled') AND created_at < $1`,
@@ -410,15 +575,6 @@ export function createNodeRunRepo(pool: Pool) {
};
}
function rowToNodeRun(row: Record<string, unknown>): NodeRunRecord {
return {
id: row.id as string,
graphId: row.graph_id as string,
status: row.status as RunStatus,
createdAt: (row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at)),
};
}
// ---------------------------------------------------------------------------
// Persona Source repository
// ---------------------------------------------------------------------------
@@ -431,7 +587,7 @@ export function createPersonaSourceRepo(pool: Pool) {
[personaId],
);
if (result.rows.length === 0) return null;
return rowToPersonaSource(result.rows[0]);
return mapPersonaSourceRow(result.rows[0]);
},
async upsert(source: PersonaSourceRecord): Promise<PersonaSourceRecord> {
@@ -445,20 +601,13 @@ export function createPersonaSourceRepo(pool: Pool) {
RETURNING persona_id, subject_name, summary, references_`,
[source.personaId, source.subjectName, source.summary, JSON.stringify(source.references)],
);
return rowToPersonaSource(result.rows[0]);
const mapped = mapPersonaSourceRow(result.rows[0]);
if (!mapped) throw new Error("PersonaSource upsert returned invalid row");
return mapped;
},
};
}
function rowToPersonaSource(row: Record<string, unknown>): PersonaSourceRecord {
return {
personaId: row.persona_id as string,
subjectName: row.subject_name as string,
summary: row.summary as string,
references: (row.references_ as string[]) || [],
};
}
// ---------------------------------------------------------------------------
// Persona Feedback repository
// ---------------------------------------------------------------------------
@@ -470,7 +619,7 @@ export function createPersonaFeedbackRepo(pool: Pool) {
`SELECT id, persona_id, kind, message, created_at FROM persona_feedback WHERE persona_id = $1 ORDER BY created_at`,
[personaId],
);
return result.rows.map(rowToPersonaFeedback);
return filterValid(result.rows, mapPersonaFeedbackRow);
},
async create(record: PersonaFeedbackRecord): Promise<PersonaFeedbackRecord> {
@@ -480,21 +629,13 @@ export function createPersonaFeedbackRepo(pool: Pool) {
RETURNING id, persona_id, kind, message, created_at`,
[record.id, record.personaId, record.kind, record.message, record.createdAt],
);
return rowToPersonaFeedback(result.rows[0]);
const mapped = mapPersonaFeedbackRow(result.rows[0]);
if (!mapped) throw new Error("PersonaFeedback create returned invalid row");
return mapped;
},
};
}
function rowToPersonaFeedback(row: Record<string, unknown>): PersonaFeedbackRecord {
return {
id: row.id as string,
personaId: row.persona_id as string,
kind: row.kind as PersonaFeedbackRecord["kind"],
message: row.message as string,
createdAt: (row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at)),
};
}
// ---------------------------------------------------------------------------
// Persona Proposal repository
// ---------------------------------------------------------------------------
@@ -507,7 +648,7 @@ export function createPersonaProposalRepo(pool: Pool) {
FROM persona_proposals WHERE persona_id = $1 ORDER BY created_at`,
[personaId],
);
return result.rows.map(rowToPersonaProposal);
return filterValid(result.rows, mapPersonaProposalRow);
},
async create(record: PersonaProposalRecord): Promise<PersonaProposalRecord> {
@@ -525,7 +666,9 @@ export function createPersonaProposalRepo(pool: Pool) {
record.createdAt,
],
);
return rowToPersonaProposal(result.rows[0]);
const mapped = mapPersonaProposalRow(result.rows[0]);
if (!mapped) throw new Error("PersonaProposal create returned invalid row");
return mapped;
},
async markApplied(id: string): Promise<void> {
@@ -536,15 +679,3 @@ export function createPersonaProposalRepo(pool: Pool) {
},
};
}
function rowToPersonaProposal(row: Record<string, unknown>): PersonaProposalRecord {
return {
id: row.id as string,
personaId: row.persona_id as string,
before: row.before_snapshot as PersonaProposalRecord["before"],
after: row.after_snapshot as PersonaProposalRecord["after"],
reason: row.reason as string,
applied: Boolean(row.applied),
createdAt: (row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at)),
};
}
+83 -210
View File
@@ -1,219 +1,92 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
SHELL_THEME,
STATUS_COLORS,
RUN_STATUS_COLORS,
PERSONA_PALETTE,
getPersonaColor,
createUiCssVariables,
UI_CSS_VARIABLES,
RUN_STATUS_CLASSES,
getRunStatusClass,
publishUiCssVariables,
createUiCssText,
UI_THEME,
SHELL_THEME, STATUS_COLORS, RUN_STATUS_COLORS, PERSONA_PALETTE,
getPersonaColor, createUiCssVariables, getRunStatusClass,
publishUiCssVariables, createUiCssText, UI_THEME, UI_CSS_VARIABLES,
} from "./index.js";
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
describe("SHELL_THEME", () => {
it("has all expected keys", () => {
const keys = [
"background", "panel", "ink", "muted", "accent",
"border", "borderLight", "fontMono", "fontDisplay", "gap", "radius",
];
for (const k of keys) {
assert.ok(k in SHELL_THEME, `missing key: ${k}`);
}
});
});
describe("STATUS_COLORS", () => {
it("has info, warn, danger", () => {
assert.equal(typeof STATUS_COLORS.info, "string");
assert.equal(typeof STATUS_COLORS.warn, "string");
assert.equal(typeof STATUS_COLORS.danger, "string");
});
});
describe("RUN_STATUS_COLORS", () => {
it("has 5 statuses", () => {
const keys = ["running", "queued", "completed", "failed", "cancelled"];
assert.deepEqual(Object.keys(RUN_STATUS_COLORS).sort(), keys.sort());
});
});
describe("PERSONA_PALETTE", () => {
it("has 8 elements", () => {
assert.equal(PERSONA_PALETTE.length, 8);
});
it("all elements are hex color strings", () => {
for (const c of PERSONA_PALETTE) {
assert.match(c, /^#[0-9a-f]{6}$/i);
}
});
});
/* ------------------------------------------------------------------ */
/* getPersonaColor */
/* ------------------------------------------------------------------ */
describe("getPersonaColor", () => {
it("returns a color from the palette for a nick", () => {
const color = getPersonaColor("Merzbow");
assert.ok(PERSONA_PALETTE.includes(color as typeof PERSONA_PALETTE[number]));
});
it("is deterministic (same nick -> same color)", () => {
const a = getPersonaColor("Pharmacius");
const b = getPersonaColor("Pharmacius");
assert.equal(a, b);
});
it("wraps around the palette (9th persona maps back)", () => {
// With a custom 3-color palette, hash % 3 always in range
const palette = ["#aaa", "#bbb", "#ccc"];
const color = getPersonaColor("anything", palette);
assert.ok(palette.includes(color));
});
it("returns accent if palette is empty", () => {
assert.equal(getPersonaColor("test", []), SHELL_THEME.accent);
});
it("handles short nicks (single char)", () => {
const color = getPersonaColor("X");
assert.ok(PERSONA_PALETTE.includes(color as typeof PERSONA_PALETTE[number]));
});
it("handles long nicks", () => {
const long = "A".repeat(500);
const color = getPersonaColor(long);
assert.ok(PERSONA_PALETTE.includes(color as typeof PERSONA_PALETTE[number]));
});
});
/* ------------------------------------------------------------------ */
/* createUiCssVariables */
/* ------------------------------------------------------------------ */
describe("createUiCssVariables", () => {
const vars = createUiCssVariables();
it("returns an object with --kxkm-* keys", () => {
for (const key of Object.keys(vars)) {
assert.ok(key.startsWith("--kxkm-"), `key ${key} missing --kxkm- prefix`);
}
});
it("contains shell, status, and persona variables", () => {
assert.ok("--kxkm-shell-background" in vars);
assert.ok("--kxkm-status-info" in vars);
assert.ok("--kxkm-persona-1" in vars);
});
it("has 27 variables (19 base + 8 persona)", () => {
// 11 shell + 3 status + 5 run-status + 8 persona = 27
assert.equal(Object.keys(vars).length, 27);
});
});
/* ------------------------------------------------------------------ */
/* UI_CSS_VARIABLES */
/* ------------------------------------------------------------------ */
describe("UI_CSS_VARIABLES", () => {
it("equals a fresh createUiCssVariables() call", () => {
assert.deepEqual(UI_CSS_VARIABLES, createUiCssVariables());
});
});
/* ------------------------------------------------------------------ */
/* getRunStatusClass */
/* ------------------------------------------------------------------ */
describe("getRunStatusClass", () => {
for (const [status, cls] of Object.entries(RUN_STATUS_CLASSES)) {
it(`returns "${cls}" for "${status}"`, () => {
assert.equal(getRunStatusClass(status), cls);
describe("@kxkm/ui", () => {
describe("SHELL_THEME", () => {
it("has all required keys", () => {
assert.ok(SHELL_THEME.background);
assert.ok(SHELL_THEME.ink);
assert.ok(SHELL_THEME.accent);
assert.ok(SHELL_THEME.fontMono);
});
}
});
it('returns "status-muted" for unknown status', () => {
assert.equal(getRunStatusClass("unknown"), "status-muted");
});
});
/* ------------------------------------------------------------------ */
/* publishUiCssVariables */
/* ------------------------------------------------------------------ */
describe("publishUiCssVariables", () => {
it("calls setProperty for each variable", () => {
const calls: [string, string][] = [];
const target = { setProperty: (n: string, v: string) => calls.push([n, v]) };
const vars = { "--kxkm-test-a": "red", "--kxkm-test-b": "blue" };
publishUiCssVariables(target, vars);
assert.deepEqual(calls, [["--kxkm-test-a", "red"], ["--kxkm-test-b", "blue"]]);
});
it("uses default UI_CSS_VARIABLES when none specified", () => {
const calls: [string, string][] = [];
const target = { setProperty: (n: string, v: string) => calls.push([n, v]) };
publishUiCssVariables(target);
assert.equal(calls.length, Object.keys(UI_CSS_VARIABLES).length);
assert.deepEqual(calls[0][0], Object.keys(UI_CSS_VARIABLES)[0]);
});
});
/* ------------------------------------------------------------------ */
/* createUiCssText */
/* ------------------------------------------------------------------ */
describe("createUiCssText", () => {
it("generates a CSS block with :root by default", () => {
const css = createUiCssText();
assert.ok(css.startsWith(":root {"));
assert.ok(css.endsWith("}"));
});
it("uses a custom selector if provided", () => {
const css = createUiCssText(".my-app");
assert.ok(css.startsWith(".my-app {"));
});
it("contains --kxkm-* variables", () => {
const css = createUiCssText();
assert.ok(css.includes("--kxkm-shell-background:"));
assert.ok(css.includes("--kxkm-persona-1:"));
});
});
/* ------------------------------------------------------------------ */
/* UI_THEME aggregate */
/* ------------------------------------------------------------------ */
describe("UI_THEME", () => {
it("aggregates all sub-objects", () => {
assert.equal(UI_THEME.shell, SHELL_THEME);
assert.equal(UI_THEME.status, STATUS_COLORS);
assert.equal(UI_THEME.runStatus, RUN_STATUS_COLORS);
assert.equal(UI_THEME.personaPalette, PERSONA_PALETTE);
assert.equal(UI_THEME.runStatusClasses, RUN_STATUS_CLASSES);
});
it("has typography from shell fonts", () => {
assert.equal(UI_THEME.typography.mono, SHELL_THEME.fontMono);
assert.equal(UI_THEME.typography.display, SHELL_THEME.fontDisplay);
});
it("has spacing from shell gap/radius", () => {
assert.equal(UI_THEME.spacing.gap, SHELL_THEME.gap);
assert.equal(UI_THEME.spacing.radius, SHELL_THEME.radius);
describe("getPersonaColor", () => {
it("returns a string from PERSONA_PALETTE", () => {
const color = getPersonaColor("Pharmacius");
assert.ok(PERSONA_PALETTE.includes(color as any));
});
it("is deterministic", () => {
assert.equal(getPersonaColor("Sherlock"), getPersonaColor("Sherlock"));
});
it("returns accent for empty palette", () => {
assert.equal(getPersonaColor("test", []), SHELL_THEME.accent);
});
it("distributes across palette", () => {
const colors = new Set(["A","B","C","D","E","F","G","H"].map(n => getPersonaColor(n)));
assert.ok(colors.size >= 3, `Expected >=3 distinct colors, got ${colors.size}`);
});
});
describe("createUiCssVariables", () => {
it("returns object with CSS custom properties", () => {
const vars = createUiCssVariables();
assert.ok(vars["--kxkm-shell-background"]);
assert.ok(vars["--kxkm-shell-accent"]);
assert.ok(vars["--kxkm-persona-1"]);
});
it("includes all persona palette entries", () => {
const vars = createUiCssVariables();
for (let i = 1; i <= PERSONA_PALETTE.length; i++) {
assert.ok(vars[`--kxkm-persona-${i}`]);
}
});
});
describe("getRunStatusClass", () => {
it("maps known statuses", () => {
assert.equal(getRunStatusClass("running"), "status-running");
assert.equal(getRunStatusClass("failed"), "status-failed");
});
it("returns status-muted for unknown", () => {
assert.equal(getRunStatusClass("unknown"), "status-muted");
});
});
describe("publishUiCssVariables", () => {
it("calls setProperty for each variable", () => {
const calls: [string, string][] = [];
const target = { setProperty: (n: string, v: string) => calls.push([n, v]) };
publishUiCssVariables(target);
assert.ok(calls.length > 10);
assert.ok(calls.some(([n]) => n === "--kxkm-shell-accent"));
});
});
describe("createUiCssText", () => {
it("generates valid CSS block", () => {
const css = createUiCssText();
assert.ok(css.startsWith(":root {"));
assert.ok(css.includes("--kxkm-shell-background"));
assert.ok(css.endsWith("}"));
});
it("accepts custom selector", () => {
const css = createUiCssText(".minitel");
assert.ok(css.startsWith(".minitel {"));
});
});
describe("UI_THEME", () => {
it("aggregates all theme objects", () => {
assert.ok(UI_THEME.shell);
assert.ok(UI_THEME.status);
assert.ok(UI_THEME.personaPalette);
assert.ok(UI_THEME.typography);
});
});
});
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
KXKM_DIR=/home/kxkm/KXKM_Clown
find $KXKM_DIR/data/chat-logs -name "*.jsonl" -mtime +30 -delete 2>/dev/null
for f in $KXKM_DIR/data/persona-memory/*.json; do
size=$(stat -c%s "$f" 2>/dev/null || echo 0)
[ "$size" -gt 102400 ] && python3 -c "
import json
with open(\"$f\") as fh: d = json.load(fh)
d[\"facts\"] = d.get(\"facts\", [])[-20:]
with open(\"$f\", \"w\") as fh: json.dump(d, fh, indent=2)
" && echo "[cleanup] Trimmed $f"
done
echo "[cleanup] Done $(date)"
+34 -12
View File
@@ -59,31 +59,53 @@ $SSH "cd $REMOTE_DIR && \
docker restart kxkm_clown-api-1" || fail "Docker deploy failed"
log "Docker restarted"
# ─── Step 5: Restart TTS server (chatterbox-remote + piper fallback) ──
# ─── Step 5: Restart TTS server (systemd user unit) ───────
if [[ "$MODE" == "--full" || "$MODE" == "--tts" ]]; then
log "Restarting TTS server..."
$SSH "tmux kill-session -t tts 2>/dev/null || true; \
sleep 1; \
tmux new-session -d -s tts \
'source /home/kxkm/venv/bin/activate && cd $REMOTE_DIR && CHATTERBOX_URL=http://127.0.0.1:9200 python3 scripts/tts-server.py --port 9100 --backend chatterbox-remote 2>&1 | tee /tmp/tts-server.log'; \
log "Restarting TTS server (systemd)..."
$SSH "systemctl --user restart kxkm-tts.service; \
sleep 3; \
curl -sf http://127.0.0.1:9100/health && echo ' TTS OK' || echo ' TTS FAIL'"
fi
# ─── Step 5b: Restart LightRAG server ────────────────────
# ─── Step 5b: Restart LightRAG server (systemd user unit)
if [[ "$MODE" == "--full" ]]; then
log "Restarting LightRAG server..."
$SSH "tmux kill-session -t lightrag 2>/dev/null || true; \
sleep 1; \
tmux new-session -d -s lightrag \
'source /home/kxkm/venv/bin/activate && cd $REMOTE_DIR && EMBEDDING_DIM=768 LLM_MODEL=qwen3:8b EMBEDDING_MODEL=nomic-embed-text OLLAMA_HOST=http://localhost:11434 lightrag-server --host 0.0.0.0 --port 9621 --working-dir $REMOTE_DIR/data/lightrag --llm-binding ollama --embedding-binding ollama 2>&1 | tee /tmp/lightrag-server.log'; \
log "Restarting LightRAG server (systemd)..."
$SSH "systemctl --user restart kxkm-lightrag.service; \
sleep 5; \
curl -sf http://127.0.0.1:9621/health | head -c 30 && echo ' LightRAG OK' || echo ' LightRAG FAIL'"
fi
# ─── Step 5c: Restart Reranker server (systemd user unit) ─
if [[ "$MODE" == "--full" ]]; then
log "Restarting Reranker server (systemd)..."
$SSH "systemctl --user restart kxkm-reranker.service; \
sleep 3; \
curl -sf http://127.0.0.1:9500/health && echo ' Reranker OK' || echo ' Reranker FAIL'"
fi
# ─── Step 5d: Restart Qwen3-TTS server (on-demand, GPU-heavy) ─
if [[ "$MODE" == "--full" ]]; then
log "Checking Qwen3-TTS server (on-demand)..."
$SSH "if systemctl --user is-active kxkm-qwen3-tts.service >/dev/null 2>&1; then \
systemctl --user restart kxkm-qwen3-tts.service; \
sleep 5; \
curl -sf http://127.0.0.1:9300/health && echo ' Qwen3-TTS OK' || echo ' Qwen3-TTS FAIL'; \
else \
echo ' Qwen3-TTS not active (on-demand, skipped)'; \
fi"
fi
# ─── Step 6: Health check ──────────────────────────────────
log "Health check..."
sleep 3
$SSH "curl -sf http://localhost:3333/api/v2/health | head -c 50" && echo " API OK" || echo " API FAIL"
# Docker container health
log "Docker containers status..."
$SSH "docker compose --profile v2 ps --format 'table {{.Name}}\t{{.Status}}' 2>/dev/null"
# Journal disk usage
log "Journal disk usage..."
$SSH "journalctl --user --disk-usage 2>/dev/null"
log "═══ Deploy complete ═══"
+99 -25
View File
@@ -1,6 +1,92 @@
#!/usr/bin/env python3
"""Extract text from PDF using Docling (tables, layout, OCR)."""
import argparse, json, sys, time
"""Extract text from PDF using Docling (HTTP API → local library → PyMuPDF fallback)."""
import argparse, json, os, sys, time
def try_docling_http(filepath: str, max_chars: int) -> dict | None:
"""Try Docling-serve HTTP API if DOCLING_URL is set."""
docling_url = os.environ.get("DOCLING_URL", "").rstrip("/")
if not docling_url:
return None
import urllib.request, urllib.error
from pathlib import Path
url = f"{docling_url}/v1/convert/file"
filename = Path(filepath).name
# Build multipart form data manually (no requests dependency)
boundary = f"----DoclingBoundary{int(time.time()*1000)}"
file_data = Path(filepath).read_bytes()
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="files"; filename="{filename}"\r\n'
f"Content-Type: application/pdf\r\n\r\n"
).encode() + file_data + f"\r\n--{boundary}--\r\n".encode()
req = urllib.request.Request(
url,
data=body,
headers={
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Accept": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
result = json.loads(resp.read())
# docling-serve response: {document: {md_content, filename}, status, processing_time}
text = ""
if isinstance(result, dict):
doc = result.get("document", {})
if isinstance(doc, dict):
text = doc.get("md_content", "") or doc.get("text_content", "") or ""
if not text:
text = result.get("text", "") or result.get("markdown", "")
if not text:
text = json.dumps(result, ensure_ascii=False)
text = text[:max_chars]
status = result.get("status", "unknown") if isinstance(result, dict) else "unknown"
return {"status": "completed", "text": text, "pages": "?", "backend": "docling-serve",
"docling_status": status}
except (urllib.error.URLError, urllib.error.HTTPError, OSError, json.JSONDecodeError) as e:
print(f"[docling-http] fallback: {e}", file=sys.stderr)
return None
def try_docling_local(filepath: str, max_chars: int) -> dict | None:
"""Try local Docling Python library."""
try:
from docling.document_converter import DocumentConverter
converter = DocumentConverter()
doc = converter.convert(filepath)
text = doc.document.export_to_markdown()[:max_chars]
pages = len(doc.document.pages) if hasattr(doc.document, "pages") else 0
return {"status": "completed", "text": text, "pages": pages, "backend": "docling-local"}
except ImportError:
return None
except Exception as e:
print(f"[docling-local] fallback: {e}", file=sys.stderr)
return None
def try_pymupdf(filepath: str, max_chars: int) -> dict | None:
"""Fallback to PyMuPDF."""
try:
import fitz
doc = fitz.open(filepath)
text = ""
for page in doc:
text += page.get_text()
text = text[:max_chars]
return {"status": "completed", "text": text, "pages": len(doc), "backend": "pymupdf"}
except ImportError:
return None
except Exception as e:
print(f"[pymupdf] error: {e}", file=sys.stderr)
return None
def main():
p = argparse.ArgumentParser()
@@ -8,29 +94,17 @@ def main():
p.add_argument("--max-chars", type=int, default=12000)
args = p.parse_args()
start = time.time()
result = {"status": "failed", "text": "", "error": None}
try:
from docling.document_converter import DocumentConverter
converter = DocumentConverter()
doc = converter.convert(args.input)
text = doc.document.export_to_markdown()[:args.max_chars]
pages = len(doc.document.pages) if hasattr(doc.document, 'pages') else 0
result = {"status": "completed", "text": text, "pages": pages, "duration": round(time.time()-start, 2)}
except ImportError:
# Fallback to pdf-parse style
try:
import fitz # PyMuPDF
doc = fitz.open(args.input)
text = ""
for page in doc:
text += page.get_text()
text = text[:args.max_chars]
result = {"status": "completed", "text": text, "pages": len(doc), "duration": round(time.time()-start, 2)}
except ImportError:
result["error"] = "Neither docling nor PyMuPDF installed"
except Exception as e:
result["error"] = str(e)
print(json.dumps(result))
# Try backends in order: HTTP API → local library → PyMuPDF
for backend_fn in [try_docling_http, try_docling_local, try_pymupdf]:
result = backend_fn(args.input, args.max_chars)
if result:
result["duration"] = round(time.time() - start, 2)
print(json.dumps(result))
return
print(json.dumps({"status": "failed", "error": "No PDF backend available (docling-serve, docling, PyMuPDF)"}))
if __name__ == "__main__":
main()
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# ─── KXKM_Clown Health Check TUI ───────────────────────────
# Usage: bash scripts/health-check.sh [--remote kxkm@kxkm-ai]
set -uo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
PASS="${GREEN}${NC}"; FAIL="${RED}${NC}"; WARN="${YELLOW}${NC}"
HOST="localhost"
SSH=""
if [[ "${1:-}" == "--remote" && -n "${2:-}" ]]; then
SSH="ssh $2"
HOST="localhost"
echo -e "${CYAN}═══ KXKM_Clown Health Check (remote: $2) ═══${NC}"
else
echo -e "${CYAN}═══ KXKM_Clown Health Check (local) ═══${NC}"
fi
run() { if [[ -n "$SSH" ]]; then $SSH "$@"; else eval "$@"; fi; }
ok=0; fail=0; warn=0
check() {
local label="$1"; shift
if result=$(run "$@" 2>/dev/null); then
echo -e " ${PASS} ${label}: ${result}"
((ok++))
else
echo -e " ${FAIL} ${label}: FAILED"
((fail++))
fi
}
checkwarn() {
local label="$1"; shift
if result=$(run "$@" 2>/dev/null); then
echo -e " ${PASS} ${label}: ${result}"
((ok++))
else
echo -e " ${WARN} ${label}: NOT AVAILABLE"
((warn++))
fi
}
echo ""
echo -e "${CYAN}── Services ──${NC}"
check "API V2 (:3333)" "curl -sf http://${HOST}:3333/api/v2/health | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get(\"data\",{}).get(\"app\",\"?\"))'"
check "PostgreSQL" "docker exec kxkm_clown-postgres-1 pg_isready -q && echo 'ready'"
check "Ollama (:11434)" "curl -sf http://${HOST}:11434/api/tags | python3 -c 'import sys,json; d=json.load(sys.stdin); print(len(d.get(\"models\",[])),\"models\")'"
checkwarn "SearXNG (:8080)" "curl -sf 'http://${HOST}:8080/search?q=test&format=json' -H 'Accept: application/json' | python3 -c 'import sys,json; d=json.load(sys.stdin); print(len(d.get(\"results\",[])),\"results\")'"
checkwarn "TTS Sidecar (:9100)" "curl -sf http://${HOST}:9100/health | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get(\"backend\",\"?\"))'"
checkwarn "Chatterbox (:9200)" "curl -sf http://${HOST}:9200/health | python3 -c 'import sys,json; d=json.load(sys.stdin); print(\"GPU docker\")'"
checkwarn "LightRAG (:9621)" "curl -sf http://${HOST}:9621/health | head -c 50"
echo ""
echo -e "${CYAN}── Docker Containers ──${NC}"
check "Containers" "docker ps --format '{{.Names}}: {{.Status}}' | grep -c 'Up' | xargs -I{} echo '{} running'"
echo ""
echo -e "${CYAN}── Data ──${NC}"
check "Chat logs" "ls -1 data/chat-logs/v2-*.jsonl 2>/dev/null | wc -l | xargs -I{} echo '{} log files'"
check "Context store" "ls -1 data/context/*.jsonl 2>/dev/null | wc -l | xargs -I{} echo '{} channels'"
check "Media images" "ls -1 data/media/images/*.png 2>/dev/null | wc -l | xargs -I{} echo '{} images'"
check "Media audio" "ls -1 data/media/audio/*.wav 2>/dev/null | wc -l | xargs -I{} echo '{} audio files'"
check "Persona memory" "ls -1 data/persona-memory/*.json 2>/dev/null | wc -l | xargs -I{} echo '{} personas with memory'"
echo ""
echo -e "${CYAN}── API Endpoints ──${NC}"
check "Session login" "curl -sf -X POST http://${HOST}:3333/api/session/login -H 'Content-Type: application/json' -d '{\"username\":\"healthcheck\",\"role\":\"viewer\"}' | python3 -c 'import sys,json; print(json.load(sys.stdin).get(\"ok\",False))'"
check "Personas list" "curl -sf -c /tmp/hc.txt -X POST http://${HOST}:3333/api/session/login -H 'Content-Type: application/json' -d '{\"username\":\"hc\"}' > /dev/null && curl -sf -b /tmp/hc.txt http://${HOST}:3333/api/personas | python3 -c 'import sys,json; d=json.load(sys.stdin); print(len(d.get(\"data\",[])),\"personas\")'"
check "Media images API" "curl -sf -b /tmp/hc.txt http://${HOST}:3333/api/v2/media/images | python3 -c 'import sys,json; d=json.load(sys.stdin); print(len(d.get(\"data\",[])),\"images\")'"
check "Node Engine" "curl -sf -c /tmp/hcadm.txt -X POST http://${HOST}:3333/api/session/login -H 'Content-Type: application/json' -d '{\"username\":\"admin\",\"role\":\"admin\",\"token\":\"kxkm\"}' > /dev/null && curl -sf -b /tmp/hcadm.txt http://${HOST}:3333/api/admin/node-engine/overview | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d[\"data\"][\"registry\"][\"models\"],\"models\")'"
echo ""
echo -e "${CYAN}── GPU ──${NC}"
checkwarn "NVIDIA GPU" "nvidia-smi --query-gpu=name,memory.used,memory.total --format=csv,noheader,nounits | head -1"
echo ""
echo -e "${CYAN}── Disk ──${NC}"
check "Disk usage" "df -h / | tail -1 | awk '{print \$3\"/\"\$2\" used (\"\$5\")\"}'"
check "Data dir size" "du -sh data/ 2>/dev/null | cut -f1 | xargs -I{} echo '{} total'"
echo ""
echo -e "═══════════════════════════════════════"
echo -e " ${GREEN}${ok} passed${NC} ${RED}${fail} failed${NC} ${YELLOW}${warn} warnings${NC}"
echo -e "═══════════════════════════════════════"
[[ $fail -eq 0 ]] && exit 0 || exit 1
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Preload Ollama models into VRAM after boot/restart
# Run via: systemctl --user start kxkm-ollama-warmup
OLLAMA=http://localhost:11434
echo "[warmup] Loading models..."
for model in qwen3:8b mistral:7b nomic-embed-text; do
curl -sf $OLLAMA/api/chat -d "{\"model\":\"\",\"messages\":[{\"role\":\"user\",\"content\":\".\"}],\"stream\":false,\"options\":{\"num_predict\":1,\"num_ctx\":8192},\"keep_alive\":\"30m\"}" -o /dev/null 2>/dev/null
echo " : loaded"
done
echo "[warmup] Done. Models in VRAM:"
curl -sf $OLLAMA/api/ps 2>/dev/null | python3 -c 'import sys,json; [print(f" {m[\"name\"]}: {m[\"size\"]//1e9:.1f}GB {m[\"details\"][\"family\"]}") for m in json.load(sys.stdin).get(\"models\",[])]' 2>/dev/null || ollama ps
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Start Qwen3-TTS on demand, stop after 5 min idle
# Usage: called by tts-server.py when qwen3 backend is requested
PORT=9300
SERVICE=kxkm-qwen3-tts.service
IDLE_TIMEOUT=300 # 5 minutes
# Check if already running
if systemctl --user is-active $SERVICE >/dev/null 2>&1; then
# Already running, just check health
curl -sf http://localhost:$PORT/health >/dev/null && exit 0
fi
# Start service
systemctl --user start $SERVICE
echo "[qwen3-tts] Starting on-demand..." >&2
# Wait for health (max 30s)
for i in $(seq 1 30); do
curl -sf http://localhost:$PORT/health >/dev/null 2>&1 && break
sleep 1
done
# Schedule auto-stop after idle timeout
(sleep $IDLE_TIMEOUT && systemctl --user stop $SERVICE && echo "[qwen3-tts] Auto-stopped after ${IDLE_TIMEOUT}s idle" >&2) &
disown
echo "[qwen3-tts] Ready on :$PORT" >&2

Some files were not shown because too many files have changed in this diff Show More