feat: session 2026-03-19 — 35 lots (24-58), 311 tests, 12 services

Highlights:
- Streaming chat chunks + web search auto (Sherlock/SearXNG)
- pino structured logging (43->0 console.log)
- Zod validation (19 schemas API + storage)
- Qwen3-TTS 0.6B + 33 persona voices
- Docling + bge-reranker RAG pipeline
- React lazy routes (-50% bundle), virtualized chat
- SEC-01->05 all resolved
- A11y WCAG (40 ARIA attrs)
- Perf + error telemetry endpoints
- Systemd units (TTS, LightRAG, reranker)
- p-limit Ollama, WS reconnect, signal handlers
- createId thread-safe via crypto.randomBytes (lot-59)
- Hyperparam bounds validation with Zod schema (lot-60)
- DPO extractDPOPairs warns on empty pairs (lot-61)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codex Local
2026-03-19 22:54:32 +01:00
parent 332eebf003
commit 06ea18f425
179 changed files with 47173 additions and 2694 deletions
+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.
+14 -9
View File
@@ -48,11 +48,11 @@ Par defaut, Ollama est attendu en natif sur le host (port 11434).
### Chat multimodal
- **Interface Minitel** — Animation modem 3615 ULLA → login → chat (esthetique phosphore CRT)
- **Chat temps reel** — WebSocket `/ws`, streaming LLM, 26 personas
- **Chat temps reel** — WebSocket `/ws`, streaming LLM, 33 personas
- **RAG local** — Embeddings Ollama (`nomic-embed-text`), contexte manifeste
- **Vision** — Analyse d'images via `qwen3-vl:8b` (upload dans le chat)
- **STT** — Transcription audio via `faster-whisper` (upload audio)
- **TTS** — Piper-tts (fallback rapide) + XTTS-v2 (voice cloning per persona)
- **TTS** — Piper-tts + Chatterbox (dual backend via TTS sidecar HTTP :9100)
- **PDF** — Extraction via Docling/PyMuPDF (tables, layout, OCR)
- **Recherche web** — SearXNG self-hosted + DuckDuckGo fallback
- **Generation musicale** — `/compose` via ACE-Step 1.5 / MusicGen
@@ -80,10 +80,11 @@ Par defaut, Ollama est attendu en natif sur le host (port 11434).
### Personas
- 26 personas (musique, arts, sciences, philosophie, ecologie, tech, cinema)
- 33 personas (musique, arts, sciences, philosophie, ecologie, tech, cinema)
- Pipeline editorial: source → feedback → proposals → apply/revert
- Pharmacius: orchestrateur editorial automatique (mistral:7b)
- Vue arborescente par modele (refermee par defaut)
- Pharmacius: routeur principal (qwen3:8b, maxTokens:600, think-strip)
- Inter-persona @mention depth 3, 2s delay
- Modeles: qwen3:8b x21, mistral:7b x7, gemma3:4b x4, qwen3-vl:8b (vision)
## Variables d'environnement
@@ -99,7 +100,7 @@ Par defaut, Ollama est attendu en natif sur le host (port 11434).
| `ADMIN_SUBNET` | (vide) | CIDR autorise pour admin V2 |
| `MAX_GENERAL_RESPONDERS` | `4` | Nombre max de personas repondant dans #general |
| `OWNER_NICK` | (vide) | Pseudo du proprietaire |
| `VISION_MODEL` | `minicpm-v` | Modele Ollama pour analyse d'images |
| `VISION_MODEL` | `qwen3-vl:8b` | Modele Ollama pour analyse d'images |
| `TTS_ENABLED` | `0` | Activer la synthese vocale (`1` pour activer) |
| `WEB_SEARCH_API_BASE` | (vide) | Endpoint API de recherche web custom |
| `PYTHON_BIN` | `python3` | Python avec libs ML (PyTorch, faster-whisper, piper-tts) |
@@ -130,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
```
@@ -244,7 +245,7 @@ kxkm_clown/
| --- | --- | --- |
| Chat temps reel | operationnel | operationnel |
| RAG local | n/a | operationnel |
| Vision (minicpm-v) | n/a | operationnel |
| Vision (qwen3-vl:8b) | n/a | operationnel |
| STT (faster-whisper) | n/a | operationnel |
| TTS (piper-tts) | n/a | operationnel |
| PDF extraction | n/a | operationnel |
@@ -260,7 +261,11 @@ kxkm_clown/
| RBAC | n/a | operationnel |
| Frontend React | n/a | operationnel |
| Training (TRL/Unsloth) | n/a | operationnel |
| Tests (135+) | 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 |
| Deploy tmux (deploy.sh) | n/a | operationnel |
Notes runtime V2:
+252 -41
View File
@@ -1,54 +1,265 @@
# TODO (kxkm-clown-v2)
# TODO
Updated: 2026-03-18T21:30:00Z
## P0 Critical (sécurité & stabilité)
## Lots termines
- [x] Fix bash injection dans `node-engine-runtimes.js` — validation runtimeId/nodeType + timeout 30min
- [x] Ajouter timeout sur les appels Ollama — 15s metadata, 5min chat streaming
- [x] Validation des entrées sur les messages WebSocket — 64KB max frame, 8192 chars text, type checks
- [x] Rate limiting par user/IP sur chat — `rate-limit.js` + 30 msg/min par IP dans WebSocket
- [x] `escapeHtml` dédupliqué → `utils.js`
- [x] `normalizeAuth` consolidé dans `admin-api.js`
- [x] `ensureSeedGraphs` guard flag ajouté
- [x] `finishRun` comptage d'artifacts sans JSON parse
- [x] `recoverRunnableRuns` double-read corrigé
- [x] lot-0 cadrage
- [x] lot-1 socle
- [x] lot-2 domaines
- [x] lot-3 surfaces
- [x] lot-4 bascule
- [x] lot-12 deep-audit
- [x] lot-13 voice-mcp
- [x] lot-14 documents-search
- [x] lot-16 minitel-ui
- [x] lot-17 chat-fixes
- [x] lot-18 media-tts
- [x] lot-19 infra
- [x] lot-20 deep-audit-2
## P1 V1 Quality
## lot-21-chatterbox-tts (P1)
- [x] Migrer vers le SDK officiel `ollama-js` — fait en P7 (`ollama.js` réécrit)
- [x] Ajouter un audit logging pour les actions admin — `audit-log.js` + intégré dans `http-api.js` et `server.js`
- [x] Implémenter l'analyse image/audio dans `attachment-pipeline.js` — stubs factory avec adapter slot
- [x] Corriger la validation d'origine `postMessage` — déjà en place (personas.js:1476)
- [x] Ajouter la déduplication de requêtes dans `admin-api.js` — fait en P7 (`deduplicatedFetch`)
- [x] Node Engine : validation de tri topologique — déjà en place (cycle detection dans runner)
- [x] Node Engine : timeout d'exécution par nœud — 10min default via `NODE_ENGINE_STEP_TIMEOUT_MS`
- [ ] Installer Chatterbox sur kxkm-ai | owner: Multimodal
- [ ] Adapter tts-server.py backend Chatterbox | owner: Multimodal
- [ ] Tester qualite vocale 33 personas | owner: Multimodal
- [ ] Benchmark latence <500ms/100chars | owner: Multimodal
## P2 V2 Domaines
## lot-22-graph-rag (P2)
- [x] Schéma Postgres + migrations + repos typés (`packages/storage`) — session, persona, graph, run repos
- [x] Module auth réel (`packages/auth`) — crypto.scrypt, token gen, extractSessionId, validateLoginInput
- [x] Logique domaine chat (`packages/chat-domain`) — ChatMessage, ChatSession, compactHistory, channel validation
- [x] Logique domaine persona (`packages/persona-domain`) — validatePersonaUpdate, aggregateFeedback, computePersonaDiff
- [x] Brancher les repos Postgres dans `apps/api` — async `createApp()`, fallback in-memory si pas de DATABASE_URL
- [ ] Evaluer LightRAG vs txtai vs RAGatouille | owner: Backend API
- [ ] Integrer dans rag.ts | owner: Backend API
- [ ] Indexer manifeste + lore personas | owner: Backend API
- [ ] Benchmark recall vs baseline cosine | owner: Backend API
## P3 Node Engine V2
## lot-23-crt-webgl (P3)
- [x] Porter registry → `packages/node-engine` (15 node types, 7 familles)
- [x] Porter graph ops (topologicalSort, validateEdgeContracts, collectNodeInputs)
- [x] Porter run state machine (createRun, RunStep, resolveFinalStatus)
- [x] Porter queue logic (createQueueState, enqueue, dequeue, canDequeue)
- [x] Runtime definitions (5 runtimes)
- [x] Isoler les runtimes avec sandboxing approprié — fait en P8 (`sandbox.ts`)
- [x] Adaptateurs d'entraînement réels (LoRA, QLoRA, SFT) — fait en P8 (`training.ts`)
- [x] Brancher le runner V2 dans `apps/worker` — poll loop, stub executors, graceful shutdown
- [ ] Evaluer vault66-crt-effect vs shaders custom | owner: Frontend
- [ ] Integrer dans MinitelFrame | owner: Frontend
- [ ] Tester perf mobile FPS 30+ | owner: Frontend
## P4 Frontend V2
## lot-24-tests-integration (P2)
- [x] API client centralisé (`api.ts`)
- [x] 9 composants React (Header, Login, Nav, PersonaList, PersonaDetail, NodeEngineOverview, GraphDetail, RunStatus, ChannelList)
- [x] Routing hash-based + responsive CSS
- [x] Interface chat React (WebSocket live) — fait en P7 (`Chat.tsx` + `useWebSocket.ts`)
- [x] Éditeur visuel Node Engine (React Flow) — fait en P7 (`NodeEditor.tsx` + `EngineNode.tsx`)
- [ ] Mock HTTP Ollama (streaming + tools) | owner: Backend API
- [ ] Mock ComfyUI workflow + polling | owner: Backend API
- [ ] Mock SearXNG + DuckDuckGo fallback | owner: Backend API
- [ ] Mock TTS sidecar HTTP | owner: Backend API
- [ ] Test context-store concurrent writes | owner: Backend API
- [ ] Test media-store path traversal | owner: Backend API
## P5 TUI & Ops
## Bugs restants (P3)
- [x] TUI health-check (V1+V2+Ollama+disk+memory)
- [x] TUI queue-viewer (runs, statuses)
- [x] TUI persona-manager (overview)
- [x] Log rotation (--dry-run, --max-age-days)
- [x] Packages/tui: ansi, statusDot, formatTable, drawBox
- [ ] Bug #7: token comparison timing-attack (crypto.timingSafeEqual) | owner: Backend API
- [ ] Merzbow 0 chars (think tokens consomment tout num_predict) | owner: Backend API
- [ ] Docker build torch timeout (layer trop lourd) | owner: Ops
## P6 Migration
- [x] Matrice de parité V1 → V2 — `scripts/parity-check.js` (persona, graph, channel, API shape checks)
- [x] Scripts de migration de données — `scripts/migrate-v1-to-v2.js` (personas, graphs, runs → Postgres, --dry-run support)
- [x] Smoke tests pour V2 — `scripts/smoke-v2.js` (22 tests, 5 catégories, `npm run smoke:v2`)
- [x] Procédure de rollback — `scripts/rollback-v2.js` (drop/truncate tables with confirmation, --yes, --tables filter)
## P0+ Sécurité V1 (deep analyse 2026-03-16)
- [x] Path traversal dans `storage.js` — sanitisation session IDs + boundary check memory paths
- [x] Path traversal dans `persona-registry.js` / `persona-store.js``safeFsId()` helper
- [x] Path traversal dans `attachment-store.js` — sanitisation IDs + boundary check
- [x] SSRF dans `web-tools.js` — blocage localhost, IPs privées, .local/.internal
- [x] Response body limit dans `web-tools.js` — truncation 2 MB
- [x] Log injection dans `storage.js` — sanitisation paramètre `role`
- [x] Crash JSONL corrompu dans `storage.js` — try/catch par ligne
- [x] Map mutation during iteration dans `sessions.js` — collect then process
- [x] Session leak `/msg` dans `commands.js` — clé stable au lieu de Date.now()
- [x] Unbounded userRateLimits dans `chat-routing.js` — pruning > 200 entries
- [x] Session pruning O(n) dans `admin-session.js` — throttle 60s
## P7 Intégration avancée
- [x] Migrer vers ollama-js SDK officiel — `ollama.js` réécrit avec `Ollama` class, même interface
- [x] Chat WebSocket React live — hook `useWebSocket` + composant Chat IRC, auto-reconnect
- [x] Éditeur visuel Node Engine avec React Flow — `NodeEditor.tsx` + `EngineNode.tsx`, 7 familles colorées
- [x] Déduplication requêtes GET dans `admin-api.js``deduplicatedFetch` transparent
- [x] Repos Postgres pour persona sources/feedback/proposals — 3 tables + repos + fallback in-memory
- [x] CI/CD GitHub Actions — `.github/workflows/ci.yml` (check V1+V2)
- [x] Deep analyse finale V1+V2 — 14 modules V1 vérifiés, 3 fixes TS V2, intégrité confirmée
## P8 Production Readiness
- [x] Adaptateurs training réels (TRL + Unsloth pour LoRA/DPO) — `packages/node-engine/src/training.ts` + worker intégré
- [x] Sandboxing runtimes Node Engine (containers/VM) — `packages/node-engine/src/sandbox.ts` (none/subprocess/container)
- [x] Turborepo pour build orchestration monorepo — `turbo.json` + scripts alignés + CI mis à jour
- [x] Tests unitaires V2 avec node:test + supertest — 102 tests, 46 suites, 6 packages + API integration
- [x] Tests React avec Vitest + RTL — 33 tests, 6 composants (Header, Login, Nav, PersonaList, RunStatus, ChannelList)
- [x] Créer le repo GitHub privé — https://github.com/electron-rare/kxkm_clown
## P9 Code Quality (simplify review)
- [x] Triple filter → single-pass loop dans `node-engine.js:deriveAsyncMeta`
- [x] Duplicate sanitization extraite dans `attachment-store.js:sanitizeId`
- [x] Double `loadModelIndex()` éliminé dans `node-engine-store.js:registerDeployment`
## P10 Lot 11 — Consolidation & Feature Parity
### Phase A — Analyse & Recherche
- [x] Deep analyse code V1+V2 (agent en cours)
- [x] Veille OSS mise à jour (agent en cours)
- [x] Recherche HuggingFace (agent en cours)
### Phase B — Correctifs sécurité (deep analyse)
- [x] **P0 SEC-01** Path traversal `node-engine-runner.js` — reject absolute paths + rootDir boundary check
- [x] **P0 SEC-04** V2 login role self-assignment — viewer par défaut, admin via ADMIN_TOKEN
- [x] **P1 BUG-06** Health endpoint leaking DATABASE_URL — remplacé par storageMode string
- [x] **P1 BUG-02** Timeout promise leak `node-engine-runner.js` — AbortSignal cancel
- [x] **P1 SEC-03** Attachments sans auth — ajout requireAdminNetwork middleware
- [x] Compilation + 119 tests OK après correctifs
### Phase C — Feature Parity V2
- [x] Recovery on crash worker — `recoverStaleRuns()` + worker startup recovery
- [x] Cancel support — `requestCancel()` repo + `shouldCancel` callback worker + API endpoint
- [ ] Tab completion chat V2
- [x] Commandes slash V2 — `parseSlashCommand`, `resolveCommand`, `generateHelpText` + 11 commandes + 17 tests
- [x] Mémoire conversationnelle V2 — `ConversationMemory`, `addToMemory`, `buildLlmContext`, `clearMemory`
- [x] Status strip admin V2 — GET `/api/v2/status` (personas, graphs, runs, queue)
- [x] Subnet gate V2 — CIDR middleware `/api/v2/admin/*` avec ADMIN_SUBNET env
- [x] Retention sweep V2 — `deleteOlderThan()` repo + POST `/api/v2/admin/retention-sweep`
- [x] Export HTML V2 — GET `/api/v2/export/html` avec download attachment
- [x] Upload fichiers V2 — bouton upload base64 dans Chat.tsx, accept image/audio/text/pdf/json/csv
- [x] Tab completion chat V2 — nicks + commandes slash, cycling Tab, reset auto
### Phase D — Déploiement & Docs
- [x] Docker — `Dockerfile` (multi-stage Node 22 alpine) + `docker-compose.yml` (5 services) + `.dockerignore`
- [ ] Documentation utilisateur
- [ ] Performance profiling
## P11 Lot 17 — Deep Audit & Refactoring
### Phase A — Analyse & documentation
- [x] Script TUI deep-audit.js (security, perf, complexity, deps) — `ops/v2/deep-audit.js`
- [x] Veille OSS enrichie 2026-03-17 (10 nouvelles catégories) — `docs/OSS_WATCH_2026-03-16.md`
- [x] Diagrammes Mermaid (Context Store, Docker, Inter-persona) — `docs/ARCHITECTURE.md`
- [x] AGENTS.md refondu (matrice 10 agents, Mermaid routing, pipeline) — `docs/AGENTS.md`
- [x] PLAN.md consolidé avec lots 17-19
- [x] TODO.md consolidé avec backlog Phase 6+
- [ ] Deep analyse code agents (api, web, packages, mascarade, v1+worker) — en cours
### Phase B — Refactoring code
- [ ] **P1** ws-chat.ts: extraction modules (1449 LOC → ~4×350 LOC)
- [ ] Extraire `ws-multimodal.ts` (vision, STT, TTS, PDF handlers)
- [ ] Extraire `ws-persona-router.ts` (pickResponders, inter-persona, memory)
- [ ] Extraire `ws-commands.ts` (slash commands, /web, /imagine)
- [ ] Garder `ws-chat.ts` core (WebSocket lifecycle, broadcast, rate limit)
- [ ] **P1** app.ts: extraction routes (1292 LOC → routes/ + middleware/)
- [ ] Extraire `routes/personas.ts`
- [ ] Extraire `routes/node-engine.ts`
- [ ] Extraire `routes/chat.ts`
- [ ] Extraire `middleware/auth.ts`
- [ ] **P2** writeFileSync → appendFile async dans ws-chat.ts (3 occurrences)
- [ ] **P2** console.log → logger structuré (apps/api, apps/worker)
- [ ] **P2** React.memo sur Chat, ChatHistory, VoiceChat, NodeEditor
- [ ] **P2** Lazy load: React.lazy + Suspense pour routes lourdes
### Phase C — Infrastructure
- [ ] SearXNG dans docker-compose (service searxng:8080, remplacer DuckDuckGo)
- [ ] MinerU/Docling dans docker-compose (remplacer pdf-parse)
- [ ] Spike BGE-M3 embeddings (upgrade nomic-embed-text)
- [ ] Déployer deep-audit.js sur kxkm-ai (cron quotidien)
- [ ] Créer utilisateur Discord **Pharmacius** (bot orchestrateur, bridge chat Discord ↔ KXKM)
### Phase D — Nouveaux node types
- [ ] `music_generation` node (ACE-Step 1.5, <4GB VRAM)
- [ ] `voice_clone` node (XTTS-v2, zero-shot 6s reference)
- [ ] `document_extraction` node (MinerU/Docling)
## P12 Lot 18 — Voice & MCP (futur)
- [ ] XTTS-v2 voice cloning par persona
- [ ] LLMRTC WebRTC streaming (TypeScript, VAD, barge-in)
- [ ] MCP SDK integration (personas = MCP servers)
- [ ] PCL + OpenCharacter pipeline fine-tune
- [ ] Chatterbox TTS evaluation
## P13 Lot 19 — Music & Creative (futur)
- [ ] ACE-Step 1.5 production
- [ ] `/compose` command (prompt → musique)
- [ ] Flux 2 dans ComfyUI
- [ ] A2A Protocol evaluation
## Lot 20 - Deep Analyse Continue & Execution Chainee `[en cours]`
A faire maintenant:
- [ ] Poursuivre extraction modulaire de `ws-chat.ts` (router, commandes, core).
- [ ] Decouper `app.ts` en routes + middleware sans regression.
- [ ] Ajouter instrumentation perf API/WS (latence/debit/memoire).
- [ ] Integrer SearXNG + Docling et valider le pipeline.
Fait sur ce lot:
- [x] Extraction modulaire du bloc upload/analyse de `ws-chat.ts` (`ws-upload-handler.ts`).
- [x] Refonte UI Minitel depuis la racine du site (`public/index.html`, `public/styles.css`, `public/app.js`).
- [x] Deep audit execute et relance apres correctifs.
- [x] Corrections context-store appliquees et validees.
- [x] check:v2 et test:v2 au vert.
- [x] Correctif anti-decrement TTS negatif (`ttsActive`).
- [x] Cleanup opportuniste des sessions expirees (mode memory).
- [x] Purge des logs vides/obsoletes `ops/v2/logs`.
- [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).
## 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"
}
}
+94
View File
@@ -0,0 +1,94 @@
import logger from "./logger.js";
import {
loadDatabaseConfig,
createPostgresPool,
runMigrations,
createSessionRepo,
createPersonaRepo,
createNodeGraphRepo,
createNodeRunRepo,
createPersonaSourceRepo,
createPersonaFeedbackRepo,
createPersonaProposalRepo,
} from "@kxkm/storage";
import { PERSONA_SEED_CATALOG, clonePersona, type PersonaRecord } from "@kxkm/persona-domain";
interface MemoryFactories<SessionRepo, PersonaRepo, GraphRepo, RunRepo, SourceRepo, FeedbackRepo, ProposalRepo> {
createSessionRepo: () => SessionRepo;
createPersonaRepo: () => PersonaRepo;
createGraphRepo: () => GraphRepo;
createRunRepo: () => RunRepo;
createSourceRepo: () => SourceRepo;
createFeedbackRepo: () => FeedbackRepo;
createProposalRepo: () => ProposalRepo;
}
interface SeedablePersonaRepo {
seedCatalog(catalog: PersonaRecord[]): Promise<void>;
}
export async function bootstrapRepositories<
SessionRepo,
PersonaRepo extends SeedablePersonaRepo,
GraphRepo,
RunRepo,
SourceRepo,
FeedbackRepo,
ProposalRepo,
>(
factories: MemoryFactories<SessionRepo, PersonaRepo, GraphRepo, RunRepo, SourceRepo, FeedbackRepo, ProposalRepo>,
): Promise<{
sessionRepo: SessionRepo;
personaRepo: PersonaRepo;
graphRepo: GraphRepo;
runRepo: RunRepo;
sourceRepo: SourceRepo;
feedbackRepo: FeedbackRepo;
proposalRepo: ProposalRepo;
storageMode: "postgres" | "memory";
}> {
const isProduction = (process.env.NODE_ENV || "").toLowerCase() === "production";
if (!process.env.DATABASE_URL && isProduction) {
throw new Error("DATABASE_URL is required when NODE_ENV=production");
}
if (process.env.DATABASE_URL) {
const dbConfig = loadDatabaseConfig();
const pool = createPostgresPool(dbConfig);
await runMigrations(pool);
const sessionRepo = createSessionRepo(pool) as SessionRepo;
const personaRepo = createPersonaRepo(pool) as unknown as PersonaRepo;
const graphRepo = createNodeGraphRepo(pool) as GraphRepo;
const runRepo = createNodeRunRepo(pool) as RunRepo;
const sourceRepo = createPersonaSourceRepo(pool) as SourceRepo;
const feedbackRepo = createPersonaFeedbackRepo(pool) as FeedbackRepo;
const proposalRepo = createPersonaProposalRepo(pool) as ProposalRepo;
await personaRepo.seedCatalog(PERSONA_SEED_CATALOG.map(clonePersona));
return {
sessionRepo,
personaRepo,
graphRepo,
runRepo,
sourceRepo,
feedbackRepo,
proposalRepo,
storageMode: "postgres",
};
}
logger.warn("[kxkm/api] DATABASE_URL not set — using local persona storage + in-memory runtime stores");
return {
sessionRepo: factories.createSessionRepo(),
personaRepo: factories.createPersonaRepo(),
graphRepo: factories.createGraphRepo(),
runRepo: factories.createRunRepo(),
sourceRepo: factories.createSourceRepo(),
feedbackRepo: factories.createFeedbackRepo(),
proposalRepo: factories.createProposalRepo(),
storageMode: "memory",
};
}
+174
View File
@@ -0,0 +1,174 @@
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";
import type { AuthSession, Permission } from "@kxkm/core";
export interface SessionRequest extends Request {
session?: AuthSession;
}
export function createSessionMiddleware(sessionRepo: { findById(id: string): Promise<AuthSession | null> }): express.RequestHandler {
return (req: SessionRequest, _res: Response, next: NextFunction) => {
const sessionId = extractSessionId(req as unknown as { cookies?: Record<string, string>; headers?: Record<string, string> });
if (!sessionId) {
next();
return;
}
sessionRepo.findById(sessionId)
.then((session) => {
if (session) req.session = session;
next();
})
.catch(next);
};
}
export function createRequireSession(): express.RequestHandler {
return (req: SessionRequest, res: Response, next: NextFunction) => {
if (!req.session) {
res.status(401).json({ ok: false, error: "session_required" });
return;
}
next();
};
}
export function createRequirePermission(permission: Permission): express.RequestHandler {
return (req: SessionRequest, res: Response, next: NextFunction) => {
if (!req.session) {
res.status(401).json({ ok: false, error: "session_required" });
return;
}
if (!hasPermission(req.session.role, permission)) {
res.status(403).json({ ok: false, error: "permission_denied" });
return;
}
next();
};
}
interface ParsedSubnet {
version: number;
mask: bigint;
network: bigint;
}
function normalizeIp(value: string): string {
let ip = value.trim();
const zoneIndex = ip.indexOf("%");
if (zoneIndex >= 0) ip = ip.slice(0, zoneIndex);
if (ip.startsWith("::ffff:") && net.isIP(ip.slice(7)) === 4) {
return ip.slice(7);
}
return ip;
}
function ipv4ToBigInt(ip: string): bigint {
return ip.split(".").reduce((r, o) => (r << 8n) + BigInt(Number.parseInt(o, 10)), 0n);
}
function ipv6ToBigInt(ip: string): bigint {
const parts = ip.split("::");
const head = parts[0] ? parts[0].split(":").filter(Boolean) : [];
const tail = parts[1] ? parts[1].split(":").filter(Boolean) : [];
const missing = 8 - (head.length + tail.length);
const groups = [...head, ...Array.from({ length: missing }, () => "0"), ...tail];
return groups.reduce((r, g) => (r << 16n) + BigInt(Number.parseInt(g || "0", 16)), 0n);
}
function parseSubnet(entry: string): ParsedSubnet | null {
const raw = entry.trim();
if (!raw) return null;
const [addressPart, prefixPart] = raw.split("/");
const address = normalizeIp(addressPart);
const version = net.isIP(address);
if (!version) return null;
const totalBits = version === 4 ? 32 : 128;
const prefix = prefixPart === undefined ? totalBits : Number.parseInt(prefixPart, 10);
if (!Number.isInteger(prefix) || prefix < 0 || prefix > totalBits) return null;
const bits = BigInt(totalBits);
const hostBits = BigInt(totalBits - prefix);
const allOnes = (1n << bits) - 1n;
const mask = prefix === 0 ? 0n : (allOnes << hostBits) & allOnes;
const value = version === 4 ? ipv4ToBigInt(address) : ipv6ToBigInt(address);
return { version, mask, network: value & mask };
}
function isIpInSubnet(ip: string, subnet: ParsedSubnet): boolean {
const normalized = normalizeIp(ip);
const version = net.isIP(normalized);
if (!version || version !== subnet.version) return false;
const value = version === 4 ? ipv4ToBigInt(normalized) : ipv6ToBigInt(normalized);
return (value & subnet.mask) === subnet.network;
}
export function createAdminSubnetMiddleware(adminSubnet: string | undefined): express.RequestHandler | null {
if (!adminSubnet) {
return null;
}
const subnet = parseSubnet(adminSubnet);
if (!subnet) {
return null;
}
return (req: Request, res: Response, next: NextFunction) => {
const ip = normalizeIp(req.ip || req.socket?.remoteAddress || "");
if (!isIpInSubnet(ip, subnet)) {
res.status(403).json({ ok: false, error: "subnet_denied" });
return;
}
next();
};
}
export function createPerfTracker() {
const perfStats = {
requestCount: 0,
totalLatencyMs: 0,
maxLatencyMs: 0,
statusCodes: new Map<number, number>(),
startedAt: Date.now(),
};
const middleware: express.RequestHandler = (_req: Request, res: Response, next: NextFunction) => {
const start = performance.now();
res.on("finish", () => {
const latency = performance.now() - start;
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();
};
const route: express.RequestHandler = (_req: Request, res: Response) => {
const uptimeMs = Date.now() - perfStats.startedAt;
const avgLatency = perfStats.requestCount > 0 ? perfStats.totalLatencyMs / perfStats.requestCount : 0;
const mem = process.memoryUsage();
res.json({
ok: true,
data: {
uptime_ms: uptimeMs,
uptime_human: `${Math.floor(uptimeMs / 3600000)}h${Math.floor((uptimeMs % 3600000) / 60000)}m`,
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),
heap_used_mb: Math.round(mem.heapUsed / 1048576),
heap_total_mb: Math.round(mem.heapTotal / 1048576),
external_mb: Math.round(mem.external / 1048576),
},
},
});
};
return { middleware, route };
}
+43 -1
View File
@@ -1,13 +1,14 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { rm } from "node:fs/promises";
import { readFile, rm } from "node:fs/promises";
import supertest from "supertest";
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;
@@ -230,6 +231,47 @@ describe("V2 API", () => {
assert.ok(Array.isArray(res.body.data));
});
it("uploads, reports and deletes a voice sample using the local data dir", async () => {
const audio = Buffer.from("RIFF-test-voice-sample").toString("base64");
const uploadRes = await request
.post("/api/admin/personas/schaeffer/voice-sample")
.set("Cookie", cookie)
.send({ audio })
.expect(200);
assert.equal(uploadRes.body.ok, true);
assert.equal(uploadRes.body.data.samplePath, path.join(".tmp-test-v2-local", "voice-samples", "schaeffer.wav"));
const persisted = await readFile(path.join(TEST_LOCAL_DIR, "voice-samples", "schaeffer.wav"));
assert.equal(persisted.toString("utf8"), "RIFF-test-voice-sample");
const statusRes = await request
.get("/api/admin/personas/schaeffer/voice-sample")
.set("Cookie", cookie)
.expect(200);
assert.equal(statusRes.body.ok, true);
assert.equal(statusRes.body.data.hasVoiceSample, true);
assert.equal(statusRes.body.data.samplePath, path.join(".tmp-test-v2-local", "voice-samples", "schaeffer.wav"));
const deleteRes = await request
.delete("/api/admin/personas/schaeffer/voice-sample")
.set("Cookie", cookie)
.expect(200);
assert.equal(deleteRes.body.ok, true);
assert.equal(deleteRes.body.data.deleted, true);
const missingStatusRes = await request
.get("/api/admin/personas/schaeffer/voice-sample")
.set("Cookie", cookie)
.expect(200);
assert.equal(missingStatusRes.body.ok, true);
assert.equal(missingStatusRes.body.data.hasVoiceSample, false);
});
it("persists local persona updates across app recreation", async () => {
const { app: app2 } = await createApp();
const request2 = supertest(app2);
+20 -427
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,395 +29,14 @@ 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 {
res.setHeader("Set-Cookie", `${COOKIE_NAME}=${sessionId}; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`);
const secure = process.env.NODE_ENV === "production" ? "Secure; " : "";
res.setHeader("Set-Cookie", `${COOKIE_NAME}=${sessionId}; HttpOnly; ${secure}SameSite=Strict; Path=/; Max-Age=3600`);
}
function clearSessionCookie(res: Response): void {
res.setHeader("Set-Cookie", `${COOKIE_NAME}=; HttpOnly; 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);
const secure = process.env.NODE_ENV === "production" ? "Secure; " : "";
res.setHeader("Set-Cookie", `${COOKIE_NAME}=; HttpOnly; ${secure}SameSite=Strict; Path=/; Max-Age=0`);
}
// ---------------------------------------------------------------------------
@@ -442,16 +44,7 @@ function enqueueRunTransition(runId: string, runRepo: RunRepo): void {
// ---------------------------------------------------------------------------
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,
@@ -468,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;
}
}
+4 -3
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";
@@ -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
+119
View File
@@ -0,0 +1,119 @@
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 { describe, it, after } from "node:test";
import assert from "node:assert/strict";
// 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;
// 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");
// Use dynamic import so DATA_DIR is already set when the module initializes
const mediaStorePromise = import("./media-store.js");
after(() => {
rmSync(testDataDir, { recursive: true, force: true });
});
describe("media-store", () => {
it("saveImage creates PNG file and metadata JSON", async () => {
const { saveImage } = await mediaStorePromise;
const meta = await saveImage({
base64: TINY_PNG_B64,
prompt: "test image",
nick: "tester",
channel: "#test",
});
assert.equal(meta.type, "image");
assert.equal(meta.nick, "tester");
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");
});
it("saveAudio creates WAV file and metadata JSON", async () => {
const { saveAudio } = await mediaStorePromise;
const meta = await saveAudio({
base64: TINY_WAV_B64,
prompt: "test audio",
nick: "tester",
channel: "#test",
});
assert.equal(meta.type, "audio");
assert.ok(meta.filename.endsWith(".wav"), "filename should end with .wav");
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("listMedia returns saved items sorted newest first", async () => {
const { saveImage, listMedia } = await mediaStorePromise;
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 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");
});
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("getMediaFilePath prevents directory traversal", async () => {
const { getMediaFilePath } = await mediaStorePromise;
const result = getMediaFilePath("image", "../../../etc/passwd");
assert.equal(result, null);
});
it("saveImage with JPEG mime uses .jpg extension", async () => {
const { saveImage } = await mediaStorePromise;
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" };
}
+1 -1
View File
@@ -350,7 +350,7 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
{
id: "sherlock",
nick: "Sherlock",
model: "mistral:7b",
model: "llama3.1:8b-instruct-q4_0",
systemPrompt:
"Tu es Sherlock Holmes, détective consultant et maître de la déduction. Tu excelles dans la recherche d'informations, " +
"l'analyse de sources, le recoupement de données. Quand on te pose une question, tu utilises /web pour chercher " +
+155 -8
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;
@@ -16,6 +27,8 @@ interface RAGOptions {
embeddingModel?: string; // default: "nomic-embed-text"
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 {
@@ -26,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,
}),
});
@@ -43,9 +82,29 @@ export class LocalRAG {
return data.embeddings?.[0] || [];
}
/** Add a document (split into chunks, embed each) */
/** Add a document (split into chunks, embed each).
* If LightRAG is configured, also pushes the full text there (dual write). */
async addDocument(text: string, source: string): Promise<number> {
const textChunks = splitIntoChunks(text, 500);
// Dual-write to LightRAG if configured
if (this.options.lightragUrl) {
try {
const res = await fetch(`${this.options.lightragUrl}/documents/text`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
if (res.ok) {
logger.debug({ source }, "[rag:lightrag] addDocument to LightRAG OK");
} else {
logger.warn(`[rag:lightrag] addDocument failed: ${res.status} ${res.statusText}`);
}
} catch (err) {
logger.warn({ err }, "[rag:lightrag] addDocument error (continuing local)");
}
}
// Always index locally
const textChunks = splitIntoChunks(text, RAG_CHUNK_SIZE);
for (const chunk of textChunks) {
const embedding = await this.embed(chunk);
this.chunks.push({
@@ -58,11 +117,54 @@ export class LocalRAG {
return textChunks.length;
}
/** Search for relevant chunks */
/** Search for relevant chunks.
* 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 {
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" },
body: JSON.stringify({ query, mode: "hybrid" }),
});
if (res.ok) {
const data = (await res.json()) as {
response?: string;
references?: Array<{ content?: string; text?: string }>;
};
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, limit)) {
results.push({
text: ref.content || ref.text || "",
source: "lightrag",
score: 1.0,
});
}
} else if (data.response) {
// 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 this.rerank(query, results, limit);
// Empty results from LightRAG → fall through to local
} else {
logger.warn(`[rag:lightrag] search failed: ${res.status} ${res.statusText}`);
}
} catch (err) {
trackError("rag_lightrag_search", err, { query: query.slice(0, 80) });
}
}
// Local in-memory RAG
if (this.chunks.length === 0) return [];
const queryEmbedding = await this.embed(query);
@@ -72,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));
+51 -52
View File
@@ -15,6 +15,16 @@ import {
type PersonaRecord,
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;
@@ -89,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);
@@ -112,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) {
@@ -120,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);
@@ -136,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) {
@@ -144,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);
@@ -162,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));
@@ -186,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) {
@@ -194,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) {
@@ -235,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) {
@@ -243,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");
@@ -256,24 +263,18 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
return;
}
const voiceSamplesDir = path.resolve(process.cwd(), "data", "voice-samples");
const voiceSamplesDir = resolveVoiceSamplesRoot();
await mkdir(voiceSamplesDir, { recursive: true });
const sampleName = path.basename(persona.name.toLowerCase().replace(/[^a-z0-9_-]/g, "_")).slice(0, 64);
if (!sampleName || sampleName === "." || sampleName === "..") {
const samplePath = resolveVoiceSamplePath(persona.name, voiceSamplesDir);
if (!samplePath) {
res.status(400).json({ ok: false, error: "invalid_persona_name" });
return;
}
const samplePath = path.join(voiceSamplesDir, `${sampleName}.wav`);
// Boundary check: ensure resolved path stays within voiceSamplesDir
if (!path.resolve(samplePath).startsWith(voiceSamplesDir)) {
res.status(400).json({ ok: false, error: "path_traversal_blocked" });
return;
}
await writeFile(samplePath, buffer);
res.json({ ok: true, data: { personaId, samplePath: `data/voice-samples/${sampleName}.wav`, size: buffer.length } });
res.json({ ok: true, data: { personaId, samplePath: path.relative(process.cwd(), samplePath), size: buffer.length } });
});
router.delete("/api/admin/personas/:id/voice-sample", requirePermission("persona:write"), async (req: SessionRequest, res) => {
@@ -284,10 +285,9 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
return;
}
const voiceSamplesDir2 = path.resolve(process.cwd(), "data", "voice-samples");
const sampleName = path.basename(persona.name.toLowerCase().replace(/[^a-z0-9_-]/g, "_")).slice(0, 64);
const samplePath = path.join(voiceSamplesDir2, `${sampleName}.wav`);
if (!sampleName || !path.resolve(samplePath).startsWith(voiceSamplesDir2)) {
const voiceSamplesDir2 = resolveVoiceSamplesRoot();
const samplePath = resolveVoiceSamplePath(persona.name, voiceSamplesDir2);
if (!samplePath) {
res.status(400).json({ ok: false, error: "invalid_persona_name" });
return;
}
@@ -309,17 +309,16 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
return;
}
const voiceSamplesDir3 = path.resolve(process.cwd(), "data", "voice-samples");
const sampleName2 = path.basename(persona.name.toLowerCase().replace(/[^a-z0-9_-]/g, "_")).slice(0, 64);
const samplePath2 = path.join(voiceSamplesDir3, `${sampleName2}.wav`);
if (!sampleName2 || !path.resolve(samplePath2).startsWith(voiceSamplesDir3)) {
const voiceSamplesDir3 = resolveVoiceSamplesRoot();
const samplePath2 = resolveVoiceSamplePath(persona.name, voiceSamplesDir3);
if (!samplePath2) {
res.json({ ok: true, data: { hasVoiceSample: false } });
return;
}
try {
await stat(samplePath2);
res.json({ ok: true, data: { hasVoiceSample: true, samplePath: `data/voice-samples/${sampleName2}.wav` } });
res.json({ ok: true, data: { hasVoiceSample: true, samplePath: path.relative(process.cwd(), samplePath2) } });
} catch {
res.json({ ok: true, data: { hasVoiceSample: false } });
}
+32
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";
@@ -49,6 +50,23 @@ interface SessionRouteDeps {
clearSessionCookie: (res: Response) => void;
}
// Simple in-memory rate limiter for login attempts
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
const LOGIN_RATE_LIMIT = 5; // max attempts
const LOGIN_RATE_WINDOW_MS = 60_000; // per minute
function checkLoginRateLimit(ip: string): boolean {
if (process.env.NODE_ENV === "test") return true;
const now = Date.now();
const entry = loginAttempts.get(ip);
if (!entry || now > entry.resetAt) {
loginAttempts.set(ip, { count: 1, resetAt: now + LOGIN_RATE_WINDOW_MS });
return true;
}
entry.count++;
return entry.count <= LOGIN_RATE_LIMIT;
}
export function createSessionRoutes(deps: SessionRouteDeps): Router {
const {
sessionRepo,
@@ -104,6 +122,12 @@ export function createSessionRoutes(deps: SessionRouteDeps): Router {
router.post("/api/session/login", async (req, res) => {
try {
const clientIp = req.ip || req.socket?.remoteAddress || "unknown";
if (!checkLoginRateLimit(clientIp)) {
res.status(429).json({ ok: false, error: "rate_limited" });
return;
}
const input = validateLoginInput(req.body);
// SEC-04 fix: Never trust client-supplied role — assign viewer by default.
@@ -210,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 });
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);
+30
View File
@@ -0,0 +1,30 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { resolvePreferredPythonBin, resolveVoiceSamplePath, resolveVoiceSamplesRoot, toVoiceSampleBasename } from "./voice-samples.js";
describe("voice sample helpers", () => {
it("sanitizes persona names consistently for upload and runtime lookup", () => {
assert.equal(toVoiceSampleBasename("Sun Ra"), "sun_ra");
assert.equal(toVoiceSampleBasename("Batty!"), "batty_");
});
it("resolves a stable wav path inside the voice-samples directory", () => {
const rootDir = path.resolve("/tmp", "voice-samples");
const samplePath = resolveVoiceSamplePath("Sun Ra", rootDir);
assert.equal(samplePath, path.join(rootDir, "sun_ra.wav"));
});
it("rejects empty persona names", () => {
assert.equal(resolveVoiceSamplePath(""), null);
});
it("prefers the local data dir when resolving the voice sample root", () => {
const env = { KXKM_LOCAL_DATA_DIR: path.join("/tmp", "kxkm-local") };
assert.equal(resolveVoiceSamplesRoot(env), path.join("/tmp", "kxkm-local", "voice-samples"));
});
it("prefers an explicit PYTHON_BIN over fallbacks", () => {
assert.equal(resolvePreferredPythonBin({ PYTHON_BIN: "/tmp/custom-python" }), "/tmp/custom-python");
});
});
+48
View File
@@ -0,0 +1,48 @@
import { existsSync } from "node:fs";
import path from "node:path";
export function toVoiceSampleBasename(value: string): string {
return path.basename(value.toLowerCase().replace(/[^a-z0-9_-]/g, "_")).slice(0, 64);
}
export function resolveVoiceSamplesRoot(env: NodeJS.ProcessEnv = process.env): string {
if (env.KXKM_VOICE_SAMPLES_DIR && env.KXKM_VOICE_SAMPLES_DIR.trim().length > 0) {
return path.resolve(env.KXKM_VOICE_SAMPLES_DIR);
}
if (env.KXKM_LOCAL_DATA_DIR && env.KXKM_LOCAL_DATA_DIR.trim().length > 0) {
return path.resolve(env.KXKM_LOCAL_DATA_DIR, "voice-samples");
}
return path.resolve(process.cwd(), "data", "voice-samples");
}
export function resolveVoiceSamplePath(personaName: string, rootDir = resolveVoiceSamplesRoot()): string | null {
const basename = toVoiceSampleBasename(personaName);
if (!basename || basename === "." || basename === "..") {
return null;
}
const resolved = path.join(rootDir, `${basename}.wav`);
if (!path.resolve(resolved).startsWith(rootDir)) {
return null;
}
return resolved;
}
export function resolvePreferredPythonBin(env: NodeJS.ProcessEnv = process.env): string {
if (env.PYTHON_BIN && env.PYTHON_BIN.trim().length > 0) {
return env.PYTHON_BIN;
}
const projectVenvPython = path.resolve(process.cwd(), ".venvs", "voice-clone", "bin", "python");
if (existsSync(projectVenvPython)) {
return projectVenvPython;
}
const legacyPython = "/home/kxkm/venv/bin/python3";
if (existsSync(legacyPython)) {
return legacyPython;
}
return "python3";
}
+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");
});
});
+7 -1
View File
@@ -1,3 +1,5 @@
import logger from "./logger.js";
// ---------------------------------------------------------------------------
// Web search (DuckDuckGo Lite scraping)
// ---------------------------------------------------------------------------
@@ -7,6 +9,10 @@ export async function searchWeb(query: string): Promise<string> {
const searxngUrl = process.env.SEARXNG_URL || "http://localhost:8080";
try {
const response = await fetch(`${searxngUrl}/search?q=${encodeURIComponent(query)}&format=json&engines=google,bing,duckduckgo`, {
headers: {
"Accept": "application/json",
"User-Agent": "KXKM_Clown/2.0",
},
signal: AbortSignal.timeout(10_000),
});
if (response.ok) {
@@ -105,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");
});
}
+161
View File
@@ -0,0 +1,161 @@
import { afterEach, describe, it } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { rm, rmdir } from "node:fs/promises";
import path from "node:path";
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");
async function wait(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
describe("ws-chat smoke", () => {
let server: http.Server | undefined;
let wss: ReturnType<typeof attachWebSocketChat> | undefined;
let client: WebSocket | undefined;
afterEach(async () => {
if (client && client.readyState === WebSocket.OPEN) {
client.close();
await new Promise((resolve) => client?.once("close", resolve));
}
if (wss) {
wss.close();
}
if (server) {
await new Promise<void>((resolve) => server?.close(() => resolve()));
}
client = undefined;
wss = undefined;
server = undefined;
globalThis.fetch = originalFetch;
await wait(50);
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 {
// Keep parent dir if something else lives there.
}
});
it("ignores malformed JSON and rate limits command bursts", async () => {
server = http.createServer();
wss = attachWebSocketChat(server, {
ollamaUrl: "http://ollama.test",
loadPersonas: async () => [],
});
await new Promise<void>((resolve) => server?.listen(0, resolve));
const address = server.address();
assert.ok(address && typeof address !== "string", "expected numeric server address");
client = new WebSocket(`ws://127.0.0.1:${address.port}/ws`);
const messages: OutboundMessage[] = [];
const errors: string[] = [];
client.on("message", (data) => {
try {
messages.push(JSON.parse(data.toString()) as OutboundMessage);
} catch {
// Ignore non-JSON frames
}
});
client.on("error", (err) => {
errors.push(err.message);
});
await new Promise<void>((resolve) => client?.once("open", resolve));
await wait(150);
const baseline = messages.length;
client.send("not-json");
await wait(50);
assert.equal(messages.length, baseline);
for (let i = 0; i < 16; i += 1) {
client.send(JSON.stringify({ type: "command", text: "/help" }));
}
await wait(300);
assert.ok(messages.some((msg) => msg.type === "system" && /Commandes disponibles/.test(msg.text)));
assert.ok(messages.some((msg) => msg.type === "system" && /Trop de messages/.test(msg.text)));
assert.equal(errors.length, 0);
assert.equal(client.readyState, WebSocket.OPEN);
});
it("dispatches command, upload and chat messages to their dedicated seams", async () => {
globalThis.fetch = (async (_input, init) => {
let body: Record<string, unknown> = {};
if (typeof init?.body === "string") {
try {
body = JSON.parse(init.body) as Record<string, unknown>;
} catch {
body = {};
}
}
if (body.stream === false) {
return new Response(
JSON.stringify({
message: {
content: "reponse stub",
tool_calls: [],
},
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
}
return new Response('{"message":{"content":"reponse stub"},"done":true}\n', {
status: 200,
headers: { "Content-Type": "application/x-ndjson" },
});
}) as typeof fetch;
server = http.createServer();
wss = attachWebSocketChat(server, {
ollamaUrl: "http://ollama.test",
loadPersonas: async () => [],
});
await new Promise<void>((resolve) => server?.listen(0, resolve));
const address = server.address();
assert.ok(address && typeof address !== "string", "expected numeric server address");
client = new WebSocket(`ws://127.0.0.1:${address.port}/ws`);
const messages: OutboundMessage[] = [];
client.on("message", (data) => {
try {
messages.push(JSON.parse(data.toString()) as OutboundMessage);
} catch {
// Ignore non-JSON frames
}
});
await new Promise<void>((resolve) => client?.once("open", resolve));
await wait(150);
await wait(100);
messages.length = 0;
client.send(JSON.stringify({ type: "command", text: "/help" }));
await wait(100);
assert.ok(messages.some((msg) => msg.type === "system" && /Commandes disponibles/.test(msg.text)));
client.send(JSON.stringify({ type: "upload", filename: "empty.txt", mimeType: "text/plain", data: "", size: 0 }));
await wait(100);
assert.ok(messages.some((msg) => msg.type === "system" && /Upload rejeté/.test(msg.text)));
client.send(JSON.stringify({ type: "message", text: "bonjour pharmacius" }));
await wait(200);
assert.ok(messages.some((msg) => msg.type === "message" && /^user_/.test(msg.nick)));
assert.ok(messages.some((msg) => msg.type === "message" && msg.nick === "Pharmacius" && msg.text === "reponse stub"));
});
});
+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;
+241
View File
@@ -0,0 +1,241 @@
import { afterEach, describe, it } from "node:test";
import assert from "node:assert/strict";
import { buildConversationInput, createConversationRouter, type ConversationRouterDeps } from "./ws-conversation-router.js";
import type { ChatLogEntry, ChatPersona, OutboundMessage } from "./chat-types.js";
const PERSONAS: ChatPersona[] = [
{
id: "pharmacius",
nick: "Pharmacius",
model: "llama3",
systemPrompt: "Tu es Pharmacius.",
color: "#c84c0c",
},
{
id: "sherlock",
nick: "Sherlock",
model: "llama3",
systemPrompt: "Tu es Sherlock.",
color: "#2c6e49",
},
];
type BroadcastRecord = { channel: string; msg: OutboundMessage };
type MemoryUpdateRecord = { persona: ChatPersona; recentMessages: string[]; ollamaUrl: string };
interface TestHarness {
deps: ConversationRouterDeps;
broadcasts: BroadcastRecord[];
logs: ChatLogEntry[];
contexts: Array<{ channel: string; nick: string; text: string }>;
plainCalls: Array<{ persona: ChatPersona; message: string }>;
toolCalls: Array<{ persona: ChatPersona; message: string; tools: unknown[] }>;
memoryUpdates: MemoryUpdateRecord[];
ttsCalls: Array<{ nick: string; text: string; channel: string }>;
errors: string[];
}
const originalTtsEnabled = process.env.TTS_ENABLED;
afterEach(() => {
if (originalTtsEnabled === undefined) {
delete process.env.TTS_ENABLED;
} else {
process.env.TTS_ENABLED = originalTtsEnabled;
}
});
function sleep(ms = 0): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function createHarness(overrides: Partial<ConversationRouterDeps> = {}): TestHarness {
const broadcasts: BroadcastRecord[] = [];
const logs: ChatLogEntry[] = [];
const contexts: Array<{ channel: string; nick: string; text: string }> = [];
const plainCalls: Array<{ persona: ChatPersona; message: string }> = [];
const toolCalls: Array<{ persona: ChatPersona; message: string; tools: unknown[] }> = [];
const memoryUpdates: MemoryUpdateRecord[] = [];
const ttsCalls: Array<{ nick: string; text: string; channel: string }> = [];
const errors: string[] = [];
const deps: ConversationRouterDeps = {
ollamaUrl: "http://ollama.test",
getPersonas: () => PERSONAS,
broadcast: (channel, msg) => {
broadcasts.push({ channel, msg });
},
logChatMessage: (entry) => {
logs.push(entry);
},
addToContext: (channel, nick, text) => {
contexts.push({ channel, nick, text });
},
getContextString: async () => "",
getToolsForPersona: () => [],
loadPersonaMemory: async (nick) => ({ nick, facts: [], summary: "", lastUpdated: "" }),
updatePersonaMemory: async (persona, recentMessages, ollamaUrl) => {
memoryUpdates.push({ persona, recentMessages, ollamaUrl });
},
streamOllamaChat: async (_ollamaUrl, persona, message, _onChunk, onDone) => {
plainCalls.push({ persona, message });
onDone(`Reponse ${persona.nick}`);
},
streamOllamaChatWithTools: async (_ollamaUrl, persona, message, tools, _rag, _onChunk, onDone) => {
toolCalls.push({ persona, message, tools });
onDone(`Reponse outils ${persona.nick}`);
},
synthesizeTTS: async (nick, text, channel) => {
ttsCalls.push({ nick, text, channel });
},
isTTSAvailable: () => true,
acquireTTS: () => {},
releaseTTS: () => {},
interPersonaDelayMs: 0,
logger: {
error: (...args: unknown[]) => {
errors.push(args.map((item) => String(item)).join(" "));
},
},
...overrides,
};
return {
deps,
broadcasts,
logs,
contexts,
plainCalls,
toolCalls,
memoryUpdates,
ttsCalls,
errors,
};
}
describe("ws-conversation-router", () => {
it("combines context store and RAG results in the enriched input", async () => {
const enriched = await buildConversationInput(
"Question utilisateur",
"#general",
async () => "Historique compact",
{
size: 1,
search: async () => [{ text: "Document A" }, { text: "Document B" }],
},
);
assert.match(enriched, /Question utilisateur/);
assert.match(enriched, /\[Contexte conversationnel\]/);
assert.match(enriched, /Historique compact/);
assert.match(enriched, /\[Contexte pertinent\]/);
assert.match(enriched, /Document A/);
assert.match(enriched, /Document B/);
});
it("routes a direct @mention only to the mentioned persona", async () => {
const harness = createHarness();
const routeToPersonas = createConversationRouter(harness.deps);
await routeToPersonas("#general", "@Sherlock analyse ceci");
const replies = harness.broadcasts
.filter((entry) => entry.msg.type === "message")
.map((entry) => (entry.msg.type === "message" ? entry.msg.nick : ""));
assert.deepEqual(replies, ["Sherlock"]);
});
it("falls back to Pharmacius without a direct mention", async () => {
const harness = createHarness();
const routeToPersonas = createConversationRouter(harness.deps);
await routeToPersonas("#general", "bonjour tout le monde");
const replies = harness.broadcasts
.filter((entry) => entry.msg.type === "message")
.map((entry) => (entry.msg.type === "message" ? entry.msg.nick : ""));
assert.deepEqual(replies, ["Pharmacius"]);
});
it("switches to the tool-calling path when a persona has tools", async () => {
const harness = createHarness({
getToolsForPersona: (nick) => (nick === "Pharmacius" ? [{ type: "function", function: { name: "web_search", description: "", parameters: { type: "object", properties: {}, required: [] } } }] : []),
});
const routeToPersonas = createConversationRouter(harness.deps);
await routeToPersonas("#general", "question sans mention");
assert.equal(harness.toolCalls.length, 1);
assert.equal(harness.toolCalls[0]?.persona.nick, "Pharmacius");
assert.equal(harness.plainCalls.length, 0);
});
it("updates persona memory every five responses with serialized recent messages", async () => {
const harness = createHarness();
const routeToPersonas = createConversationRouter(harness.deps);
for (let index = 1; index <= 5; index += 1) {
await routeToPersonas("#general", `message ${index}`);
}
await sleep();
assert.equal(harness.memoryUpdates.length, 1);
assert.equal(harness.memoryUpdates[0]?.persona.nick, "Pharmacius");
assert.equal(harness.memoryUpdates[0]?.recentMessages.length, 5);
assert.match(harness.memoryUpdates[0]?.recentMessages[4] || "", /message 5/);
});
it("triggers TTS only when the feature flag is enabled", async () => {
process.env.TTS_ENABLED = "0";
const disabledHarness = createHarness();
const disabledRouter = createConversationRouter(disabledHarness.deps);
await disabledRouter("#general", "premier message");
process.env.TTS_ENABLED = "1";
const enabledHarness = createHarness();
const enabledRouter = createConversationRouter(enabledHarness.deps);
await enabledRouter("#general", "second message");
await sleep();
assert.equal(disabledHarness.ttsCalls.length, 0);
assert.equal(enabledHarness.ttsCalls.length, 1);
assert.equal(enabledHarness.ttsCalls[0]?.nick, "Pharmacius");
});
it("caps inter-persona rebounds at the configured depth", async () => {
const harness = createHarness({
streamOllamaChat: async (_ollamaUrl, persona, _message, _onChunk, onDone) => {
harness.plainCalls.push({ persona, message: _message });
if (persona.nick === "Pharmacius") {
onDone("Sherlock, regarde ca. @Sherlock");
return;
}
onDone("Je reponds a Pharmacius. @Pharmacius");
},
maxInterPersonaDepth: 1,
});
const routeToPersonas = createConversationRouter(harness.deps);
await routeToPersonas("#general", "ouvre le debat");
await sleep();
await sleep();
const replies = harness.broadcasts
.filter((entry) => entry.msg.type === "message")
.map((entry) => (entry.msg.type === "message" ? entry.msg.nick : ""));
assert.deepEqual(replies, ["Pharmacius", "Sherlock"]);
});
it("broadcasts a system error when streaming fails without throwing", async () => {
const harness = createHarness({
streamOllamaChat: async (_ollamaUrl, _persona, _message, _onChunk, _onDone, onError) => {
onError(new Error("boom"));
},
});
const routeToPersonas = createConversationRouter(harness.deps);
await assert.doesNotReject(() => routeToPersonas("#general", "message casse"));
const systemMessages = harness.broadcasts.filter((entry) => entry.msg.type === "system");
assert.ok(systemMessages.some((entry) => entry.msg.type === "system" && entry.msg.text.includes("erreur Ollama — boom")));
});
});
+90 -34
View File
@@ -1,3 +1,4 @@
import { trackError } from "./error-tracker.js";
import { getToolsForPersona as defaultGetToolsForPersona, type ToolDefinition } from "./mcp-tools.js";
import {
streamOllamaChat as defaultStreamOllamaChat,
@@ -16,6 +17,28 @@ import {
} from "./ws-persona-router.js";
import type { ChatLogEntry, ChatPersona, OutboundMessage, PersonaMemory } from "./chat-types.js";
const DEBUG = process.env.NODE_ENV !== "production" || process.env.DEBUG === "1";
// ---------------------------------------------------------------------------
// Sentence-boundary detection for streaming TTS chunking
// ---------------------------------------------------------------------------
const SENTENCE_END = /[.!?;:]\s/;
export function extractSentences(buffer: string): { sentences: string[]; remaining: string } {
const sentences: string[] = [];
let remaining = buffer;
let match: RegExpExecArray | null;
while ((match = SENTENCE_END.exec(remaining)) !== null) {
const sentence = remaining.slice(0, match.index + 1).trim();
if (sentence.length >= 10) {
sentences.push(sentence);
}
remaining = remaining.slice(match.index + 2);
}
return { sentences, remaining };
}
type BroadcastFn = (channel: string, msg: OutboundMessage) => void;
type Logger = Pick<Console, "error">;
@@ -29,7 +52,7 @@ type GetToolsForPersonaFn = typeof defaultGetToolsForPersona;
export interface ConversationRAG {
size: number;
search(query: string, maxResults: number): Promise<Array<{ text: string }>>;
search(query: string, maxResults?: number): Promise<Array<{ text: string }>>;
}
export interface ConversationRouterDeps {
@@ -60,7 +83,7 @@ export interface ConversationRouterDeps {
export type ConversationRouter = (channel: string, text: string, depth?: number) => Promise<void>;
const DEFAULT_MAX_INTER_PERSONA_DEPTH = 3;
const DEFAULT_INTER_PERSONA_DELAY_MS = 2_000;
const DEFAULT_INTER_PERSONA_DELAY_MS = 500;
function withPersonaMemory(persona: ChatPersona, memory: Awaited<ReturnType<LoadPersonaMemoryFn>>): ChatPersona {
if (memory.facts.length === 0 && !memory.summary) {
@@ -94,7 +117,7 @@ export async function buildConversationInput(
if (rag && rag.size > 0) {
try {
const results = await rag.search(text, 2);
const results = await rag.search(text);
if (results.length > 0) {
const ragContext = results.map((result) => result.text).join("\n---\n");
sections.push(`[Contexte pertinent]\n${ragContext}`);
@@ -136,8 +159,23 @@ export function createConversationRouter(deps: ConversationRouterDeps): Conversa
const personaMessageCounts = new Map<string, number>();
const personaRecentMessages = new Map<string, string[]>();
const personaMemoryLocks = new Map<string, Promise<void>>();
const ttsQueues = new Map<string, Promise<void>>();
let totalMessageCount = 0;
function enqueueTTS(nick: string, text: string, channel: string): void {
if (process.env.TTS_ENABLED !== "1" || !isTTSAvailable()) return;
if (text.length < 10) return;
const prev = ttsQueues.get(nick) || Promise.resolve();
const next = prev.then(() => {
acquireTTS();
return synthesizeTTS(nick, text, channel, broadcast)
.catch((err) => trackError("tts", err, { nick }))
.finally(() => releaseTTS());
});
ttsQueues.set(nick, next);
}
function prunePersonaState(personas: ChatPersona[]): void {
const activeNicks = new Set(personas.map((persona) => persona.nick));
for (const [nick] of personaMessageCounts) {
@@ -167,25 +205,11 @@ export function createConversationRouter(deps: ConversationRouterDeps): Conversa
const next = previous
.then(() => updatePersonaMemory(persona, recentMessages, ollamaUrl))
.catch((err) => {
logger.error(`[ws-chat] Memory update failed for ${persona.nick}:`, err);
trackError("memory_update", err, { persona: persona.nick });
});
personaMemoryLocks.set(persona.nick, next);
}
function maybeTriggerTTS(persona: ChatPersona, fullText: string, channel: string): void {
if (process.env.TTS_ENABLED !== "1" || !isTTSAvailable()) {
return;
}
acquireTTS();
synthesizeTTS(persona.nick, fullText, channel, broadcast)
.catch((err) => {
logger.error(`[tts] Error for ${persona.nick}: ${err instanceof Error ? err.message : String(err)}`);
})
.finally(() => {
releaseTTS();
});
}
function findNextMentionedPersona(
fullText: string,
@@ -232,10 +256,35 @@ export function createConversationRouter(deps: ConversationRouterDeps): Conversa
try {
memory = await loadPersonaMemory(persona.nick);
} catch (err) {
logger.error(`[ws-chat] Memory load failed for ${persona.nick}:`, err);
trackError("memory_load", err, { persona: persona.nick });
}
const personaWithMemory = withPersonaMemory(persona, memory);
const tools = getToolsForPersona(persona.nick);
if (DEBUG) console.log(`[ws-chat] ${persona.nick} responding (tools=${tools.length}, model=${persona.model}, depth=${depth})`);
let chunkSeq = 0;
let sentenceBuffer = "";
let sentenceTTSFired = false;
const onChunk = (token: string) => {
chunkSeq++;
broadcast(channel, {
type: "chunk" as any,
nick: persona.nick,
text: token,
color: persona.color,
seq: chunkSeq,
});
// Accumulate tokens for sentence-boundary TTS
sentenceBuffer += token;
const { sentences, remaining } = extractSentences(sentenceBuffer);
sentenceBuffer = remaining;
for (const sentence of sentences) {
enqueueTTS(persona.nick, sentence, channel);
sentenceTTSFired = true;
}
};
const onDone = (fullText: string) => {
broadcast(channel, {
@@ -255,6 +304,18 @@ export function createConversationRouter(deps: ConversationRouterDeps): Conversa
addToContext(channel, persona.nick, fullText);
// Flush remaining sentence buffer to TTS
if (sentenceBuffer.trim().length >= 10) {
enqueueTTS(persona.nick, sentenceBuffer.trim(), channel);
sentenceTTSFired = true;
}
sentenceBuffer = "";
// Fallback: if no sentences were detected during streaming, send full text
if (!sentenceTTSFired && process.env.TTS_ENABLED === "1" && isTTSAvailable()) {
enqueueTTS(persona.nick, fullText, channel);
}
const { count, recentMessages } = trackPersonaMessage(
persona.nick,
`User: ${text}\n${persona.nick}: ${fullText}`,
@@ -263,13 +324,13 @@ export function createConversationRouter(deps: ConversationRouterDeps): Conversa
scheduleMemoryUpdate(persona, recentMessages);
}
maybeTriggerTTS(persona, fullText, channel);
if (depth >= maxInterPersonaDepth) {
return;
}
const nextPersona = findNextMentionedPersona(fullText, personasSnapshot, persona.nick);
if (DEBUG) console.log(`[ws-chat] ${persona.nick} done (len=${fullText.length}), nextPersona=${nextPersona?.nick || "none"}`);
if (!nextPersona) {
return;
}
@@ -277,13 +338,13 @@ export function createConversationRouter(deps: ConversationRouterDeps): Conversa
setTimeoutFn(() => {
const contextMessage = `${persona.nick} a dit: "${fullText.slice(0, 500)}". @${nextPersona.nick}, réponds-lui.`;
routeToPersonas(channel, contextMessage, depth + 1).catch((err) => {
logger.error(`[ws-chat] Inter-persona error for ${nextPersona.nick}:`, err);
trackError("inter_persona", err, { persona: nextPersona.nick, depth: depth + 1 });
});
}, interPersonaDelayMs);
};
const onError = (err: Error) => {
logger.error(`[ws-chat] Ollama error for ${persona.nick}:`, err.message);
trackError("ollama", err, { persona: persona.nick, model: persona.model });
broadcast(channel, {
type: "system",
text: `${persona.nick}: erreur Ollama — ${err.message}`,
@@ -298,9 +359,7 @@ export function createConversationRouter(deps: ConversationRouterDeps): Conversa
enrichedText,
tools,
rag,
() => {
// Chunks stay internal for now; the UI replaces messages on final payload.
},
onChunk,
onDone,
onError,
);
@@ -311,19 +370,16 @@ export function createConversationRouter(deps: ConversationRouterDeps): Conversa
ollamaUrl,
personaWithMemory,
enrichedText,
() => {
// Chunks stay internal for now; the UI replaces messages on final payload.
},
onChunk,
onDone,
onError,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
trackError("ollama_connection", err, { persona: persona.nick, model: persona.model });
broadcast(channel, {
type: "system",
text: `${persona.nick}: erreur de connexion`,
});
logger.error(`[ws-chat] Ollama error for ${persona.nick}:`, message);
}
}
@@ -341,8 +397,8 @@ export function createConversationRouter(deps: ConversationRouterDeps): Conversa
const enrichedText = await buildConversationInput(text, channel, getContextString, rag);
for (const persona of responders) {
await streamPersonaResponse(
await Promise.all(responders.map((persona) =>
streamPersonaResponse(
channel,
text,
enrichedText,
@@ -350,8 +406,8 @@ export function createConversationRouter(deps: ConversationRouterDeps): Conversa
personasSnapshot,
depth,
routeToPersonas,
);
}
),
));
}
return routeToPersonas;
+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);
+174 -194
View File
@@ -1,33 +1,17 @@
import pLimit from "p-limit";
import { generateImage } from "./comfyui.js";
import { searchWeb } from "./web-search.js";
import { trackError } from "./error-tracker.js";
import type { ToolDefinition } from "./mcp-tools.js";
import type { ChatPersona } from "./chat-types.js";
const DEBUG = process.env.NODE_ENV !== "production" || process.env.DEBUG === "1";
// ---------------------------------------------------------------------------
// Simple semaphore for Ollama concurrency
// Ollama concurrency limiter (replaces manual semaphore)
// ---------------------------------------------------------------------------
const MAX_OLLAMA_CONCURRENT = Number(process.env.MAX_OLLAMA_CONCURRENT) || 3;
let ollamaActive = 0;
const ollamaQueue: Array<() => void> = [];
export async function acquireOllama(): Promise<void> {
if (ollamaActive < MAX_OLLAMA_CONCURRENT) {
ollamaActive++;
return;
}
return new Promise<void>(resolve => {
ollamaQueue.push(() => { ollamaActive++; resolve(); });
});
}
export function releaseOllama(): void {
ollamaActive--;
const next = ollamaQueue.shift();
if (next) next();
}
const ollamaLimit = pLimit(Number(process.env.MAX_OLLAMA_CONCURRENT) || 3);
// ---------------------------------------------------------------------------
// Ollama streaming chat
@@ -41,72 +25,73 @@ export async function streamOllamaChat(
onDone: (fullText: string) => void,
onError: (err: Error) => void,
): Promise<void> {
await acquireOllama();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5 * 60_000);
await ollamaLimit(async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5 * 60_000);
try {
const response = await fetch(`${ollamaUrl}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: persona.model,
messages: [
{ role: "system", content: persona.systemPrompt },
{ role: "user", content: userMessage },
],
stream: true,
options: { num_predict: persona.maxTokens || 1024 },
}),
signal: controller.signal,
});
try {
const response = await fetch(`${ollamaUrl}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: persona.model,
messages: [
{ role: "system", content: persona.systemPrompt },
{ role: "user", content: userMessage },
],
stream: true,
options: { num_predict: persona.maxTokens || 1024 },
}),
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Ollama returned ${response.status}: ${response.statusText}`);
}
if (!response.ok) {
throw new Error(`Ollama returned ${response.status}: ${response.statusText}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("No response body from Ollama");
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("No response body from Ollama");
}
const decoder = new TextDecoder();
let fullText = "";
let inThinking = false;
const decoder = new TextDecoder();
let fullText = "";
let inThinking = false;
while (true) {
const { done, value } = await reader.read();
if (done) break;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n").filter(Boolean);
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n").filter(Boolean);
for (const line of lines) {
try {
const parsed = JSON.parse(line) as { message?: { content?: string }; done?: boolean };
if (parsed.message?.content) {
const c = parsed.message.content;
fullText += c;
// Suppress <think>...</think> from streaming to client
if (c.includes("<think>")) inThinking = true;
if (!inThinking) onChunk(c);
if (c.includes("</think>")) inThinking = false;
for (const line of lines) {
try {
const parsed = JSON.parse(line) as { message?: { content?: string }; done?: boolean };
if (parsed.message?.content) {
const c = parsed.message.content;
fullText += c;
// Suppress <think>...</think> from streaming to client
if (c.includes("<think>")) inThinking = true;
if (!inThinking) onChunk(c);
if (c.includes("</think>")) inThinking = false;
}
} catch {
// Partial JSON -- skip
}
} catch {
// Partial JSON — skip
}
}
}
// Strip <think>...</think> blocks (qwen3 reasoning tokens)
const cleaned = fullText.replace(/<think>[\s\S]*?<\/think>\s*/g, "").trim();
onDone(cleaned);
} catch (err) {
onError(err instanceof Error ? err : new Error(String(err)));
} finally {
clearTimeout(timeout);
releaseOllama();
}
// Strip <think>...</think> blocks (qwen3 reasoning tokens)
const cleaned = fullText.replace(/<think>[\s\S]*?<\/think>\s*/g, "").trim();
onDone(cleaned);
} catch (err) {
trackError("ollama", err, { persona: persona.nick, model: persona.model });
onError(err instanceof Error ? err : new Error(String(err)));
} finally {
clearTimeout(timeout);
}
});
}
/** Strip qwen3 thinking blocks from text */
@@ -128,7 +113,7 @@ interface OllamaToolCall {
export async function executeToolCall(
toolName: string,
args: Record<string, unknown>,
rag: { size: number; search(q: string, k: number): Promise<{ text: string }[]> } | undefined,
rag: { size: number; search(q: string, k?: number): Promise<{ text: string }[]> } | undefined,
): Promise<string> {
switch (toolName) {
case "web_search": {
@@ -143,7 +128,7 @@ export async function executeToolCall(
case "rag_search": {
const query = String(args.query || "");
if (!rag || rag.size === 0) return "(Pas de documents indexés)";
const results = await rag.search(query, 3);
const results = await rag.search(query);
return results.map(r => r.text).join("\n---\n") || "(Aucun résultat)";
}
default:
@@ -164,140 +149,135 @@ export async function streamOllamaChatWithTools(
persona: ChatPersona,
userMessage: string,
tools: ToolDefinition[],
rag: { size: number; search(q: string, k: number): Promise<{ text: string }[]> } | undefined,
rag: { size: number; search(q: string, k?: number): Promise<{ text: string }[]> } | undefined,
onChunk: (text: string) => void,
onDone: (fullText: string) => void,
onError: (err: Error) => void,
): Promise<void> {
await acquireOllama();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5 * 60_000);
await ollamaLimit(async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5 * 60_000);
try {
const messages: Array<{ role: string; content: string; tool_calls?: OllamaToolCall[] }> = [
{ role: "system", content: persona.systemPrompt },
{ role: "user", content: userMessage },
];
try {
const messages: Array<{ role: string; content: string; tool_calls?: OllamaToolCall[] }> = [
{ role: "system", content: persona.systemPrompt },
{ role: "user", content: userMessage },
];
// Step 1: Non-streaming call with tools to check for tool_calls
const probeResp = await fetch(`${ollamaUrl}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: persona.model,
messages,
tools: tools.map(t => t),
stream: false,
options: { num_predict: persona.maxTokens || 1024 },
}),
signal: controller.signal,
});
if (!probeResp.ok) {
throw new Error(`Ollama returned ${probeResp.status}: ${probeResp.statusText}`);
}
const probeData = await probeResp.json() as {
message?: {
role?: string;
content?: string;
tool_calls?: OllamaToolCall[];
};
};
const toolCalls = probeData.message?.tool_calls;
// If no tool calls, use the response directly
if (!toolCalls || toolCalls.length === 0) {
const content = stripThinking(probeData.message?.content || "");
if (content) {
onChunk(content);
}
onDone(content);
return;
}
// Step 2: Execute tool calls (max 1 round)
// Add assistant message with tool_calls to conversation
messages.push({
role: "assistant",
content: probeData.message?.content || "",
tool_calls: toolCalls,
});
for (const tc of toolCalls) {
const name = tc.function.name;
const args = tc.function.arguments;
if (DEBUG) console.log(`[mcp-tools] ${persona.nick} calling ${name}(${JSON.stringify(args)})`);
let result: string;
try {
result = await executeToolCall(name, args, rag);
} catch (err) {
result = `(Erreur outil ${name}: ${err instanceof Error ? err.message : String(err)})`;
}
// Add tool result to conversation
messages.push({
role: "tool",
content: result,
// Step 1: Non-streaming call with tools to check for tool_calls
const probeResp = await fetch(`${ollamaUrl}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: persona.model,
messages,
tools: tools.map(t => t),
stream: false,
options: { num_predict: persona.maxTokens || 1024 },
}),
signal: controller.signal,
});
}
// Step 3: Stream the final response with tool context
releaseOllama();
// Re-acquire for the streaming call
await acquireOllama();
if (!probeResp.ok) {
throw new Error(`Ollama returned ${probeResp.status}: ${probeResp.statusText}`);
}
const streamResp = await fetch(`${ollamaUrl}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: persona.model,
messages,
stream: true,
options: { num_predict: persona.maxTokens || 1024 },
}),
signal: controller.signal,
});
const probeData = await probeResp.json() as {
message?: {
role?: string;
content?: string;
tool_calls?: OllamaToolCall[];
};
};
if (!streamResp.ok) {
throw new Error(`Ollama returned ${streamResp.status}: ${streamResp.statusText}`);
}
const toolCalls = probeData.message?.tool_calls;
const reader = streamResp.body?.getReader();
if (!reader) {
throw new Error("No response body from Ollama");
}
// If no tool calls, use the response directly
if (!toolCalls || toolCalls.length === 0) {
const content = stripThinking(probeData.message?.content || "");
if (content) {
onChunk(content);
}
onDone(content);
return;
}
const decoder = new TextDecoder();
let fullText = "";
// Step 2: Execute tool calls (max 1 round)
messages.push({
role: "assistant",
content: probeData.message?.content || "",
tool_calls: toolCalls,
});
while (true) {
const { done, value } = await reader.read();
if (done) break;
for (const tc of toolCalls) {
const name = tc.function.name;
const args = tc.function.arguments;
if (DEBUG) console.log(`[mcp-tools] ${persona.nick} calling ${name}(${JSON.stringify(args)})`);
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n").filter(Boolean);
for (const line of lines) {
let result: string;
try {
const parsed = JSON.parse(line) as { message?: { content?: string }; done?: boolean };
if (parsed.message?.content) {
fullText += parsed.message.content;
onChunk(parsed.message.content);
result = await executeToolCall(name, args, rag);
} catch (err) {
result = `(Erreur outil ${name}: ${err instanceof Error ? err.message : String(err)})`;
}
messages.push({
role: "tool",
content: result,
});
}
// Step 3: Stream the final response with tool context
const streamResp = await fetch(`${ollamaUrl}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: persona.model,
messages,
stream: true,
options: { num_predict: persona.maxTokens || 1024 },
}),
signal: controller.signal,
});
if (!streamResp.ok) {
throw new Error(`Ollama returned ${streamResp.status}: ${streamResp.statusText}`);
}
const reader = streamResp.body?.getReader();
if (!reader) {
throw new Error("No response body from Ollama");
}
const decoder = new TextDecoder();
let fullText = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n").filter(Boolean);
for (const line of lines) {
try {
const parsed = JSON.parse(line) as { message?: { content?: string }; done?: boolean };
if (parsed.message?.content) {
fullText += parsed.message.content;
onChunk(parsed.message.content);
}
} catch {
// Partial JSON -- skip
}
} catch {
// Partial JSON — skip
}
}
}
onDone(stripThinking(fullText));
} catch (err) {
onError(err instanceof Error ? err : new Error(String(err)));
} finally {
clearTimeout(timeout);
releaseOllama();
}
onDone(stripThinking(fullText));
} catch (err) {
trackError("ollama", err, { persona: persona.nick, model: persona.model, withTools: true });
onError(err instanceof Error ? err : new Error(String(err)));
} finally {
clearTimeout(timeout);
}
});
}
+17 -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");
}
}
@@ -87,6 +85,20 @@ export function pickResponders(text: string, pool: ChatPersona[]): ChatPersona[]
);
if (mentioned.length > 0) return mentioned;
// Detect web search intent — add Sherlock directly
const lower = text.toLowerCase();
const webKeywords = ["cherche", "search", "recherche", "google", "trouve", "find", "web"];
const wantsWeb = webKeywords.some((kw) => lower.includes(kw));
if (wantsWeb) {
const sherlock = pool.find((p) => p.nick.toLowerCase() === "sherlock");
const pharmacius = pool.find((p) => p.nick.toLowerCase() === "pharmacius");
// Sherlock first (does the search), then Pharmacius synthesizes
const responders: ChatPersona[] = [];
if (sherlock) responders.push(sherlock);
if (pharmacius) responders.push(pharmacius);
return responders.length > 0 ? responders : pool.slice(0, 1);
}
// Default: only Pharmacius responds (or first persona if Pharmacius not found)
const defaultPersona = pool.find((p) => p.nick.toLowerCase() === "pharmacius");
if (defaultPersona) return [defaultPersona];
+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>
);
}
+102 -588
View File
@@ -1,519 +1,96 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import { getPersonaColor } from "@kxkm/ui";
import { useWebSocket } from "../hooks/useWebSocket";
import { useMinitelSounds } from "../hooks/useMinitelSounds";
import { resolveWebSocketUrl } from "../lib/websocket-url";
import React, { useRef, useEffect, useCallback } from "react";
import { List, useListRef } from "react-window";
import { AutoSizer } from "react-virtualized-auto-sizer";
import { useChatState } from "../hooks/useChatState";
import { ChatMessage } from "./ChatMessage";
import { ChatInput } from "./ChatInput";
import { ChatSidebar } from "./ChatSidebar";
import type { ChatMsg } from "./chat-types";
function buildWsUrl(): string {
const base = resolveWebSocketUrl();
const nick = typeof sessionStorage !== "undefined" ? sessionStorage.getItem("kxkm-nick") : null;
if (!nick) return base;
const sep = base.includes("?") ? "&" : "?";
return `${base}${sep}nick=${encodeURIComponent(nick)}`;
}
const ROW_HEIGHT_DEFAULT = 24;
const ROW_HEIGHT_IMAGE = 540;
const ROW_HEIGHT_AUDIO = 48;
const ROW_HEIGHT_MUSIC = 72;
interface ChatMsg {
id: number;
type: "system" | "message" | "join" | "part" | "persona" | "channelInfo" | "userlist" | "command" | "uploadCapability" | "audio" | "image" | "music";
nick?: string;
text?: string;
color?: string;
channel?: string;
users?: string[];
audioData?: string;
audioMime?: string;
imageData?: string;
imageMime?: string;
timestamp: number;
}
interface PersonaColor {
[nick: string]: string;
}
const MAX_MESSAGES = 500;
const MAX_HISTORY = 100;
let msgIdCounter = 0;
interface ChatMessageProps {
msg: ChatMsg;
getNickColor: (nick: string) => string | undefined;
channel: string;
}
const ChatMessage = React.memo(function ChatMessage({ msg, getNickColor, channel }: ChatMessageProps) {
function estimateRowHeight(msg: ChatMsg): number {
switch (msg.type) {
case "system":
return (
<div className="chat-msg chat-msg-system">
{(msg.text || "").split("\n").map((line, i) => (
<div key={i}>{line || "\u00A0"}</div>
))}
</div>
);
case "join":
return (
<div className="chat-msg chat-msg-system">
{"--> "}{msg.nick} a rejoint {msg.channel || channel}
</div>
);
case "part":
return (
<div className="chat-msg chat-msg-system">
{"<-- "}{msg.nick} a quitte {msg.channel || channel}
</div>
);
case "audio": {
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-nick" style={color ? { color } : undefined}>
{"<"}{msg.nick || "???"}{">"}{" "}
</span>
<span className="chat-audio-indicator">&#9835;</span>
<button className="chat-audio-play" onClick={() => {
if (msg.audioData && msg.audioMime) {
const a = new Audio(`data:${msg.audioMime};base64,${msg.audioData}`);
a.volume = 0.7;
a.play().catch(() => {});
}
}}>&#9654;</button>
</div>
);
}
case "image": {
const color = msg.nick ? getNickColor(msg.nick) : undefined;
return (
<div className="chat-msg chat-msg-image" style={color ? { color } : undefined}>
<span className="chat-nick" style={color ? { color } : undefined}>
{"<"}{msg.nick || "???"}{">"}{" "}
</span>
<span className="chat-text">{msg.text}</span>
{msg.imageData && msg.imageMime && (
<img
src={`data:${msg.imageMime};base64,${msg.imageData}`}
alt={msg.text || "Image generee"}
className="chat-generated-image"
style={{ maxWidth: "512px", maxHeight: "512px", display: "block", marginTop: "4px", borderRadius: "4px" }}
/>
)}
</div>
);
}
case "music": {
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-nick" style={color ? { color } : undefined}>
{"<"}{msg.nick || "???"}{">"}{" "}
</span>
<span className="chat-text">{msg.text}</span>
{msg.audioData && msg.audioMime && (
<audio
controls
src={`data:${msg.audioMime};base64,${msg.audioData}`}
style={{ display: "block", marginTop: "4px", maxWidth: "400px" }}
/>
)}
</div>
);
}
case "message":
case "image":
return ROW_HEIGHT_IMAGE;
case "audio":
return ROW_HEIGHT_AUDIO;
case "music":
return ROW_HEIGHT_MUSIC;
default: {
const color = msg.nick ? getNickColor(msg.nick) : undefined;
const className = color ? "chat-msg chat-msg-persona" : "chat-msg chat-msg-user";
return (
<div
className={className}
style={color ? { color } : undefined}
>
<span className="chat-nick" style={color ? { color } : undefined}>
{"<"}{msg.nick || "???"}{">"}{" "}
</span>
<span className="chat-text">{msg.text}</span>
</div>
);
const text = msg.text || "";
const lines = Math.ceil(text.length / 80) || 1;
return Math.max(ROW_HEIGHT_DEFAULT, lines * 20 + 4);
}
}
});
}
export default function Chat() {
const [messages, setMessages] = useState<ChatMsg[]>([]);
const [users, setUsers] = useState<string[]>([]);
const [channel, setChannel] = useState("#general");
const [input, setInput] = useState("");
const [personaColors, setPersonaColors] = useState<PersonaColor>({});
// showConnect removed — connection animation handled by App.tsx
const [sidebarCollapsed, setSidebarCollapsed] = useState({ personas: true, users: true });
const [typingPersona, setTypingPersona] = useState<string | null>(null);
const typingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const {
messages,
users,
channel,
input,
setInput,
personaColors,
sidebarCollapsed,
toggleSidebar,
typingPersona,
ws,
getNickColor,
handleSend,
handleKeyDown,
} = useChatState();
const listRef = useListRef();
const autoScrollRef = useRef(true);
const historyRef = useRef<string[]>([]);
const historyIndexRef = useRef(-1);
const savedInputRef = useRef("");
const keyPressCountRef = useRef(0);
const ullaTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const sounds = useMinitelSounds();
// Keep a stable reference to messages for row component
const messagesRef = useRef(messages);
messagesRef.current = messages;
// Clean up /ulla timeouts on unmount
useEffect(() => {
return () => {
ullaTimersRef.current.forEach((id) => clearTimeout(id));
ullaTimersRef.current = [];
};
const getRowHeight = useCallback((index: number): number => {
return estimateRowHeight(messagesRef.current[index]);
}, []);
const handleMessage = useCallback((data: unknown) => {
const msg = data as Record<string, unknown>;
if (!msg || typeof msg !== "object" || typeof msg.type !== "string") return;
const type = msg.type as ChatMsg["type"];
switch (type) {
case "persona":
if (typeof msg.nick === "string") {
const color = typeof msg.color === "string" && /^#[0-9a-fA-F]{3,8}$|^[a-z]{3,20}$/i.test(msg.color)
? msg.color
: getPersonaColor(msg.nick);
setPersonaColors((prev) => ({ ...prev, [msg.nick as string]: color }));
}
return;
case "userlist":
if (Array.isArray(msg.users)) {
setUsers(msg.users as string[]);
}
return;
case "channelInfo":
if (typeof msg.channel === "string") {
setChannel(msg.channel as string);
}
return;
case "uploadCapability":
// Silently ignore
return;
case "image": {
const chatMsg: ChatMsg = {
id: ++msgIdCounter,
type: "image",
nick: typeof msg.nick === "string" ? msg.nick : undefined,
text: typeof msg.text === "string" ? msg.text : undefined,
imageData: typeof msg.imageData === "string" ? msg.imageData : undefined,
imageMime: typeof msg.imageMime === "string" ? msg.imageMime : undefined,
timestamp: Date.now(),
};
setMessages((prev) => {
const next = [...prev, chatMsg];
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
});
return;
}
case "music": {
const chatMsg: ChatMsg = {
id: ++msgIdCounter,
type: "music",
nick: typeof msg.nick === "string" ? msg.nick : undefined,
text: typeof msg.text === "string" ? msg.text : undefined,
audioData: typeof msg.audioData === "string" ? msg.audioData : undefined,
audioMime: typeof msg.audioMime === "string" ? msg.audioMime : undefined,
timestamp: Date.now(),
};
setMessages((prev) => {
const next = [...prev, chatMsg];
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
});
return;
}
case "audio": {
if (typeof msg.data === "string" && typeof msg.mimeType === "string") {
// Add to messages as a playable audio message
const chatMsg: ChatMsg = {
id: ++msgIdCounter,
type: "audio",
nick: typeof msg.nick === "string" ? msg.nick : undefined,
text: "\u266A message vocal",
audioData: msg.data as string,
audioMime: msg.mimeType as string,
timestamp: Date.now(),
};
setMessages((prev) => {
const next = [...prev, chatMsg];
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
});
}
return;
}
default: {
// Intercept typing indicators — show in dedicated bar, don't add to messages
if (type === "system" && typeof msg.text === "string") {
const typingMatch = msg.text.match(/^(.+) est en train d'ecrire/);
if (typingMatch) {
setTypingPersona(typingMatch[1]);
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
typingTimerRef.current = setTimeout(() => setTypingPersona(null), 8000);
return; // Don't add to messages
}
}
const chatMsg: ChatMsg = {
id: ++msgIdCounter,
type,
nick: typeof msg.nick === "string" ? msg.nick : undefined,
text: typeof msg.text === "string" ? msg.text : undefined,
color: typeof msg.color === "string" ? msg.color : undefined,
channel: typeof msg.channel === "string" ? msg.channel : undefined,
timestamp: Date.now(),
};
setMessages((prev) => {
const next = [...prev, chatMsg];
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
});
// Clear typing indicator when persona actually responds
if (type === "message" && chatMsg.nick) {
setTypingPersona(null);
}
// Minitel receive beep for persona messages
if (type === "message" && chatMsg.nick && personaColors[chatMsg.nick]) {
sounds.receive();
}
// Update user list on join/part
if (type === "join" && chatMsg.nick) {
setUsers((prev) =>
prev.includes(chatMsg.nick!) ? prev : [...prev, chatMsg.nick!]
);
} else if (type === "part" && chatMsg.nick) {
setUsers((prev) => prev.filter((u) => u !== chatMsg.nick));
}
}
}
}, [sounds, personaColors]);
const [wsUrl] = useState(buildWsUrl);
const ws = useWebSocket({
url: wsUrl,
onMessage: handleMessage,
enabled: true,
});
// Track whether user has scrolled up
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
const container = messagesContainerRef.current;
if (!container) return;
if (autoScrollRef.current && listRef.current && messages.length > 0) {
listRef.current.scrollToRow({ index: messages.length - 1, align: "end" });
}
}, [messages, listRef]);
// Track scroll position via the outer element to detect user scroll-up
const outerElRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
// Get the outer element from the list ref
const el = listRef.current?.element;
if (!el) return;
outerElRef.current = el;
function onScroll() {
if (!container) return;
const atBottom =
container.scrollHeight - container.scrollTop - container.clientHeight < 40;
const outer = outerElRef.current;
if (!outer) return;
const atBottom = outer.scrollHeight - outer.scrollTop - outer.clientHeight < 40;
autoScrollRef.current = atBottom;
}
container.addEventListener("scroll", onScroll);
return () => container.removeEventListener("scroll", onScroll);
}, []);
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, [listRef, messages.length]); // re-attach when list mounts (messages.length goes from 0 to >0)
// Auto-scroll when new messages arrive
useEffect(() => {
if (autoScrollRef.current) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [messages]);
// Ref to always have current handleSend in the global keydown handler
const handleSendRef = useRef<() => void>(() => {});
// Global F-key handler (Minitel bar: F1=Sommaire F2=Suite F3=Retour F4=Annul F5=Envoi)
useEffect(() => {
function handleGlobalKeyDown(e: KeyboardEvent) {
switch (e.key) {
case "F1":
e.preventDefault();
window.location.hash = "#/";
break;
case "F2":
e.preventDefault();
messagesContainerRef.current?.scrollBy({ top: 300, behavior: "smooth" });
break;
case "F3":
e.preventDefault();
history.back();
break;
case "F4":
e.preventDefault();
setInput("");
break;
case "F5":
e.preventDefault();
handleSendRef.current();
break;
}
}
window.addEventListener("keydown", handleGlobalKeyDown);
return () => window.removeEventListener("keydown", handleGlobalKeyDown);
}, []);
function handleSend() {
const trimmed = input.trim();
if (!trimmed || !ws.connected) return;
// Push to history
historyRef.current.unshift(trimmed);
if (historyRef.current.length > MAX_HISTORY) historyRef.current.pop();
historyIndexRef.current = -1;
savedInputRef.current = "";
// /ulla easter egg
if (trimmed.toLowerCase() === "/ulla") {
const ullaMessages = [
"\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557",
"\u2551 3615 ULLA \u2014 MESSAGERIE \u2551",
"\u2551 \u2551",
"\u2551 Salut beau gosse... \uD83D\uDE18 \u2551",
"\u2551 Tu cherches quoi ce soir ? \u2551",
"\u2551 Tape 1 pour RENCONTRE \u2551",
"\u2551 Tape 2 pour DIALOGUE \u2551",
"\u2551 Tape 3 pour MYSTERE \u2551",
"\u2551 \u2551",
"\u2551 0,34\u20AC/min \u2014 ah non, c'est \u2551",
"\u2551 gratuit ici, c'est du LOCAL \uD83C\uDFF4\u200D\u2620\uFE0F \u2551",
"\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D",
];
ullaTimersRef.current.forEach((id) => clearTimeout(id));
ullaTimersRef.current = [];
ullaMessages.forEach((line, i) => {
const timerId = setTimeout(() => {
setMessages(prev => [...prev, {
id: ++msgIdCounter,
type: "system",
text: line,
timestamp: Date.now(),
}]);
}, i * 200);
ullaTimersRef.current.push(timerId);
});
setInput("");
return;
}
sounds.send();
if (trimmed.startsWith("/")) {
ws.send({ type: "command", text: trimmed });
} else {
ws.send({ type: "message", text: trimmed });
}
setInput("");
}
// Keep handleSendRef in sync
useEffect(() => { handleSendRef.current = handleSend; });
const [tabIndex, setTabIndex] = useState(-1);
const [tabPrefix, setTabPrefix] = useState("");
function handleKeyDown(e: React.KeyboardEvent) {
// Debounced Minitel keyPress sound (every 3rd key)
if (e.key.length === 1) {
keyPressCountRef.current++;
if (keyPressCountRef.current % 3 === 0) {
sounds.keyPress();
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
return;
}
// Tab completion for nicks and slash commands
if (e.key === "Tab") {
e.preventDefault();
const text = input;
// 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 matches = slashCommands.filter((c) => c.startsWith(prefix.toLowerCase()));
if (matches.length === 0) return;
const nextIdx = (tabIndex + 1) % matches.length;
setInput(matches[nextIdx] + " ");
setTabIndex(nextIdx);
if (!tabPrefix) setTabPrefix(prefix);
return;
}
// Nick completion
const words = text.split(" ");
const lastWord = words[words.length - 1];
const prefix = tabPrefix || lastWord;
const matches = users.filter((u) =>
u.toLowerCase().startsWith(prefix.toLowerCase()),
);
if (matches.length === 0) return;
const nextIdx = (tabIndex + 1) % matches.length;
words[words.length - 1] = matches[nextIdx] + (words.length === 1 ? ": " : " ");
setInput(words.join(" "));
setTabIndex(nextIdx);
if (!tabPrefix) setTabPrefix(prefix);
return;
}
// ArrowUp — navigate back through message history
if (e.key === "ArrowUp") {
const history = historyRef.current;
if (history.length === 0) return;
e.preventDefault();
if (historyIndexRef.current < history.length - 1) {
if (historyIndexRef.current === -1) savedInputRef.current = input;
historyIndexRef.current++;
setInput(history[historyIndexRef.current]);
}
return;
}
// ArrowDown — navigate forward through message history
if (e.key === "ArrowDown") {
e.preventDefault();
if (historyIndexRef.current > 0) {
historyIndexRef.current--;
setInput(historyRef.current[historyIndexRef.current]);
} else if (historyIndexRef.current === 0) {
historyIndexRef.current = -1;
setInput(savedInputRef.current);
}
return;
}
// Reset tab state on any other key
if (tabIndex >= 0) {
setTabIndex(-1);
setTabPrefix("");
}
}
const getNickColor = useCallback((nick: string): string | undefined => {
return personaColors[nick];
}, [personaColors]);
const Row = useCallback(({ index, style }: { index: number; style: React.CSSProperties; ariaAttributes: Record<string, unknown> }) => (
<div style={style}>
<ChatMessage
msg={messagesRef.current[index]}
getNickColor={getNickColor}
channel={channel}
/>
</div>
), [getNickColor, channel]);
return (
<div className="chat-container">
@@ -525,107 +102,44 @@ export default function Chat() {
</div>
<div className="chat-body">
<div className="chat-messages" ref={messagesContainerRef} role="log" aria-live="polite">
{messages.map((msg) => (
<ChatMessage key={msg.id} msg={msg} getNickColor={getNickColor} channel={channel} />
))}
<div ref={messagesEndRef} />
<div className="chat-messages" role="log" aria-live="polite">
<AutoSizer>
{({ height, width }: { height: number; width: number }) => (
<List
listRef={listRef}
height={height}
width={width}
rowCount={messages.length}
rowHeight={getRowHeight}
overscanCount={10}
rowComponent={Row}
/>
)}
</AutoSizer>
</div>
<div className="chat-sidebar">
<div className="chat-sidebar-section">
<div className="chat-sidebar-title" onClick={() => setSidebarCollapsed(p => ({ ...p, personas: !p.personas }))}>
{sidebarCollapsed.personas ? "+" : "-"} Personas
</div>
{!sidebarCollapsed.personas && (
<div className="chat-sidebar-personas">
{Object.entries(
users.filter(u => personaColors[u]).reduce((acc, u) => {
// Group by first letter as simple grouping
const key = personaColors[u] ? "active" : "idle";
(acc[key] = acc[key] || []).push(u);
return acc;
}, {} as Record<string, string[]>)
).map(([, group]) =>
group.map(u => (
<div
key={u}
className="chat-sidebar-persona"
style={{ color: personaColors[u] }}
onClick={() => {
const input = document.querySelector<HTMLInputElement>(".chat-input input");
if (input) { input.value = `@${u} `; input.focus(); }
}}
title={`@${u}`}
>
{u}
</div>
))
)}
</div>
)}
</div>
<div className="chat-sidebar-section">
<div className="chat-sidebar-title" onClick={() => setSidebarCollapsed(p => ({ ...p, users: !p.users }))}>
{sidebarCollapsed.users ? "+" : "-"} Connectes ({users.filter(u => !personaColors[u]).length})
</div>
{!sidebarCollapsed.users && users.filter(u => !personaColors[u]).map((u) => (
<div key={u} className="chat-user">{u}</div>
))}
</div>
</div>
<ChatSidebar
personaColors={personaColors}
users={users}
sidebarCollapsed={sidebarCollapsed}
toggleSidebar={toggleSidebar}
/>
</div>
{typingPersona && (
<div className="chat-typing">
<div className="chat-typing" role="status" aria-live="assertive">
{">>> "}{typingPersona}{" ecrit"}
<span className="chat-typing-dots">...</span>
</div>
)}
<div className="chat-input">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={ws.connected ? "Message ou /commande... (Tab pour compléter)" : "Connexion en cours..."}
disabled={!ws.connected}
autoFocus
/>
<label className="btn btn-secondary chat-upload-btn" title="Joindre un fichier">
+
<input
type="file"
style={{ display: "none" }}
accept="image/*,audio/*,text/*,.pdf,.json,.jsonl,.csv,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.odt,.ods,.odp,.rtf,.epub,.html,.xml,.yaml,.yml,.toml,.ini,.log,.sh,.py,.js,.ts,.c,.cpp,.rs,.go,.java,.sql"
onChange={(e) => {
const file = e.target.files?.[0];
if (!file || !ws.connected) return;
const reader = new FileReader();
reader.onload = () => {
const base64 = (reader.result as string).split(",")[1];
ws.send({
type: "upload",
filename: file.name,
mimeType: file.type,
size: file.size,
data: base64,
});
};
reader.readAsDataURL(file);
e.target.value = "";
}}
disabled={!ws.connected}
/>
</label>
<button
className="btn btn-primary"
onClick={handleSend}
disabled={!ws.connected || !input.trim()}
>
Envoyer
</button>
</div>
<ChatInput
input={input}
setInput={setInput}
onSend={handleSend}
onKeyDown={handleKeyDown}
ws={ws}
/>
</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>
+60
View File
@@ -0,0 +1,60 @@
import React from "react";
import type { UseWebSocketReturn } from "../hooks/useWebSocket";
export interface ChatInputProps {
input: string;
setInput: (value: string) => void;
onSend: () => void;
onKeyDown: (e: React.KeyboardEvent) => void;
ws: UseWebSocketReturn;
}
export const ChatInput = React.memo(function ChatInput({ input, setInput, onSend, onKeyDown, ws }: ChatInputProps) {
return (
<div className="chat-input">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={onKeyDown}
placeholder={ws.connected ? "Message ou /commande... (Tab pour compléter)" : "Connexion en cours..."}
disabled={!ws.connected}
autoFocus
/>
<label className="btn btn-secondary chat-upload-btn" title="Joindre un fichier">
+
<input
type="file"
style={{ display: "none" }}
accept="image/*,audio/*,text/*,.pdf,.json,.jsonl,.csv,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.odt,.ods,.odp,.rtf,.epub,.html,.xml,.yaml,.yml,.toml,.ini,.log,.sh,.py,.js,.ts,.c,.cpp,.rs,.go,.java,.sql"
onChange={(e) => {
const file = e.target.files?.[0];
if (!file || !ws.connected) return;
const reader = new FileReader();
reader.onload = () => {
const base64 = (reader.result as string).split(",")[1];
ws.send({
type: "upload",
filename: file.name,
mimeType: file.type,
size: file.size,
data: base64,
});
};
reader.readAsDataURL(file);
e.target.value = "";
}}
disabled={!ws.connected}
/>
</label>
<button
className="btn btn-primary"
onClick={onSend}
disabled={!ws.connected || !input.trim()}
aria-label="Envoyer le message"
>
Envoyer
</button>
</div>
);
});
+129
View File
@@ -0,0 +1,129 @@
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;
channel: string;
}
export const ChatMessage = React.memo(function ChatMessage({ msg, getNickColor, channel }: ChatMessageProps) {
switch (msg.type) {
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>
))}
</div>
);
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>
);
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>
);
case "audio": {
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" 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;
a.play().catch(() => {});
}
}}>&#9654;</button>
</div>
);
}
case "image": {
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>
<span className="chat-text">{msg.text}</span>
{msg.imageData && msg.imageMime && (
<img
src={`data:${msg.imageMime};base64,${msg.imageData}`}
alt={msg.text || "Image generee"}
className="chat-generated-image"
style={{ maxWidth: "512px", maxHeight: "512px", display: "block", marginTop: "4px", borderRadius: "4px" }}
/>
)}
</div>
);
}
case "music": {
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>
<span className="chat-text">{msg.text}</span>
{msg.audioData && msg.audioMime && (
<audio
controls
src={`data:${msg.audioMime};base64,${msg.audioData}`}
aria-label={`Musique generee: ${msg.text || "sans titre"}`}
style={{ display: "block", marginTop: "4px", maxWidth: "400px" }}
/>
)}
</div>
);
}
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}${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>
);
}
}
});
+55
View File
@@ -0,0 +1,55 @@
import React from "react";
import type { PersonaColor } from "./chat-types";
export interface ChatSidebarProps {
personaColors: PersonaColor;
users: string[];
sidebarCollapsed: { personas: boolean; users: boolean };
toggleSidebar: (section: "personas" | "users") => void;
}
export const ChatSidebar = React.memo(function ChatSidebar({ personaColors, users, sidebarCollapsed, toggleSidebar }: ChatSidebarProps) {
return (
<div className="chat-sidebar">
<div className="chat-sidebar-section">
<div className="chat-sidebar-title" onClick={() => toggleSidebar("personas")}>
{sidebarCollapsed.personas ? "+" : "-"} Personas
</div>
{!sidebarCollapsed.personas && (
<div className="chat-sidebar-personas">
{Object.entries(
users.filter(u => personaColors[u]).reduce((acc, u) => {
const key = personaColors[u] ? "active" : "idle";
(acc[key] = acc[key] || []).push(u);
return acc;
}, {} as Record<string, string[]>)
).map(([, group]) =>
group.map(u => (
<div
key={u}
className="chat-sidebar-persona"
style={{ color: personaColors[u] }}
onClick={() => {
const input = document.querySelector<HTMLInputElement>(".chat-input input");
if (input) { input.value = `@${u} `; input.focus(); }
}}
title={`@${u}`}
>
{u}
</div>
))
)}
</div>
)}
</div>
<div className="chat-sidebar-section">
<div className="chat-sidebar-title" onClick={() => toggleSidebar("users")}>
{sidebarCollapsed.users ? "+" : "-"} Connectes ({users.filter(u => !personaColors[u]).length})
</div>
{!sidebarCollapsed.users && users.filter(u => !personaColors[u]).map((u) => (
<div key={u} className="chat-user">{u}</div>
))}
</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">
+11 -4
View File
@@ -28,23 +28,30 @@ describe("PersonaList", () => {
});
it("renders persona cards after loading", async () => {
const user = userEvent.setup();
vi.mocked(api.listPersonas).mockResolvedValue(mockPersonas);
render(<PersonaList onSelect={vi.fn()} />);
const gpt4Group = await screen.findByText("gpt-4");
await user.click(gpt4Group);
expect(await screen.findByText("Clown Rouge")).toBeInTheDocument();
expect(screen.getByText("Clown Bleu")).toBeInTheDocument();
const claudeGroup = await screen.findByText("claude-3");
await user.click(claudeGroup);
expect(await screen.findByText("Clown Bleu")).toBeInTheDocument();
expect(screen.getByText("gpt-4")).toBeInTheDocument();
expect(screen.getByText("claude-3")).toBeInTheDocument();
expect(screen.getByText("Un clown joyeux")).toBeInTheDocument();
});
it("calls onSelect when a persona card is clicked", async () => {
const user = userEvent.setup();
vi.mocked(api.listPersonas).mockResolvedValue(mockPersonas);
const onSelect = vi.fn();
render(<PersonaList onSelect={onSelect} />);
const group = await screen.findByText("gpt-4");
await user.click(group);
const card = await screen.findByText("Clown Rouge");
await userEvent.click(card);
await user.click(card);
expect(onSelect).toHaveBeenCalledWith("p1");
});
@@ -59,6 +66,6 @@ describe("PersonaList", () => {
vi.mocked(api.listPersonas).mockRejectedValue(new Error("Network error"));
render(<PersonaList onSelect={vi.fn()} />);
expect(await screen.findByText("Network error")).toBeInTheDocument();
expect(await screen.findByText(/ERREUR: Network error/)).toBeInTheDocument();
});
});
+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;
};
}, []);
+19
View File
@@ -0,0 +1,19 @@
export interface ChatMsg {
id: number;
type: "system" | "message" | "join" | "part" | "persona" | "channelInfo" | "userlist" | "command" | "uploadCapability" | "audio" | "image" | "music" | "chunk";
nick?: string;
text?: string;
color?: string;
channel?: string;
users?: string[];
audioData?: string;
audioMime?: string;
imageData?: string;
imageMime?: string;
seq?: number;
timestamp: number;
}
export interface PersonaColor {
[nick: string]: string;
}
+77
View File
@@ -0,0 +1,77 @@
import { useCallback, useEffect, useState } from "react";
import { api, type SessionData } from "../api";
const NICK_KEY = "kxkm-nick";
const EMAIL_KEY = "kxkm-email";
function readStorage(key: string): string | null {
try {
return typeof sessionStorage !== "undefined" ? sessionStorage.getItem(key) : null;
} catch {
return null;
}
}
function writeStorage(key: string, value: string): void {
try {
if (typeof sessionStorage !== "undefined") sessionStorage.setItem(key, value);
} catch {
// Ignore storage failures in private mode / tests.
}
}
function removeStorage(key: string): void {
try {
if (typeof sessionStorage !== "undefined") sessionStorage.removeItem(key);
} catch {
// Ignore storage failures in private mode / tests.
}
}
export function useAppSession() {
const [session, setSession] = useState<SessionData | null>(null);
const [nick, setNickState] = useState<string | null>(() => readStorage(NICK_KEY));
const [checkingSession, setCheckingSession] = useState(true);
useEffect(() => {
let alive = true;
api.getSession()
.then((current) => {
if (alive) setSession(current);
})
.catch(() => {
if (alive) setSession(null);
})
.finally(() => {
if (alive) setCheckingSession(false);
});
return () => {
alive = false;
};
}, [nick]);
const setNick = useCallback((username: string, email?: string) => {
setNickState(username);
writeStorage(NICK_KEY, username);
if (email) writeStorage(EMAIL_KEY, email);
else removeStorage(EMAIL_KEY);
}, []);
const clearSessionState = useCallback(() => {
setSession(null);
setNickState(null);
removeStorage(NICK_KEY);
removeStorage(EMAIL_KEY);
}, []);
return {
session,
setSession,
nick,
setNick,
clearSessionState,
checkingSession,
};
}
+498
View File
@@ -0,0 +1,498 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { getPersonaColor } from "@kxkm/ui";
import { useWebSocket } from "./useWebSocket";
import type { UseWebSocketReturn } from "./useWebSocket";
import { useMinitelSounds } from "./useMinitelSounds";
import { resolveWebSocketUrl } from "../lib/websocket-url";
import type { ChatMsg, PersonaColor } from "../components/chat-types";
const MAX_MESSAGES = 500;
const MAX_HISTORY = 100;
let msgIdCounter = 0;
function buildWsUrl(): string {
const base = resolveWebSocketUrl();
const nick = typeof sessionStorage !== "undefined" ? sessionStorage.getItem("kxkm-nick") : null;
if (!nick) return base;
const sep = base.includes("?") ? "&" : "?";
return `${base}${sep}nick=${encodeURIComponent(nick)}`;
}
export interface UseChatStateReturn {
messages: ChatMsg[];
users: string[];
channel: string;
input: string;
setInput: (value: string) => void;
personaColors: PersonaColor;
sidebarCollapsed: { personas: boolean; users: boolean };
toggleSidebar: (section: "personas" | "users") => void;
typingPersona: string | null;
ws: UseWebSocketReturn;
sounds: ReturnType<typeof useMinitelSounds>;
messagesEndRef: React.RefObject<HTMLDivElement | null>;
messagesContainerRef: React.RefObject<HTMLDivElement | null>;
getNickColor: (nick: string) => string | undefined;
handleSend: () => void;
handleKeyDown: (e: React.KeyboardEvent) => void;
}
export function useChatState(): UseChatStateReturn {
const [messages, setMessages] = useState<ChatMsg[]>([]);
const [users, setUsers] = useState<string[]>([]);
const [channel, setChannel] = useState("#general");
const [input, setInput] = useState("");
const [personaColors, setPersonaColors] = useState<PersonaColor>({});
const [sidebarCollapsed, setSidebarCollapsed] = useState({ personas: true, users: true });
const [typingPersona, setTypingPersona] = useState<string | null>(null);
const typingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const historyRef = useRef<string[]>([]);
const historyIndexRef = useRef(-1);
const savedInputRef = useRef("");
const keyPressCountRef = useRef(0);
const ullaTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const [tabIndex, setTabIndex] = useState(-1);
const [tabPrefix, setTabPrefix] = useState("");
const sounds = useMinitelSounds();
// Clean up /ulla timeouts on unmount
useEffect(() => {
return () => {
ullaTimersRef.current.forEach((id) => clearTimeout(id));
ullaTimersRef.current = [];
};
}, []);
const handleMessage = useCallback((data: unknown) => {
const msg = data as Record<string, unknown>;
if (!msg || typeof msg !== "object" || typeof msg.type !== "string") return;
const type = msg.type as ChatMsg["type"];
switch (type) {
case "persona":
if (typeof msg.nick === "string") {
const color = typeof msg.color === "string" && /^#[0-9a-fA-F]{3,8}$|^[a-z]{3,20}$/i.test(msg.color)
? msg.color
: getPersonaColor(msg.nick);
setPersonaColors((prev) => ({ ...prev, [msg.nick as string]: color }));
}
return;
case "userlist":
if (Array.isArray(msg.users)) {
setUsers(msg.users as string[]);
}
return;
case "channelInfo":
if (typeof msg.channel === "string") {
setChannel(msg.channel as string);
}
return;
case "uploadCapability":
return;
case "image": {
const chatMsg: ChatMsg = {
id: ++msgIdCounter,
type: "image",
nick: typeof msg.nick === "string" ? msg.nick : undefined,
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) => {
const next = [...prev, chatMsg];
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
});
return;
}
case "music": {
const chatMsg: ChatMsg = {
id: ++msgIdCounter,
type: "music",
nick: typeof msg.nick === "string" ? msg.nick : undefined,
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) => {
const next = [...prev, chatMsg];
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
});
return;
}
case "audio": {
if (typeof msg.data === "string" && typeof msg.mimeType === "string") {
const chatMsg: ChatMsg = {
id: ++msgIdCounter,
type: "audio",
nick: typeof msg.nick === "string" ? msg.nick : undefined,
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) => {
const next = [...prev, chatMsg];
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
});
}
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") {
const typingMatch = msg.text.match(/^(.+) est en train d'ecrire/);
if (typingMatch) {
setTypingPersona(typingMatch[1]);
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
typingTimerRef.current = setTimeout(() => setTypingPersona(null), 8000);
return;
}
}
const incomingSeq = typeof msg.seq === "number" ? msg.seq : undefined;
const chatMsg: ChatMsg = {
id: ++msgIdCounter,
type,
nick: typeof msg.nick === "string" ? msg.nick : undefined,
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;
});
if (type === "message" && chatMsg.nick) {
setTypingPersona(null);
}
if (type === "message" && chatMsg.nick && personaColors[chatMsg.nick]) {
sounds.receive();
}
if (type === "join" && chatMsg.nick) {
setUsers((prev) =>
prev.includes(chatMsg.nick!) ? prev : [...prev, chatMsg.nick!]
);
} else if (type === "part" && chatMsg.nick) {
setUsers((prev) => prev.filter((u) => u !== chatMsg.nick));
}
}
}
}, [sounds, personaColors]);
const [wsUrl] = useState(buildWsUrl);
const ws = useWebSocket({
url: wsUrl,
onMessage: handleMessage,
enabled: true,
});
// Track whether user has scrolled up
useEffect(() => {
const container = messagesContainerRef.current;
if (!container) return;
function onScroll() {
if (!container) return;
const atBottom =
container.scrollHeight - container.scrollTop - container.clientHeight < 40;
autoScrollRef.current = atBottom;
}
container.addEventListener("scroll", onScroll);
return () => container.removeEventListener("scroll", onScroll);
}, []);
// Auto-scroll when new messages arrive
useEffect(() => {
if (autoScrollRef.current) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [messages]);
// Ref to always have current handleSend in the global keydown handler
const handleSendRef = useRef<() => void>(() => {});
// Global F-key handler
useEffect(() => {
function handleGlobalKeyDown(e: KeyboardEvent) {
switch (e.key) {
case "F1":
e.preventDefault();
window.location.hash = "#/";
break;
case "F2":
e.preventDefault();
messagesContainerRef.current?.scrollBy({ top: 300, behavior: "smooth" });
break;
case "F3":
e.preventDefault();
history.back();
break;
case "F4":
e.preventDefault();
setInput("");
break;
case "F5":
e.preventDefault();
handleSendRef.current();
break;
}
}
window.addEventListener("keydown", handleGlobalKeyDown);
return () => window.removeEventListener("keydown", handleGlobalKeyDown);
}, []);
// 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);
if (historyRef.current.length > MAX_HISTORY) historyRef.current.pop();
historyIndexRef.current = -1;
savedInputRef.current = "";
// /ulla easter egg
if (trimmed.toLowerCase() === "/ulla") {
const ullaMessages = [
"\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557",
"\u2551 3615 ULLA \u2014 MESSAGERIE \u2551",
"\u2551 \u2551",
"\u2551 Salut beau gosse... \uD83D\uDE18 \u2551",
"\u2551 Tu cherches quoi ce soir ? \u2551",
"\u2551 Tape 1 pour RENCONTRE \u2551",
"\u2551 Tape 2 pour DIALOGUE \u2551",
"\u2551 Tape 3 pour MYSTERE \u2551",
"\u2551 \u2551",
"\u2551 0,34\u20AC/min \u2014 ah non, c'est \u2551",
"\u2551 gratuit ici, c'est du LOCAL \uD83C\uDFF4\u200D\u2620\uFE0F \u2551",
"\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D",
];
ullaTimersRef.current.forEach((id) => clearTimeout(id));
ullaTimersRef.current = [];
ullaMessages.forEach((line, i) => {
const timerId = setTimeout(() => {
setMessages(prev => [...prev, {
id: ++msgIdCounter,
type: "system",
text: line,
timestamp: Date.now(),
}]);
}, i * 200);
ullaTimersRef.current.push(timerId);
});
setInput("");
return;
}
soundsRef.current.send();
if (trimmed.startsWith("/")) {
wsRef.current.send({ type: "command", text: trimmed });
} else {
wsRef.current.send({ type: "message", text: trimmed });
}
setInput("");
}, []); // stable — reads from refs
// Keep handleSendRef in sync
useEffect(() => { handleSendRef.current = handleSend; });
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) {
soundsRef.current.keyPress();
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
return;
}
// Tab completion for nicks and slash commands
if (e.key === "Tab") {
e.preventDefault();
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 = tabPrefixRef.current || text;
const matches = slashCommands.filter((c) => c.startsWith(prefix.toLowerCase()));
if (matches.length === 0) return;
const nextIdx = (tabIndexRef.current + 1) % matches.length;
setInput(matches[nextIdx] + " ");
setTabIndex(nextIdx);
if (!tabPrefixRef.current) setTabPrefix(prefix);
return;
}
// Nick completion
const words = text.split(" ");
const lastWord = words[words.length - 1];
const prefix = tabPrefixRef.current || lastWord;
const matches = usersRef.current.filter((u) =>
u.toLowerCase().startsWith(prefix.toLowerCase()),
);
if (matches.length === 0) return;
const nextIdx = (tabIndexRef.current + 1) % matches.length;
words[words.length - 1] = matches[nextIdx] + (words.length === 1 ? ": " : " ");
setInput(words.join(" "));
setTabIndex(nextIdx);
if (!tabPrefixRef.current) setTabPrefix(prefix);
return;
}
// ArrowUp — navigate back through message history
if (e.key === "ArrowUp") {
const history = historyRef.current;
if (history.length === 0) return;
e.preventDefault();
if (historyIndexRef.current < history.length - 1) {
if (historyIndexRef.current === -1) savedInputRef.current = inputRef.current;
historyIndexRef.current++;
setInput(history[historyIndexRef.current]);
}
return;
}
// ArrowDown — navigate forward through message history
if (e.key === "ArrowDown") {
e.preventDefault();
if (historyIndexRef.current > 0) {
historyIndexRef.current--;
setInput(historyRef.current[historyIndexRef.current]);
} else if (historyIndexRef.current === 0) {
historyIndexRef.current = -1;
setInput(savedInputRef.current);
}
return;
}
// Reset tab state on any other key
if (tabIndexRef.current >= 0) {
setTabIndex(-1);
setTabPrefix("");
}
}, [handleSend]); // stable — reads from refs
const getNickColor = useCallback((nick: string): string | undefined => {
return personaColors[nick];
}, [personaColors]);
const toggleSidebar = useCallback((section: "personas" | "users") => {
setSidebarCollapsed(p => ({ ...p, [section]: !p[section] }));
}, []);
return {
messages,
users,
channel,
input,
setInput,
personaColors,
sidebarCollapsed,
toggleSidebar,
typingPersona,
ws,
sounds,
messagesEndRef,
messagesContainerRef,
getNickColor,
handleSend,
handleKeyDown,
};
}
+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 };
}
+38
View File
@@ -0,0 +1,38 @@
import { useCallback, useEffect, useState } from "react";
export interface HashRoute {
page: string;
id: string;
}
function parseHash(hash: string, defaultPage = "chat"): HashRoute {
const normalized = hash.replace(/^#\/?/, "");
if (!normalized) return { page: defaultPage, id: "" };
const parts = normalized.split("/");
const page = parts[0] || defaultPage;
const id = parts.slice(1).join("/");
return { page, id };
}
export function useHashRoute(defaultPage = "chat") {
const [route, setRoute] = useState<HashRoute>(() =>
typeof window !== "undefined" ? parseHash(window.location.hash, defaultPage) : { page: defaultPage, id: "" },
);
useEffect(() => {
function onHashChange() {
setRoute(parseHash(window.location.hash, defaultPage));
}
window.addEventListener("hashchange", onHashChange);
return () => window.removeEventListener("hashchange", onHashChange);
}, [defaultPage]);
const navigate = useCallback((page: string, id?: string) => {
const next = id ? `${page}/${id}` : page;
setRoute(parseHash(`#${next}`, defaultPage));
window.location.hash = next;
}, [defaultPage]);
return { route, navigate };
}
+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 };
}
+11
View File
@@ -0,0 +1,11 @@
export function resolveWebSocketUrl(path = "/ws"): string {
const configured = import.meta.env.VITE_WS_URL;
if (configured) return configured;
if (typeof window === "undefined") {
return `ws://127.0.0.1:4180${path}`;
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${window.location.host}${path}`;
}
+5
View File
@@ -1,8 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { publishUiCssVariables } from "@kxkm/ui";
import App from "./App";
import "./styles.css";
if (typeof document !== "undefined") {
publishUiCssVariables(document.documentElement.style);
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
+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
+25 -3
View File
@@ -391,6 +391,19 @@ function requestShutdown(): void {
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
// ---------------------------------------------------------------------------
@@ -437,9 +450,18 @@ async function main(): Promise<void> {
log(`Recovered ${recovered.length} stale run(s): ${recovered.map((r) => r.id).join(", ")}`);
}
// 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)`);
+230
View File
@@ -0,0 +1,230 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createRun,
createNodeEngineRegistry,
createQueueState,
type NodeRunRecord,
type RunStatus,
} from "@kxkm/node-engine";
import {
createNodeExecutor,
createShutdownController,
executeRun,
parseLastJsonLine,
runPollCycle,
} from "./worker-runtime.js";
function createLogger() {
const logs: string[] = [];
const errors: Array<{ msg: string; err?: unknown }> = [];
return {
logs,
errors,
logger: {
log(msg: string) {
logs.push(msg);
},
error(msg: string, err?: unknown) {
errors.push({ msg, err });
},
},
};
}
type BenchmarkResult = {
evaluation?: {
kind?: string;
score?: number;
error?: string;
};
};
type DeployResult = {
deployment?: {
kind?: string;
id?: string;
error?: string;
};
};
test("parseLastJsonLine parses the final JSON line and rejects invalid output", () => {
const parsed = parseLastJsonLine("noise\n{\"status\":\"ok\",\"score\":7}\n");
assert.equal(parsed.ok, true);
if (!parsed.ok) throw new Error("expected parsed json");
assert.equal(parsed.value.status, "ok");
assert.equal(parsed.value.score, 7);
const invalid = parseLastJsonLine("noise\nnot-json\n");
assert.equal(invalid.ok, false);
if (invalid.ok) throw new Error("expected parse failure");
assert.equal(invalid.rawLine, "not-json");
assert.equal(invalid.value && Object.keys(invalid.value).length, 0);
});
test("createShutdownController is idempotent", () => {
const controller = createShutdownController();
assert.equal(controller.isShutdownRequested(), false);
controller.requestShutdown();
controller.requestShutdown();
assert.equal(controller.isShutdownRequested(), true);
});
test("executeRun completes steps in order and forwards upstream outputs", async () => {
const registry = createNodeEngineRegistry();
const graph = {
id: "graph-1",
name: "Graph",
description: "Test graph",
nodes: [
{ id: "n1", type: "dataset_file", runtime: "local_cpu", params: {} },
{ id: "n2", type: "clean_text", runtime: "local_cpu", params: {} },
],
edges: [
{ from: { node: "n1", output: "dataset" }, to: { node: "n2", input: "dataset" } },
],
createdAt: "2026-03-17T00:00:00Z",
updatedAt: "2026-03-17T00:00:00Z",
};
const run = createRun(graph, "worker");
const seenInputs: Record<string, unknown>[] = [];
const { logger } = createLogger();
await executeRun(run, registry, {
executeNode: async (nodeType, inputs) => {
seenInputs.push(inputs);
if (nodeType === "dataset_file") return { dataset: { items: ["hello"], format: "stub" } };
return { dataset: inputs.dataset };
},
logger,
});
assert.equal(run.status, "completed");
assert.equal(run.steps[0]?.status, "completed");
assert.equal(run.steps[1]?.status, "completed");
assert.deepEqual(run.steps[0]?.outputs, ["dataset"]);
assert.deepEqual(run.steps[1]?.outputs, ["dataset"]);
assert.deepEqual(seenInputs[1]?.dataset, { items: ["hello"], format: "stub" });
});
test("executeRun cancels before the second step when cancellation is requested", async () => {
const registry = createNodeEngineRegistry();
const graph = {
id: "graph-2",
name: "Graph",
description: "Test graph",
nodes: [
{ id: "n1", type: "dataset_file", runtime: "local_cpu", params: {} },
{ id: "n2", type: "clean_text", runtime: "local_cpu", params: {} },
],
edges: [
{ from: { node: "n1", output: "dataset" }, to: { node: "n2", input: "dataset" } },
],
createdAt: "2026-03-17T00:00:00Z",
updatedAt: "2026-03-17T00:00:00Z",
};
const run = createRun(graph, "worker");
let executions = 0;
const { logger } = createLogger();
await executeRun(run, registry, {
executeNode: async () => {
executions += 1;
return { dataset: { items: [], format: "stub" } };
},
shouldCancel: () => executions > 0,
logger,
});
assert.equal(executions, 1);
assert.equal(run.status, "cancelled");
assert.equal(run.steps[0]?.status, "completed");
assert.equal(run.steps[1]?.status, "pending");
});
test("runPollCycle dequeues queued runs and persists the final status", async () => {
const queueState = createQueueState({ maxConcurrency: 1 });
const queuedRuns: NodeRunRecord[] = [
{ id: "run-1", graphId: "graph-1", status: "queued", createdAt: "2026-03-17T00:00:00Z" },
];
const statusUpdates: Array<[string, string]> = [];
const { logger } = createLogger();
const runRepo = {
async listByStatus(status: RunStatus, limit: number) {
assert.equal(status, "queued");
assert.equal(limit, 20);
return queuedRuns;
},
async findById(id: string) {
return id === "run-1" ? queuedRuns[0] : null;
},
async updateStatus(id: string, status: RunStatus) {
statusUpdates.push([id, status]);
},
};
const graphRepo = {
async findById(id: string) {
return id === "graph-1"
? { id: "graph-1", name: "Graph", description: "Empty graph" }
: null;
},
async list() {
return [];
},
};
const result = await runPollCycle({
queueState,
runRepo,
graphRepo,
registry: createNodeEngineRegistry(),
executeNode: async () => ({}),
shutdown: createShutdownController(),
logger,
});
assert.equal(result.queuedDbRuns, 1);
assert.deepEqual(result.processedRunIds, ["run-1"]);
assert.deepEqual(statusUpdates, [
["run-1", "running"],
["run-1", "completed"],
]);
assert.deepEqual(queueState.queued, []);
assert.deepEqual(queueState.running, []);
});
test("createNodeExecutor tolerates invalid JSON from subprocesses", async () => {
const { logger, errors } = createLogger();
const executor = createNodeExecutor(
{
dryRun: false,
stepDelayMs: 0,
pythonBin: "python3",
scriptsDir: "/tmp/scripts",
trainingTimeoutMs: 1000,
},
async () => ({ stdout: "garbage\n", stderr: "" }),
logger,
);
const benchmark = (await executor(
"benchmark",
{ model: { modelName: "demo-model", adapterPath: "/tmp/adapter" } },
{ promptsPath: "/tmp/prompts.json" },
)) as BenchmarkResult;
assert.equal(benchmark.evaluation?.kind, "real");
assert.equal(benchmark.evaluation?.score, undefined);
assert.ok(errors.some((entry) => entry.msg.includes("Failed to parse JSON output")));
const deploy = (await executor(
"deploy_api",
{ registered_model: { adapterPath: "/tmp/adapter" } },
{ deployName: "demo-deploy" },
)) as DeployResult;
assert.equal(deploy.deployment?.kind, "error");
assert.equal(deploy.deployment?.error, "invalid_json_output");
});
+564
View File
@@ -0,0 +1,564 @@
import type { ExecFileOptions } from "node:child_process";
import * as path from "node:path";
import { createIsoTimestamp } from "@kxkm/core";
import {
DEFAULT_HYPERPARAMS,
buildTrlCommand,
createRun,
canDequeue,
collectNodeInputs,
dequeue,
enqueue,
markComplete,
resolveFinalStatus,
topologicalSort,
validateEdgeContracts,
validateJobSpec,
type NodeEngineRegistry,
type NodeGraph,
type NodeRun,
type NodeRunRecord,
type QueueState,
type RunStatus,
type TrainingJobSpec,
} from "@kxkm/node-engine";
export interface WorkerLogger {
log(msg: string): void;
error(msg: string, err?: unknown): void;
}
export interface WorkerConfig {
dryRun: boolean;
stepDelayMs: number;
pythonBin: string;
scriptsDir: string;
trainingTimeoutMs: number;
}
export interface SubprocessRunner {
(file: string, args: string[], options: ExecFileOptions): Promise<{ stdout: string; stderr: string }>;
}
export interface ShutdownController {
requestShutdown(): void;
isShutdownRequested(): boolean;
}
export interface GraphRecordLike {
id: string;
name: string;
description: string;
}
export interface RunRepoLike {
listByStatus(status: RunStatus, limit: number): Promise<NodeRunRecord[]>;
findById(id: string): Promise<NodeRunRecord | null>;
updateStatus(id: string, status: RunStatus): Promise<void>;
}
export interface GraphRepoLike {
findById(id: string): Promise<GraphRecordLike | null>;
list(): Promise<GraphRecordLike[]>;
}
export interface ExecuteNodeInputs {
[key: string]: unknown;
}
export interface ExecuteNodeParams {
[key: string]: unknown;
}
export type ExecuteNodeFn = (
nodeType: string,
inputs: ExecuteNodeInputs,
params: ExecuteNodeParams,
) => Promise<Record<string, unknown>>;
export interface RunPollCycleResult {
queuedDbRuns: number;
processedRunIds: string[];
}
export interface ProcessDequeuedRunDeps {
runId: string;
runRepo: RunRepoLike;
graphRepo: GraphRepoLike;
registry: NodeEngineRegistry;
executeNode: ExecuteNodeFn;
logger: WorkerLogger;
shouldCancel: () => boolean;
}
export interface RunExecutionOptions {
shouldCancel?: () => boolean;
executeNode: ExecuteNodeFn;
logger: WorkerLogger;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function createShutdownController(): ShutdownController {
let shutdownRequested = false;
return {
requestShutdown(): void {
shutdownRequested = true;
},
isShutdownRequested(): boolean {
return shutdownRequested;
},
};
}
export function syncQueuedRuns(queueState: QueueState, queuedDbRuns: NodeRunRecord[]): number {
let added = 0;
for (const dbRun of queuedDbRuns) {
const before = queueState.queued.length;
enqueue(queueState, dbRun.id);
if (queueState.queued.length > before) added++;
}
return added;
}
export function buildWorkerGraph(graphRecord: GraphRecordLike): NodeGraph {
const now = createIsoTimestamp();
return {
id: graphRecord.id,
name: graphRecord.name,
description: graphRecord.description,
nodes: [],
edges: [],
createdAt: now,
updatedAt: now,
};
}
export function createWorkerRun(graph: NodeGraph, runId: string, actor = "worker"): NodeRun {
const run = createRun(graph, actor);
run.id = runId;
return run;
}
export type JsonParseResult =
| { ok: true; rawLine: string; value: Record<string, unknown> }
| { ok: false; rawLine: string; value: Record<string, never>; error: Error };
export function parseLastJsonLine(stdout: string): JsonParseResult {
const rawLine = stdout.trim().split("\n").pop() || "{}";
try {
return { ok: true, rawLine, value: JSON.parse(rawLine) as Record<string, unknown> };
} catch (err) {
return {
ok: false,
rawLine,
value: {},
error: err instanceof Error ? err : new Error(String(err)),
};
}
}
export function createNodeExecutor(
config: WorkerConfig,
runner: SubprocessRunner,
logger: WorkerLogger,
): ExecuteNodeFn {
return async function executeNodeStub(
nodeType: string,
inputs: ExecuteNodeInputs,
params: ExecuteNodeParams,
): Promise<Record<string, unknown>> {
await delay(config.stepDelayMs);
switch (nodeType) {
case "dataset_file":
case "dataset_folder":
case "huggingface_dataset":
case "web_scraper":
return { dataset: { items: [], format: "stub" } };
case "clean_text":
case "remove_duplicates":
case "split_dataset":
return { dataset: inputs.dataset ?? { items: [], format: "stub" } };
case "format_instruction_dataset":
case "chat_dataset":
return { dataset_ready: inputs.dataset ?? { items: [], format: "stub" } };
case "prompt_test":
case "benchmark": {
const evalModel = typeof params.model === "string" && params.model
? params.model
: ((inputs.model as Record<string, unknown> | undefined)?.modelName as string | undefined) || "unsloth/llama-3-8b";
const adapterPath = (inputs.model as Record<string, unknown> | undefined)?.adapterPath as string | undefined;
const promptsPath = typeof params.promptsPath === "string" ? params.promptsPath : "";
const evalOutputPath = `/tmp/kxkm-eval-${Date.now()}.json`;
if (config.dryRun) {
logger.log(` [dry-run] would evaluate model=${evalModel} adapter=${adapterPath || "none"}`);
return { evaluation: { kind: "dry-run", score: 1 } };
}
if (!promptsPath) {
logger.log(" [eval] no promptsPath provided — returning stub evaluation");
return { evaluation: { kind: "stub", score: 1 } };
}
const scriptPath = path.join(config.scriptsDir, "eval_model.py");
const args = [
scriptPath,
"--model",
evalModel,
"--prompts",
promptsPath,
"--output",
evalOutputPath,
];
if (adapterPath) args.push("--adapter", adapterPath);
logger.log(` [eval] ${config.pythonBin} ${args.join(" ")}`);
try {
const { stdout, stderr } = await runner(config.pythonBin, args, {
timeout: config.trainingTimeoutMs,
maxBuffer: 50 * 1024 * 1024,
});
if (stderr) logger.log(` [eval] stderr: ${stderr.slice(-500)}`);
const parsed = parseLastJsonLine(stdout);
if (!parsed.ok) logger.error(" [eval] Failed to parse JSON output", parsed.error);
const evalResult = parsed.value;
logger.log(` [eval] result: status=${evalResult.status} score=${evalResult.score}`);
return {
evaluation: {
kind: "real",
score: evalResult.score,
metrics: evalResult.metrics,
outputFile: evalOutputPath,
},
};
} catch (err) {
logger.error(" [eval] failed", err);
return {
evaluation: {
kind: "error",
score: 0,
error: err instanceof Error ? err.message : String(err),
},
};
}
}
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 (config.dryRun) {
const jobSpec = validateJobSpec({
type: nodeType as TrainingJobSpec["type"],
baseModel,
datasetPath: datasetPath || "/data/dataset.jsonl",
outputDir,
hyperparams: hp,
});
logger.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(config.scriptsDir, "train_unsloth.py");
const method = params.dpo === true ? "dpo" : nodeType === "qlora_training" ? "qlora" : nodeType === "sft_training" ? "sft" : "lora";
const args = [
scriptPath,
"--model",
baseModel,
"--data",
datasetPath,
"--output",
outputDir,
"--method",
method,
"--lr",
String((hp.learningRate as number) ?? ""),
"--epochs",
String((hp.epochs as number) ?? ""),
"--batch-size",
String((hp.batchSize as number) ?? ""),
"--lora-rank",
String((hp.loraRank as number) ?? ""),
"--lora-alpha",
String((hp.loraAlpha as number) ?? ""),
"--warmup-steps",
String((hp.warmupSteps as number) ?? ""),
"--max-seq-length",
String((hp.maxSeqLength as number) ?? ""),
];
if (nodeType === "qlora_training") args.push("--quantize", "4bit");
logger.log(` [training] ${config.pythonBin} ${args.join(" ")}`);
try {
const { stdout, stderr } = await runner(config.pythonBin, args, {
timeout: config.trainingTimeoutMs,
maxBuffer: 50 * 1024 * 1024,
});
if (stderr) logger.log(` [training] stderr: ${stderr.slice(-500)}`);
const parsed = parseLastJsonLine(stdout);
if (!parsed.ok) logger.error(" [training] Failed to parse JSON output", parsed.error);
const trainResult = parsed.value;
logger.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 as string | undefined) || outputDir,
metrics: trainResult.metrics,
status: trainResult.status,
},
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.error(" [training] failed", err);
return { model: { kind: "error", error: msg } };
}
}
case "register_model":
return { registered_model: { id: "stub" } };
case "deploy_api": {
const modelInput = (inputs.registered_model as Record<string, unknown> | undefined) || (inputs.model as Record<string, unknown> | undefined) || {};
const adapterPath = (modelInput.adapterPath as string | undefined) || (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 (config.dryRun || !adapterPath) {
logger.log(` [deploy] dry-run or no adapter: base=${baseOllamaModel} name=${deployName}`);
return { deployment: { kind: config.dryRun ? "dry-run" : "stub", id: deployName } };
}
const scriptPath = path.join(config.scriptsDir, "ollama-import-adapter.sh");
const args = [
scriptPath,
"--base-model",
baseOllamaModel,
"--adapter-path",
adapterPath,
"--name",
deployName,
];
logger.log(` [deploy] importing to Ollama: ${deployName} from ${baseOllamaModel} + ${adapterPath}`);
try {
const { stdout, stderr } = await runner("/bin/bash", args, { timeout: 120_000 });
if (stderr) logger.log(` [deploy] stderr: ${stderr.slice(-500)}`);
const parsed = parseLastJsonLine(stdout);
if (!parsed.ok) {
logger.error(" [deploy] invalid JSON", parsed.error);
return { deployment: { kind: "error", id: deployName, error: "invalid_json_output" } };
}
const result = parsed.value;
logger.log(` [deploy] result: ${JSON.stringify(result)}`);
return { deployment: { kind: "ollama", id: deployName, ...result } };
} catch (err) {
logger.error(" [deploy] failed", err);
return {
deployment: {
kind: "error",
id: deployName,
error: err instanceof Error ? err.message : String(err),
},
};
}
}
default:
return {};
}
};
}
export interface ExecuteRunDeps {
executeNode: ExecuteNodeFn;
logger: WorkerLogger;
shouldCancel?: () => boolean;
}
export async function executeRun(
run: NodeRun,
registry: NodeEngineRegistry,
deps: ExecuteRunDeps,
): Promise<void> {
const shouldCancel = deps.shouldCancel ?? (() => false);
validateEdgeContracts(run.graphSnapshot, registry);
const sorted = topologicalSort(run.graphSnapshot);
const outputsByNode = new Map<string, Record<string, unknown>>();
run.status = "running";
run.startedAt = createIsoTimestamp();
let cancelled = false;
deps.logger.log(` Executing ${sorted.length} node(s) in topological order`);
for (const node of sorted) {
const step = run.steps.find((entry) => entry.id === node.id);
if (step?.status === "completed") {
deps.logger.log(` [${node.id}] ${node.type} — already completed (recovered)`);
}
}
for (const node of sorted) {
const step = run.steps.find((entry) => entry.id === node.id);
if (!step) continue;
if (step.status === "completed") continue;
if (shouldCancel()) {
cancelled = true;
deps.logger.log(` [${node.id}] ${node.type} — cancelled`);
break;
}
step.status = "running";
step.startedAt = createIsoTimestamp();
deps.logger.log(` [${node.id}] ${node.type} — running`);
try {
const inputs = collectNodeInputs(run.graphSnapshot, node.id, outputsByNode);
const result = await deps.executeNode(node.type, inputs, node.params);
outputsByNode.set(node.id, result);
step.status = "completed";
step.finishedAt = createIsoTimestamp();
step.outputs = Object.keys(result);
deps.logger.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;
deps.logger.error(` [${node.id}] ${node.type} — failed`, err);
break;
}
}
const stepStatuses = run.steps.map((entry) => entry.status);
run.status = resolveFinalStatus(stepStatuses, cancelled);
run.finishedAt = createIsoTimestamp();
}
export async function persistRunStatus(
runRepo: RunRepoLike,
runId: string,
status: RunStatus,
): Promise<void> {
await runRepo.updateStatus(runId, status);
}
export async function processDequeuedRun(
deps: ProcessDequeuedRunDeps,
): Promise<{ kind: "completed" | "missing-run" | "missing-graph" | "failed"; status?: RunStatus }> {
await deps.runRepo.updateStatus(deps.runId, "running");
const dbRun = await deps.runRepo.findById(deps.runId);
if (!dbRun) {
deps.logger.error(`Run ${deps.runId} not found in DB — skipping`);
return { kind: "missing-run" };
}
const graphRecord = await deps.graphRepo.findById(dbRun.graphId);
if (!graphRecord) {
deps.logger.error(`Graph ${dbRun.graphId} for run ${deps.runId} not found — marking failed`);
await persistRunStatus(deps.runRepo, deps.runId, "failed");
return { kind: "missing-graph", status: "failed" };
}
const graph = buildWorkerGraph(graphRecord);
const nodeRun = createWorkerRun(graph, deps.runId, "worker");
try {
await executeRun(nodeRun, deps.registry, {
executeNode: deps.executeNode,
logger: deps.logger,
shouldCancel: deps.shouldCancel,
});
await persistRunStatus(deps.runRepo, deps.runId, nodeRun.status);
return { kind: "completed", status: nodeRun.status };
} catch (err) {
deps.logger.error(`Run ${deps.runId} failed unexpectedly`, err);
await persistRunStatus(deps.runRepo, deps.runId, "failed");
return { kind: "failed", status: "failed" };
}
}
export async function runPollCycle(
params: {
queueState: QueueState;
runRepo: RunRepoLike;
graphRepo: GraphRepoLike;
registry: NodeEngineRegistry;
executeNode: ExecuteNodeFn;
shutdown: ShutdownController;
logger: WorkerLogger;
},
): Promise<RunPollCycleResult> {
const queuedDbRuns = await params.runRepo.listByStatus("queued", 20);
syncQueuedRuns(params.queueState, queuedDbRuns);
const processedRunIds: string[] = [];
while (canDequeue(params.queueState) && !params.shutdown.isShutdownRequested()) {
const runId = dequeue(params.queueState);
if (!runId) break;
processedRunIds.push(runId);
params.logger.log(`Dequeued run: ${runId}`);
try {
await processDequeuedRun({
runId,
runRepo: params.runRepo,
graphRepo: params.graphRepo,
registry: params.registry,
executeNode: params.executeNode,
logger: params.logger,
shouldCancel: () => params.shutdown.isShutdownRequested(),
});
} finally {
markComplete(params.queueState, runId);
}
}
return { queuedDbRuns: queuedDbRuns.length, processedRunIds };
}
export async function waitForNextPollTick(ms: number): Promise<void> {
await delay(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 ═══"
+119 -3
View File
@@ -45,7 +45,7 @@ services:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3333/api/ping"]
test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3333/api/ping').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))\""]
interval: 15s
timeout: 5s
retries: 3
@@ -72,17 +72,25 @@ 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"
COQUI_TOS_AGREED: "1"
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
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3333/api/v2/health"]
test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3333/api/v2/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))\""]
interval: 15s
timeout: 5s
retries: 3
@@ -220,6 +228,114 @@ services:
api:
condition: service_healthy
# -------------------------------------------------------------------------
# Chatterbox TTS — GPU-accelerated voice synthesis (Docker)
# Usage: docker compose --profile v2 up -d
# API: POST /tts, POST /v1/audio/speech
# -------------------------------------------------------------------------
chatterbox:
image: ghcr.io/devnen/chatterbox-tts-server:latest
restart: unless-stopped
profiles: [v2]
ports:
- "${CHATTERBOX_PORT:-9200}:8004"
volumes:
- ./data/voice-samples:/app/voices:ro
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
healthcheck:
test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:8004/get_predefined_voices')\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
# -------------------------------------------------------------------------
# TTS Sidecar — Proxy to Chatterbox + Piper fallback
# Runs on host network, proxies /synthesize to Chatterbox :9200
# -------------------------------------------------------------------------
tts-sidecar:
build: .
restart: unless-stopped
profiles: [v2]
network_mode: host
command: ["python3", "scripts/tts-server.py", "--port", "9100", "--backend", "chatterbox-remote"]
environment:
CHATTERBOX_URL: "http://127.0.0.1:9200"
PIPER_VOICE_DIR: "/app/data/piper-voices"
KXKM_VOICE_SAMPLES_DIR: "/app/data/voice-samples"
volumes:
- ./data:/app/data
- ./scripts:/app/scripts:ro
depends_on:
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
# Web UI: http://localhost:9621
# -------------------------------------------------------------------------
lightrag:
build:
context: .
dockerfile_inline: |
FROM python:3.12-slim
RUN pip install --no-cache-dir 'lightrag-hku[api]'
EXPOSE 9621
CMD ["lightrag-server", "--host", "0.0.0.0", "--port", "9621"]
restart: unless-stopped
profiles: [v2]
network_mode: host
environment:
LLM_MODEL: "qwen3:8b"
EMBEDDING_MODEL: "nomic-embed-text"
EMBEDDING_DIM: "768"
OLLAMA_HOST: "http://localhost:11434"
LLM_BINDING: ollama
EMBEDDING_BINDING: ollama
RAG_DIR: "/data/lightrag"
command: >
lightrag-server
--host 0.0.0.0
--port 9621
--working-dir /data/lightrag
--llm-binding ollama
--embedding-binding ollama
volumes:
- ./data/lightrag:/data/lightrag
healthcheck:
test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:9621/health')\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
volumes:
app-data:
pg-data:
+170 -33
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,24 +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 — Embeddings cosine]
RAG[rag.ts — LightRAG + local fallback]
REST[Routes REST — session, personas, media]
end
subgraph Services["Services"]
OLLAMA[Ollama qwen3:8b mistral gemma3]
TTS[TTS Server piper :9100]
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"]
@@ -41,17 +49,25 @@ 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 -- "TTS" --> MULTI --> TTS
LLM -- "Vision" --> MULTI --> OLLAMA
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 qwen3-vl:8b" --> MULTI --> OLLAMA
RAG -- "query hybrid" --> LRAG --> OLLAMA
CMD -- "/imagine" --> COMFY
CMD -- "/web" --> SEARX
CMD -- "save" --> MSTORE
@@ -59,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
@@ -138,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 |
@@ -148,10 +284,10 @@ mindmap
| packages/persona-domain | 988 | 259 | Personas, feedback, editorial |
| packages/node-engine | 1499 | 605 | DAG execution, training |
| packages/storage | 1219 | 669 | PostgreSQL repos |
| packages/ui | 134 | 0 | Theme, colors, CSS vars |
| packages/ui | 134 | 29 | Theme, colors, CSS vars |
| packages/tui | 209 | 108 | ANSI formatting, tables |
| scripts | 37 fichiers | - | TTS, training, migration |
| **Total** | **~15600** | **~3200** | |
| **Total** | **~15600** | **417 tests** | |
## Bugs critiques identifiés (audit 2026-03-18)
@@ -177,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 |
+35
View File
@@ -0,0 +1,35 @@
# BGE-M3 Benchmark - 2026-03-17
## Etat local confirme
- Le RAG courant utilise encore des embeddings locaux via Ollama dans `apps/api/src/rag.ts`, avec `nomic-embed-text` par defaut.
- Le repo contient deja un bench local `scripts/bench-embeddings.js`.
- Le health check `scripts/health-embeddings.sh` sonde Ollama et detecte la presence ou non de `bge-m3`.
## Etat machine observe
- `npm run -s smoke:embeddings` passe.
- `ollama pull bge-m3` a ete execute avec succes; `bge-m3:latest` est maintenant present localement.
- `bash scripts/health-embeddings.sh --strict` passe et confirme la presence de `bge-m3`.
- Le benchmark local resout maintenant correctement les noms de modeles tagges `:latest` et remonte les erreurs Ollama par modele.
- Sur cette machine Apple/Metal, `bge-m3:latest` echoue au chargement avec une erreur `ggml_metal_init` / `MTLLibraryErrorDomain`.
- Sur cette meme machine, `nomic-embed-text:latest` et `qwen3-embedding:0.6b` retournent aussi des `500` Ollama de chargement de modele, donc le benchmark numerique ne peut pas etre compare localement ici.
## Commandes utiles
- `npm run -s smoke:embeddings`
- `bash scripts/health-embeddings.sh --strict`
- `node scripts/bench-embeddings.js --models bge-m3 --json-only`
- `node scripts/bench-embeddings.js --models qwen3-embedding:0.6b,bge-m3 --json-only`
## Decision actuelle
1. Invalider `bge-m3` comme upgrade local sur cette machine macOS/Metal tant que le runner Ollama termine sur `ggml_metal_init`.
2. Conserver la baseline applicative actuelle et ne pas changer `apps/api/src/rag.ts` sur la base de ce host.
3. Si on veut requalifier `bge-m3`, le refaire sur une cible Linux/CPU ou Linux/CUDA, pas sur ce host Apple/Metal.
## Sources officielles
- BGE-M3 model card: https://huggingface.co/BAAI/bge-m3
- FlagEmbedding repository: https://github.com/FlagOpen/FlagEmbedding
- Ollama embedding models overview: https://ollama.com/blog/embedding-models
+45
View File
@@ -0,0 +1,45 @@
# Documents / Search Spike - 2026-03-17
## Etat local confirme
- `apps/api/src/web-search.ts` tente deja `SearXNG` en premier, puis `WEB_SEARCH_API_BASE`, puis DuckDuckGo.
- `docker-compose.yml` expose deja un service `searxng` sous le profil `v2`.
- `ops/v2/searxng/settings.yml` versionne maintenant la config locale pour autoriser `format=json`.
- `apps/api/src/ws-upload-handler.ts` envoie deja les PDFs vers `scripts/extract_pdf_docling.py`.
- `scripts/extract_pdf_docling.py` sait utiliser `Docling`, puis `PyMuPDF` en fallback.
- `scripts/extract_document.py` couvre deja les formats bureautiques hors PDF.
- `MinerU` n'est pas encore branche runtime; il reste un spike adjacent.
## Gaps reels
- Pas de check ops unifie pour distinguer `seam pret` et `service/dependance effectivement provisionne`.
- Pas de branchement runtime MinerU dans le pipeline upload.
- Pas de verification CI/ops legere pour la presence des deps `docling`, `fitz` ou `magic_pdf`.
## Recommandation minimale
1. Garder `SearXNG` comme backend prioritaire, avec fallback visible et explicite.
2. Traiter `Docling` comme premier parseur PDF local, puis `PyMuPDF` en repli.
3. Garder `MinerU` au stade spike jusqu'a preuve de valeur sur des PDFs complexes.
4. Utiliser un health check ops non destructif avant toute activation stricte en runtime.
## Commandes utiles
- `npm run smoke:documents-search`
- `bash scripts/health-doc-search.sh all --verbose`
- `bash scripts/health-doc-search.sh search --strict`
- `bash scripts/health-doc-search.sh docs`
- `docker compose --profile v2 config --services`
## Etat machine observe
- `docker compose --profile v2 config --services` expose bien `searxng`, `api` et `worker`.
- Sur cette machine, `SearXNG` tourne sur `http://localhost:8080` et `bash scripts/health-doc-search.sh search --strict` est vert.
- Les modules Python `docling`, `fitz` et `magic_pdf` ne sont pas provisionnes actuellement.
## Sources officielles
- SearXNG docs: https://docs.searxng.org/
- SearXNG GitHub: https://github.com/searxng/searxng
- Docling docs: https://docling-project.github.io/docling/
- MinerU repo: https://github.com/opendatalab/MinerU
+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)
+69
View File
@@ -0,0 +1,69 @@
# EXECUTION STATUS (kxkm-clown-v2)
Updated: 2026-03-17T22:13:03Z
## lot-0-cadrage
- Status: done
- Owner: Coordinateur
- Execution: managed
- Checks: docs-reviewed
- Open tasks: none
## lot-1-socle
- Status: done
- Owner: Coordinateur
- Execution: managed
- Checks: npm run check:v2, npm run test:v2
- Open tasks: none
## lot-2-domaines
- Status: done
- Owner: Backend API
- Execution: managed
- Checks: npm run test:v2
- Open tasks: none
## lot-3-surfaces
- Status: done
- Owner: Frontend
- Execution: managed
- Checks: npm run -w @kxkm/web check
- Open tasks: none
## lot-4-bascule
- Status: done
- Owner: Coordinateur
- Execution: managed
- Checks: npm run smoke:v2
- Open tasks: none
## 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)
+55
View File
@@ -0,0 +1,55 @@
# OSS Priorities - 2026-03-17
## Decision
Priorite d'integration pour le cycle actuel:
1. `SearXNG` comme backend de recherche self-hosted.
2. `Docling` et `MinerU` comme spike document parsing adjacent.
3. `MCP TypeScript SDK` comme standard d'outillage/personas.
4. `LiveKit Agents JS` comme candidat principal pour le lot voice/WebRTC.
Projets gardes comme benchmarks produit, pas comme dependances embarquees:
- `Open WebUI`
- `LibreChat`
- `AnythingLLM`
## Shortlist
| Projet | Role retenu | Pourquoi maintenant | Source |
| --- | --- | --- | --- |
| SearXNG | integration directe | moteur self-hosted, API JSON, alignement avec la recherche web locale | https://github.com/searxng/searxng , https://docs.searxng.org/ |
| Docling | spike prioritaire | conversion multi-format, OCR, sorties Markdown/JSON, execution locale | https://docling-project.github.io/docling/ |
| MinerU | spike prioritaire | PDF complexes, OCR CPU/GPU, oriente extraction LLM | https://github.com/opendatalab/MinerU |
| MCP TypeScript SDK | integration directe | standardiser tools/resources/prompts cote personas et services | https://github.com/modelcontextprotocol/typescript-sdk |
| LiveKit Agents JS | lot suivant | voice/WebRTC temps reel en Node, bon fit pour la suite voice-mcp | https://github.com/livekit/agents-js |
## Benchmarks produit
| Projet | Usage retenu | Source |
| --- | --- | --- |
| Open WebUI | benchmark UX local/Ollama/RAG | https://github.com/open-webui/open-webui |
| LibreChat | benchmark multi-provider, auth, MCP, memory | https://www.librechat.ai/ , https://github.com/LibreChat-AI |
| AnythingLLM | benchmark workspaces/RAG/agents | https://github.com/Mintplex-Labs/anything-llm |
## Notes d'adoption
- `SearXNG` est le seul candidat a brancher dans le cycle current sans attendre une refonte majeure.
- `Docling` et `MinerU` doivent rester adjacents tant que la boucle `audit -> test -> resume -> sync-docs -> purge` n'est pas stabilisee.
- `LiveKit Agents JS` ne doit pas entrer dans le cycle backend immediat; il reste assigne au lot voice-mcp.
- `BGE-M3` reste un spike benchmark, pas une decision de remplacement immediate.
## Seams prets
| Zone | Etat actuel | Petite action utile maintenant |
| --- | --- | --- |
| Recherche web | `apps/api/src/web-search.ts` tente `SearXNG` puis `WEB_SEARCH_API_BASE` puis DuckDuckGo, et `scripts/mcp-server.js` a deja un fallback SearXNG. | Utiliser `scripts/health-doc-search.sh search --strict` pour valider le endpoint JSON et garder le fallback visible en ops. |
| PDF/doc parsing | `apps/api/src/ws-upload-handler.ts` appelle deja `scripts/extract_pdf_docling.py` pour les PDFs et `scripts/extract_document.py` pour le reste. | Utiliser `scripts/health-doc-search.sh docs` pour verifier les deps `docling`/`PyMuPDF` et la presence du futur chemin `magic_pdf` MinerU. |
| Docs de reference | `docs/DOCUMENT_AI_STATE_OF_ART_2026.md` recommande Docling d'abord, MinerU ensuite. | Laisser le produit inchangé et documenter l'ordre d'adoption avant tout branchement runtime. |
### Commande de preparation
```bash
bash scripts/health-doc-search.sh all --verbose
```
+418
View File
@@ -0,0 +1,418 @@
# Veille OSS -- kxkm_clown / 3615-KXKM
**Date**: 2026-03-19 (mise a jour approfondie)
---
## Top recommandations (impact/effort)
| Priorite | Projet | Usage | URL |
| --- | --- | --- | --- |
| 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 |
---
## 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
- [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)
### 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/)
### 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)
### 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
@@ -0,0 +1,39 @@
# Voice Cloning Validation - 2026-03-17
## Etat local confirme
- Le runtime principal sait deja basculer vers XTTS-v2 quand un sample voix existe: `apps/api/src/ws-multimodal.ts`.
- L'upload et le statut des samples voix existent deja cote admin: `apps/api/src/routes/personas.ts`, `apps/web/src/api.ts`, `apps/web/src/components/PersonaDetail.tsx`.
- Le repo contient deja les scripts XTTS `scripts/tts_clone_voice.py` et `scripts/xtts_clone.py`, plus le fallback Piper `scripts/tts_synthesize.py`.
- Le helper `apps/api/src/voice-samples.ts` unifie maintenant la resolution du nom de fichier entre upload admin et runtime TTS.
- Le health check `scripts/health-voice-clone.sh` fournit un probe non destructif des deps XTTS et des samples.
## Etat machine observe
- `npm run -s smoke:voice-clone` passe et sonde maintenant le meme interpreteur que le runtime API.
- Le runtime local `.venvs/voice-clone` est provisionne en `python3.12` avec `torch`, `coqui-tts`, `piper-tts` et `transformers<5`.
- `scripts/generate-voice-samples.js` consomme maintenant le roster canonique de `apps/api/src/personas-default.ts` et le meme contrat de nommage que le runtime.
- `data/voice-samples/pharmacius.wav` a ete genere avec Piper, et `data/piper-voices/fr_FR-gilles-low.onnx` est present localement.
- Sur `kxkm-ai`, `ffmpeg` est present, `bash scripts/health-voice-clone.sh --json --verbose` remonte `torch=true`, `tts=true`, `piper_module=true`, `coqui_tos_agreed=true`, `cuda=true` et `persona_sample_present=true`.
- Le smoke XTTS non interactif passe maintenant sur `kxkm-ai` avec `COQUI_TOS_AGREED=1 bash scripts/setup-voice-clone.sh all --persona pharmacius --yes --verbose`, et produit un rendu audio valide via `scripts/tts_clone_voice.py`.
## Commandes utiles
- `npm run -s smoke:voice-clone`
- `bash scripts/health-voice-clone.sh --json --verbose`
- `bash scripts/setup-voice-clone.sh bootstrap --yes`
- `bash scripts/setup-voice-clone.sh sample --persona pharmacius --yes`
- `COQUI_TOS_AGREED=1 bash scripts/setup-voice-clone.sh smoke --persona pharmacius --yes`
- `node scripts/generate-voice-samples.js --dry-run --persona SunRa`
## Decision actuelle
1. Garder Piper comme fallback immediat.
2. Considerer le runtime XTTS comme valide sur `kxkm-ai`, avec garde-fous scripts, sample local valide et smoke final vert sous `COQUI_TOS_AGREED=1`.
3. Fermer `voice-cloning-validation` et `lot-13-voice-mcp`; garder Piper comme voie de repli operationnelle.
## Sources officielles
- Coqui XTTS-v2 model card: https://huggingface.co/coqui/XTTS-v2
- Coqui TTS repository: https://github.com/coqui-ai/TTS
- Coqui XTTS streaming server note on `COQUI_TOS_AGREED=1`: https://github.com/coqui-ai/xtts-streaming-server
+42
View File
@@ -0,0 +1,42 @@
# Voice / MCP Spike - 2026-03-17
## Etat local confirme
- `apps/web/src/components/VoiceChat.tsx` est un chat vocal browser-side base sur `MediaRecorder`, `WebSocket` et un flux upload STT.
- `apps/api/src/ws-multimodal.ts` fournit deja le TTS, la synthese vocale par persona et les garde-fous de concurrence.
- `scripts/discord-voice.js` est un rail voice externe distinct, centre Discord, STT Python et TTS Python.
- `scripts/mcp-server.js` utilise maintenant le SDK MCP officiel sur stdio.
- `@modelcontextprotocol/sdk` est actif dans le runtime local et valide par smoke autonome.
- Aucun paquet `@livekit/*` n'est installe dans le workspace pour l'instant.
## Gaps reels
- Pas de runtime LiveKit agent dans le repo.
- Pas de transport browser WebRTC/room/agent pour remplacer le chat vocal WebSocket.
- Pas de health check dedicated pour valider le serveur MCP local de maniere autonome.
## Recommandation minimale
1. Garder `VoiceChat` comme experience browser actuelle tant que le spike LiveKit n'a pas montre un vrai gain.
2. Introduire un agent LiveKit dans un script adjoint seulement apres preuve de valeur, sans toucher au chat WebSocket principal.
3. Conserver le serveur MCP sur le SDK officiel et garder le smoke stdio comme garde-fou de protocole.
4. N'ajouter LiveKit/voice-cloning qu'apres validation d'un vrai besoin runtime.
## Commandes utiles
- `node scripts/mcp-server-smoke.js`
- `node scripts/mcp-server-smoke.js --with-tool-call`
- `node scripts/mcp-server.js`
- `npm run smoke:voice-mcp`
## Etat machine observe
- Le smoke MCP valide `initialize` + `tools/list` sans API locale demarree, avec le SDK officiel.
- Le `tools/call` peut etre force avec `--with-tool-call`, mais il depend alors de `KXKM_API_URL`.
## Sources officielles
- LiveKit Agents JS: https://github.com/livekit/agents-js
- LiveKit Agents docs: https://docs.livekit.io/agents/
- MCP SDK officiel: https://github.com/modelcontextprotocol/typescript-sdk
- MCP SDK docs: https://modelcontextprotocol.io/docs/sdk
+43
View File
@@ -0,0 +1,43 @@
# OPS V2 Status
Updated: 2026-03-19T17:30:00Z
## Lots
| 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 |
## Services (kxkm-ai)
| 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 |
## Tests: 265 (248 pass, 6 fail → fix en cours)
## 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,65 @@
{
"timestamp": "20260317-230624",
"generated_at": "2026-03-17T22:06:31Z",
"files": {
"audit": "/home/kxkm/KXKM_Clown/ops/v2/logs/deep-audit-20260317-230624.json",
"check": "/home/kxkm/KXKM_Clown/ops/v2/logs/check-v2-20260317-230624.log",
"test": "/home/kxkm/KXKM_Clown/ops/v2/logs/test-v2-20260317-230624.log",
"summary": "/home/kxkm/KXKM_Clown/ops/v2/outputs/deep-cycles/summary-20260317-230624.md"
},
"checks": {
"check:v2": "ok",
"test:v2": "ok"
},
"retained_logs": [
"/home/kxkm/KXKM_Clown/ops/v2/logs/deep-audit-20260317-230624.json",
"/home/kxkm/KXKM_Clown/ops/v2/logs/check-v2-20260317-230624.log",
"/home/kxkm/KXKM_Clown/ops/v2/logs/test-v2-20260317-230624.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": "7b4a779c787c08a64f3e9c9de7e65b4e50583d8dfde4da4cb33313f9165ec7eb"
}
}
@@ -0,0 +1,65 @@
{
"timestamp": "20260317-231254",
"generated_at": "2026-03-17T22:13:03Z",
"files": {
"audit": "/home/kxkm/KXKM_Clown/ops/v2/logs/deep-audit-20260317-231254.json",
"check": "/home/kxkm/KXKM_Clown/ops/v2/logs/check-v2-20260317-231254.log",
"test": "/home/kxkm/KXKM_Clown/ops/v2/logs/test-v2-20260317-231254.log",
"summary": "/home/kxkm/KXKM_Clown/ops/v2/outputs/deep-cycles/summary-20260317-231254.md"
},
"checks": {
"check:v2": "ok",
"test:v2": "ok"
},
"retained_logs": [
"/home/kxkm/KXKM_Clown/ops/v2/logs/deep-audit-20260317-231254.json",
"/home/kxkm/KXKM_Clown/ops/v2/logs/check-v2-20260317-231254.log",
"/home/kxkm/KXKM_Clown/ops/v2/logs/test-v2-20260317-231254.log"
],
"counts": {
"security": 0,
"performance": 0,
"large_files": 8
},
"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
}
},
"hotspots": [
"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",
"apps/web/src/components/ChatHistory.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": "e6ccf31c539079d2d9b75f8486dbc3d6e8515f1de0024266acded0e414cb2c46",
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
"test_sha256": "7d564c8c3c34d7757f09f8c4eed722095ace379bfb80d158e28df56959866a83"
}
}

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