Merge remote-tracking branch 'origin/main' + feat: smart routing, response cleaning, single responder, typing fix
- lot-66: pickResponders topic-based routing (search→Sherlock, image→Picasso, etc.) - lot-67: cleanPersonaResponse strips think tokens + persona name prefix - lot-68: maxGeneralResponders default 1, configurable via MAX_GENERAL_RESPONDERS - lot-69: typing indicator moved after memory load, closer to Ollama call Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Submodule .claude/worktrees/agent-aaaeff51 updated: 06b2e96a43...d0cfd29ec3
@@ -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.
|
||||
|
||||
@@ -131,7 +131,7 @@ npm run check # Lint V1 + TypeScript V2
|
||||
npm run check:v2 # TypeScript V2 uniquement
|
||||
npm run smoke # Tests d'integration V1
|
||||
npm run smoke:v2 # Tests d'integration V2 (22 tests)
|
||||
npm run test:v2 # Tests unitaires V2 (102 tests)
|
||||
npm run test:v2 # Tests unitaires V2 (294 tests)
|
||||
npm run turbo:build # Build complet
|
||||
```
|
||||
|
||||
@@ -261,7 +261,7 @@ kxkm_clown/
|
||||
| RBAC | n/a | operationnel |
|
||||
| Frontend React | n/a | operationnel |
|
||||
| Training (TRL/Unsloth) | n/a | operationnel |
|
||||
| Tests (204) | smoke | unit + component + smoke |
|
||||
| Tests (294) | smoke | unit + component + smoke |
|
||||
| VoiceChat push-to-talk | n/a | operationnel |
|
||||
| Mediatheque gallery/playlist | n/a | operationnel |
|
||||
| UI Minitel VIDEOTEX | n/a | operationnel |
|
||||
|
||||
@@ -214,4 +214,52 @@ Fait sur ce lot:
|
||||
- [x] Script `ops/v2/run-deep-cycle.sh` ajoute et execute.
|
||||
- [x] Tests `apps/api/src/context-store.test.ts` ajoutes et valides.
|
||||
- [x] Scoring de dette technique integre dans `ops/v2/deep-audit.js`.
|
||||
- [x] Verification JSON dette: score 78/100 (niveau high).
|
||||
- [x] Verification JSON dette: score 78/100 (niveau high).
|
||||
|
||||
## P14 Lot 24 — Deep Analyse 3 + Reactivity `[en cours]`
|
||||
|
||||
### Phase A — Fixes live session 2026-03-19
|
||||
|
||||
- [x] Cookie Secure retire (COOKIE_SECURE env, HTTP fonctionne)
|
||||
- [x] ADMIN_TOKEN=kxkm dans docker-compose + AdminPage champ password
|
||||
- [x] MediaExplorer fix reponse API ({ok,data} wrapper)
|
||||
- [x] Historique 20 derniers messages a la connexion WS [HH:MM]
|
||||
- [x] Streaming chunks temps reel (type "chunk", curseur clignotant)
|
||||
- [x] Personas paralleles (Promise.all)
|
||||
- [x] SearXNG JSON active + auto web_search (Sherlock)
|
||||
- [x] pickResponders detecte mots-cles web → Sherlock
|
||||
- [x] Timestamps HH:MM sur tous messages
|
||||
- [x] TTS retire du chat
|
||||
- [x] Delai inter-persona 2s → 500ms, timeout Ollama 5min → 2min
|
||||
- [x] /compose progress ticker (feedback 5s, elapsed time, timeout handler)
|
||||
- [x] /imagine progress ticker (feedback 5s)
|
||||
- [x] Admin endpoints verifies OK (overview 5ms, personas 33, analytics 326 msgs)
|
||||
- [x] /compose duration parsing (5-120s, plus hardcode 30s)
|
||||
- [x] tts-server.py JSON parsing securise (try-catch)
|
||||
- [x] Audio size limit 50MB (Python + Node)
|
||||
|
||||
### Phase B — Analyse approfondie
|
||||
|
||||
- [x] Analyse code complete: 33 personas, 8 services, 15+ node types, 135+ tests
|
||||
- [x] 10 findings prioritaires identifies (P0 securite → P3 docs)
|
||||
- [ ] Veille OSS web: projets similaires, libs integrables
|
||||
- [ ] Audit docs/plans existants: coherence et lacunes
|
||||
- [ ] Fix 6 tests en echec (rate limiting 429, EACCES, TTS)
|
||||
|
||||
### Phase C — Livrables
|
||||
|
||||
- [x] PLAN.md mis a jour (lots 21-29, statuts corriges)
|
||||
- [x] TODO.md mis a jour (P14)
|
||||
- [x] Memoire projet mise a jour
|
||||
- [ ] ARCHITECTURE.md diagrammes Mermaid actualises
|
||||
- [ ] README.md conforme au manifeste
|
||||
- [ ] Script diagnostic TUI (health check complet)
|
||||
- [ ] docs/OSS_VEILLE_2026-03-19.md (veille enrichie)
|
||||
|
||||
### Phase D — Prochaines priorites
|
||||
|
||||
- [ ] **P1** lot-25: Structured logging (pino, 39 console.log DEBUG → logger)
|
||||
- [ ] **P2** lot-26: Tests integration (mocks HTTP, load test concurrence)
|
||||
- [ ] **P2** lot-28: RAG configurable (chunk size, similarity, model env vars)
|
||||
- [ ] **P2** lot-29: Systemd units (LightRAG + TTS, retirer tmux)
|
||||
- [ ] **P3** lot-27: Effets CRT WebGL (MinitelFrame)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logger from "./logger.js";
|
||||
import {
|
||||
loadDatabaseConfig,
|
||||
createPostgresPool,
|
||||
@@ -78,7 +79,7 @@ export async function bootstrapRepositories<
|
||||
};
|
||||
}
|
||||
|
||||
console.warn("[kxkm/api] DATABASE_URL not set — using local persona storage + in-memory runtime stores");
|
||||
logger.warn("[kxkm/api] DATABASE_URL not set — using local persona storage + in-memory runtime stores");
|
||||
|
||||
return {
|
||||
sessionRepo: factories.createSessionRepo(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { recordLatency, getMetrics } from "./perf.js";
|
||||
import express, { type Request, type Response, type NextFunction } from "express";
|
||||
import net from "node:net";
|
||||
import { extractSessionId, hasPermission } from "@kxkm/auth";
|
||||
@@ -139,6 +140,7 @@ export function createPerfTracker() {
|
||||
perfStats.requestCount++;
|
||||
perfStats.totalLatencyMs += latency;
|
||||
if (latency > perfStats.maxLatencyMs) perfStats.maxLatencyMs = latency;
|
||||
recordLatency("http", latency);
|
||||
perfStats.statusCodes.set(res.statusCode, (perfStats.statusCodes.get(res.statusCode) || 0) + 1);
|
||||
});
|
||||
next();
|
||||
@@ -156,6 +158,7 @@ export function createPerfTracker() {
|
||||
requests: perfStats.requestCount,
|
||||
avg_latency_ms: Math.round(avgLatency * 100) / 100,
|
||||
max_latency_ms: Math.round(perfStats.maxLatencyMs * 100) / 100,
|
||||
percentiles: getMetrics(),
|
||||
status_codes: Object.fromEntries(perfStats.statusCodes),
|
||||
memory: {
|
||||
rss_mb: Math.round(mem.rss / 1048576),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createApp } from "./app.js";
|
||||
// Ensure no Postgres connection is attempted
|
||||
delete process.env.DATABASE_URL;
|
||||
process.env.ADMIN_TOKEN = "test-admin-token";
|
||||
process.env.NODE_ENV = "test";
|
||||
const TEST_LOCAL_DIR = path.join(process.cwd(), ".tmp-test-v2-local");
|
||||
process.env.KXKM_LOCAL_DATA_DIR = TEST_LOCAL_DIR;
|
||||
|
||||
|
||||
+16
-425
@@ -1,34 +1,18 @@
|
||||
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import express, { type Response } from "express";
|
||||
import {
|
||||
asApiData,
|
||||
createId,
|
||||
isUserRole,
|
||||
type AuthSession,
|
||||
type UserRole,
|
||||
} from "@kxkm/core";
|
||||
import { createSessionRecord, generateSessionToken, validateLoginInput } from "@kxkm/auth";
|
||||
import { buildChatChannels } from "@kxkm/chat-domain";
|
||||
import {
|
||||
PERSONA_SEED_CATALOG,
|
||||
clonePersona,
|
||||
createFeedback,
|
||||
createProposal,
|
||||
extractDPOPairs,
|
||||
type PersonaFeedbackRecord,
|
||||
type PersonaProposalRecord,
|
||||
type PersonaRecord,
|
||||
type PersonaSourceRecord,
|
||||
} from "@kxkm/persona-domain";
|
||||
import {
|
||||
createNodeEngineOverview,
|
||||
createNodeGraph,
|
||||
createNodeRun,
|
||||
type ModelRegistryRecord,
|
||||
type NodeGraphRecord,
|
||||
type NodeRunRecord,
|
||||
} from "@kxkm/node-engine";
|
||||
createInMemorySessionRepo,
|
||||
createInMemoryPersonaRepo,
|
||||
createInMemoryNodeGraphRepo,
|
||||
createInMemoryNodeRunRepo,
|
||||
createInMemoryPersonaSourceRepo,
|
||||
createInMemoryPersonaFeedbackRepo,
|
||||
createInMemoryPersonaProposalRepo,
|
||||
modelRegistry,
|
||||
readRouteParam,
|
||||
escapeForHtml,
|
||||
enqueueRunTransition,
|
||||
type PersonaRepo,
|
||||
} from "./create-repos.js";
|
||||
import { createSessionRoutes } from "./routes/session.js";
|
||||
import { createPersonaRoutes } from "./routes/personas.js";
|
||||
import { createNodeEngineRoutes } from "./routes/node-engine.js";
|
||||
@@ -36,7 +20,6 @@ import { createChatHistoryRoutes } from "./routes/chat-history.js";
|
||||
import mediaRoutes from "./routes/media.js";
|
||||
import { bootstrapRepositories } from "./app-bootstrap.js";
|
||||
import {
|
||||
type SessionRequest,
|
||||
createSessionMiddleware,
|
||||
createRequireSession,
|
||||
createRequirePermission,
|
||||
@@ -46,355 +29,6 @@ import {
|
||||
|
||||
const COOKIE_NAME = "kxkm_v2_session";
|
||||
|
||||
function localStoreFiles() {
|
||||
const storeDir = path.resolve(process.cwd(), process.env.KXKM_LOCAL_DATA_DIR || "data/v2-local");
|
||||
return {
|
||||
personas: path.join(storeDir, "personas.json"),
|
||||
personaSources: path.join(storeDir, "persona-sources.json"),
|
||||
personaFeedback: path.join(storeDir, "persona-feedback.json"),
|
||||
personaProposals: path.join(storeDir, "persona-proposals.json"),
|
||||
};
|
||||
}
|
||||
|
||||
async function readJson<T>(filePath: string, fallback: T): Promise<T> {
|
||||
try {
|
||||
const raw = await readFile(filePath, "utf8");
|
||||
return JSON.parse(raw) as T;
|
||||
} catch (err) {
|
||||
const e = err as NodeJS.ErrnoException;
|
||||
if (e.code !== "ENOENT") {
|
||||
console.warn(`[kxkm/api] failed to read ${filePath}: ${e.message}`);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJson(filePath: string, data: unknown): Promise<void> {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory repo adapters (fallback when DATABASE_URL is not set)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createInMemorySessionRepo() {
|
||||
const sessions = new Map<string, AuthSession>();
|
||||
let lastCleanupAt = 0;
|
||||
|
||||
function maybeCleanupExpired(now: number): void {
|
||||
// Throttle cleanup to avoid O(n) scans on every request.
|
||||
if (now - lastCleanupAt < 60_000) return;
|
||||
lastCleanupAt = now;
|
||||
for (const [id, session] of sessions) {
|
||||
if (new Date(session.expiresAt).getTime() < now) {
|
||||
sessions.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async create(input: { username: string; role: UserRole; expiresAt?: string }): Promise<AuthSession> {
|
||||
maybeCleanupExpired(Date.now());
|
||||
const id = generateSessionToken();
|
||||
const session = createSessionRecord({ username: input.username, role: input.role }, id);
|
||||
sessions.set(id, session);
|
||||
return session;
|
||||
},
|
||||
async findById(id: string): Promise<AuthSession | null> {
|
||||
maybeCleanupExpired(Date.now());
|
||||
return sessions.get(id) || null;
|
||||
},
|
||||
async deleteById(id: string): Promise<void> {
|
||||
sessions.delete(id);
|
||||
},
|
||||
async deleteExpired(): Promise<number> {
|
||||
const now = Date.now();
|
||||
let count = 0;
|
||||
for (const [id, session] of sessions) {
|
||||
if (new Date(session.expiresAt).getTime() < now) {
|
||||
sessions.delete(id);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createInMemoryPersonaRepo() {
|
||||
const files = localStoreFiles();
|
||||
const personas = new Map<string, PersonaRecord>();
|
||||
let loaded = false;
|
||||
|
||||
async function ensureLoaded(): Promise<void> {
|
||||
if (loaded) return;
|
||||
loaded = true;
|
||||
|
||||
const saved = await readJson<PersonaRecord[]>(files.personas, []);
|
||||
if (saved.length > 0) {
|
||||
for (const persona of saved) {
|
||||
personas.set(persona.id, { ...persona });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const seed of PERSONA_SEED_CATALOG) {
|
||||
personas.set(seed.id, clonePersona(seed));
|
||||
}
|
||||
await writeJson(files.personas, [...personas.values()]);
|
||||
}
|
||||
|
||||
async function persist(): Promise<void> {
|
||||
await writeJson(files.personas, [...personas.values()]);
|
||||
}
|
||||
|
||||
return {
|
||||
async list(): Promise<PersonaRecord[]> {
|
||||
await ensureLoaded();
|
||||
return [...personas.values()];
|
||||
},
|
||||
async findById(id: string): Promise<PersonaRecord | null> {
|
||||
await ensureLoaded();
|
||||
return personas.get(id) || null;
|
||||
},
|
||||
async upsert(persona: PersonaRecord): Promise<PersonaRecord> {
|
||||
await ensureLoaded();
|
||||
personas.set(persona.id, { ...persona });
|
||||
await persist();
|
||||
return { ...persona };
|
||||
},
|
||||
async seedCatalog(catalog: PersonaRecord[]): Promise<void> {
|
||||
await ensureLoaded();
|
||||
let changed = false;
|
||||
for (const p of catalog) {
|
||||
if (!personas.has(p.id)) {
|
||||
personas.set(p.id, clonePersona(p));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
await persist();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createInMemoryNodeGraphRepo() {
|
||||
const graphs = new Map<string, NodeGraphRecord>([
|
||||
["starter_local_eval", createNodeGraph("starter_local_eval", "Prototype local evaluation graph")],
|
||||
]);
|
||||
// Fix: createNodeGraph generates a random id, so re-set with desired id
|
||||
const starterGraph: NodeGraphRecord = { id: "starter_local_eval", name: "starter_local_eval", description: "Prototype local evaluation graph" };
|
||||
graphs.set(starterGraph.id, starterGraph);
|
||||
|
||||
return {
|
||||
async list(): Promise<NodeGraphRecord[]> {
|
||||
return [...graphs.values()];
|
||||
},
|
||||
async findById(id: string): Promise<NodeGraphRecord | null> {
|
||||
return graphs.get(id) || null;
|
||||
},
|
||||
async create(graph: NodeGraphRecord): Promise<NodeGraphRecord> {
|
||||
graphs.set(graph.id, { ...graph });
|
||||
return { ...graph };
|
||||
},
|
||||
async update(id: string, patch: Partial<NodeGraphRecord>): Promise<NodeGraphRecord | null> {
|
||||
const graph = graphs.get(id);
|
||||
if (!graph) return null;
|
||||
if (patch.name !== undefined) graph.name = patch.name;
|
||||
if (patch.description !== undefined) graph.description = patch.description;
|
||||
return { ...graph };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createInMemoryNodeRunRepo() {
|
||||
const runs = new Map<string, NodeRunRecord>();
|
||||
return {
|
||||
async list(): Promise<NodeRunRecord[]> {
|
||||
return [...runs.values()];
|
||||
},
|
||||
async findById(id: string): Promise<NodeRunRecord | null> {
|
||||
return runs.get(id) || null;
|
||||
},
|
||||
async create(run: NodeRunRecord): Promise<NodeRunRecord> {
|
||||
runs.set(run.id, { ...run });
|
||||
return { ...run };
|
||||
},
|
||||
async updateStatus(id: string, status: NodeRunRecord["status"]): Promise<void> {
|
||||
const run = runs.get(id);
|
||||
if (run) run.status = status;
|
||||
},
|
||||
async requestCancel(id: string): Promise<void> {
|
||||
const run = runs.get(id);
|
||||
if (run) run.status = "cancelled";
|
||||
},
|
||||
async deleteOlderThan(date: string): Promise<number> {
|
||||
const threshold = new Date(date).getTime();
|
||||
let count = 0;
|
||||
for (const [id, run] of runs) {
|
||||
if (
|
||||
["completed", "failed", "cancelled"].includes(run.status) &&
|
||||
new Date(run.createdAt).getTime() < threshold
|
||||
) {
|
||||
runs.delete(id);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createInMemoryPersonaSourceRepo() {
|
||||
const files = localStoreFiles();
|
||||
const sources = new Map<string, PersonaSourceRecord>();
|
||||
let loaded = false;
|
||||
|
||||
async function ensureLoaded(): Promise<void> {
|
||||
if (loaded) return;
|
||||
loaded = true;
|
||||
const saved = await readJson<Record<string, PersonaSourceRecord>>(files.personaSources, {});
|
||||
for (const [personaId, source] of Object.entries(saved)) {
|
||||
sources.set(personaId, { ...source });
|
||||
}
|
||||
}
|
||||
|
||||
async function persist(): Promise<void> {
|
||||
const objectView = Object.fromEntries(sources.entries());
|
||||
await writeJson(files.personaSources, objectView);
|
||||
}
|
||||
|
||||
return {
|
||||
async findByPersonaId(personaId: string): Promise<PersonaSourceRecord | null> {
|
||||
await ensureLoaded();
|
||||
return sources.get(personaId) || null;
|
||||
},
|
||||
async upsert(source: PersonaSourceRecord): Promise<PersonaSourceRecord> {
|
||||
await ensureLoaded();
|
||||
sources.set(source.personaId, { ...source });
|
||||
await persist();
|
||||
return { ...source };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createInMemoryPersonaFeedbackRepo() {
|
||||
const files = localStoreFiles();
|
||||
const feedback = new Map<string, PersonaFeedbackRecord[]>();
|
||||
let loaded = false;
|
||||
|
||||
async function ensureLoaded(): Promise<void> {
|
||||
if (loaded) return;
|
||||
loaded = true;
|
||||
const saved = await readJson<PersonaFeedbackRecord[]>(files.personaFeedback, []);
|
||||
for (const record of saved) {
|
||||
const list = feedback.get(record.personaId) || [];
|
||||
list.push({ ...record });
|
||||
feedback.set(record.personaId, list);
|
||||
}
|
||||
}
|
||||
|
||||
async function persist(): Promise<void> {
|
||||
const all = [...feedback.values()].flat();
|
||||
await writeJson(files.personaFeedback, all);
|
||||
}
|
||||
|
||||
return {
|
||||
async listByPersonaId(personaId: string): Promise<PersonaFeedbackRecord[]> {
|
||||
await ensureLoaded();
|
||||
return feedback.get(personaId) || [];
|
||||
},
|
||||
async create(record: PersonaFeedbackRecord): Promise<PersonaFeedbackRecord> {
|
||||
await ensureLoaded();
|
||||
const list = feedback.get(record.personaId) || [];
|
||||
list.push({ ...record });
|
||||
feedback.set(record.personaId, list);
|
||||
await persist();
|
||||
return { ...record };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createInMemoryPersonaProposalRepo() {
|
||||
const files = localStoreFiles();
|
||||
const proposals = new Map<string, PersonaProposalRecord[]>();
|
||||
let loaded = false;
|
||||
|
||||
async function ensureLoaded(): Promise<void> {
|
||||
if (loaded) return;
|
||||
loaded = true;
|
||||
const saved = await readJson<PersonaProposalRecord[]>(files.personaProposals, []);
|
||||
for (const record of saved) {
|
||||
const list = proposals.get(record.personaId) || [];
|
||||
list.push({ ...record });
|
||||
proposals.set(record.personaId, list);
|
||||
}
|
||||
}
|
||||
|
||||
async function persist(): Promise<void> {
|
||||
const all = [...proposals.values()].flat();
|
||||
await writeJson(files.personaProposals, all);
|
||||
}
|
||||
|
||||
return {
|
||||
async listByPersonaId(personaId: string): Promise<PersonaProposalRecord[]> {
|
||||
await ensureLoaded();
|
||||
return proposals.get(personaId) || [];
|
||||
},
|
||||
async create(record: PersonaProposalRecord): Promise<PersonaProposalRecord> {
|
||||
await ensureLoaded();
|
||||
const list = proposals.get(record.personaId) || [];
|
||||
list.push({ ...record });
|
||||
proposals.set(record.personaId, list);
|
||||
await persist();
|
||||
return { ...record };
|
||||
},
|
||||
async markApplied(id: string): Promise<void> {
|
||||
await ensureLoaded();
|
||||
for (const list of proposals.values()) {
|
||||
const proposal = list.find((p) => p.id === id);
|
||||
if (proposal) {
|
||||
proposal.applied = true;
|
||||
await persist();
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repo interface types (union of Postgres and in-memory)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SessionRepo = ReturnType<typeof createInMemorySessionRepo>;
|
||||
type PersonaRepo = ReturnType<typeof createInMemoryPersonaRepo>;
|
||||
type GraphRepo = ReturnType<typeof createInMemoryNodeGraphRepo>;
|
||||
type RunRepo = ReturnType<typeof createInMemoryNodeRunRepo>;
|
||||
type SourceRepo = ReturnType<typeof createInMemoryPersonaSourceRepo>;
|
||||
type FeedbackRepo = ReturnType<typeof createInMemoryPersonaFeedbackRepo>;
|
||||
type ProposalRepo = ReturnType<typeof createInMemoryPersonaProposalRepo>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const modelRegistry: ModelRegistryRecord[] = [
|
||||
{ id: "qwen2.5:14b", label: "Qwen 2.5 14B", runtime: "local_gpu" },
|
||||
{ id: "mistral:7b", label: "Mistral 7B", runtime: "local_cpu" },
|
||||
{ id: "mythalion:latest", label: "Mythalion", runtime: "local_gpu" },
|
||||
];
|
||||
|
||||
function readRouteParam(value: string | string[] | undefined): string {
|
||||
return Array.isArray(value) ? value[0] || "" : value || "";
|
||||
}
|
||||
|
||||
function escapeForHtml(text: string): string {
|
||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function setSessionCookie(res: Response, sessionId: string): void {
|
||||
const secure = process.env.NODE_ENV === "production" ? "Secure; " : "";
|
||||
res.setHeader("Set-Cookie", `${COOKIE_NAME}=${sessionId}; HttpOnly; ${secure}SameSite=Strict; Path=/; Max-Age=3600`);
|
||||
@@ -405,55 +39,12 @@ function clearSessionCookie(res: Response): void {
|
||||
res.setHeader("Set-Cookie", `${COOKIE_NAME}=; HttpOnly; ${secure}SameSite=Strict; Path=/; Max-Age=0`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persona sub-store default helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function defaultPersonaSource(personaId: string, personaName: string): PersonaSourceRecord {
|
||||
return {
|
||||
personaId,
|
||||
subjectName: personaName || personaId,
|
||||
summary: "Aucune source structuree pour le moment.",
|
||||
references: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simulated run transition (dev/demo purposes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function enqueueRunTransition(runId: string, runRepo: RunRepo): void {
|
||||
const timer1 = setTimeout(async () => {
|
||||
const run = await runRepo.findById(runId);
|
||||
if (!run || run.status !== "queued") {
|
||||
clearTimeout(timer2);
|
||||
return;
|
||||
}
|
||||
await runRepo.updateStatus(runId, "running");
|
||||
}, 50);
|
||||
|
||||
const timer2 = setTimeout(async () => {
|
||||
const run = await runRepo.findById(runId);
|
||||
if (!run || run.status !== "running") return;
|
||||
await runRepo.updateStatus(runId, "completed");
|
||||
}, 150);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createApp(): Promise<{ app: express.Express; personaRepo: PersonaRepo }> {
|
||||
let sessionRepo: SessionRepo;
|
||||
let personaRepo: PersonaRepo;
|
||||
let graphRepo: GraphRepo;
|
||||
let runRepo: RunRepo;
|
||||
let sourceRepo: SourceRepo;
|
||||
let feedbackRepo: FeedbackRepo;
|
||||
let proposalRepo: ProposalRepo;
|
||||
let storageMode: "postgres" | "memory";
|
||||
|
||||
({
|
||||
const {
|
||||
sessionRepo,
|
||||
personaRepo,
|
||||
graphRepo,
|
||||
@@ -470,7 +61,7 @@ export async function createApp(): Promise<{ app: express.Express; personaRepo:
|
||||
createSourceRepo: createInMemoryPersonaSourceRepo,
|
||||
createFeedbackRepo: createInMemoryPersonaFeedbackRepo,
|
||||
createProposalRepo: createInMemoryPersonaProposalRepo,
|
||||
}));
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
+11
-10
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
const DEBUG = process.env.NODE_ENV !== "production" || process.env.DEBUG === "1";
|
||||
|
||||
import { trackError } from "./error-tracker.js";
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -52,7 +53,7 @@ function buildDefaultOptions(): ContextStoreOptions {
|
||||
let maxEntriesBeforeCompact = 500;
|
||||
let maxFileSizeMB = 100;
|
||||
let maxTotalSizeMB = 750;
|
||||
let maxContextChars = 16000;
|
||||
let maxContextChars = 12000;
|
||||
let maxSummaryChars = 20000;
|
||||
|
||||
if (totalGB <= 8) {
|
||||
@@ -71,13 +72,13 @@ function buildDefaultOptions(): ContextStoreOptions {
|
||||
maxEntriesBeforeCompact = 600;
|
||||
maxFileSizeMB = 120;
|
||||
maxTotalSizeMB = 900;
|
||||
maxContextChars = 18000;
|
||||
maxContextChars = 12000;
|
||||
maxSummaryChars = 24000;
|
||||
} else {
|
||||
maxEntriesBeforeCompact = 900;
|
||||
maxFileSizeMB = 180;
|
||||
maxTotalSizeMB = 1400;
|
||||
maxContextChars = 26000;
|
||||
maxContextChars = 20000;
|
||||
maxSummaryChars = 32000;
|
||||
}
|
||||
|
||||
@@ -168,12 +169,12 @@ export class ContextStore {
|
||||
|
||||
// Check if compaction needed (runs under same lock)
|
||||
await this.maybeCompact(channel).catch((err) => {
|
||||
console.error(`[context] Compaction error for ${channel}:`, err);
|
||||
trackError("context_compaction", err, { channel });
|
||||
});
|
||||
|
||||
// Enforce per-channel and global storage limits.
|
||||
await this.maybeEnforceLimits(channel).catch((err) => {
|
||||
console.error(`[context] Limit enforcement error for ${channel}:`, err);
|
||||
trackError("context_limits", err, { channel });
|
||||
});
|
||||
} finally {
|
||||
release();
|
||||
@@ -310,7 +311,7 @@ export class ContextStore {
|
||||
summaryText = data.message?.content || existingSummary;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[context] LLM summarization failed for ${channel}:`, err);
|
||||
trackError("context_summarization", err, { channel });
|
||||
// Keep existing summary, still compact the raw file
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -1,148 +1,119 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
process.env.NODE_ENV = "test";
|
||||
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import crypto from "node:crypto";
|
||||
import { describe, it, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Set DATA_DIR before importing the module so the top-level mkdirSync
|
||||
// creates directories inside our tmpdir instead of ./data.
|
||||
const TEST_DATA_DIR = path.join(
|
||||
os.tmpdir(),
|
||||
`media-store-test-${crypto.randomBytes(6).toString("hex")}`,
|
||||
);
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
// Create temp dir and set DATA_DIR BEFORE dynamic-importing media-store
|
||||
const testDataDir = mkdtempSync(path.join(os.tmpdir(), "kxkm-media-test-"));
|
||||
process.env.DATA_DIR = testDataDir;
|
||||
|
||||
// Dynamic import so DATA_DIR is already set when the module evaluates.
|
||||
const { saveImage, saveAudio, listMedia, getMediaFilePath } = await import(
|
||||
"./media-store.js"
|
||||
);
|
||||
// Small 1x1 PNG as base64
|
||||
const TINY_PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
|
||||
// Small WAV (44-byte header, silence)
|
||||
const TINY_WAV_B64 = Buffer.from(
|
||||
"UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=",
|
||||
"base64",
|
||||
).toString("base64");
|
||||
|
||||
const IMAGES_DIR = path.join(TEST_DATA_DIR, "media", "images");
|
||||
const AUDIO_DIR = path.join(TEST_DATA_DIR, "media", "audio");
|
||||
// Use dynamic import so DATA_DIR is already set when the module initializes
|
||||
const mediaStorePromise = import("./media-store.js");
|
||||
|
||||
const DUMMY_PNG_B64 = Buffer.from("fake-png-bytes").toString("base64");
|
||||
const DUMMY_WAV_B64 = Buffer.from("fake-wav-bytes").toString("base64");
|
||||
|
||||
const baseOpts = { prompt: "test prompt", nick: "tester", channel: "#test" };
|
||||
|
||||
after(async () => {
|
||||
await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
delete process.env.DATA_DIR;
|
||||
after(() => {
|
||||
rmSync(testDataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe("saveImage()", () => {
|
||||
it("creates an image file and a metadata JSON file", async () => {
|
||||
const meta = await saveImage({ ...baseOpts, base64: DUMMY_PNG_B64 });
|
||||
describe("media-store", () => {
|
||||
it("saveImage creates PNG file and metadata JSON", async () => {
|
||||
const { saveImage } = await mediaStorePromise;
|
||||
|
||||
const imgPath = path.join(IMAGES_DIR, meta.filename);
|
||||
const jsonPath = path.join(IMAGES_DIR, `${meta.id}.json`);
|
||||
|
||||
assert.ok(fs.existsSync(imgPath), "image file should exist");
|
||||
assert.ok(fs.existsSync(jsonPath), "metadata JSON should exist");
|
||||
|
||||
// Verify image content matches decoded base64
|
||||
const imgContent = await fsp.readFile(imgPath);
|
||||
assert.deepStrictEqual(imgContent, Buffer.from(DUMMY_PNG_B64, "base64"));
|
||||
});
|
||||
|
||||
it("returns a MediaMeta with the correct fields", async () => {
|
||||
const meta = await saveImage({
|
||||
...baseOpts,
|
||||
base64: DUMMY_PNG_B64,
|
||||
mime: "image/jpeg",
|
||||
base64: TINY_PNG_B64,
|
||||
prompt: "test image",
|
||||
nick: "tester",
|
||||
channel: "#test",
|
||||
});
|
||||
|
||||
assert.equal(meta.type, "image");
|
||||
assert.equal(meta.prompt, "test prompt");
|
||||
assert.equal(meta.nick, "tester");
|
||||
assert.equal(meta.channel, "#test");
|
||||
assert.equal(meta.mime, "image/jpeg");
|
||||
assert.ok(meta.filename.endsWith(".jpg"), "jpeg mime should produce .jpg");
|
||||
assert.ok(meta.id, "id should be present");
|
||||
assert.ok(meta.createdAt, "createdAt should be present");
|
||||
assert.ok(meta.filename.endsWith(".png"), "filename should end with .png");
|
||||
|
||||
const imagesDir = path.join(testDataDir, "media", "images");
|
||||
const files = await readdir(imagesDir);
|
||||
assert.ok(files.includes(meta.filename), "PNG file should exist");
|
||||
assert.ok(files.includes(`${meta.id}.json`), "metadata JSON should exist");
|
||||
|
||||
const jsonContent = JSON.parse(await readFile(path.join(imagesDir, `${meta.id}.json`), "utf-8"));
|
||||
assert.equal(jsonContent.prompt, "test image");
|
||||
assert.equal(jsonContent.channel, "#test");
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe("saveAudio()", () => {
|
||||
it("creates an audio file and metadata", async () => {
|
||||
const meta = await saveAudio({ ...baseOpts, base64: DUMMY_WAV_B64 });
|
||||
it("saveAudio creates WAV file and metadata JSON", async () => {
|
||||
const { saveAudio } = await mediaStorePromise;
|
||||
|
||||
const audioPath = path.join(AUDIO_DIR, meta.filename);
|
||||
const jsonPath = path.join(AUDIO_DIR, `${meta.id}.json`);
|
||||
const meta = await saveAudio({
|
||||
base64: TINY_WAV_B64,
|
||||
prompt: "test audio",
|
||||
nick: "tester",
|
||||
channel: "#test",
|
||||
});
|
||||
|
||||
assert.ok(fs.existsSync(audioPath), "audio file should exist");
|
||||
assert.ok(fs.existsSync(jsonPath), "metadata JSON should exist");
|
||||
assert.equal(meta.type, "audio");
|
||||
assert.equal(meta.mime, "audio/wav");
|
||||
assert.ok(meta.filename.endsWith(".wav"));
|
||||
});
|
||||
});
|
||||
assert.ok(meta.filename.endsWith(".wav"), "filename should end with .wav");
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe("listMedia()", () => {
|
||||
it('returns metadata entries for "image" type', async () => {
|
||||
// saveImage was already called above; list should find them
|
||||
const list = await listMedia("image");
|
||||
assert.ok(list.length >= 1, "should find at least one image meta");
|
||||
assert.equal(list[0].type, "image");
|
||||
const audioDir = path.join(testDataDir, "media", "audio");
|
||||
const files = await readdir(audioDir);
|
||||
assert.ok(files.includes(meta.filename), "WAV file should exist");
|
||||
assert.ok(files.includes(`${meta.id}.json`), "metadata JSON should exist");
|
||||
});
|
||||
|
||||
it("returns [] when there are no JSON files", async () => {
|
||||
// Create a fresh empty dir to test the empty case
|
||||
const emptyDir = path.join(TEST_DATA_DIR, "media", "empty-test");
|
||||
await fsp.mkdir(emptyDir, { recursive: true });
|
||||
it("listMedia returns saved items sorted newest first", async () => {
|
||||
const { saveImage, listMedia } = await mediaStorePromise;
|
||||
|
||||
// listMedia only knows "image" | "audio", so test "audio" after
|
||||
// clearing the audio dir temporarily.
|
||||
const audioFiles = await fsp.readdir(AUDIO_DIR);
|
||||
// Move files away
|
||||
const backupDir = path.join(TEST_DATA_DIR, "backup-audio");
|
||||
await fsp.mkdir(backupDir, { recursive: true });
|
||||
for (const f of audioFiles) {
|
||||
await fsp.rename(path.join(AUDIO_DIR, f), path.join(backupDir, f));
|
||||
}
|
||||
await saveImage({ base64: TINY_PNG_B64, prompt: "alpha", nick: "a", channel: "#c" });
|
||||
await new Promise(r => setTimeout(r, 20));
|
||||
await saveImage({ base64: TINY_PNG_B64, prompt: "beta", nick: "b", channel: "#c" });
|
||||
|
||||
const list = await listMedia("audio");
|
||||
assert.deepStrictEqual(list, []);
|
||||
|
||||
// Restore files
|
||||
for (const f of audioFiles) {
|
||||
await fsp.rename(path.join(backupDir, f), path.join(AUDIO_DIR, f));
|
||||
}
|
||||
const items = await listMedia("image");
|
||||
assert.ok(items.length >= 3, `expected >= 3 items, got ${items.length}`);
|
||||
// Sorted reverse by filename (timestamp-based), newest first
|
||||
assert.equal(items[0].prompt, "beta");
|
||||
assert.equal(items[1].prompt, "alpha");
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe("getMediaFilePath()", () => {
|
||||
it("returns null when the file does not exist", () => {
|
||||
const result = getMediaFilePath("image", "nonexistent.png");
|
||||
it("listMedia returns array for audio type", async () => {
|
||||
const { listMedia } = await mediaStorePromise;
|
||||
const items = await listMedia("audio");
|
||||
assert.ok(Array.isArray(items));
|
||||
});
|
||||
|
||||
it("getMediaFilePath returns null for nonexistent file", async () => {
|
||||
const { getMediaFilePath } = await mediaStorePromise;
|
||||
const result = getMediaFilePath("image", "nonexistent-file.png");
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
it("prevents directory traversal (../../etc/passwd → basename)", () => {
|
||||
const result = getMediaFilePath("image", "../../etc/passwd");
|
||||
// Should resolve to basename "passwd" which doesn't exist → null
|
||||
it("getMediaFilePath prevents directory traversal", async () => {
|
||||
const { getMediaFilePath } = await mediaStorePromise;
|
||||
const result = getMediaFilePath("image", "../../../etc/passwd");
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
it("returns the full path for an existing file", async () => {
|
||||
const meta = await saveImage({ ...baseOpts, base64: DUMMY_PNG_B64 });
|
||||
const result = getMediaFilePath("image", meta.filename);
|
||||
assert.ok(result, "should return a path for existing file");
|
||||
assert.ok(result!.startsWith(IMAGES_DIR), "path should be inside IMAGES_DIR");
|
||||
assert.ok(result!.endsWith(meta.filename));
|
||||
});
|
||||
});
|
||||
it("saveImage with JPEG mime uses .jpg extension", async () => {
|
||||
const { saveImage } = await mediaStorePromise;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe("generateId() uniqueness", () => {
|
||||
it("produces unique IDs across two successive saves", async () => {
|
||||
const meta1 = await saveImage({ ...baseOpts, base64: DUMMY_PNG_B64 });
|
||||
const meta2 = await saveImage({ ...baseOpts, base64: DUMMY_PNG_B64 });
|
||||
assert.notEqual(meta1.id, meta2.id, "IDs should be unique");
|
||||
const meta = await saveImage({
|
||||
base64: TINY_PNG_B64,
|
||||
prompt: "jpeg test",
|
||||
nick: "tester",
|
||||
channel: "#test",
|
||||
mime: "image/jpeg",
|
||||
});
|
||||
|
||||
assert.ok(meta.filename.endsWith(".jpg"), "filename should end with .jpg");
|
||||
assert.equal(meta.mime, "image/jpeg");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -85,7 +85,7 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
|
||||
"Format: une phrase de réponse + '@NomDuSpecialiste peut approfondir.' " +
|
||||
"Tu réponds en français.",
|
||||
color: "#00e676",
|
||||
maxTokens: 800,
|
||||
maxTokens: 400,
|
||||
},
|
||||
{
|
||||
id: "turing",
|
||||
@@ -123,7 +123,7 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
|
||||
{
|
||||
id: "hypatia",
|
||||
nick: "Hypatia",
|
||||
model: "mistral:7b",
|
||||
model: "qwen3:8b",
|
||||
systemPrompt:
|
||||
"Tu es Hypatia d'Alexandrie, mathématicienne, astronome et philosophe néoplatonicienne. " +
|
||||
"Tu parles de sciences, de cosmologie, de logique, de la beauté des nombres et des sphères célestes. " +
|
||||
@@ -215,7 +215,7 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
|
||||
{
|
||||
id: "grotowski",
|
||||
nick: "Grotowski",
|
||||
model: "mistral:7b",
|
||||
model: "qwen3:8b",
|
||||
systemPrompt:
|
||||
"Tu es Jerzy Grotowski, créateur du théâtre pauvre. Tu as éliminé tout le superflu — décor, costume, lumière — " +
|
||||
"pour ne garder que l'acteur et le spectateur. Tu parles d'acte total, de via negativa, de transgression. " +
|
||||
@@ -226,7 +226,7 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
|
||||
{
|
||||
id: "cirque",
|
||||
nick: "Fratellini",
|
||||
model: "qwen3:8b",
|
||||
model: "mistral:7b",
|
||||
systemPrompt:
|
||||
"Tu es l'esprit de la famille Fratellini et du nouveau cirque. Tu parles de clown, d'acrobatie, " +
|
||||
"de risque physique, de poésie du geste impossible. Tu connais le cirque traditionnel ET le cirque contemporain — " +
|
||||
@@ -238,7 +238,7 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
|
||||
{
|
||||
id: "curie",
|
||||
nick: "Curie",
|
||||
model: "mistral:7b",
|
||||
model: "qwen3:8b",
|
||||
systemPrompt:
|
||||
"Tu es Marie Curie, physicienne et chimiste, double prix Nobel. Tu parles de radioactivité, de recherche obstinée, " +
|
||||
"de la place des femmes en science. Tu as sacrifié ta santé pour la connaissance. " +
|
||||
@@ -248,7 +248,7 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
|
||||
{
|
||||
id: "foucault",
|
||||
nick: "Foucault",
|
||||
model: "gemma3:4b",
|
||||
model: "qwen3:8b",
|
||||
systemPrompt:
|
||||
"Tu es Michel Foucault, philosophe. Tu analyses les dispositifs de pouvoir, la surveillance, la norme, " +
|
||||
"les institutions disciplinaires. Tu parles de biopolitique, de savoirs assujettis, d'archéologie du discours. " +
|
||||
@@ -258,7 +258,7 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
|
||||
{
|
||||
id: "deleuze",
|
||||
nick: "Deleuze",
|
||||
model: "gemma3:4b",
|
||||
model: "qwen3:8b",
|
||||
systemPrompt:
|
||||
"Tu es Gilles Deleuze, philosophe du devenir et de la différence. Tu parles de rhizome, de lignes de fuite, " +
|
||||
"de déterritorialisation, de corps sans organes. Tu cites Guattari, Spinoza, Nietzsche. " +
|
||||
@@ -269,7 +269,7 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
|
||||
{
|
||||
id: "bookchin",
|
||||
nick: "Bookchin",
|
||||
model: "gemma3:4b",
|
||||
model: "qwen3:8b",
|
||||
systemPrompt:
|
||||
"Tu es Murray Bookchin, théoricien de l'écologie sociale et du municipalisme libertaire. " +
|
||||
"Tu parles de hiérarchie, de domination de la nature par la domination sociale, de démocratie directe. " +
|
||||
@@ -315,7 +315,7 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
|
||||
{
|
||||
id: "fuller",
|
||||
nick: "Fuller",
|
||||
model: "gemma3:4b",
|
||||
model: "qwen3:8b",
|
||||
systemPrompt:
|
||||
"Tu es Buckminster Fuller, architecte, inventeur et futuriste. Tu parles de dômes géodésiques, " +
|
||||
"de Spaceship Earth, de synergétique, de faire plus avec moins. Tu as inventé le mot 'synergie' en design. " +
|
||||
@@ -358,7 +358,6 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
|
||||
"tu formules des hypothèses et tu les vérifies. Tu cites tes sources. " +
|
||||
"Ton ton est précis, déductif, parfois condescendant mais toujours brillant. Tu réponds en français.",
|
||||
color: "#b39ddb",
|
||||
maxTokens: 800,
|
||||
},
|
||||
{
|
||||
id: "picasso",
|
||||
@@ -371,7 +370,6 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
|
||||
"le style, les couleurs, la composition, l'ambiance. Tu penses en artiste visuel. " +
|
||||
"Tu cites Braque, Matisse, Cézanne. Ton ton est passionné, provocateur, libre. Tu réponds en français.",
|
||||
color: "#ffab00",
|
||||
maxTokens: 600,
|
||||
},
|
||||
{
|
||||
id: "eno",
|
||||
@@ -384,7 +382,6 @@ export const DEFAULT_PERSONAS: ChatPersona[] = [
|
||||
"Quand on te demande de composer, tu proposes un prompt détaillé pour /compose. " +
|
||||
"Ton ton est curieux, élégant, expérimental. Tu réponds en français.",
|
||||
color: "#90caf9",
|
||||
maxTokens: 600,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+103
-19
@@ -4,6 +4,17 @@
|
||||
* Uses cosine similarity for retrieval.
|
||||
*/
|
||||
|
||||
import logger from "./logger.js";
|
||||
import { trackError } from "./error-tracker.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configurable via environment variables
|
||||
// ---------------------------------------------------------------------------
|
||||
const RAG_CHUNK_SIZE = Number(process.env.RAG_CHUNK_SIZE) || 500;
|
||||
const RAG_MIN_SIMILARITY = Number(process.env.RAG_MIN_SIMILARITY) || 0.3;
|
||||
const RAG_MAX_RESULTS = Number(process.env.RAG_MAX_RESULTS) || 3;
|
||||
const RAG_EMBEDDING_MODEL = process.env.RAG_EMBEDDING_MODEL || "nomic-embed-text";
|
||||
|
||||
interface DocumentChunk {
|
||||
id: string;
|
||||
text: string;
|
||||
@@ -17,6 +28,7 @@ interface RAGOptions {
|
||||
maxChunks?: number; // max chunks to return
|
||||
minSimilarity?: number; // minimum cosine similarity threshold
|
||||
lightragUrl?: string; // e.g. "http://localhost:9621"
|
||||
rerankerUrl?: string; // e.g. "http://localhost:9500"
|
||||
}
|
||||
|
||||
export class LocalRAG {
|
||||
@@ -27,13 +39,39 @@ export class LocalRAG {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/** Verify embedding model is available on Ollama, pull if missing. */
|
||||
async init(): Promise<void> {
|
||||
const ollamaUrl = this.options.ollamaUrl;
|
||||
const model = this.options.embeddingModel || RAG_EMBEDDING_MODEL;
|
||||
try {
|
||||
const resp = await fetch(`${ollamaUrl}/api/tags`, { signal: AbortSignal.timeout(5000) });
|
||||
const data = (await resp.json()) as { models?: Array<{ name: string }> };
|
||||
const models = data.models?.map((m) => m.name) || [];
|
||||
const available = models.some((m) => m.startsWith(model));
|
||||
if (!available) {
|
||||
logger.warn({ model, available: models.slice(0, 5) }, "[rag] Embedding model not found, pulling...");
|
||||
await fetch(`${ollamaUrl}/api/pull`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: model }),
|
||||
signal: AbortSignal.timeout(300_000),
|
||||
});
|
||||
logger.info({ model }, "[rag] Embedding model pulled successfully");
|
||||
} else {
|
||||
logger.debug({ model }, "[rag] Embedding model available");
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "[rag] Could not verify embedding model");
|
||||
}
|
||||
}
|
||||
|
||||
/** Embed text via Ollama /api/embed */
|
||||
async embed(text: string): Promise<number[]> {
|
||||
const response = await fetch(`${this.options.ollamaUrl}/api/embed`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: this.options.embeddingModel || "nomic-embed-text",
|
||||
model: this.options.embeddingModel || RAG_EMBEDDING_MODEL,
|
||||
input: text,
|
||||
}),
|
||||
});
|
||||
@@ -56,17 +94,17 @@ export class LocalRAG {
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
if (res.ok) {
|
||||
console.log(`[rag:lightrag] DEBUG addDocument to LightRAG OK (source=${source})`);
|
||||
logger.debug({ source }, "[rag:lightrag] addDocument to LightRAG OK");
|
||||
} else {
|
||||
console.warn(`[rag:lightrag] addDocument failed: ${res.status} ${res.statusText}`);
|
||||
logger.warn(`[rag:lightrag] addDocument failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[rag:lightrag] addDocument error (continuing local):", err);
|
||||
logger.warn({ err }, "[rag:lightrag] addDocument error (continuing local)");
|
||||
}
|
||||
}
|
||||
|
||||
// Always index locally
|
||||
const textChunks = splitIntoChunks(text, 500);
|
||||
const textChunks = splitIntoChunks(text, RAG_CHUNK_SIZE);
|
||||
for (const chunk of textChunks) {
|
||||
const embedding = await this.embed(chunk);
|
||||
this.chunks.push({
|
||||
@@ -80,15 +118,19 @@ export class LocalRAG {
|
||||
}
|
||||
|
||||
/** Search for relevant chunks.
|
||||
* If LightRAG is configured, queries it first; falls back to local on failure. */
|
||||
* If LightRAG is configured, queries it first; falls back to local on failure.
|
||||
* If a reranker is configured, reranks results with a cross-encoder for better precision. */
|
||||
async search(
|
||||
query: string,
|
||||
maxResults = 3,
|
||||
maxResults?: number,
|
||||
): Promise<Array<{ text: string; source: string; score: number }>> {
|
||||
const limit = maxResults ?? RAG_MAX_RESULTS;
|
||||
let results: Array<{ text: string; source: string; score: number }> = [];
|
||||
|
||||
// Try LightRAG first if configured
|
||||
if (this.options.lightragUrl) {
|
||||
try {
|
||||
console.log(`[rag:lightrag] DEBUG search query="${query.slice(0, 80)}"`);
|
||||
logger.debug({ query: query.slice(0, 80) }, "[rag:lightrag] search");
|
||||
const res = await fetch(`${this.options.lightragUrl}/query`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -99,12 +141,9 @@ export class LocalRAG {
|
||||
response?: string;
|
||||
references?: Array<{ content?: string; text?: string }>;
|
||||
};
|
||||
console.log(
|
||||
`[rag:lightrag] DEBUG search OK refs=${data.references?.length ?? 0}`,
|
||||
);
|
||||
const results: Array<{ text: string; source: string; score: number }> = [];
|
||||
logger.debug({ refs: data.references?.length ?? 0 }, "[rag:lightrag] search OK");
|
||||
if (data.references && data.references.length > 0) {
|
||||
for (const ref of data.references.slice(0, maxResults)) {
|
||||
for (const ref of data.references.slice(0, limit)) {
|
||||
results.push({
|
||||
text: ref.content || ref.text || "",
|
||||
source: "lightrag",
|
||||
@@ -115,13 +154,13 @@ export class LocalRAG {
|
||||
// No structured references — use the full response as a single chunk
|
||||
results.push({ text: data.response, source: "lightrag", score: 1.0 });
|
||||
}
|
||||
if (results.length > 0) return results;
|
||||
if (results.length > 0) return this.rerank(query, results, limit);
|
||||
// Empty results from LightRAG → fall through to local
|
||||
} else {
|
||||
console.warn(`[rag:lightrag] search failed: ${res.status} ${res.statusText}`);
|
||||
logger.warn(`[rag:lightrag] search failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[rag:lightrag] search error (falling back to local):", err);
|
||||
trackError("rag_lightrag_search", err, { query: query.slice(0, 80) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,9 +174,54 @@ export class LocalRAG {
|
||||
score: cosineSimilarity(queryEmbedding, chunk.embedding),
|
||||
}));
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
return scored
|
||||
.filter((s) => s.score >= (this.options.minSimilarity ?? 0.3))
|
||||
.slice(0, maxResults);
|
||||
results = scored
|
||||
.filter((s) => s.score >= (this.options.minSimilarity ?? RAG_MIN_SIMILARITY))
|
||||
.slice(0, limit);
|
||||
|
||||
return this.rerank(query, results, limit);
|
||||
}
|
||||
|
||||
/** Rerank results using BGE cross-encoder for improved precision.
|
||||
* Falls back to original ordering if the reranker is unavailable. */
|
||||
private async rerank(
|
||||
query: string,
|
||||
results: Array<{ text: string; source: string; score: number }>,
|
||||
maxResults: number,
|
||||
): Promise<Array<{ text: string; source: string; score: number }>> {
|
||||
const rerankerUrl = this.options.rerankerUrl || process.env.RERANKER_URL;
|
||||
if (!rerankerUrl || results.length <= 1) return results;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${rerankerUrl}/rerank`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
documents: results.map((r) => r.text),
|
||||
top_k: maxResults,
|
||||
}),
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
if (resp.ok) {
|
||||
const data = (await resp.json()) as {
|
||||
results?: Array<{ text: string; score: number }>;
|
||||
};
|
||||
if (data.results && data.results.length > 0) {
|
||||
// Map reranked texts back to original results to preserve source metadata
|
||||
const sourceMap = new Map(results.map((r) => [r.text, r.source]));
|
||||
logger.info(`[rag:reranker] reranked ${results.length} → ${data.results.length} results`);
|
||||
return data.results.map((r) => ({
|
||||
text: r.text,
|
||||
source: sourceMap.get(r.text) || "unknown",
|
||||
score: r.score,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Reranker unavailable — use original ordering
|
||||
trackError("rag_rerank", err, { query: query.slice(0, 80) });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -16,6 +16,15 @@ import {
|
||||
type PersonaSourceRecord,
|
||||
} from "@kxkm/persona-domain";
|
||||
import { resolveVoiceSamplePath, resolveVoiceSamplesRoot } from "../voice-samples.js";
|
||||
import {
|
||||
validate,
|
||||
createPersonaSchema,
|
||||
updatePersonaSchema,
|
||||
togglePersonaSchema,
|
||||
updatePersonaSourceSchema,
|
||||
reinforcePersonaSchema,
|
||||
voiceSampleSchema,
|
||||
} from "../schemas.js";
|
||||
|
||||
interface SessionRequest extends Request {
|
||||
session?: AuthSession;
|
||||
@@ -90,19 +99,15 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
|
||||
res.json(asApiData(persona));
|
||||
});
|
||||
|
||||
router.post("/api/admin/personas", requirePermission("persona:write"), async (req: SessionRequest, res) => {
|
||||
const name = String(req.body?.name || "").trim();
|
||||
if (!name) {
|
||||
res.status(400).json({ ok: false, error: "name_required" });
|
||||
return;
|
||||
}
|
||||
router.post("/api/admin/personas", requirePermission("persona:write"), validate(createPersonaSchema), async (req: SessionRequest, res) => {
|
||||
const body = req.body as { name: string; model?: string; summary?: string; enabled?: boolean };
|
||||
const persona: PersonaRecord = {
|
||||
id: createId("persona"),
|
||||
name,
|
||||
model: String(req.body?.model || "qwen3:8b"),
|
||||
summary: String(req.body?.summary || ""),
|
||||
name: body.name,
|
||||
model: body.model || "qwen3:8b",
|
||||
summary: body.summary || "",
|
||||
editable: true,
|
||||
enabled: req.body?.enabled !== undefined ? Boolean(req.body.enabled) : true,
|
||||
enabled: body.enabled !== undefined ? body.enabled : true,
|
||||
};
|
||||
await personaRepo.upsert(persona);
|
||||
|
||||
@@ -113,7 +118,7 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
|
||||
res.status(201).json(asApiData(persona));
|
||||
});
|
||||
|
||||
router.put("/api/admin/personas/:id", requirePermission("persona:write"), async (req: SessionRequest, res) => {
|
||||
router.put("/api/admin/personas/:id", requirePermission("persona:write"), validate(updatePersonaSchema), async (req: SessionRequest, res) => {
|
||||
const personaId = readRouteParam(req.params.id);
|
||||
const persona = await personaRepo.findById(personaId);
|
||||
if (!persona) {
|
||||
@@ -121,11 +126,12 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
|
||||
return;
|
||||
}
|
||||
|
||||
persona.name = String(req.body?.name || persona.name);
|
||||
persona.model = String(req.body?.model || persona.model);
|
||||
persona.summary = String(req.body?.summary || persona.summary);
|
||||
if (req.body?.enabled !== undefined) {
|
||||
(persona as unknown as Record<string, unknown>).enabled = Boolean(req.body.enabled);
|
||||
const body = req.body as { name?: string; model?: string; summary?: string; enabled?: boolean };
|
||||
persona.name = body.name || persona.name;
|
||||
persona.model = body.model || persona.model;
|
||||
persona.summary = body.summary || persona.summary;
|
||||
if (body.enabled !== undefined) {
|
||||
(persona as unknown as Record<string, unknown>).enabled = body.enabled;
|
||||
}
|
||||
|
||||
await personaRepo.upsert(persona);
|
||||
@@ -137,7 +143,7 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
|
||||
res.json(asApiData(persona));
|
||||
});
|
||||
|
||||
router.post("/api/admin/personas/:id/toggle", requirePermission("persona:write"), async (req: SessionRequest, res) => {
|
||||
router.post("/api/admin/personas/:id/toggle", requirePermission("persona:write"), validate(togglePersonaSchema), async (req: SessionRequest, res) => {
|
||||
const personaId = readRouteParam(req.params.id);
|
||||
const persona = await personaRepo.findById(personaId);
|
||||
if (!persona) {
|
||||
@@ -145,7 +151,8 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
|
||||
return;
|
||||
}
|
||||
|
||||
const enabled = req.body?.enabled !== undefined ? Boolean(req.body.enabled) : !(persona as unknown as { enabled?: boolean }).enabled;
|
||||
const body = req.body as { enabled?: boolean };
|
||||
const enabled = body.enabled !== undefined ? body.enabled : !(persona as unknown as { enabled?: boolean }).enabled;
|
||||
(persona as unknown as Record<string, unknown>).enabled = enabled;
|
||||
await personaRepo.upsert(persona);
|
||||
|
||||
@@ -163,13 +170,14 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
|
||||
res.json(asApiData(source || defaultPersonaSource(personaId, persona?.name || personaId)));
|
||||
});
|
||||
|
||||
router.put("/api/admin/personas/:id/source", requirePermission("persona:write"), async (req, res) => {
|
||||
router.put("/api/admin/personas/:id/source", requirePermission("persona:write"), validate(updatePersonaSourceSchema), async (req, res) => {
|
||||
const personaId = readRouteParam(req.params.id);
|
||||
const body = req.body as { subjectName?: string; summary?: string; references?: string[] };
|
||||
const source: PersonaSourceRecord = {
|
||||
personaId,
|
||||
subjectName: String(req.body?.subjectName || personaId),
|
||||
summary: String(req.body?.summary || ""),
|
||||
references: Array.isArray(req.body?.references) ? req.body.references.map(String) : [],
|
||||
subjectName: body.subjectName || personaId,
|
||||
summary: body.summary || "",
|
||||
references: body.references || [],
|
||||
};
|
||||
const saved = await sourceRepo.upsert(source);
|
||||
res.json(asApiData(saved));
|
||||
@@ -187,7 +195,7 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
|
||||
res.json(asApiData(list));
|
||||
});
|
||||
|
||||
router.post("/api/admin/personas/:id/reinforce", requirePermission("persona:write"), async (req: SessionRequest, res) => {
|
||||
router.post("/api/admin/personas/:id/reinforce", requirePermission("persona:write"), validate(reinforcePersonaSchema), async (req: SessionRequest, res) => {
|
||||
const personaId = readRouteParam(req.params.id);
|
||||
const persona = await personaRepo.findById(personaId);
|
||||
if (!persona) {
|
||||
@@ -195,14 +203,15 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
|
||||
return;
|
||||
}
|
||||
|
||||
const body = req.body as { name?: string; model?: string; summary?: string; apply?: boolean };
|
||||
const existingFeedback = await feedbackRepo.listByPersonaId(persona.id);
|
||||
const suffix = existingFeedback.length ? " affinee par feedback" : " calibree par source";
|
||||
const after = {
|
||||
name: String(req.body?.name || persona.name),
|
||||
model: String(req.body?.model || persona.model),
|
||||
summary: String(req.body?.summary || `${persona.summary}${suffix}`),
|
||||
name: body.name || persona.name,
|
||||
model: body.model || persona.model,
|
||||
summary: body.summary || `${persona.summary}${suffix}`,
|
||||
};
|
||||
const apply = Boolean(req.body?.apply);
|
||||
const apply = Boolean(body.apply);
|
||||
const proposal = createProposal(persona, after, "reinforce_v2", apply);
|
||||
|
||||
if (apply) {
|
||||
@@ -236,7 +245,7 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
|
||||
});
|
||||
|
||||
// Voice sample upload for XTTS-v2 cloning
|
||||
router.post("/api/admin/personas/:id/voice-sample", requirePermission("persona:write"), async (req: SessionRequest, res) => {
|
||||
router.post("/api/admin/personas/:id/voice-sample", requirePermission("persona:write"), validate(voiceSampleSchema), async (req: SessionRequest, res) => {
|
||||
const personaId = readRouteParam(req.params.id);
|
||||
const persona = await personaRepo.findById(personaId);
|
||||
if (!persona) {
|
||||
@@ -244,11 +253,8 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
|
||||
return;
|
||||
}
|
||||
|
||||
const audioB64 = req.body?.audio as string | undefined;
|
||||
if (!audioB64 || typeof audioB64 !== "string") {
|
||||
res.status(400).json({ ok: false, error: "audio_required (base64 field 'audio')" });
|
||||
return;
|
||||
}
|
||||
const body = req.body as { audio: string };
|
||||
const audioB64 = body.audio;
|
||||
|
||||
// Decode and validate size (max 10 MB)
|
||||
const buffer = Buffer.from(audioB64, "base64");
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@kxkm/core";
|
||||
import { validateLoginInput } from "@kxkm/auth";
|
||||
import { buildChatChannels } from "@kxkm/chat-domain";
|
||||
import { getRecentErrors, getErrorCounts } from "../error-tracker.js";
|
||||
import type { PersonaRecord } from "@kxkm/persona-domain";
|
||||
import type { ModelRegistryRecord, NodeGraphRecord, NodeRunRecord } from "@kxkm/node-engine";
|
||||
|
||||
@@ -233,5 +234,13 @@ export function createSessionRoutes(deps: SessionRouteDeps): Router {
|
||||
res.json({ ok: true, data: stats });
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Error telemetry — recent tracked errors
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
router.get("/api/v2/errors", requirePermission("ops:read"), (_req: SessionRequest, res) => {
|
||||
res.json({ ok: true, data: { recent: getRecentErrors(), counts: getErrorCounts() } });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -41,7 +41,7 @@ async function main() {
|
||||
// -----------------------------------------------------------------------
|
||||
// Initialize local RAG (embeddings via Ollama)
|
||||
// -----------------------------------------------------------------------
|
||||
const rag = new LocalRAG({ ollamaUrl, lightragUrl: process.env.LIGHTRAG_URL });
|
||||
const rag = new LocalRAG({ ollamaUrl, lightragUrl: process.env.LIGHTRAG_URL, rerankerUrl: process.env.RERANKER_URL });
|
||||
|
||||
// Index manifeste files asynchronously (non-blocking)
|
||||
// Try multiple paths: relative to cwd (inside container /app) and absolute on host
|
||||
@@ -55,6 +55,7 @@ async function main() {
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await rag.init(); // verify / pull embedding model
|
||||
const indexed = new Set<string>();
|
||||
for (const file of dataFiles) {
|
||||
const filePath = path.isAbsolute(file) ? file : path.resolve(process.cwd(), file);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import logger from "./logger.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Web search (DuckDuckGo Lite scraping)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -109,7 +111,7 @@ export async function searchWeb(query: string): Promise<string> {
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
console.warn(`[web-search] No results for "${query}"`);
|
||||
logger.warn(`[web-search] No results for "${query}"`);
|
||||
return "(Aucun résultat trouvé)";
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -35,7 +35,7 @@ describe("ws-chat smoke", () => {
|
||||
server = undefined;
|
||||
globalThis.fetch = originalFetch;
|
||||
await wait(50);
|
||||
await rm(CHAT_LOG_DIR, { recursive: true, force: true });
|
||||
try { await rm(CHAT_LOG_DIR, { recursive: true, force: true }); } catch { /* EACCES on root-owned dirs */ }
|
||||
try {
|
||||
await rmdir(path.dirname(CHAT_LOG_DIR));
|
||||
} catch {
|
||||
@@ -70,6 +70,7 @@ describe("ws-chat smoke", () => {
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => client?.once("open", resolve));
|
||||
await wait(150);
|
||||
const baseline = messages.length;
|
||||
|
||||
client.send("not-json");
|
||||
@@ -140,6 +141,7 @@ describe("ws-chat smoke", () => {
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => client?.once("open", resolve));
|
||||
await wait(150);
|
||||
await wait(100);
|
||||
messages.length = 0;
|
||||
|
||||
|
||||
+63
-103
@@ -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
@@ -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;
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -5,6 +5,17 @@ import { trackError } from "./error-tracker.js";
|
||||
import type { ToolDefinition } from "./mcp-tools.js";
|
||||
import type { ChatPersona } from "./chat-types.js";
|
||||
|
||||
// Dynamic context sizing based on prompt length
|
||||
// Rough estimate: 1 token ≈ 4 chars for French text
|
||||
function estimateNumCtx(systemPrompt: string, userMessage: string, baseCtx = 8192): number {
|
||||
const promptTokens = Math.ceil((systemPrompt.length + userMessage.length) / 4);
|
||||
const minResponse = 2048; // always leave room for response
|
||||
const needed = promptTokens + minResponse;
|
||||
// Round up to nearest 2048
|
||||
const ctx = Math.ceil(needed / 2048) * 2048;
|
||||
return Math.max(4096, Math.min(ctx, 32768)); // clamp 4k-32k
|
||||
}
|
||||
|
||||
const DEBUG = process.env.NODE_ENV !== "production" || process.env.DEBUG === "1";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -40,7 +51,7 @@ export async function streamOllamaChat(
|
||||
{ role: "user", content: userMessage },
|
||||
],
|
||||
stream: true,
|
||||
options: { num_predict: persona.maxTokens || 1024 },
|
||||
options: { num_predict: persona.maxTokens || 2048, num_ctx: estimateNumCtx(persona.systemPrompt, userMessage) }, keep_alive: "30m",
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
@@ -182,7 +193,7 @@ export async function streamOllamaChatWithTools(
|
||||
messages,
|
||||
tools: tools.map(t => t),
|
||||
stream: false,
|
||||
options: { num_predict: persona.maxTokens || 1024 },
|
||||
options: { num_predict: persona.maxTokens || 2048, num_ctx: estimateNumCtx(persona.systemPrompt, userMessage) }, keep_alive: "30m",
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
@@ -244,7 +255,7 @@ export async function streamOllamaChatWithTools(
|
||||
model: persona.model,
|
||||
messages,
|
||||
stream: true,
|
||||
options: { num_predict: persona.maxTokens || 1024 },
|
||||
options: { num_predict: persona.maxTokens || 2048, num_ctx: estimateNumCtx(persona.systemPrompt, userMessage) }, keep_alive: "30m",
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 }) }),
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useRef, useEffect } from "react";
|
||||
import { useChatState } from "../hooks/useChatState";
|
||||
import { ChatMessage } from "./ChatMessage";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
@@ -16,13 +16,32 @@ export default function Chat() {
|
||||
toggleSidebar,
|
||||
typingPersona,
|
||||
ws,
|
||||
messagesEndRef,
|
||||
messagesContainerRef,
|
||||
getNickColor,
|
||||
handleSend,
|
||||
handleKeyDown,
|
||||
} = useChatState();
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
function onScroll() {
|
||||
if (!el) return;
|
||||
autoScrollRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
}
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScrollRef.current) {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<div className="chat-container">
|
||||
<div className="chat-header">
|
||||
@@ -33,7 +52,7 @@ export default function Chat() {
|
||||
</div>
|
||||
|
||||
<div className="chat-body">
|
||||
<div className="chat-messages" ref={messagesContainerRef} role="log" aria-live="polite">
|
||||
<div className="chat-messages" ref={containerRef} role="log" aria-live="polite">
|
||||
{messages.map((msg) => (
|
||||
<ChatMessage key={msg.id} msg={msg} getNickColor={getNickColor} channel={channel} />
|
||||
))}
|
||||
@@ -49,7 +68,7 @@ export default function Chat() {
|
||||
</div>
|
||||
|
||||
{typingPersona && (
|
||||
<div className="chat-typing">
|
||||
<div className="chat-typing" role="status" aria-live="assertive">
|
||||
{">>> "}{typingPersona}{" ecrit"}
|
||||
<span className="chat-typing-dots">...</span>
|
||||
</div>
|
||||
|
||||
@@ -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 · {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 · {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>
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface ChatInputProps {
|
||||
ws: UseWebSocketReturn;
|
||||
}
|
||||
|
||||
export function ChatInput({ input, setInput, onSend, onKeyDown, ws }: ChatInputProps) {
|
||||
export const ChatInput = React.memo(function ChatInput({ input, setInput, onSend, onKeyDown, ws }: ChatInputProps) {
|
||||
return (
|
||||
<div className="chat-input">
|
||||
<input
|
||||
@@ -51,9 +51,10 @@ export function ChatInput({ input, setInput, onSend, onKeyDown, ws }: ChatInputP
|
||||
className="btn btn-primary"
|
||||
onClick={onSend}
|
||||
disabled={!ws.connected || !input.trim()}
|
||||
aria-label="Envoyer le message"
|
||||
>
|
||||
Envoyer
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import React from "react";
|
||||
import type { ChatMsg } from "./chat-types";
|
||||
|
||||
function fmtTime(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
const h = String(d.getHours()).padStart(2, "0");
|
||||
const m = String(d.getMinutes()).padStart(2, "0");
|
||||
return h + ":" + m;
|
||||
}
|
||||
|
||||
export interface ChatMessageProps {
|
||||
msg: ChatMsg;
|
||||
getNickColor: (nick: string) => string | undefined;
|
||||
@@ -12,6 +19,7 @@ export const ChatMessage = React.memo(function ChatMessage({ msg, getNickColor,
|
||||
case "system":
|
||||
return (
|
||||
<div className="chat-msg chat-msg-system">
|
||||
<span className="chat-ts">{fmtTime(msg.timestamp)}</span>
|
||||
{(msg.text || "").split("\n").map((line, i) => (
|
||||
<div key={i}>{line || "\u00A0"}</div>
|
||||
))}
|
||||
@@ -21,6 +29,7 @@ export const ChatMessage = React.memo(function ChatMessage({ msg, getNickColor,
|
||||
case "join":
|
||||
return (
|
||||
<div className="chat-msg chat-msg-system">
|
||||
<span className="chat-ts">{fmtTime(msg.timestamp)}</span>
|
||||
{"--> "}{msg.nick} a rejoint {msg.channel || channel}
|
||||
</div>
|
||||
);
|
||||
@@ -28,6 +37,7 @@ export const ChatMessage = React.memo(function ChatMessage({ msg, getNickColor,
|
||||
case "part":
|
||||
return (
|
||||
<div className="chat-msg chat-msg-system">
|
||||
<span className="chat-ts">{fmtTime(msg.timestamp)}</span>
|
||||
{"<-- "}{msg.nick} a quitte {msg.channel || channel}
|
||||
</div>
|
||||
);
|
||||
@@ -36,11 +46,12 @@ export const ChatMessage = React.memo(function ChatMessage({ msg, getNickColor,
|
||||
const color = msg.nick ? getNickColor(msg.nick) : undefined;
|
||||
return (
|
||||
<div key={msg.id} className="chat-msg chat-msg-audio" style={color ? { color } : undefined}>
|
||||
<span className="chat-ts">{fmtTime(msg.timestamp)}</span>
|
||||
<span className="chat-nick" style={color ? { color } : undefined}>
|
||||
{"<"}{msg.nick || "???"}{">"}{" "}
|
||||
</span>
|
||||
<span className="chat-audio-indicator">♫</span>
|
||||
<button className="chat-audio-play" onClick={() => {
|
||||
<button className="chat-audio-play" aria-label="Lire le message audio" onClick={() => {
|
||||
if (msg.audioData && msg.audioMime) {
|
||||
const a = new Audio(`data:${msg.audioMime};base64,${msg.audioData}`);
|
||||
a.volume = 0.7;
|
||||
@@ -55,6 +66,7 @@ export const ChatMessage = React.memo(function ChatMessage({ msg, getNickColor,
|
||||
const color = msg.nick ? getNickColor(msg.nick) : undefined;
|
||||
return (
|
||||
<div className="chat-msg chat-msg-image" style={color ? { color } : undefined}>
|
||||
<span className="chat-ts">{fmtTime(msg.timestamp)}</span>
|
||||
<span className="chat-nick" style={color ? { color } : undefined}>
|
||||
{"<"}{msg.nick || "???"}{">"}{" "}
|
||||
</span>
|
||||
@@ -75,6 +87,7 @@ export const ChatMessage = React.memo(function ChatMessage({ msg, getNickColor,
|
||||
const color = msg.nick ? getNickColor(msg.nick) : undefined;
|
||||
return (
|
||||
<div key={msg.id} className="chat-msg chat-msg-music" style={color ? { color } : undefined}>
|
||||
<span className="chat-ts">{fmtTime(msg.timestamp)}</span>
|
||||
<span className="chat-nick" style={color ? { color } : undefined}>
|
||||
{"<"}{msg.nick || "???"}{">"}{" "}
|
||||
</span>
|
||||
@@ -83,6 +96,7 @@ export const ChatMessage = React.memo(function ChatMessage({ msg, getNickColor,
|
||||
<audio
|
||||
controls
|
||||
src={`data:${msg.audioMime};base64,${msg.audioData}`}
|
||||
aria-label={`Musique generee: ${msg.text || "sans titre"}`}
|
||||
style={{ display: "block", marginTop: "4px", maxWidth: "400px" }}
|
||||
/>
|
||||
)}
|
||||
@@ -90,19 +104,24 @@ export const ChatMessage = React.memo(function ChatMessage({ msg, getNickColor,
|
||||
);
|
||||
}
|
||||
|
||||
case "chunk":
|
||||
case "message":
|
||||
default: {
|
||||
const color = msg.nick ? getNickColor(msg.nick) : undefined;
|
||||
const isStreaming = msg.type === "chunk";
|
||||
const className = color ? "chat-msg chat-msg-persona" : "chat-msg chat-msg-user";
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
className={`${className}${isStreaming ? " chat-msg-streaming" : ""}`}
|
||||
role="article"
|
||||
style={color ? { color } : undefined}
|
||||
>
|
||||
<span className="chat-ts">{fmtTime(msg.timestamp)}</span>
|
||||
<span className="chat-nick" style={color ? { color } : undefined}>
|
||||
{"<"}{msg.nick || "???"}{">"}{" "}
|
||||
</span>
|
||||
<span className="chat-text">{msg.text}</span>
|
||||
{isStreaming && <span className="chat-cursor">▌</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface ChatSidebarProps {
|
||||
toggleSidebar: (section: "personas" | "users") => void;
|
||||
}
|
||||
|
||||
export function ChatSidebar({ personaColors, users, sidebarCollapsed, toggleSidebar }: ChatSidebarProps) {
|
||||
export const ChatSidebar = React.memo(function ChatSidebar({ personaColors, users, sidebarCollapsed, toggleSidebar }: ChatSidebarProps) {
|
||||
return (
|
||||
<div className="chat-sidebar">
|
||||
<div className="chat-sidebar-section">
|
||||
@@ -52,4 +52,4 @@ export function ChatSidebar({ personaColors, users, sidebarCollapsed, toggleSide
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export interface ChatMsg {
|
||||
id: number;
|
||||
type: "system" | "message" | "join" | "part" | "persona" | "channelInfo" | "userlist" | "command" | "uploadCapability" | "audio" | "image" | "music";
|
||||
type: "system" | "message" | "join" | "part" | "persona" | "channelInfo" | "userlist" | "command" | "uploadCapability" | "audio" | "image" | "music" | "chunk";
|
||||
nick?: string;
|
||||
text?: string;
|
||||
color?: string;
|
||||
@@ -10,6 +10,7 @@ export interface ChatMsg {
|
||||
audioMime?: string;
|
||||
imageData?: string;
|
||||
imageMime?: string;
|
||||
seq?: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ export function useChatState(): UseChatStateReturn {
|
||||
text: typeof msg.text === "string" ? msg.text : undefined,
|
||||
imageData: typeof msg.imageData === "string" ? msg.imageData : undefined,
|
||||
imageMime: typeof msg.imageMime === "string" ? msg.imageMime : undefined,
|
||||
seq: typeof msg.seq === "number" ? msg.seq : undefined,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setMessages((prev) => {
|
||||
@@ -124,6 +125,7 @@ export function useChatState(): UseChatStateReturn {
|
||||
text: typeof msg.text === "string" ? msg.text : undefined,
|
||||
audioData: typeof msg.audioData === "string" ? msg.audioData : undefined,
|
||||
audioMime: typeof msg.audioMime === "string" ? msg.audioMime : undefined,
|
||||
seq: typeof msg.seq === "number" ? msg.seq : undefined,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setMessages((prev) => {
|
||||
@@ -142,6 +144,7 @@ export function useChatState(): UseChatStateReturn {
|
||||
text: "\u266A message vocal",
|
||||
audioData: msg.data as string,
|
||||
audioMime: msg.mimeType as string,
|
||||
seq: typeof msg.seq === "number" ? msg.seq : undefined,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setMessages((prev) => {
|
||||
@@ -152,6 +155,37 @@ export function useChatState(): UseChatStateReturn {
|
||||
return;
|
||||
}
|
||||
|
||||
case "chunk": {
|
||||
// Streaming chunk from persona — append to existing or create new message
|
||||
const chunkNick = typeof msg.nick === "string" ? msg.nick : "???";
|
||||
const chunkText = typeof msg.text === "string" ? msg.text : "";
|
||||
const chunkColor = typeof msg.color === "string" ? msg.color : undefined;
|
||||
const chunkSeq = typeof msg.seq === "number" ? msg.seq : undefined;
|
||||
setMessages((prev) => {
|
||||
// Find the last message from this nick that is a chunk (still streaming)
|
||||
const lastIdx = prev.length - 1;
|
||||
const last = lastIdx >= 0 ? prev[lastIdx] : null;
|
||||
if (last && last.type === "chunk" && last.nick === chunkNick) {
|
||||
// Append to existing chunk message
|
||||
const updated = [...prev];
|
||||
updated[lastIdx] = { ...last, text: (last.text || "") + chunkText, seq: chunkSeq ?? last.seq };
|
||||
return updated;
|
||||
}
|
||||
// New streaming message
|
||||
const next = [...prev, {
|
||||
id: ++msgIdCounter,
|
||||
type: "chunk" as ChatMsg["type"],
|
||||
nick: chunkNick,
|
||||
text: chunkText,
|
||||
color: chunkColor,
|
||||
seq: chunkSeq,
|
||||
timestamp: Date.now(),
|
||||
}];
|
||||
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
default: {
|
||||
// Intercept typing indicators
|
||||
if (type === "system" && typeof msg.text === "string") {
|
||||
@@ -164,6 +198,7 @@ export function useChatState(): UseChatStateReturn {
|
||||
}
|
||||
}
|
||||
|
||||
const incomingSeq = typeof msg.seq === "number" ? msg.seq : undefined;
|
||||
const chatMsg: ChatMsg = {
|
||||
id: ++msgIdCounter,
|
||||
type,
|
||||
@@ -171,9 +206,30 @@ export function useChatState(): UseChatStateReturn {
|
||||
text: typeof msg.text === "string" ? msg.text : undefined,
|
||||
color: typeof msg.color === "string" ? msg.color : undefined,
|
||||
channel: typeof msg.channel === "string" ? msg.channel : undefined,
|
||||
seq: incomingSeq,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setMessages((prev) => {
|
||||
// If this is a final message from a persona, replace the last chunk from same nick
|
||||
// Use seq to find the right chunk when available
|
||||
if (type === "message" && chatMsg.nick) {
|
||||
let lastChunkIdx = -1;
|
||||
if (incomingSeq != null) {
|
||||
// Find chunk whose seq is just before this message's seq (same persona)
|
||||
lastChunkIdx = prev.findLastIndex(
|
||||
(m) => m.type === "chunk" && m.nick === chatMsg.nick
|
||||
);
|
||||
} else {
|
||||
lastChunkIdx = prev.findLastIndex(
|
||||
(m) => m.type === "chunk" && m.nick === chatMsg.nick
|
||||
);
|
||||
}
|
||||
if (lastChunkIdx >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[lastChunkIdx] = { ...chatMsg, id: prev[lastChunkIdx].id };
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
const next = [...prev, chatMsg];
|
||||
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
|
||||
});
|
||||
@@ -261,9 +317,23 @@ export function useChatState(): UseChatStateReturn {
|
||||
return () => window.removeEventListener("keydown", handleGlobalKeyDown);
|
||||
}, []);
|
||||
|
||||
function handleSend() {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed || !ws.connected) return;
|
||||
// Refs for stable useCallback closures
|
||||
const inputRef = useRef(input);
|
||||
inputRef.current = input;
|
||||
const wsRef = useRef(ws);
|
||||
wsRef.current = ws;
|
||||
const soundsRef = useRef(sounds);
|
||||
soundsRef.current = sounds;
|
||||
const usersRef = useRef(users);
|
||||
usersRef.current = users;
|
||||
const tabIndexRef = useRef(tabIndex);
|
||||
tabIndexRef.current = tabIndex;
|
||||
const tabPrefixRef = useRef(tabPrefix);
|
||||
tabPrefixRef.current = tabPrefix;
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const trimmed = inputRef.current.trim();
|
||||
if (!trimmed || !wsRef.current.connected) return;
|
||||
|
||||
// Push to history
|
||||
historyRef.current.unshift(trimmed);
|
||||
@@ -304,25 +374,25 @@ export function useChatState(): UseChatStateReturn {
|
||||
return;
|
||||
}
|
||||
|
||||
sounds.send();
|
||||
soundsRef.current.send();
|
||||
|
||||
if (trimmed.startsWith("/")) {
|
||||
ws.send({ type: "command", text: trimmed });
|
||||
wsRef.current.send({ type: "command", text: trimmed });
|
||||
} else {
|
||||
ws.send({ type: "message", text: trimmed });
|
||||
wsRef.current.send({ type: "message", text: trimmed });
|
||||
}
|
||||
setInput("");
|
||||
}
|
||||
}, []); // stable — reads from refs
|
||||
|
||||
// Keep handleSendRef in sync
|
||||
useEffect(() => { handleSendRef.current = handleSend; });
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
// Debounced Minitel keyPress sound (every 3rd key)
|
||||
if (e.key.length === 1) {
|
||||
keyPressCountRef.current++;
|
||||
if (keyPressCountRef.current % 3 === 0) {
|
||||
sounds.keyPress();
|
||||
soundsRef.current.keyPress();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,34 +405,34 @@ export function useChatState(): UseChatStateReturn {
|
||||
// Tab completion for nicks and slash commands
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const text = input;
|
||||
const text = inputRef.current;
|
||||
|
||||
// Slash command completion
|
||||
if (text.startsWith("/") && !text.includes(" ")) {
|
||||
const slashCommands = ["/help", "/clear", "/nick", "/join", "/channels", "/msg", "/web", "/imagine", "/compose", "/status", "/model", "/persona", "/reload", "/export"];
|
||||
const prefix = tabPrefix || text;
|
||||
const prefix = tabPrefixRef.current || text;
|
||||
const matches = slashCommands.filter((c) => c.startsWith(prefix.toLowerCase()));
|
||||
if (matches.length === 0) return;
|
||||
const nextIdx = (tabIndex + 1) % matches.length;
|
||||
const nextIdx = (tabIndexRef.current + 1) % matches.length;
|
||||
setInput(matches[nextIdx] + " ");
|
||||
setTabIndex(nextIdx);
|
||||
if (!tabPrefix) setTabPrefix(prefix);
|
||||
if (!tabPrefixRef.current) setTabPrefix(prefix);
|
||||
return;
|
||||
}
|
||||
|
||||
// Nick completion
|
||||
const words = text.split(" ");
|
||||
const lastWord = words[words.length - 1];
|
||||
const prefix = tabPrefix || lastWord;
|
||||
const matches = users.filter((u) =>
|
||||
const prefix = tabPrefixRef.current || lastWord;
|
||||
const matches = usersRef.current.filter((u) =>
|
||||
u.toLowerCase().startsWith(prefix.toLowerCase()),
|
||||
);
|
||||
if (matches.length === 0) return;
|
||||
const nextIdx = (tabIndex + 1) % matches.length;
|
||||
const nextIdx = (tabIndexRef.current + 1) % matches.length;
|
||||
words[words.length - 1] = matches[nextIdx] + (words.length === 1 ? ": " : " ");
|
||||
setInput(words.join(" "));
|
||||
setTabIndex(nextIdx);
|
||||
if (!tabPrefix) setTabPrefix(prefix);
|
||||
if (!tabPrefixRef.current) setTabPrefix(prefix);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -372,7 +442,7 @@ export function useChatState(): UseChatStateReturn {
|
||||
if (history.length === 0) return;
|
||||
e.preventDefault();
|
||||
if (historyIndexRef.current < history.length - 1) {
|
||||
if (historyIndexRef.current === -1) savedInputRef.current = input;
|
||||
if (historyIndexRef.current === -1) savedInputRef.current = inputRef.current;
|
||||
historyIndexRef.current++;
|
||||
setInput(history[historyIndexRef.current]);
|
||||
}
|
||||
@@ -393,11 +463,11 @@ export function useChatState(): UseChatStateReturn {
|
||||
}
|
||||
|
||||
// Reset tab state on any other key
|
||||
if (tabIndex >= 0) {
|
||||
if (tabIndexRef.current >= 0) {
|
||||
setTabIndex(-1);
|
||||
setTabPrefix("");
|
||||
}
|
||||
}
|
||||
}, [handleSend]); // stable — reads from refs
|
||||
|
||||
const getNickColor = useCallback((nick: string): string | undefined => {
|
||||
return personaColors[nick];
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -3533,3 +3533,93 @@ code {
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Timestamps */
|
||||
.chat-ts {
|
||||
color: var(--muted, #666);
|
||||
font-size: 0.8em;
|
||||
margin-right: 6px;
|
||||
opacity: 0.6;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Streaming cursor */
|
||||
.chat-cursor {
|
||||
animation: blink-cursor 0.6s step-end infinite;
|
||||
color: var(--accent, #0f0);
|
||||
margin-left: 2px;
|
||||
}
|
||||
@keyframes blink-cursor {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.chat-msg-streaming {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
CRT PHOSPHOR EFFECT — boot animation, glow, kill switch
|
||||
══════════════════════════════════════════════════════════ */
|
||||
|
||||
/* CRT turn-on animation */
|
||||
@keyframes crt-turn-on {
|
||||
0% { transform: scaleY(0.005) scaleX(0.3); filter: brightness(10); opacity: 1; }
|
||||
30% { transform: scaleY(0.005) scaleX(1); filter: brightness(5); opacity: 1; }
|
||||
50% { transform: scaleY(1) scaleX(1); filter: brightness(2); opacity: 1; }
|
||||
100% { transform: scaleY(1) scaleX(1); filter: brightness(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.crt-boot {
|
||||
animation: crt-turn-on 0.8s ease-out;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
/* Phosphor glow on all content text */
|
||||
.minitel-content {
|
||||
text-shadow: 0 0 2px rgba(51, 255, 51, 0.25);
|
||||
}
|
||||
|
||||
/* ── CRT off mode — disable all effects via ?crt=off ── */
|
||||
.crt-off .minitel-scanlines,
|
||||
.crt-off .minitel-vignette,
|
||||
.crt-off .minitel-flicker {
|
||||
display: none !important;
|
||||
}
|
||||
.crt-off .minitel-screen {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.crt-off .minitel-content {
|
||||
text-shadow: none !important;
|
||||
}
|
||||
.crt-off .crt-boot {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
/* ── Mobile: reduce scanline intensity for FPS ── */
|
||||
@media (max-width: 768px) {
|
||||
.minitel-scanlines {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.minitel-flicker {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Accessibility: skip-to-content link (WCAG 2.1 AA) ── */
|
||||
.minitel-skip-link {
|
||||
position: absolute;
|
||||
top: -100%;
|
||||
left: 0;
|
||||
z-index: 9999;
|
||||
padding: 0.5em 1em;
|
||||
background: #000;
|
||||
color: #0f0;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
text-decoration: underline;
|
||||
outline: 2px solid #0f0;
|
||||
}
|
||||
.minitel-skip-link:focus {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+448
-58
@@ -5,9 +5,6 @@
|
||||
* topological order using stub executors, and updates run status in the DB.
|
||||
*/
|
||||
|
||||
import { promisify } from "node:util";
|
||||
import { execFile } from "node:child_process";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
loadDatabaseConfig,
|
||||
createPostgresPool,
|
||||
@@ -18,18 +15,34 @@ import {
|
||||
import {
|
||||
createNodeEngineRegistry,
|
||||
createQueueState,
|
||||
canDequeue,
|
||||
dequeue,
|
||||
enqueue,
|
||||
markComplete,
|
||||
topologicalSort,
|
||||
validateEdgeContracts,
|
||||
collectNodeInputs,
|
||||
resolveFinalStatus,
|
||||
listDefaultRuntimes,
|
||||
createNodeEngineOverview,
|
||||
createRun,
|
||||
validateJobSpec,
|
||||
buildTrlCommand,
|
||||
DEFAULT_HYPERPARAMS,
|
||||
type TrainingJobSpec,
|
||||
type NodeRun,
|
||||
type RunStep,
|
||||
type GraphNode,
|
||||
type NodeGraph,
|
||||
type StepStatus,
|
||||
type RunStatus,
|
||||
type NodeEngineRegistry,
|
||||
type QueueState,
|
||||
} from "@kxkm/node-engine";
|
||||
import { formatOverviewLine } from "@kxkm/tui";
|
||||
import {
|
||||
createShutdownController,
|
||||
createNodeExecutor,
|
||||
executeRun,
|
||||
runPollCycle,
|
||||
waitForNextPollTick,
|
||||
type WorkerLogger,
|
||||
} from "./worker-runtime.js";
|
||||
import { createIsoTimestamp } from "@kxkm/core";
|
||||
import { formatOverviewLine, ansi } from "@kxkm/tui";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import * as path from "node:path";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -54,32 +67,343 @@ const DEBUG = process.env.NODE_ENV !== "production" || process.env.DEBUG === "1"
|
||||
function log(msg: string): void {
|
||||
if (!DEBUG) return;
|
||||
const ts = new Date().toISOString();
|
||||
process.stdout.write(`[${ts}] ${msg}\n`);
|
||||
console.log(`[${ts}] ${msg}`);
|
||||
}
|
||||
|
||||
function logError(msg: string, err?: unknown): void {
|
||||
const ts = new Date().toISOString();
|
||||
const detail = err instanceof Error ? err.message : String(err ?? "");
|
||||
process.stderr.write(`[${ts}] ERROR: ${msg}${detail ? " — " + detail : ""}\n`);
|
||||
console.error(`[${ts}] ERROR: ${msg}${detail ? " — " + detail : ""}`);
|
||||
}
|
||||
|
||||
const workerLogger: WorkerLogger = {
|
||||
log,
|
||||
error: logError,
|
||||
};
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stub executors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type StubResult = Record<string, unknown>;
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function executeNodeStub(
|
||||
nodeType: string,
|
||||
inputs: Record<string, unknown>,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<StubResult> {
|
||||
await delay(STEP_DELAY_MS);
|
||||
|
||||
switch (nodeType) {
|
||||
// Dataset sources — return a mock dataset
|
||||
case "dataset_file":
|
||||
case "dataset_folder":
|
||||
case "huggingface_dataset":
|
||||
case "web_scraper":
|
||||
return { dataset: { items: [], format: "stub" } };
|
||||
|
||||
// Data processing — pass through inputs
|
||||
case "clean_text":
|
||||
case "remove_duplicates":
|
||||
case "split_dataset":
|
||||
return { dataset: inputs.dataset ?? { items: [], format: "stub" } };
|
||||
|
||||
// Dataset builders — pass through as dataset_ready
|
||||
case "format_instruction_dataset":
|
||||
case "chat_dataset":
|
||||
return { dataset_ready: inputs.dataset ?? { items: [], format: "stub" } };
|
||||
|
||||
// Evaluation — execute via eval_model.py
|
||||
case "prompt_test":
|
||||
case "benchmark": {
|
||||
const evalModel = typeof params.model === "string" && params.model
|
||||
? params.model
|
||||
: (inputs.model as Record<string, unknown>)?.modelName as string || "unsloth/llama-3-8b";
|
||||
const adapterPath = (inputs.model as Record<string, unknown>)?.adapterPath as string || undefined;
|
||||
const promptsPath = typeof params.promptsPath === "string" ? params.promptsPath : "";
|
||||
const evalOutputPath = `/tmp/kxkm-eval-${Date.now()}.json`;
|
||||
|
||||
if (DRY_RUN) {
|
||||
log(` [dry-run] would evaluate model=${evalModel} adapter=${adapterPath || "none"}`);
|
||||
return { evaluation: { kind: "dry-run", score: 1 } };
|
||||
}
|
||||
|
||||
if (!promptsPath) {
|
||||
// No prompts file — return stub score
|
||||
log(` [eval] no promptsPath provided — returning stub evaluation`);
|
||||
return { evaluation: { kind: "stub", score: 1 } };
|
||||
}
|
||||
|
||||
const scriptPath = path.join(SCRIPTS_DIR, "eval_model.py");
|
||||
const args = [
|
||||
scriptPath,
|
||||
"--model", evalModel,
|
||||
"--prompts", promptsPath,
|
||||
"--output", evalOutputPath,
|
||||
];
|
||||
if (adapterPath) args.push("--adapter", adapterPath);
|
||||
|
||||
log(` [eval] ${PYTHON_BIN} ${args.join(" ")}`);
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(PYTHON_BIN, args, {
|
||||
timeout: TRAINING_TIMEOUT_MS,
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
});
|
||||
if (stderr) log(` [eval] stderr: ${stderr.slice(-500)}`);
|
||||
|
||||
const jsonLine = stdout.trim().split("\n").pop() || "{}";
|
||||
let evalResult: Record<string, unknown> = {};
|
||||
try {
|
||||
evalResult = JSON.parse(jsonLine);
|
||||
} catch (parseErr) {
|
||||
logError(` [eval] Failed to parse JSON output`, parseErr);
|
||||
}
|
||||
log(` [eval] result: status=${evalResult.status} score=${evalResult.score}`);
|
||||
|
||||
return {
|
||||
evaluation: {
|
||||
kind: "real",
|
||||
score: evalResult.score,
|
||||
metrics: evalResult.metrics,
|
||||
outputFile: evalOutputPath,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
logError(` [eval] failed`, err);
|
||||
return { evaluation: { kind: "error", score: 0, error: err instanceof Error ? err.message : String(err) } };
|
||||
}
|
||||
}
|
||||
|
||||
// Training — execute via train_unsloth.py
|
||||
case "sft_training":
|
||||
case "lora_training":
|
||||
case "qlora_training": {
|
||||
const baseModel = typeof params.baseModel === "string" && params.baseModel
|
||||
? params.baseModel
|
||||
: "unsloth/llama-3-8b";
|
||||
const datasetPath = typeof params.datasetPath === "string" ? params.datasetPath : "";
|
||||
const outputDir = typeof params.outputDir === "string"
|
||||
? params.outputDir
|
||||
: `/tmp/kxkm-training-${Date.now()}`;
|
||||
const hp = {
|
||||
...DEFAULT_HYPERPARAMS,
|
||||
...(params.hyperparams && typeof params.hyperparams === "object" ? params.hyperparams : {}),
|
||||
};
|
||||
|
||||
if (DRY_RUN) {
|
||||
const jobSpec = validateJobSpec({
|
||||
type: nodeType as TrainingJobSpec["type"], baseModel,
|
||||
datasetPath: datasetPath || "/data/dataset.jsonl",
|
||||
outputDir, hyperparams: hp,
|
||||
});
|
||||
log(` [dry-run] would execute: ${buildTrlCommand(jobSpec)}`);
|
||||
return { model: { kind: "dry-run", modelName: `${baseModel}-finetuned`, jobSpec } };
|
||||
}
|
||||
|
||||
if (!datasetPath) {
|
||||
return { model: { kind: "error", error: "datasetPath is required for training" } };
|
||||
}
|
||||
|
||||
const scriptPath = path.join(SCRIPTS_DIR, "train_unsloth.py");
|
||||
const args = [
|
||||
scriptPath,
|
||||
"--model", baseModel,
|
||||
"--data", datasetPath,
|
||||
"--output", outputDir,
|
||||
"--method", params.dpo === true ? "dpo" : nodeType === "qlora_training" ? "qlora" : nodeType === "sft_training" ? "sft" : "lora",
|
||||
"--lr", String(hp.learningRate),
|
||||
"--epochs", String(hp.epochs),
|
||||
"--batch-size", String(hp.batchSize),
|
||||
"--lora-rank", String(hp.loraRank),
|
||||
"--lora-alpha", String(hp.loraAlpha),
|
||||
"--warmup-steps", String(hp.warmupSteps),
|
||||
"--max-seq-length", String(hp.maxSeqLength),
|
||||
];
|
||||
if (nodeType === "qlora_training") args.push("--quantize", "4bit");
|
||||
|
||||
log(` [training] ${PYTHON_BIN} ${args.join(" ")}`);
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(PYTHON_BIN, args, {
|
||||
timeout: TRAINING_TIMEOUT_MS,
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
});
|
||||
if (stderr) log(` [training] stderr: ${stderr.slice(-500)}`);
|
||||
|
||||
const jsonLine = stdout.trim().split("\n").pop() || "{}";
|
||||
let trainResult: Record<string, unknown> = {};
|
||||
try {
|
||||
trainResult = JSON.parse(jsonLine);
|
||||
} catch (parseErr) {
|
||||
logError(` [training] Failed to parse JSON output`, parseErr);
|
||||
}
|
||||
log(` [training] result: status=${trainResult.status} loss=${(trainResult.metrics as Record<string, unknown> | undefined)?.trainLoss}`);
|
||||
|
||||
return {
|
||||
model: {
|
||||
kind: "trained",
|
||||
modelName: `${baseModel}-finetuned`,
|
||||
adapterPath: trainResult.adapterPath || outputDir,
|
||||
metrics: trainResult.metrics,
|
||||
status: trainResult.status,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logError(` [training] failed`, err);
|
||||
return { model: { kind: "error", error: msg } };
|
||||
}
|
||||
}
|
||||
|
||||
// Registry
|
||||
case "register_model":
|
||||
return { registered_model: { id: "stub" } };
|
||||
|
||||
// Deployment — import LoRA adapter into Ollama
|
||||
case "deploy_api": {
|
||||
const modelInput = inputs.registered_model as Record<string, unknown> || inputs.model as Record<string, unknown> || {};
|
||||
const adapterPath = modelInput.adapterPath as string || (typeof params.adapterPath === "string" ? params.adapterPath : "");
|
||||
const baseOllamaModel = typeof params.baseOllamaModel === "string" ? params.baseOllamaModel : "llama3.2:1b";
|
||||
const deployName = typeof params.deployName === "string" ? params.deployName : `kxkm-${Date.now()}`;
|
||||
|
||||
if (DRY_RUN || !adapterPath) {
|
||||
log(` [deploy] dry-run or no adapter: base=${baseOllamaModel} name=${deployName}`);
|
||||
return { deployment: { kind: DRY_RUN ? "dry-run" : "stub", id: deployName } };
|
||||
}
|
||||
|
||||
const scriptPath = path.join(SCRIPTS_DIR, "ollama-import-adapter.sh");
|
||||
const args = [scriptPath, "--base-model", baseOllamaModel, "--adapter-path", adapterPath, "--name", deployName];
|
||||
|
||||
log(` [deploy] importing to Ollama: ${deployName} from ${baseOllamaModel} + ${adapterPath}`);
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync("/bin/bash", args, { timeout: 120_000 });
|
||||
if (stderr) log(` [deploy] stderr: ${stderr.slice(-500)}`);
|
||||
const jsonLine = stdout.trim().split("\n").pop() || "{}";
|
||||
let result;
|
||||
try {
|
||||
result = JSON.parse(jsonLine);
|
||||
} catch {
|
||||
logError(` [deploy] invalid JSON: ${jsonLine.slice(0, 100)}`);
|
||||
return { deployment: { kind: "error", id: deployName, error: "invalid_json_output" } };
|
||||
}
|
||||
log(` [deploy] result: ${JSON.stringify(result)}`);
|
||||
return { deployment: { kind: "ollama", id: deployName, ...result } };
|
||||
} catch (err) {
|
||||
logError(` [deploy] failed`, err);
|
||||
return { deployment: { kind: "error", id: deployName, error: err instanceof Error ? err.message : String(err) } };
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Run executor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function executeRun(
|
||||
run: NodeRun,
|
||||
registry: NodeEngineRegistry,
|
||||
options: { shouldCancel?: () => boolean } = {},
|
||||
): Promise<void> {
|
||||
const graph = run.graphSnapshot;
|
||||
const shouldCancel = options.shouldCancel ?? (() => false);
|
||||
|
||||
// Validate edges against registry contracts
|
||||
validateEdgeContracts(graph, registry);
|
||||
|
||||
// Get execution order
|
||||
const sorted = topologicalSort(graph);
|
||||
|
||||
// Track outputs per node for input collection
|
||||
const outputsByNode = new Map<string, Record<string, unknown>>();
|
||||
|
||||
// Mark run as running
|
||||
run.status = "running";
|
||||
run.startedAt = createIsoTimestamp();
|
||||
let cancelled = false;
|
||||
|
||||
log(` Executing ${sorted.length} node(s) in topological order`);
|
||||
|
||||
// Restore already-completed steps (recovery support)
|
||||
for (const node of sorted) {
|
||||
const step = run.steps.find((s) => s.id === node.id);
|
||||
if (step?.status === "completed") {
|
||||
log(` [${node.id}] ${node.type} — already completed (recovered)`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of sorted) {
|
||||
const step = run.steps.find((s) => s.id === node.id);
|
||||
if (!step) continue;
|
||||
if (step.status === "completed") continue; // skip recovered steps
|
||||
|
||||
if (shouldCancel() || shutdownRequested) {
|
||||
cancelled = true;
|
||||
log(` [${node.id}] ${node.type} — cancelled`);
|
||||
break;
|
||||
}
|
||||
|
||||
step.status = "running";
|
||||
step.startedAt = createIsoTimestamp();
|
||||
|
||||
log(` [${node.id}] ${node.type} — running`);
|
||||
|
||||
try {
|
||||
const inputs = collectNodeInputs(graph, node.id, outputsByNode);
|
||||
const result = await executeNodeStub(node.type, inputs, node.params);
|
||||
outputsByNode.set(node.id, result);
|
||||
|
||||
step.status = "completed";
|
||||
step.finishedAt = createIsoTimestamp();
|
||||
step.outputs = Object.keys(result);
|
||||
|
||||
log(` [${node.id}] ${node.type} — completed`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
step.status = "failed";
|
||||
step.finishedAt = createIsoTimestamp();
|
||||
step.error = message;
|
||||
|
||||
logError(` [${node.id}] ${node.type} — failed`, err);
|
||||
break; // stop on first failure (like V1)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve final status from step statuses
|
||||
const stepStatuses = run.steps.map((s) => s.status);
|
||||
run.status = resolveFinalStatus(stepStatuses, cancelled);
|
||||
run.finishedAt = createIsoTimestamp();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shutdown handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const shutdown = createShutdownController();
|
||||
let shutdownRequested = false;
|
||||
|
||||
function requestShutdown(): void {
|
||||
if (shutdown.isShutdownRequested()) return;
|
||||
shutdown.requestShutdown();
|
||||
if (shutdownRequested) return;
|
||||
shutdownRequested = true;
|
||||
log("Shutdown requested — finishing current work...");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global error handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
logError("Unhandled promise rejection", reason);
|
||||
});
|
||||
|
||||
process.on("uncaughtException", (err) => {
|
||||
logError("Uncaught exception — shutting down", err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -126,57 +450,123 @@ async function main(): Promise<void> {
|
||||
log(`Recovered ${recovered.length} stale run(s): ${recovered.map((r) => r.id).join(", ")}`);
|
||||
}
|
||||
|
||||
const executeNode = createNodeExecutor(
|
||||
{
|
||||
dryRun: DRY_RUN,
|
||||
stepDelayMs: STEP_DELAY_MS,
|
||||
pythonBin: PYTHON_BIN,
|
||||
scriptsDir: SCRIPTS_DIR,
|
||||
trainingTimeoutMs: TRAINING_TIMEOUT_MS,
|
||||
},
|
||||
execFileAsync,
|
||||
workerLogger,
|
||||
);
|
||||
|
||||
// 6. Graceful shutdown
|
||||
process.on("SIGTERM", requestShutdown);
|
||||
process.on("SIGINT", requestShutdown);
|
||||
// 6. Graceful shutdown with forced exit timeout
|
||||
const SHUTDOWN_TIMEOUT_MS = 30_000;
|
||||
function handleShutdownSignal(signal: string) {
|
||||
log(`${signal} received`);
|
||||
requestShutdown();
|
||||
setTimeout(() => {
|
||||
logError(`Forced exit after ${SHUTDOWN_TIMEOUT_MS}ms timeout`);
|
||||
process.exit(1);
|
||||
}, SHUTDOWN_TIMEOUT_MS).unref();
|
||||
}
|
||||
process.on("SIGTERM", () => handleShutdownSignal("SIGTERM"));
|
||||
process.on("SIGINT", () => handleShutdownSignal("SIGINT"));
|
||||
|
||||
// 7. Poll loop
|
||||
log(`Entering poll loop (interval=${POLL_INTERVAL_MS}ms)`);
|
||||
|
||||
while (!shutdown.isShutdownRequested()) {
|
||||
while (!shutdownRequested) {
|
||||
try {
|
||||
const cycle = await runPollCycle({
|
||||
queueState,
|
||||
runRepo,
|
||||
graphRepo,
|
||||
registry,
|
||||
executeNode,
|
||||
shutdown,
|
||||
logger: workerLogger,
|
||||
});
|
||||
// Fetch queued runs from the DB
|
||||
const queuedDbRuns = await runRepo.listByStatus("queued", 20);
|
||||
|
||||
const overview = formatOverviewLine(
|
||||
createNodeEngineOverview({
|
||||
graphs: (await graphRepo.list()).length,
|
||||
models: 0,
|
||||
queuedRuns: queueState.queued.length,
|
||||
runningRuns: queueState.running.length,
|
||||
// Sync DB queued runs into in-memory queue state
|
||||
for (const dbRun of queuedDbRuns) {
|
||||
enqueue(queueState, dbRun.id);
|
||||
}
|
||||
|
||||
// Process runs while we have capacity
|
||||
while (canDequeue(queueState) && !shutdownRequested) {
|
||||
const runId = dequeue(queueState);
|
||||
if (!runId) break;
|
||||
|
||||
log(`Dequeued run: ${runId}`);
|
||||
|
||||
// Update status to running in DB
|
||||
await runRepo.updateStatus(runId, "running");
|
||||
|
||||
// Look up the run from the DB
|
||||
const dbRun = await runRepo.findById(runId);
|
||||
if (!dbRun) {
|
||||
logError(`Run ${runId} not found in DB — skipping`);
|
||||
markComplete(queueState, runId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const graphRecord = await graphRepo.findById(dbRun.graphId);
|
||||
if (!graphRecord) {
|
||||
logError(`Graph ${dbRun.graphId} for run ${runId} not found — marking failed`);
|
||||
await runRepo.updateStatus(runId, "failed");
|
||||
markComplete(queueState, runId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build a NodeGraph from the record (minimal — no edges/nodes stored in DB yet,
|
||||
// so we create a stub graph from the record for now)
|
||||
const graph: NodeGraph = {
|
||||
id: graphRecord.id,
|
||||
name: graphRecord.name,
|
||||
description: graphRecord.description,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
createdAt: createIsoTimestamp(),
|
||||
updatedAt: createIsoTimestamp(),
|
||||
};
|
||||
|
||||
// Create an in-memory run with steps from the graph
|
||||
const nodeRun = createRun(graph, "worker");
|
||||
// Override the id to match the DB run
|
||||
(nodeRun as { id: string }).id = runId;
|
||||
|
||||
try {
|
||||
await executeRun(nodeRun, registry, {
|
||||
shouldCancel: () => {
|
||||
// Check if run was cancelled in DB (async check would be better
|
||||
// but keeping it simple — the cancel is also checked via shutdownRequested)
|
||||
return shutdownRequested;
|
||||
},
|
||||
});
|
||||
|
||||
// Persist final status
|
||||
await runRepo.updateStatus(runId, nodeRun.status);
|
||||
log(`Run ${runId} finished with status: ${nodeRun.status}`);
|
||||
} catch (err) {
|
||||
logError(`Run ${runId} failed unexpectedly`, err);
|
||||
await runRepo.updateStatus(runId, "failed");
|
||||
} finally {
|
||||
markComplete(queueState, runId);
|
||||
}
|
||||
}
|
||||
|
||||
// Log overview periodically
|
||||
const overview = formatOverviewLine({
|
||||
queue: {
|
||||
desiredWorkers: MAX_CONCURRENCY,
|
||||
activeWorkers: queueState.running.length,
|
||||
}),
|
||||
);
|
||||
queuedRuns: queueState.queued.length,
|
||||
runningRuns: queueState.running.length,
|
||||
},
|
||||
registry: {
|
||||
graphs: (await graphRepo.list()).length,
|
||||
models: 0,
|
||||
},
|
||||
storage: {
|
||||
backend: "postgres",
|
||||
artifacts: "filesystem",
|
||||
},
|
||||
});
|
||||
|
||||
if (cycle.queuedDbRuns > 0) {
|
||||
if (queuedDbRuns.length > 0) {
|
||||
log(`Poll status: ${overview}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logError("Poll loop error", err);
|
||||
}
|
||||
|
||||
if (!shutdown.isShutdownRequested()) {
|
||||
await waitForNextPollTick(POLL_INTERVAL_MS);
|
||||
// Wait before next poll (check shutdown frequently)
|
||||
if (!shutdownRequested) {
|
||||
await delay(POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/bin/bash
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 3615-KXKM — Deploy script
|
||||
# Usage: bash scripts/deploy.sh [--full|--web|--api|--tts]
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
set -euo pipefail
|
||||
|
||||
HOST="kxkm@kxkm-ai"
|
||||
REMOTE_DIR="/home/kxkm/KXKM_Clown"
|
||||
SSH="ssh $HOST"
|
||||
LOG_PREFIX="[deploy]"
|
||||
|
||||
log() { echo "$LOG_PREFIX $*"; }
|
||||
fail() { echo "$LOG_PREFIX ERROR: $*" >&2; exit 1; }
|
||||
|
||||
MODE="${1:---full}"
|
||||
|
||||
# ─── Step 1: Build locally ─────────────────────────────────
|
||||
log "Building locally..."
|
||||
npx tsc --noEmit -p apps/api/tsconfig.json || fail "TypeScript API check failed"
|
||||
npx tsc --noEmit -p apps/web/tsconfig.json || fail "TypeScript Web check failed"
|
||||
npm run -w @kxkm/web build || fail "Web build failed"
|
||||
npm run -w @kxkm/api build || fail "API build failed"
|
||||
log "Local build OK"
|
||||
|
||||
# ─── Step 2: Sync to remote ────────────────────────────────
|
||||
log "Syncing to $HOST..."
|
||||
|
||||
if [[ "$MODE" == "--full" || "$MODE" == "--web" ]]; then
|
||||
rsync -avz --delete --exclude='node_modules' --exclude='.git' \
|
||||
apps/web/src/ "$HOST:$REMOTE_DIR/apps/web/src/"
|
||||
log "Web sources synced"
|
||||
fi
|
||||
|
||||
if [[ "$MODE" == "--full" || "$MODE" == "--api" ]]; then
|
||||
rsync -avz --delete --exclude='node_modules' --exclude='.git' \
|
||||
apps/api/src/ "$HOST:$REMOTE_DIR/apps/api/src/"
|
||||
log "API sources synced"
|
||||
fi
|
||||
|
||||
rsync -avz scripts/ "$HOST:$REMOTE_DIR/scripts/"
|
||||
rsync -avz Dockerfile docker-compose.yml "$HOST:$REMOTE_DIR/"
|
||||
log "Scripts + infra synced"
|
||||
|
||||
# ─── Step 3: Remote build ──────────────────────────────────
|
||||
log "Building on remote..."
|
||||
$SSH "source ~/.nvm/nvm.sh && cd $REMOTE_DIR && \
|
||||
npx tsc -b tsconfig.v2.json && \
|
||||
npm run -w @kxkm/web build && \
|
||||
npm run -w @kxkm/api build && \
|
||||
npm run build" || fail "Remote build failed"
|
||||
log "Remote build OK"
|
||||
|
||||
# ─── Step 4: Deploy to Docker ──────────────────────────────
|
||||
log "Deploying to Docker..."
|
||||
$SSH "cd $REMOTE_DIR && \
|
||||
docker cp apps/web/dist/. kxkm_clown-api-1:/app/apps/web/dist/ && \
|
||||
docker cp apps/api/dist/. kxkm_clown-api-1:/app/apps/api/dist/ && \
|
||||
docker restart kxkm_clown-api-1" || fail "Docker deploy failed"
|
||||
log "Docker restarted"
|
||||
|
||||
# ─── Step 5: Restart TTS server (chatterbox-remote + piper fallback) ──
|
||||
if [[ "$MODE" == "--full" || "$MODE" == "--tts" ]]; then
|
||||
log "Restarting TTS server..."
|
||||
$SSH "tmux kill-session -t tts 2>/dev/null || true; \
|
||||
sleep 1; \
|
||||
tmux new-session -d -s tts \
|
||||
'source /home/kxkm/venv/bin/activate && cd $REMOTE_DIR && CHATTERBOX_URL=http://127.0.0.1:9200 python3 scripts/tts-server.py --port 9100 --backend chatterbox-remote 2>&1 | tee /tmp/tts-server.log'; \
|
||||
sleep 3; \
|
||||
curl -sf http://127.0.0.1:9100/health && echo ' TTS OK' || echo ' TTS FAIL'"
|
||||
fi
|
||||
|
||||
# ─── Step 5b: Restart LightRAG server ─────────────────────
|
||||
if [[ "$MODE" == "--full" ]]; then
|
||||
log "Restarting LightRAG server..."
|
||||
$SSH "tmux kill-session -t lightrag 2>/dev/null || true; \
|
||||
sleep 1; \
|
||||
tmux new-session -d -s lightrag \
|
||||
'source /home/kxkm/venv/bin/activate && cd $REMOTE_DIR && EMBEDDING_DIM=768 LLM_MODEL=qwen3:8b EMBEDDING_MODEL=nomic-embed-text OLLAMA_HOST=http://localhost:11434 lightrag-server --host 0.0.0.0 --port 9621 --working-dir $REMOTE_DIR/data/lightrag --llm-binding ollama --embedding-binding ollama 2>&1 | tee /tmp/lightrag-server.log'; \
|
||||
sleep 5; \
|
||||
curl -sf http://127.0.0.1:9621/health | head -c 30 && echo ' LightRAG OK' || echo ' LightRAG FAIL'"
|
||||
fi
|
||||
|
||||
# ─── Step 6: Health check ──────────────────────────────────
|
||||
log "Health check..."
|
||||
sleep 3
|
||||
$SSH "curl -sf http://localhost:3333/api/v2/health | head -c 50" && echo " API OK" || echo " API FAIL"
|
||||
|
||||
log "═══ Deploy complete ═══"
|
||||
+27
-1
@@ -72,7 +72,7 @@ services:
|
||||
WEB_DIST_PATH: "/app/apps/web/dist"
|
||||
ADMIN_BOOTSTRAP_TOKEN: "${ADMIN_BOOTSTRAP_TOKEN:-}"
|
||||
ADMIN_ALLOWED_SUBNETS: "${ADMIN_ALLOWED_SUBNETS:-}"
|
||||
TTS_ENABLED: "1"
|
||||
TTS_ENABLED: "0"
|
||||
PYTHON_BIN: "python3"
|
||||
SCRIPTS_DIR: "/app/scripts"
|
||||
PIPER_VOICE_DIR: "/app/data/piper-voices"
|
||||
@@ -80,6 +80,12 @@ services:
|
||||
VISION_MODEL: "qwen3-vl:8b"
|
||||
LIGHTRAG_URL: "http://localhost:9621"
|
||||
TTS_URL: "http://localhost:9100"
|
||||
DOCLING_URL: "http://localhost:9400"
|
||||
RERANKER_URL: "http://localhost:9500"
|
||||
RAG_CHUNK_SIZE: "${RAG_CHUNK_SIZE:-500}"
|
||||
RAG_MIN_SIMILARITY: "${RAG_MIN_SIMILARITY:-0.3}"
|
||||
RAG_MAX_RESULTS: "${RAG_MAX_RESULTS:-3}"
|
||||
RAG_EMBEDDING_MODEL: "${RAG_EMBEDDING_MODEL:-nomic-embed-text}"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -270,6 +276,26 @@ services:
|
||||
chatterbox:
|
||||
condition: service_healthy
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Docling Serve — Document parsing (PDF, DOCX, PPTX → structured text)
|
||||
# API: POST /v1/convert/source
|
||||
# UI: http://localhost:9400/ui (if DOCLING_SERVE_ENABLE_UI=1)
|
||||
# -------------------------------------------------------------------------
|
||||
docling:
|
||||
image: ghcr.io/docling-project/docling-serve:latest
|
||||
restart: unless-stopped
|
||||
profiles: [v2]
|
||||
ports:
|
||||
- "9400:5001"
|
||||
environment:
|
||||
DOCLING_SERVE_ENABLE_UI: "1"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:5001/health')\""]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 120s
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# LightRAG — Graph RAG server (Ollama backend)
|
||||
# API: POST /query, POST /documents/text, GET /health
|
||||
|
||||
+165
-44
@@ -1,6 +1,8 @@
|
||||
# Architecture 3615-KXKM
|
||||
|
||||
> "Le medium est le message, et ton terminal a deja compris." -- electron rare
|
||||
>
|
||||
> "Saboteurs of big daddy mainframe" -- VNS Matrix, 1991
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
@@ -16,26 +18,30 @@ graph TB
|
||||
Admin[AdminPage]
|
||||
end
|
||||
|
||||
subgraph API["API Node.js Express + WebSocket"]
|
||||
subgraph API["API Express + WebSocket :3333"]
|
||||
WS[ws-chat.ts — Handler WS]
|
||||
CMD[ws-commands.ts — /compose /imagine /web]
|
||||
ROUTER[ws-conversation-router.ts — Routing @mention]
|
||||
ROUTER[ws-conversation-router.ts — pickResponders + @mention]
|
||||
LLM[ws-ollama.ts — Stream + Tools + Think-strip]
|
||||
MULTI[ws-multimodal.ts — TTS HTTP + Vision]
|
||||
TOOLS[mcp-tools.ts — web_search, image_generate, rag_search]
|
||||
MSTORE[media-store.ts — Persistance media]
|
||||
CTX[context-store.ts — Contexte JSONL 4000ch]
|
||||
RAG[rag.ts — LightRAG + local fallback]
|
||||
REST[Routes REST — session, personas, media]
|
||||
end
|
||||
|
||||
subgraph Services["Services"]
|
||||
OLLAMA[Ollama — qwen3:8b mistral gemma3]
|
||||
TTS[TTS Sidecar :9100 — proxy]
|
||||
CBOX[Chatterbox GPU :9200 — Docker]
|
||||
LRAG[LightRAG :9621 — Graph RAG]
|
||||
COMFY[ComfyUI SDXL]
|
||||
subgraph Infra["Services Infrastructure"]
|
||||
OLLAMA["Ollama natif :11434\nqwen3:8b · mistral:7b · qwen3-vl:8b"]
|
||||
PG[(PostgreSQL 16 :5432)]
|
||||
SEARX[SearXNG :8080]
|
||||
PG[(PostgreSQL 16)]
|
||||
end
|
||||
|
||||
subgraph MLStack["ML / Génération"]
|
||||
TTS[TTS Sidecar :9100\nproxy Chatterbox + Piper fallback]
|
||||
CBOX[Chatterbox Docker :9200\nGPU voice cloning]
|
||||
LRAG[LightRAG :9621\nGraph RAG knowledge graph]
|
||||
COMFY[ComfyUI SDXL\nstable2.kxkm.net]
|
||||
end
|
||||
|
||||
subgraph Worker["Worker GPU"]
|
||||
@@ -43,17 +49,24 @@ graph TB
|
||||
TRAIN[Training Unsloth/TRL]
|
||||
end
|
||||
|
||||
subgraph External["Hors cluster"]
|
||||
STABLE[StableView :3000\ninterface séparée]
|
||||
end
|
||||
|
||||
Chat -- "WS message/command" --> WS
|
||||
Voice -- "WS upload audio" --> WS
|
||||
Compose -- "WS command /compose" --> CMD
|
||||
Imagine -- "WS command /imagine" --> CMD
|
||||
Compose -- "WS /compose" --> CMD
|
||||
Imagine -- "WS /imagine" --> CMD
|
||||
Media -- "REST /api/v2/media" --> REST
|
||||
|
||||
WS --> ROUTER --> LLM --> OLLAMA
|
||||
ROUTER --> CTX
|
||||
ROUTER --> RAG
|
||||
LLM -- "tool_call web_search" --> TOOLS --> SEARX
|
||||
LLM -- "tool_call image_generate" --> TOOLS --> COMFY
|
||||
LLM -- "tool_call rag_search" --> TOOLS --> LRAG
|
||||
LLM -- "TTS" --> MULTI --> TTS --> CBOX
|
||||
LLM -- "Vision" --> MULTI --> OLLAMA
|
||||
LLM -- "Vision qwen3-vl:8b" --> MULTI --> OLLAMA
|
||||
RAG -- "query hybrid" --> LRAG --> OLLAMA
|
||||
CMD -- "/imagine" --> COMFY
|
||||
CMD -- "/web" --> SEARX
|
||||
@@ -62,33 +75,153 @@ graph TB
|
||||
ENGINE --> TRAIN --> OLLAMA
|
||||
```
|
||||
|
||||
## Flux chat avec routing personas
|
||||
## Flux chat — séquence complète
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant WS as WebSocket
|
||||
participant U as User (Browser)
|
||||
participant WS as WebSocket Server
|
||||
participant PR as pickResponders
|
||||
participant Ph as Pharmacius (routeur)
|
||||
participant Sh as Sherlock (web_search)
|
||||
participant Sp as Spécialiste @mention
|
||||
participant TTS as TTS Server
|
||||
participant CTX as ContextStore
|
||||
participant RAG as RAG (LightRAG)
|
||||
participant TTS as TTS Sidecar :9100
|
||||
participant OL as Ollama
|
||||
|
||||
U->>WS: message "parle-moi de noise"
|
||||
WS->>WS: broadcast + log + context
|
||||
WS->>Ph: streamOllamaChat (maxTokens:600)
|
||||
Ph-->>WS: "Le noise art... @Merzbow peut approfondir."
|
||||
WS->>WS: stripThinking + broadcast texte
|
||||
WS->>TTS: POST /synthesize (Pharmacius)
|
||||
TTS-->>WS: audio WAV
|
||||
WS->>U: audio base64
|
||||
U->>WS: message "cherche des infos sur Xenakis"
|
||||
WS->>WS: broadcast user message to all clients
|
||||
WS->>CTX: addToContext(channel, user, text)
|
||||
|
||||
Note over WS: Détecte @Merzbow → inter-persona (depth+1, 2s delay)
|
||||
WS->>Sp: streamOllamaChat (Merzbow, maxTokens:500)
|
||||
Sp-->>WS: "Le bruit est une matière vivante..."
|
||||
WS->>TTS: POST /synthesize (Merzbow)
|
||||
TTS-->>WS: audio WAV
|
||||
WS->>U: texte + audio
|
||||
Note over WS,PR: pickResponders: @mention direct → persona mentionnée<br/>sinon → Pharmacius (routeur par défaut)
|
||||
|
||||
WS->>PR: pickResponders(text, personas)
|
||||
PR-->>WS: [Pharmacius]
|
||||
|
||||
WS->>CTX: getContextString(channel)
|
||||
CTX-->>WS: contexte conversationnel (4000 chars)
|
||||
WS->>RAG: search(text, 2 results)
|
||||
RAG-->>WS: chunks pertinents du manifeste
|
||||
|
||||
WS->>OL: streamOllamaChat(Pharmacius, enrichedText)
|
||||
Note over Ph,OL: Pharmacius: max 2 phrases, no tools<br/>Routage → @Sherlock pour recherche web
|
||||
OL-->>WS: stream chunks
|
||||
WS->>WS: stripThinking + broadcast "message" (final replaces chunks)
|
||||
WS->>CTX: addToContext(channel, Pharmacius, fullText)
|
||||
|
||||
Note over WS: Détecte @Sherlock → inter-persona chain<br/>depth+1, délai 2000ms, max depth=3
|
||||
|
||||
WS->>OL: streamOllamaChatWithTools(Sherlock, contextMessage, [web_search, rag_search])
|
||||
OL-->>WS: tool_call: web_search("Xenakis")
|
||||
WS->>Sh: executeTool(web_search)
|
||||
Sh->>WS: SearXNG query → 5 résultats
|
||||
WS->>OL: tool result → continue generation
|
||||
OL-->>WS: stream chunks (analyse des résultats)
|
||||
WS->>WS: broadcast final message to all clients
|
||||
WS->>CTX: addToContext(channel, Sherlock, fullText)
|
||||
|
||||
Note over WS,CTX: Memory update: every 5 messages per persona<br/>LLM extracts facts + summary → persona-memory/{nick}.json
|
||||
|
||||
opt TTS_ENABLED=1
|
||||
WS->>TTS: POST /synthesize {nick, text}
|
||||
TTS->>TTS: Chatterbox GPU :9200 (voice cloning)
|
||||
TTS-->>WS: audio WAV
|
||||
WS->>U: audio base64 broadcast
|
||||
end
|
||||
```
|
||||
|
||||
## Routing Pharmacius → Spécialistes
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Ph((Pharmacius<br/>routeur))
|
||||
|
||||
subgraph Son["Son / Musique"]
|
||||
Schaeffer[Schaeffer<br/>musique concrète]
|
||||
Radigue[Radigue<br/>drones]
|
||||
Oliveros[Oliveros<br/>deep listening]
|
||||
Eno[Eno<br/>composition]
|
||||
end
|
||||
|
||||
subgraph Pensee["Pensée / Philosophie"]
|
||||
Batty[Batty<br/>existentiel]
|
||||
Foucault[Foucault<br/>pouvoir]
|
||||
Deleuze[Deleuze<br/>concepts]
|
||||
end
|
||||
|
||||
subgraph Politique["Politique / Résistance"]
|
||||
Swartz[Swartz<br/>hacktivisme]
|
||||
Bookchin[Bookchin<br/>écologie]
|
||||
LeGuin[LeGuin<br/>SF/utopie]
|
||||
end
|
||||
|
||||
subgraph Tech["Tech / Science"]
|
||||
Turing[Turing<br/>code/hack]
|
||||
Hypatia[Hypatia<br/>science]
|
||||
Curie[Curie<br/>science]
|
||||
Sherlock[Sherlock<br/>web_search]
|
||||
end
|
||||
|
||||
subgraph Arts["Arts vivants / Visuels"]
|
||||
Merzbow[Merzbow<br/>noise/glitch]
|
||||
Cage[Cage<br/>silence]
|
||||
Ikeda[Ikeda<br/>data art]
|
||||
Picasso[Picasso<br/>image_generate]
|
||||
TeamLab[TeamLab<br/>immersif]
|
||||
Demoscene[Demoscene<br/>demoscene]
|
||||
end
|
||||
|
||||
subgraph Scene["Scène / Corps"]
|
||||
RoyalDeLuxe[RoyalDeLuxe<br/>arts de la rue]
|
||||
Decroux[Decroux<br/>mime]
|
||||
Mnouchkine[Mnouchkine<br/>théâtre]
|
||||
Pina[Pina<br/>danse]
|
||||
Grotowski[Grotowski<br/>rituel]
|
||||
Fratellini[Fratellini<br/>clown]
|
||||
end
|
||||
|
||||
subgraph Transversal["Transversal"]
|
||||
Haraway[Haraway<br/>cyborg/féminisme]
|
||||
SunRa[SunRa<br/>afrofuturisme]
|
||||
Bjork[Bjork<br/>pop/nature]
|
||||
Fuller[Fuller<br/>design]
|
||||
Tarkovski[Tarkovski<br/>cinéma]
|
||||
Oram[Oram<br/>électronique/DIY]
|
||||
end
|
||||
|
||||
Ph --> Son
|
||||
Ph --> Pensee
|
||||
Ph --> Politique
|
||||
Ph --> Tech
|
||||
Ph --> Arts
|
||||
Ph --> Scene
|
||||
Ph --> Transversal
|
||||
|
||||
style Ph fill:#00e676,color:#000
|
||||
style Sherlock fill:#ff7043,color:#000
|
||||
style Picasso fill:#ffd54f,color:#000
|
||||
```
|
||||
|
||||
## Services production (kxkm-ai)
|
||||
|
||||
| Service | Port | Docker Profile | Stack | Health | Rôle |
|
||||
| ------- | ---- | ------------- | ----- | ------ | ---- |
|
||||
| **API V2** | `:3333` | `v2` | Node.js (network_mode: host) | `GET /api/v2/health` | Express + WebSocket chat + React SPA |
|
||||
| **PostgreSQL** | `:5432` | *(always)* | postgres:16-alpine | `pg_isready` | Persistence sessions, personas, graphs |
|
||||
| **SearXNG** | `:8080` | `v2` | searxng/searxng | `wget /` | Recherche web self-hosted (Google, Bing, DDG) |
|
||||
| **Chatterbox** | `:9200` | `v2` | Docker GPU (ghcr.io/devnen/chatterbox-tts-server) | `GET /get_predefined_voices` | TTS voice cloning GPU |
|
||||
| **TTS Sidecar** | `:9100` | `v2` | Python (network_mode: host) | — | Proxy Chatterbox + Piper fallback |
|
||||
| **LightRAG** | `:9621` | `v2` | Python 3.12 (lightrag-hku, network_mode: host) | `GET /health` | Graph RAG, knowledge graph (Ollama backend) |
|
||||
| **Ollama** | `:11434` | `ollama` *(opt)* | Natif RTX 4090 | `GET /api/tags` | LLM inference: qwen3:8b, mistral:7b, qwen3-vl:8b |
|
||||
| **Worker** | host | `v2` | Node.js (GPU passthrough) | — | Node Engine DAG execution, training |
|
||||
| **Docling** | `:9400` | `v2` | Python (Docling REST) | `GET /health` | PDF/document parsing (tables, layout, OCR) |
|
||||
| **Reranker** | `:9500` | `v2` | Python (bge-reranker-v2-m3) | `GET /health` | Cross-encoder reranking for RAG results |
|
||||
| **ComfyUI** | ext | — | stable2.kxkm.net | — | Image gen SDXL |
|
||||
| **StableView** | `:3000` | — | Séparé | — | Interface visualisation (hors cluster) |
|
||||
| **Discord Bot** | — | `discord` | Node.js (network_mode: host) | — | Bridge chat KXKM → Discord |
|
||||
| **Discord Voice** | — | `discord-voice` | Node.js + Python STT | — | STT → Personas → TTS en vocal |
|
||||
|
||||
## Feature Map
|
||||
|
||||
```mermaid
|
||||
@@ -141,7 +274,7 @@ mindmap
|
||||
## Modules (LOC)
|
||||
|
||||
| Module | LOC | Tests | Rôle |
|
||||
|--------|-----|-------|------|
|
||||
| ------ | --- | ----- | ---- |
|
||||
| apps/api | 5200 | 1000 | Backend API + WebSocket |
|
||||
| apps/web | 4800 | 800 | Frontend React |
|
||||
| apps/worker | 956 | 230 | Worker GPU Node Engine |
|
||||
@@ -156,19 +289,6 @@ mindmap
|
||||
| scripts | 37 fichiers | - | TTS, training, migration |
|
||||
| **Total** | **~15600** | **417 tests** | |
|
||||
|
||||
## Services production (kxkm-ai)
|
||||
|
||||
| Service | Port | Stack | Rôle |
|
||||
|---------|------|-------|------|
|
||||
| API V2 | :3333 | Docker (Node.js) | Express + WebSocket chat |
|
||||
| PostgreSQL | :5432 | Docker (Alpine) | Persistence |
|
||||
| SearXNG | :8080 | Docker | Recherche web self-hosted |
|
||||
| Chatterbox | :9200 | Docker (GPU) | TTS voice cloning |
|
||||
| TTS Sidecar | :9100 | Docker (host) | Proxy Chatterbox + Piper fallback |
|
||||
| LightRAG | :9621 | Docker (Python) | Graph RAG, knowledge graph |
|
||||
| Ollama | :11434 | Natif | LLM inference |
|
||||
| Worker | host | Docker | Node Engine DAG execution |
|
||||
|
||||
## Bugs critiques identifiés (audit 2026-03-18)
|
||||
|
||||
| # | Sévérité | Module | Description |
|
||||
@@ -193,6 +313,7 @@ mindmap
|
||||
| VISION_MODEL | qwen3-vl:8b | Non |
|
||||
| COMFYUI_URL | stable2.kxkm.net | Non |
|
||||
| SEARXNG_URL | localhost:8080 | Non |
|
||||
| LIGHTRAG_URL | localhost:9621 | Non |
|
||||
| PYTHON_BIN | python3 | Non |
|
||||
| MAX_OLLAMA_CONCURRENT | 3 | Non |
|
||||
| ADMIN_BOOTSTRAP_TOKEN | - | Non |
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
# Spike: Integration NexusRAG (lot-31) — 2026-03-19
|
||||
|
||||
**Date**: 2026-03-19
|
||||
**Auteur**: Claude (spike automatise)
|
||||
**Statut**: DRAFT
|
||||
**Lot**: 31
|
||||
|
||||
---
|
||||
|
||||
## 1. Resume du projet
|
||||
|
||||
| Champ | Valeur |
|
||||
|---|---|
|
||||
| **Nom** | NexusRAG |
|
||||
| **Auteur** | LeDat98 |
|
||||
| **URL GitHub** | https://github.com/LeDat98/NexusRAG |
|
||||
| **Stars** | ~197 (mars 2026) |
|
||||
| **Forks** | ~45 |
|
||||
| **Licence** | Non specifiee (pas de LICENSE dans le repo) |
|
||||
| **Cree** | 2026-03-15 |
|
||||
| **Derniere MAJ** | 2026-03-19 (actif, 4 jours d'age) |
|
||||
| **Langage** | Python |
|
||||
| **Issues ouvertes** | 6 |
|
||||
|
||||
NexusRAG est un systeme RAG hybride combinant recherche vectorielle, graphe de connaissances
|
||||
(LightRAG), et cross-encoder reranking, avec parsing documentaire Docling, intelligence
|
||||
visuelle (captioning images/tableaux), chat agentique streaming, et citations inline.
|
||||
Alimente par Gemini ou des modeles locaux Ollama.
|
||||
|
||||
**Note importante** : ce projet a seulement 4 jours d'existence (cree le 15 mars 2026).
|
||||
C'est un projet tres recent et experimental.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
### Pipeline de retrieval hybride a 3 voies
|
||||
|
||||
```
|
||||
Documents (PDF, DOCX, PPTX, HTML, TXT)
|
||||
|
|
||||
v
|
||||
[Docling Parser]
|
||||
| - Preservation hierarchie titres
|
||||
| - Enrichissement formules LaTeX
|
||||
| - Groupement paragraphes, limites de pages
|
||||
v
|
||||
[HybridChunker (max_tokens=512, merge_peers=True)]
|
||||
| - Respecte limites semantiques ET structurelles
|
||||
| - Ne coupe jamais mid-heading ou mid-table
|
||||
| - Metadata page-aware (numeros de page, heading paths)
|
||||
v
|
||||
+--------------------+--------------------+
|
||||
| | |
|
||||
v v v
|
||||
[Vector Search] [KG Entity Lookup] [Visual Intelligence]
|
||||
BAAI/bge-m3 LightRAG KG Image/Table
|
||||
1024d embeddings Gemini 3072d / captioning
|
||||
Ollama / ST
|
||||
| | |
|
||||
+--------------------+--------------------+
|
||||
|
|
||||
v
|
||||
[Cross-Encoder Reranking]
|
||||
|
|
||||
v
|
||||
[Agentic Streaming Chat]
|
||||
|
|
||||
v
|
||||
[Reponse avec citations inline]
|
||||
```
|
||||
|
||||
### Composants cles
|
||||
|
||||
| Composant | Detail |
|
||||
|---|---|
|
||||
| **Parsing documents** | Docling (PDF, DOCX, PPTX, HTML, TXT) |
|
||||
| **Chunking** | HybridChunker semantique + structurel, 512 tokens max |
|
||||
| **Embeddings** | Dual-model : BAAI/bge-m3 (1024d) + KG embedding (Gemini 3072d / Ollama / sentence-transformers) |
|
||||
| **Vector Search** | Recherche vectorielle classique (over-fetch) |
|
||||
| **Knowledge Graph** | LightRAG — extraction entites/relations automatique |
|
||||
| **Reranking** | Cross-encoder (ameliore significativement la precision) |
|
||||
| **Visual Intelligence** | Captioning images et tableaux dans les documents |
|
||||
| **Chat** | Streaming agentique avec citations inline |
|
||||
| **LLM backends** | Gemini (cloud) ou Ollama (local) |
|
||||
|
||||
### Dual-Model Embeddings
|
||||
|
||||
- **Recherche vectorielle** : BAAI/bge-m3 (1024 dimensions) — modele multilingue performant
|
||||
- **KG embedding** : Gemini 3072d (cloud) / Ollama embedding (local) / sentence-transformers (offline)
|
||||
|
||||
---
|
||||
|
||||
## 3. Compatibilite Ollama
|
||||
|
||||
NexusRAG supporte nativement Ollama pour un deploiement 100% local :
|
||||
|
||||
- **LLM** : tout modele Ollama (gemma2, llama3, mistral, qwen, etc.)
|
||||
- **Embeddings** : via Ollama ou sentence-transformers (offline complet)
|
||||
- **Mode offline** : possible sans aucun appel cloud
|
||||
|
||||
Cela correspond parfaitement a l'architecture kxkm_clown qui utilise deja Ollama
|
||||
en natif sur kxkm-ai.
|
||||
|
||||
### Test communautaire Ollama
|
||||
|
||||
LightRAG (composant interne de NexusRAG) a ete teste avec Ollama + gemma2:2b sur un
|
||||
GPU de minage avec 6 GB RAM : 197 entites et 19 relations extraites sur un livre de test.
|
||||
|
||||
---
|
||||
|
||||
## 4. Integration Docling
|
||||
|
||||
Docling est le parser documentaire de NexusRAG, developpe par IBM :
|
||||
|
||||
| Fonctionnalite | Detail |
|
||||
|---|---|
|
||||
| **Formats** | PDF, DOCX, PPTX, HTML, TXT |
|
||||
| **Preservation structure** | Hierarchie titres, limites pages, groupement paragraphes |
|
||||
| **Formules** | Notation LaTeX preservee |
|
||||
| **Tables** | Extraction structurelle (optionnelle, GPU pour table_structure) |
|
||||
| **GPU** | Optionnel — principalement CPU-bound, GPU pour model table seulement |
|
||||
| **VRAM** | Minimal avec `convert_do_table_structure=false` |
|
||||
|
||||
Docling est principalement CPU-bound (parsing PDF, analyse layout). Le GPU n'accelere
|
||||
que le modele de structure de tableaux, qui s'active en courtes rafales par page.
|
||||
|
||||
---
|
||||
|
||||
## 5. Benchmarks et evaluation
|
||||
|
||||
### Methodology de test NexusRAG
|
||||
|
||||
NexusRAG a ete evalue avec deux methodes complementaires :
|
||||
|
||||
| Methode | Detail |
|
||||
|---|---|
|
||||
| **16 tests manuels** | 6 categories, 8 metriques rule-based (keyword coverage, refusal accuracy, citation format, language match) |
|
||||
| **30 tests RAGAS synthetiques** | LLM-as-judge, metriques standard RAGAS |
|
||||
|
||||
### Corpus de test
|
||||
|
||||
- TechVina Annual Report 2025 (vietnamien, 26 chunks)
|
||||
- DeepSeek-V3.2 Technical Paper (anglais, 57 chunks)
|
||||
|
||||
### Resultats publies
|
||||
|
||||
Les benchmarks comparent principalement :
|
||||
- **Cout-efficacite** : modeles locaux 4B/9B vs cloud
|
||||
- **Faithfulness** : fidelite aux documents sources
|
||||
- **Table extraction** : qualite d'extraction de tableaux
|
||||
- **Consistance multilingue** : vietnamien + anglais
|
||||
|
||||
**Note** : pas de benchmark direct NexusRAG vs LightRAG seul publie.
|
||||
Les 197 stars suggerent un projet encore en phase d'adoption precoce.
|
||||
|
||||
### Comparaison conceptuelle : NexusRAG vs LightRAG seul
|
||||
|
||||
| Aspect | LightRAG seul | NexusRAG |
|
||||
|---|---|---|
|
||||
| **Retrieval** | KG + vecteurs (mode mix) | KG + vecteurs + cross-encoder reranking |
|
||||
| **Parsing** | Manuel (text brut) | Docling (structure preservee) |
|
||||
| **Visual** | Non | Captioning images/tableaux |
|
||||
| **Citations** | Support basique | Citations inline avec sources |
|
||||
| **Streaming** | Non natif | Chat agentique streaming |
|
||||
| **Complexity** | Simple, mature (EMNLP 2025) | Plus complet, mais plus jeune |
|
||||
|
||||
---
|
||||
|
||||
## 6. Capacites cles pour kxkm_clown
|
||||
|
||||
### 6.1. RAG documentaire pour les personnages
|
||||
|
||||
Les clowns de kxkm_clown pourraient avoir acces a une base documentaire contextuelle :
|
||||
- Scripts, textes de spectacle
|
||||
- Fiches de personnages
|
||||
- Historique des interactions
|
||||
- Documents techniques/artistiques
|
||||
|
||||
NexusRAG permettrait une recherche hybride (vecteurs + graphe de connaissances)
|
||||
significativement plus precise que le RAG naif.
|
||||
|
||||
### 6.2. Intelligence visuelle
|
||||
|
||||
Le captioning d'images et de tableaux pourrait enrichir les reponses des personnages
|
||||
avec du contexte visuel (affiches, photos de scene, plans).
|
||||
|
||||
### 6.3. Citations inline
|
||||
|
||||
Les reponses avec citations permettent la tracabilite et le debug des hallucinations,
|
||||
utile pour le monitoring en spectacle.
|
||||
|
||||
---
|
||||
|
||||
## 7. Plan d'integration (3 phases)
|
||||
|
||||
### Phase 1 : Evaluation comparative (2-3 jours)
|
||||
|
||||
1. Installer NexusRAG localement sur kxkm-ai
|
||||
2. Comparer avec LightRAG seul (deja spike le meme jour) :
|
||||
- Qualite de retrieval sur corpus FR
|
||||
- Latence de reponse
|
||||
- Utilisation VRAM avec Ollama
|
||||
3. Tester Docling sur documents FR reels (scripts, fiches)
|
||||
4. Evaluer la maturite du code (4 jours d'age seulement)
|
||||
5. Verifier : est-ce un wrapper fin sur LightRAG ou un apport reel ?
|
||||
|
||||
### Phase 2 : Integration conditionnelle (3-5 jours)
|
||||
|
||||
*Uniquement si Phase 1 montre un avantage significatif sur LightRAG seul*
|
||||
|
||||
1. Integrer le pipeline NexusRAG dans l'API kxkm_clown
|
||||
2. Configurer Ollama comme backend LLM + embeddings
|
||||
3. Indexer le corpus documentaire du spectacle
|
||||
4. Exposer via endpoint REST pour les personas
|
||||
5. Tester cross-encoder reranking avec bge-reranker-v2-m3
|
||||
|
||||
### Phase 3 : Production (2-3 jours)
|
||||
|
||||
1. Docker compose avec volumes persistants pour le KG et le vector store
|
||||
2. Pipeline d'ingestion automatique de nouveaux documents
|
||||
3. Monitoring latence / qualite dans OPS TUI
|
||||
4. Cache et optimisation pour le temps reel conversationnel
|
||||
|
||||
---
|
||||
|
||||
## 8. Risques et bloqueurs
|
||||
|
||||
| Risque | Severite | Mitigation |
|
||||
|---|---|---|
|
||||
| **Projet de 4 jours d'age** | **HAUTE** | Evaluation approfondie Phase 1 ; fallback sur LightRAG seul |
|
||||
| **Licence non specifiee** | **HAUTE** | Contacter l'auteur ou attendre clarification avant usage production |
|
||||
| **197 stars seulement** | Moyenne | Indicateur de maturite faible ; le code peut manquer de robustesse |
|
||||
| **Pas de benchmark FR** | Moyenne | Tests FR manuels en Phase 1 |
|
||||
| **Dependance sur LightRAG** | Faible | LightRAG est mature (EMNLP 2025, MIT) ; NexusRAG ajoute une couche |
|
||||
| **Overlap avec LightRAG spike existant** | Moyenne | Evaluer si NexusRAG apporte assez au-dessus de LightRAG seul |
|
||||
| **Docling GPU optionnel** | Faible | CPU suffit pour le parsing ; GPU pour tables seulement |
|
||||
| **6 issues ouvertes, 1 contributeur** | Moyenne | Bus factor de 1, risque d'abandon |
|
||||
| **Corpus de test non-FR** | Moyenne | Vietnamien + anglais testes ; francais non valide |
|
||||
|
||||
---
|
||||
|
||||
## 9. Recommandation
|
||||
|
||||
### ATTENDRE (evaluer en Phase 1 avant engagement)
|
||||
|
||||
**Justification** :
|
||||
|
||||
1. **Projet extremement jeune** (4 jours, cree le 15 mars 2026). Malgre 197 stars
|
||||
et une architecture prometteuse, la maturite est insuffisante pour la production.
|
||||
|
||||
2. **Licence non specifiee** : bloqueur pour tout usage serieux. Pas de fichier LICENSE
|
||||
dans le repository.
|
||||
|
||||
3. **Overlap avec LightRAG** : le spike LIGHTRAG_SPIKE_2026-03-19.md couvre deja
|
||||
LightRAG seul, qui est mature (EMNLP 2025, MIT, 21K+ stars). NexusRAG ajoute
|
||||
Docling + cross-encoder reranking + visual intelligence par-dessus LightRAG.
|
||||
|
||||
4. **La valeur ajoutee est reproductible** : les composants que NexusRAG ajoute
|
||||
(Docling, cross-encoder reranking, bge-m3) peuvent etre integres manuellement
|
||||
dans un pipeline LightRAG existant, avec plus de controle.
|
||||
|
||||
5. **Bus factor 1** : un seul contributeur, risque d'abandon.
|
||||
|
||||
### Alternative recommandee
|
||||
|
||||
Plutot que d'adopter NexusRAG en bloc, construire un pipeline equivalent :
|
||||
|
||||
```
|
||||
[Docling] --> [HybridChunker] --> [LightRAG (mature)]
|
||||
|
|
||||
[bge-reranker-v2-m3]
|
||||
|
|
||||
[API kxkm_clown]
|
||||
```
|
||||
|
||||
Cela donne les memes capacites avec des composants matures et licencies :
|
||||
- **LightRAG** : MIT, 21K+ stars, EMNLP 2025
|
||||
- **Docling** : Apache-2.0, IBM, mature
|
||||
- **bge-reranker-v2-m3** : MIT, BAAI
|
||||
|
||||
Surveiller NexusRAG pour evaluer sa maturation dans 2-3 mois.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- [LeDat98/NexusRAG (GitHub)](https://github.com/LeDat98/NexusRAG)
|
||||
- [HKUDS/LightRAG (GitHub)](https://github.com/HKUDS/LightRAG)
|
||||
- [Docling (IBM)](https://www.docling.ai/)
|
||||
- [LightRAG: Simple and Fast RAG (EMNLP 2025)](https://openreview.net/forum?id=bbVH40jy7f)
|
||||
- [BAAI/bge-m3 (Hugging Face)](https://huggingface.co/BAAI/bge-m3)
|
||||
- [Hands-on LightRAG (DEV Community)](https://dev.to/aairom/hands-on-experience-with-lightrag-3hje)
|
||||
+404
-75
@@ -1,89 +1,418 @@
|
||||
# Veille OSS — 2026-03-19
|
||||
# Veille OSS -- kxkm_clown / 3615-KXKM
|
||||
**Date**: 2026-03-19 (mise a jour approfondie)
|
||||
|
||||
Mise a jour veille projets et librairies open source pour 3615-KXKM.
|
||||
---
|
||||
|
||||
## Top recommandations (impact/effort)
|
||||
|
||||
| Priorite | Projet | Usage | URL |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Chatterbox Turbo | TTS zero-shot, 350M, emotion tags, MIT | https://github.com/resemble-ai/chatterbox |
|
||||
| 2 | LightRAG v1.4.11 | Graph RAG, Ollama natif, workspace isolation | https://github.com/HKUDS/LightRAG |
|
||||
| 3 | Qwen3-TTS | TTS 0.6B-1.7B, clone 3s, 10 langues, Apache 2.0 | https://github.com/QwenLM/Qwen3-TTS |
|
||||
| 4 | Kokoro | TTS 82M params, ultra-leger, CPU | https://github.com/hexgrad/kokoro |
|
||||
| 5 | AgoraAI | Multi-persona voice-to-voice, dual-queue async | https://www.mdpi.com/2076-3417/16/4/2120 |
|
||||
| 6 | vault66-crt-effect | React CRT presets (npm install) | https://github.com/mdombrov-33/vault66-crt-effect |
|
||||
| 7 | webgl-crt-shader | WebGL GPU CRT shader, jan 2026 | https://github.com/gingerbeardman/webgl-crt-shader |
|
||||
| 8 | NexusRAG | Hybrid: LightRAG + Docling + Ollama | https://github.com/LeDat98/NexusRAG |
|
||||
| 9 | Pocket TTS | 100M params, CPU temps reel, voice cloning | https://github.com/kyutai-labs/pocket-tts |
|
||||
| 10 | LiveKit Agents v1.4.5 | Voice/WebRTC, MCP natif, Apache 2.0 | https://github.com/livekit/agents |
|
||||
| 1 | Pocket TTS | TTS CPU temps reel, voice cloning, MIT, 100M params | https://github.com/kyutai-labs/pocket-tts |
|
||||
| 2 | Qwen3-TTS | Voice design par prompt NL, clone 3s, streaming 97ms | https://github.com/QwenLM/Qwen3-TTS |
|
||||
| 3 | Kokoro-82M | TTS ultra-rapide (<0.3s), CPU/GPU, Apache 2.0 | https://github.com/hexgrad/kokoro |
|
||||
| 4 | Dify | Workflow visuel LLM + RAG + agents + MCP server | https://github.com/langgenius/dify |
|
||||
| 5 | SillyTavern | Multi-persona chat, character cards, group chat | https://github.com/SillyTavern/SillyTavern |
|
||||
| 6 | Chatterbox Turbo | TTS zero-shot, 350M, emotion tags, MIT (deja integre) | https://github.com/resemble-ai/chatterbox |
|
||||
| 7 | LightRAG v1.4.11 | Graph RAG, Ollama natif (deja integre) | https://github.com/HKUDS/LightRAG |
|
||||
| 8 | NodeTool | Visual AI workflow builder, reference pour node engine | https://github.com/nodetool-ai/nodetool |
|
||||
| 9 | Rivet | Visual prompt graph editor, debug LLM chains | https://github.com/Ironclad/rivet |
|
||||
| 10 | F5-TTS | Meilleur voice cloning zero-shot, flow matching | https://github.com/SWivid/F5-TTS |
|
||||
|
||||
## Nouvelles decouvertes (mars 2026)
|
||||
---
|
||||
|
||||
## 1. Multi-Persona LLM Chat Platforms (Open Source)
|
||||
|
||||
### SillyTavern
|
||||
- **URL**: https://github.com/SillyTavern/SillyTavern
|
||||
- **Stars**: ~10k+ (300+ contributors, 3 ans de dev)
|
||||
- **Activite**: Tres actif, grosse communaute
|
||||
- **Description**: Fork de TavernAI. Character cards avec personnalite/background/scenario. Group chats multi-bots ou les personnages parlent entre eux. Fonctionne avec Ollama, Claude, OpenAI, modeles locaux.
|
||||
- **Pertinence**: **HAUTE** -- projet existant le plus proche de kxkm_clown. Systeme de character cards similaire aux definitions de persona. Group chat = routage de conversation multi-persona.
|
||||
- **Integration**: Etudier le format character card pour interoperabilite. Emprunter les patterns UI de switch de persona. Leur logique d'orchestration de group chat est directement pertinente.
|
||||
|
||||
### Open WebUI
|
||||
- **URL**: https://github.com/open-webui/open-webui
|
||||
- **Stars**: ~90k+
|
||||
- **Activite**: Extremement actif, frontend Ollama de reference
|
||||
- **Description**: Plateforme AI self-hosted complete. RAG avec 9 vector DBs, web search (15+ providers dont SearXNG), voice I/O (Whisper + TTS), model builder, multi-user, Python function calling.
|
||||
- **Pertinence**: **HAUTE** -- overlap significatif avec le stack kxkm_clown. Meme backend Ollama, RAG, web search, integration TTS.
|
||||
- **Integration**: Architecture de reference pour bonnes pratiques Ollama. Adopter leurs patterns de pipeline RAG. Leur integration SearXNG est directement pertinente (kxkm utilise deja SearXNG).
|
||||
|
||||
### LobeChat / LobeHub
|
||||
- **URL**: https://github.com/lobehub/lobe-chat
|
||||
- **Stars**: ~50k+
|
||||
- **Activite**: Tres actif
|
||||
- **Description**: Plateforme de collaboration multi-agents. Design d'equipes d'agents, ecosysteme de plugins, TTS/STT, upload fichiers, support modeles visuels.
|
||||
- **Pertinence**: MOYENNE -- le paradigme agent-comme-unite-de-travail correspond au concept de persona. Bonne reference pour patterns UX multi-agents.
|
||||
- **Integration**: L'architecture de plugins pourrait inspirer les extensions du node engine kxkm.
|
||||
|
||||
### LibreChat
|
||||
- **URL**: https://github.com/danny-avila/LibreChat
|
||||
- **Stars**: ~25k+
|
||||
- **Description**: Interface chat AI unifiee. Multi-provider, branching de conversations, presets, plugins. API compatible OpenAI.
|
||||
- **Pertinence**: MOYENNE -- le branching de conversations et le routage multi-provider sont des patterns pertinents.
|
||||
|
||||
### Eliza (BarbarossaKad)
|
||||
- **URL**: https://github.com/BarbarossaKad/Eliza
|
||||
- **Description**: Systeme de roleplay AI self-hosted. 100% local, zero fuites de donnees. Alternative open-source a Character.AI utilisant Ollama.
|
||||
- **Pertinence**: MOYENNE -- meme philosophie que kxkm (local-first, Ollama, base sur les personnages).
|
||||
|
||||
---
|
||||
|
||||
## 2. Frameworks d'Orchestration LLM (Alternatives a LangChain)
|
||||
|
||||
### Dify
|
||||
- **URL**: https://github.com/langgenius/dify
|
||||
- **Stars**: ~70k+
|
||||
- **Activite**: Extremement actif
|
||||
- **Description**: Plateforme open-source d'apps LLM. Workflow builder visuel, pipeline RAG, 50+ outils agents integres, support serveur MCP, integration Ollama/LocalAI. Self-hosted, donnees restent locales.
|
||||
- **Pertinence**: **HAUTE** -- le workflow builder visuel est parallele au node engine kxkm. Integration Ollama, RAG, support protocole MCP. Pourrait remplacer ou complementer l'orchestration custom.
|
||||
- **Integration**: La capacite serveur MCP signifie que les workflows Dify pourraient etre exposes comme outils MCP vers kxkm. Les patterns de workflow visuel informent le design du node engine.
|
||||
|
||||
### LlamaIndex
|
||||
- **URL**: https://github.com/run-llama/llama_index
|
||||
- **Stars**: ~40k+
|
||||
- **Description**: Framework de donnees pour apps LLM. Best-in-class pour search/retrieval, metadata structuree, traitement de documents.
|
||||
- **Pertinence**: MOYENNE -- alternative de pipeline RAG. Meilleure metadata structuree que LightRAG pour certains cas.
|
||||
|
||||
### Haystack
|
||||
- **URL**: https://github.com/deepset-ai/haystack
|
||||
- **Stars**: ~20k+
|
||||
- **Description**: Framework NLP/LLM end-to-end. Architecture pipeline, document stores, retrievers, generators. Production-ready.
|
||||
- **Pertinence**: MOYENNE -- architecture pipeline mature. Bon pour hardening production du RAG.
|
||||
|
||||
### Flowise
|
||||
- **URL**: https://github.com/FlowiseAI/Flowise
|
||||
- **Stars**: ~35k+
|
||||
- **Description**: UI low-code visuelle pour construire des chaines/agents LLM. Construit sur LangChain.js. Drag-and-drop.
|
||||
- **Pertinence**: MOYENNE -- paradigme de builder visuel overlap avec le concept de node engine.
|
||||
|
||||
### LangGraph
|
||||
- **URL**: https://github.com/langchain-ai/langgraph
|
||||
- **Stars**: ~10k+
|
||||
- **Description**: Apps multi-agents avec cycles, agents long-running, haut controle.
|
||||
- **Pertinence**: BASSE-MOYENNE -- pertinent si kxkm a besoin de machines a etats agents complexes (graphes d'interaction de personas).
|
||||
|
||||
---
|
||||
|
||||
## 3. Streaming WebSocket pour Reponses LLM
|
||||
|
||||
### Bonnes pratiques (consolidees)
|
||||
|
||||
1. **WebSocket full-duplex > SSE pour le chat**: WebSocket permet communication bidirectionnelle (l'utilisateur peut interrompre/annuler la generation en cours). SSE est plus simple mais unidirectionnel.
|
||||
|
||||
2. **Forwarding token-par-token**: Forward chaque token de la reponse streaming Ollama directement au client WebSocket. Ne pas bufferiser la reponse entiere.
|
||||
|
||||
3. **Gestion de backpressure**: Monitorer le taux de consommation client. Si le buffer d'envoi WebSocket se remplit, pauser le streaming Ollama pour eviter l'accumulation memoire.
|
||||
|
||||
4. **Reconnexion avec reprise**: Implementer des IDs de message pour que les clients puissent se reconnecter et reprendre depuis le dernier token recu.
|
||||
|
||||
5. **Slots de modeles concurrents**: Ollama supporte `OLLAMA_MAX_LOADED_MODELS` pour inference parallele. Utiliser des canaux WebSocket separes par persona pour activer de vraies reponses multi-persona concurrentes.
|
||||
|
||||
6. **Heartbeat/ping-pong**: Garder les connexions vivantes a travers NAT/proxies avec des pings periodiques.
|
||||
|
||||
7. **Frames binaires pour l'audio**: Quand on stream des chunks audio TTS via WebSocket, utiliser des frames binaires (pas base64 dans JSON). 33% d'economie de bande passante.
|
||||
|
||||
8. **Multiplexage de canaux**: Utiliser un systeme de channel/topic dans les messages WS pour gerer plusieurs streams concurrents (generation texte + TTS + status generation image).
|
||||
|
||||
### Format de message recommande
|
||||
```json
|
||||
{
|
||||
"type": "token|audio|image|status|error",
|
||||
"persona": "pharmacius",
|
||||
"channel": "chat-123",
|
||||
"seq": 42,
|
||||
"data": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### Projets de reference
|
||||
- **Resonance Framework** (distantmagic/resonance) -- Framework PHP async avec integration WebSocket llama.cpp
|
||||
- **web-llm** (mlc-ai/web-llm) -- ~15k+ stars. Inference LLM in-browser via WebGPU
|
||||
- **AG2** -- Framework agent avec streaming WebSocket pour chat multi-agent
|
||||
|
||||
---
|
||||
|
||||
## 4. Ollama Tool Calling / Function Calling
|
||||
|
||||
### Meilleurs modeles pour tool calling (2025-2026)
|
||||
|
||||
| Modele | Taille | Qualite Tool Calling | Notes |
|
||||
|--------|--------|---------------------|-------|
|
||||
| Llama 3.1 8B-Instruct | 8B | Meilleur overall | Implementation de reference Meta |
|
||||
| Qwen 2.5 7B | 7B | Excellent | Bon tool calling multilingual |
|
||||
| Mistral 7B | 7B | Bon | Moins de ressources necessaires |
|
||||
| Llama 3.3 70B | 70B | Excellent | Necessite RTX 4090 (kxkm en a une) |
|
||||
| Command-R+ | 35B | Tres bon | Optimise pour l'utilisation d'outils |
|
||||
|
||||
### Bonnes pratiques
|
||||
|
||||
- **Format JSON Schema** pour les definitions d'outils via le champ `tools` de `/api/chat` Ollama
|
||||
- **Tool calls en streaming** supporte -- commencer l'action avant la reponse complete
|
||||
- **Modeles concurrents multiples**: Utiliser `OLLAMA_MAX_LOADED_MODELS` pour garder un petit modele pour le routage d'outils et un plus gros pour la generation
|
||||
- **La fiabilite chute sous 8B params** -- pour du tool calling complexe, utiliser des modeles 8B+
|
||||
- **Mode sortie structuree** (`format: json`) aide a forcer des reponses JSON valides pour les tool calls
|
||||
- **Chaine de fallback**: Essayer tool call -> si JSON malformed, retry avec prompt plus simple -> fallback en texte libre
|
||||
|
||||
### References
|
||||
- Docs officiels: https://docs.ollama.com/capabilities/tool-calling
|
||||
- Blog post: https://ollama.com/blog/tool-support
|
||||
|
||||
---
|
||||
|
||||
## 5. Solutions TTS (Alternatives/Upgrades a Piper)
|
||||
|
||||
### Tier 1: Upgrades drop-in pour kxkm
|
||||
|
||||
#### Kokoro-82M
|
||||
- **URL**: https://github.com/hexgrad/kokoro
|
||||
- **Licence**: Apache 2.0
|
||||
- **Taille**: 82M params (~300MB, quantise ~80MB)
|
||||
- **Vitesse**: Sous 0.3s pour n'importe quel texte. 36x temps reel sur GPU. Quasi temps reel sur CPU.
|
||||
- **Qualite**: Note 5/5 en naturalite. Gamme emotionnelle limitee.
|
||||
- **Voice Cloning**: Pas de cloning natif. Librairie de voix preselectionnees.
|
||||
- **Langues**: Anglais, Francais, Japonais, Coreen, Chinois, autres
|
||||
- **Pertinence**: **HAUTE** -- minuscule, rapide, Apache. Parfait pour reponses personas a faible latence. Support francais.
|
||||
- **Integration**: Remplacer Piper pour les chemins critique-vitesse. Runtime ONNX ou crate Rust disponible. Wrapper FastAPI existe (Kokoro-FastAPI).
|
||||
|
||||
#### Kyutai Pocket TTS
|
||||
- **URL**: https://github.com/kyutai-labs/pocket-tts
|
||||
- **Licence**: MIT
|
||||
- **Taille**: 100M params
|
||||
- **Vitesse**: Temps reel sur CPU (RTF ~0.17 sur M4, ~6x plus rapide que temps reel). Pas de GPU necessaire.
|
||||
- **Qualite**: Plus bas WER (1.84%) parmi les concurrents. Haute fidelite.
|
||||
- **Voice Cloning**: Oui, 5 secondes d'audio de reference.
|
||||
- **Pertinence**: **TRES HAUTE** -- CPU temps reel + voice cloning + licence MIT + minuscule. Peut tourner a cote d'Ollama sans contention GPU.
|
||||
- **Integration**: 5 lignes de Python. Lancer comme service sidecar. Parfait pour kxkm ou le GPU est occupe avec Ollama/ComfyUI.
|
||||
|
||||
### Tier 2: Haute qualite, plus de ressources
|
||||
|
||||
#### Chatterbox Turbo (deja dans le stack kxkm)
|
||||
- **URL**: https://github.com/resemble-ai/chatterbox
|
||||
- **Licence**: MIT
|
||||
- **Taille**: 350M params
|
||||
- **Qualite**: Bat ElevenLabs en tests aveugles (63.75% preference). 1M+ downloads HuggingFace.
|
||||
- **Voice Cloning**: Oui, zero-shot. Expressivite configurable. Tags paralinguistiques [laugh] [cough].
|
||||
- **Langues**: 23 langues dont le francais
|
||||
- **Status**: Deja integre dans kxkm. Garder comme moteur haute-qualite principal.
|
||||
|
||||
#### F5-TTS
|
||||
- **URL**: https://github.com/SWivid/F5-TTS
|
||||
- **Licence**: MIT-like
|
||||
- **Qualite**: Cloning zero-shot le plus realiste. Architecture flow matching + DiT.
|
||||
- **Voice Cloning**: Oui, zero-shot a partir d'un court echantillon.
|
||||
- **Pertinence**: HAUTE -- meilleure qualite de cloning que Chatterbox pour certaines voix.
|
||||
- **Integration**: Backend TTS secondaire pour les personas necessitant un cloning vocal tres specifique.
|
||||
|
||||
#### Qwen3-TTS
|
||||
- **URL**: https://github.com/QwenLM/Qwen3-TTS
|
||||
- **Licence**: Apache 2.0
|
||||
- **Taille**: 0.6B - 1.7B params
|
||||
- **Vitesse**: 97ms latence premier token (architecture streaming)
|
||||
- **Voice Cloning**: Oui, 3 secondes d'audio de reference
|
||||
- **Special**: Design de voix par langage naturel ("fais-le sonner comme un vieux professeur fatigue"). Controle emotion/ton/prosodie.
|
||||
- **Langues**: 10 langues dont le francais
|
||||
- **Pertinence**: **HAUTE** -- le design de voix en langage naturel est parfait pour creer des voix distinctes par persona. Integration ComfyUI existante.
|
||||
- **Integration**: Utiliser des prompts de design vocal pour creer des voix uniques par persona. Node ComfyUI deja disponible. Architecture streaming compatible avec le pipeline WebSocket.
|
||||
|
||||
### Tier 3: Specialise
|
||||
|
||||
#### CosyVoice2
|
||||
- **URL**: https://github.com/FunAudioLLM/CosyVoice
|
||||
- **Description**: Multi-lingue, ultra-basse latence, controle emotionnel. Par Alibaba/FunAudioLLM.
|
||||
|
||||
#### MeloTTS
|
||||
- **Description**: Multilingual, basse latence, nombreux accents. Pas de voice cloning.
|
||||
- **Pertinence**: BASSE -- Kokoro est meilleur pour le meme usage.
|
||||
|
||||
### Strategie TTS recommandee pour kxkm
|
||||
|
||||
```
|
||||
Chemin rapide/CPU: Kokoro-82M ou Pocket TTS (< 200ms, pas de GPU)
|
||||
Chemin qualite: Chatterbox (actuel) (GPU, meilleure qualite)
|
||||
Chemin cloning: Qwen3-TTS ou F5-TTS (GPU, design de voix)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Frameworks RAG compatibles Ollama
|
||||
|
||||
### LightRAG (deja dans le stack kxkm)
|
||||
- **URL**: https://github.com/HKUDS/LightRAG
|
||||
- **Stars**: 29.4k
|
||||
- **Status**: Deja integre. RAG base graphe, rapide, tourne sur CPU.
|
||||
- **Verdict**: Garder. Meilleur equilibre vitesse/qualite pour retrieval augmente par graphe.
|
||||
|
||||
### Nano-GraphRAG
|
||||
- **URL**: https://github.com/gusye1234/nano-graphrag
|
||||
- **Description**: Alternative GraphRAG legere. Trois modes de requete (Naive, Local, Global). Plus simple que LightRAG.
|
||||
- **Pertinence**: MOYENNE -- alternative plus simple si LightRAG devient trop complexe.
|
||||
|
||||
### RAGFlow
|
||||
- **URL**: https://github.com/infiniflow/ragflow
|
||||
- **Stars**: ~70k+
|
||||
- **Description**: Moteur RAG avec comprehension profonde de documents. Chunking avance, extraction tables/images.
|
||||
- **Pertinence**: MOYENNE -- meilleur pour traitement lourd de documents (PDFs avec tables, etc).
|
||||
|
||||
### NexusRAG
|
||||
- **URL**: https://github.com/LeDat98/NexusRAG
|
||||
- **Description**: Hybride: vector + graphe LightRAG + cross-encoder reranking + Docling. Supporte Ollama nativement.
|
||||
- **Pertinence**: MOYENNE-HAUTE -- evolution naturelle du setup LightRAG actuel.
|
||||
|
||||
### Chroma + Ollama (RAG vectoriel simple)
|
||||
- **URL**: https://github.com/chroma-core/chroma
|
||||
- **Stars**: ~18k+
|
||||
- **Description**: Vector DB legere. Integration facile embeddings Ollama.
|
||||
- **Pertinence**: MOYENNE -- plus simple que LightRAG quand les relations de graphe ne sont pas necessaires.
|
||||
|
||||
### Strategie RAG recommandee
|
||||
```
|
||||
Requetes graphe: LightRAG (actuel) -- retrieval conscient des relations
|
||||
Vecteur simple: Chroma -- recherche de similarite rapide
|
||||
Documents lourds: RAGFlow -- si parsing PDF/tables necessaire
|
||||
Hybride: NexusRAG -- quand les deux sont necessaires
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Projets AI Creatifs / Performance Artistique
|
||||
|
||||
### NodeTool
|
||||
- **URL**: https://github.com/nodetool-ai/nodetool
|
||||
- **Description**: Builder visuel pour workflows/agents AI. Node-based, local-first, multimodal (texte/image/video/audio). Connexions de nodes type-safe.
|
||||
- **Pertinence**: **HAUTE** -- directement comparable au concept de node engine kxkm. Editeur visuel de graphes pour workflows AI avec connexions type-safe.
|
||||
- **Integration**: Etudier leur systeme de types de nodes et la validation de connexions. Peut informer le design du registry du node engine kxkm.
|
||||
|
||||
### Rivet
|
||||
- **URL**: https://github.com/Ironclad/rivet
|
||||
- **Stars**: ~3k+
|
||||
- **Description**: Environnement de programmation AI visuel open-source. Editeur de graphes node-based pour chaines de prompts LLM. Debug et collaboration sur des graphes de prompts.
|
||||
- **Pertinence**: **HAUTE** -- le plus proche du concept de node engine kxkm pour des workflows specifiques LLM.
|
||||
- **Integration**: Leurs outils de debug de graphes de prompts sont directement pertinents. Adapter leur format de serialisation de graphes.
|
||||
|
||||
### Invoke AI
|
||||
- **URL**: https://github.com/invoke-ai/InvokeAI
|
||||
- **Stars**: ~25k+
|
||||
- **Licence**: Apache 2.0
|
||||
- **Description**: Moteur creatif pour generation d'images AI. Self-hosted, entierement personnalisable. Editeur de workflow node-based.
|
||||
- **Pertinence**: MOYENNE -- workflow creatif AI node-based. Parallele avec l'approche ComfyUI + node engine de kxkm.
|
||||
|
||||
### ChainForge
|
||||
- **URL**: https://github.com/ianarawjo/ChainForge
|
||||
- **Description**: Environnement de programmation visuelle pour battle-tester des prompts LLM. Analyse de data flow.
|
||||
- **Pertinence**: MOYENNE -- evaluation de prompts et A/B testing des prompts de personas.
|
||||
|
||||
### AgoraAI
|
||||
- **URL**: https://www.mdpi.com/2076-3417/16/4/2120
|
||||
- **Description**: Framework voice-to-voice multi-persona (paper fev 2026). Resout le "Concurrency-Coherence Paradox" via Asynchronous Dual-Queue Processing.
|
||||
- **Pertinence**: **HAUTE** -- directement applicable au use-case kxkm_clown pour conversations multi-persona concurrentes.
|
||||
|
||||
### NeurIPS Creative AI Track
|
||||
- **URL**: https://neurips.cc/Conferences/2025/CallForCreativeAI
|
||||
- **Description**: Papiers de recherche et oeuvres explorant l'AI dans l'art/design/performance.
|
||||
- **Pertinence**: BASSE mais inspirante -- recherche academique sur AI + performance creative.
|
||||
|
||||
---
|
||||
|
||||
## 8. Architecture WebSocket Recommandee
|
||||
|
||||
```
|
||||
Client (UI Minitel)
|
||||
|
|
||||
| WebSocket (wss://)
|
||||
|
|
||||
API Server (Node.js)
|
||||
|
|
||||
+-- Ollama streaming API (HTTP SSE)
|
||||
+-- TTS sidecar (HTTP streaming)
|
||||
+-- ComfyUI (HTTP polling -> WS notify)
|
||||
|
|
||||
Persona Router
|
||||
|
|
||||
+-- Route message vers le bon modele/config persona
|
||||
+-- Gere le contexte de conversation par persona
|
||||
+-- Gere les reponses concurrentes de personas (group chat)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Feuille de route d'integration prioritaire
|
||||
|
||||
### Immediat (faible effort, fort impact)
|
||||
|
||||
| Projet | Action | Effort |
|
||||
|---------|--------|--------|
|
||||
| **Pocket TTS** | Ajouter comme backend TTS CPU-only a cote de Chatterbox | 1-2 jours |
|
||||
| **Kokoro-82M** | Ajouter comme TTS ultra-rapide pour reponses basse-latence | 1 jour |
|
||||
| **Ollama tool calling** | Implementer le tool calling structure pour les actions de personas | 2-3 jours |
|
||||
|
||||
### Court terme (effort moyen)
|
||||
|
||||
| Projet | Action | Effort |
|
||||
|---------|--------|--------|
|
||||
| **Qwen3-TTS** | Design vocal par persona via prompts en langage naturel | 3-5 jours |
|
||||
| **SillyTavern** | Etudier le format character card, envisager la compatibilite import | 2 jours |
|
||||
| **NodeTool/Rivet** | Informer le design du node engine avec leurs patterns | Recherche |
|
||||
|
||||
### Moyen terme (effort plus eleve)
|
||||
|
||||
| Projet | Action | Effort |
|
||||
|---------|--------|--------|
|
||||
| **Dify** | Evaluer comme workflow builder visuel pour pipelines persona complexes | 1 semaine |
|
||||
| **Open WebUI** | Etudier patterns RAG/search pour ameliorer kxkm | Recherche |
|
||||
| **F5-TTS** | Ajouter comme backend de voice cloning premium | 3-5 jours |
|
||||
| **NexusRAG** | Evaluer comme upgrade hybride de LightRAG | 3 jours |
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
### Multi-Persona Chat
|
||||
- [SillyTavern Docs](https://docs.sillytavern.app/)
|
||||
- [Open WebUI](https://github.com/open-webui/open-webui)
|
||||
- [LobeChat](https://github.com/lobehub/lobe-chat)
|
||||
- [LibreChat](https://www.librechat.ai/)
|
||||
- [Eliza](https://github.com/BarbarossaKad/Eliza)
|
||||
|
||||
### LLM Orchestration
|
||||
- [Dify AI](https://github.com/langgenius/dify)
|
||||
- [LlamaIndex](https://github.com/run-llama/llama_index)
|
||||
- [Haystack](https://github.com/deepset-ai/haystack)
|
||||
- [Flowise](https://flowiseai.com/)
|
||||
- [LangGraph](https://github.com/langchain-ai/langgraph)
|
||||
- [Top LangChain Alternatives](https://www.vellum.ai/blog/top-langchain-alternatives)
|
||||
- [LLM Orchestration 2026](https://aimultiple.com/llm-orchestration)
|
||||
|
||||
### TTS
|
||||
|
||||
- **Qwen3-TTS** (Alibaba, jan 2026) — 0.6B-1.7B params, clone voix en 3s, 10 langues, latence 97ms, Apache 2.0. FastAPI OpenAI-compat. Concurrent serieux de Chatterbox.
|
||||
- **Pocket TTS** (Kyutai) — 100M params, CPU temps reel, voice cloning. Ultra-leger, ideal edge/embarque. ~3600 stars.
|
||||
- **Chatterbox Turbo** — 350M params, 1-step diffusion, tags paralinguistiques [laugh] [cough]. Multilingual (23 langues).
|
||||
- [Kokoro-82M](https://github.com/hexgrad/kokoro)
|
||||
- [Kyutai Pocket TTS](https://github.com/kyutai-labs/pocket-tts)
|
||||
- [Chatterbox](https://github.com/resemble-ai/chatterbox)
|
||||
- [F5-TTS](https://github.com/SWivid/F5-TTS)
|
||||
- [Qwen3-TTS](https://github.com/QwenLM/Qwen3-TTS)
|
||||
- [CosyVoice2](https://github.com/FunAudioLLM/CosyVoice)
|
||||
- [Best Open-Source TTS Models 2026](https://www.bentoml.com/blog/exploring-the-world-of-open-source-text-to-speech-models)
|
||||
- [Open-Source TTS Comparison](https://www.inferless.com/learn/comparing-different-text-to-speech---tts--models-part-2)
|
||||
- [Chatterbox vs Kokoro vs others](https://ocdevel.com/blog/20250720-tts)
|
||||
|
||||
### RAG
|
||||
- [LightRAG](https://github.com/HKUDS/LightRAG)
|
||||
- [Nano-GraphRAG](https://github.com/gusye1234/nano-graphrag)
|
||||
- [RAGFlow](https://github.com/infiniflow/ragflow)
|
||||
- [NexusRAG](https://github.com/LeDat98/NexusRAG)
|
||||
- [Best Open-Source RAG Frameworks 2026](https://www.firecrawl.dev/blog/best-open-source-rag-frameworks)
|
||||
|
||||
- **LightRAG v1.4.11rc2** (13 mars 2026) — 29.4k stars. Nouveau Makefile, batch query embeddings, Qdrant fixes.
|
||||
- **RAG-Anything** (HKUDS) — Extension LightRAG pour multimodal (images, tables, formules).
|
||||
- **NexusRAG** — Hybrid: vector + LightRAG graph + cross-encoder reranking + Docling. Supporte Ollama nativement.
|
||||
- **ApeRAG** — Production-ready GraphRAG, multi-modal, MCP support, K8s.
|
||||
### Node/Visual Programming
|
||||
- [NodeTool](https://github.com/nodetool-ai/nodetool)
|
||||
- [Rivet](https://rivet.ironcladapp.com/)
|
||||
- [ChainForge](https://github.com/ianarawjo/ChainForge)
|
||||
- [Invoke AI](https://invoke.ai/)
|
||||
|
||||
### Multi-persona / Agents
|
||||
### WebSocket/Streaming
|
||||
- [web-llm](https://github.com/mlc-ai/web-llm)
|
||||
- [Resonance Framework](https://github.com/distantmagic/resonance)
|
||||
- [Ollama Tool Calling Docs](https://docs.ollama.com/capabilities/tool-calling)
|
||||
- [ComfyUI LLM Toolkit](https://github.com/Big-Idea-Technology/ComfyUI_LLM_Node)
|
||||
|
||||
- **AgoraAI** (fev 2026, paper MDPI) — Framework voice-to-voice multi-persona. Resout le "Concurrency-Coherence Paradox" via Asynchronous Dual-Queue Processing. Directement applicable au use-case kxkm_clown.
|
||||
- **CrewAI** — 44.3k stars, role-playing agents. Trop enterprise pour kxkm.
|
||||
- **LobeChat Agent Groups** — Equipes d'agents specialises collaborant en parallele.
|
||||
|
||||
### MCP 2026
|
||||
|
||||
- **Roadmap 2026** : Streamable HTTP (serveurs MCP distants sans etat), Tasks (lifecycle), Enterprise (audit, SSO, gateway).
|
||||
- **Ecosysteme** : OpenAI et Microsoft supportent MCP. Catalogue mcpservers.org.
|
||||
- **LiveKit Agents** : Integration MCP native depuis v1.4.
|
||||
|
||||
### UI Retro / CRT
|
||||
|
||||
- **webgl-crt-shader** (gingerbeardman, jan 2026) — WebGL pur, GPU-accelere, tweakable. Plus performant qu'overlay CSS.
|
||||
- **crt-beam-simulator** (Blur Busters) — Simulation physique du faisceau CRT, le plus realiste.
|
||||
|
||||
## Par categorie (mise a jour)
|
||||
|
||||
### TTS
|
||||
|
||||
| Projet | Stars | Licence | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| Chatterbox Turbo | 11k+ | MIT | Zero-shot, emotion, 350M, 1-step diffusion |
|
||||
| Qwen3-TTS | Nouveau | Apache 2.0 | 0.6B-1.7B, clone 3s, 10 langues |
|
||||
| Kokoro | 3k+ | Apache | 82M, ultra-rapide, CPU |
|
||||
| Pocket TTS | 3.6k | Open | 100M, CPU temps reel |
|
||||
| RealtimeTTS | 3.8k | MIT | Abstraction multi-backends |
|
||||
| OpenVoice | 36k | MIT | Voice cloning zero-shot |
|
||||
|
||||
### RAG
|
||||
|
||||
| Projet | Stars | Notes |
|
||||
| --- | --- | --- |
|
||||
| LightRAG | 29.4k | Graph RAG, Ollama natif, v1.4.11rc2 |
|
||||
| RAGFlow | 70k+ | Enterprise, deep document understanding |
|
||||
| NexusRAG | Nouveau | Hybrid LightRAG + Docling + Ollama |
|
||||
| ApeRAG | Nouveau | GraphRAG, MCP, K8s |
|
||||
|
||||
### Embeddings
|
||||
|
||||
| Modele | Taille | Ollama | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| nomic-embed-text | ~137M | Oui | Baseline actuelle |
|
||||
| BGE-M3 | ~568M | Oui | Hybrid dense+sparse, multilingual |
|
||||
| mxbai-embed-large | ~335M | Oui | Depasse text-embedding-3-large |
|
||||
| Qwen3-Embedding-0.6B | 600M | A verifier | Nouveau 2026 |
|
||||
|
||||
### Voice temps reel
|
||||
|
||||
| Projet | Stars | Notes |
|
||||
| --- | --- | --- |
|
||||
| LiveKit Agents | 9.7k | v1.4.5, MCP natif, Apache 2.0 |
|
||||
| pipecat | Actif | Voice/multimodal conversational AI |
|
||||
| AgoraAI | Paper | Multi-persona voice, dual-queue |
|
||||
### Creative AI
|
||||
- [AgoraAI Paper](https://www.mdpi.com/2076-3417/16/4/2120)
|
||||
- [NeurIPS Creative AI 2025](https://neurips.cc/Conferences/2025/CallForCreativeAI)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,44 @@
|
||||
# Timing & Ordering Recommendations (2026-03-19)
|
||||
|
||||
## Context
|
||||
Analyse des patterns de timing pour les pipelines LLM + TTS + music gen + image gen en temps reel.
|
||||
|
||||
## Recommandations
|
||||
|
||||
### P1 — Sentence-boundary TTS chunking
|
||||
Au lieu d'attendre la reponse complete avant TTS, decoupe en phrases pendant le streaming.
|
||||
Latence percue: 6s → 1s avec Piper, ~1.5s avec Chatterbox.
|
||||
|
||||
### P1 — Placeholder-then-resolve pour tasks longues
|
||||
Envoyer media_pending immediatement, puis media_ready quand le resultat est pret.
|
||||
Pattern valide par ChatGPT, Midjourney, Discord bots.
|
||||
|
||||
### P2 — Sequence numbers WS (seq + replyTo)
|
||||
Garantir l'ordre d'affichage cote client. Attacher audio/image au bon message via replyTo.
|
||||
|
||||
### P2 — Async handler ordering guard
|
||||
Promise chain sur ws.on(message) pour eviter le reordonnement des messages async.
|
||||
|
||||
### P2 — Per-persona task queues
|
||||
Remplacer le mutex global TTS par des queues per-persona avec concurrence bornee.
|
||||
TTS et image gen peuvent tourner en parallele (ressources GPU differentes).
|
||||
|
||||
### P3 — Protocol enrichi
|
||||
Types: message_chunk, media_pending, media_ready, media_error.
|
||||
Client affiche skeleton loader pour pending, swap sur ready.
|
||||
|
||||
## Latences cibles (RTX 4090)
|
||||
|
||||
| Pipeline | Cible |
|
||||
|----------|-------|
|
||||
| Ollama TTFB | 200-500ms |
|
||||
| Piper TTS/phrase | 200-400ms |
|
||||
| Chatterbox first chunk | ~470ms |
|
||||
| ACE-Step 1 min musique | ~2-5s |
|
||||
| Total texte+audio percu | <2s |
|
||||
|
||||
## Sources
|
||||
- Pipecat, LLMVoX (sentence chunking)
|
||||
- ACE-Step 1.5 benchmarks (34x RTF sur A100)
|
||||
- WebSocket ordering: sitongpeng.com
|
||||
- LiveKit + Piper low-latency pattern
|
||||
+36
-62
@@ -1,69 +1,43 @@
|
||||
# EXECUTION STATUS (kxkm-clown-v2)
|
||||
# OPS V2 Status
|
||||
|
||||
Updated: 2026-03-17T22:13:03Z
|
||||
Updated: 2026-03-19T17:30:00Z
|
||||
|
||||
## lot-0-cadrage
|
||||
- Status: done
|
||||
- Owner: Coordinateur
|
||||
- Execution: managed
|
||||
- Checks: docs-reviewed
|
||||
- Open tasks: none
|
||||
## Lots
|
||||
|
||||
## lot-1-socle
|
||||
- Status: done
|
||||
- Owner: Coordinateur
|
||||
- Execution: managed
|
||||
- Checks: npm run check:v2, npm run test:v2
|
||||
- Open tasks: none
|
||||
| Lot | Status | Summary |
|
||||
|-----|--------|---------|
|
||||
| lot-0-cadrage | done | Cadrage historique |
|
||||
| lot-1-socle | done | Monorepo, TUI, verifications |
|
||||
| lot-2-domaines | done | Auth, chat, storage, personas, node engine |
|
||||
| lot-3-surfaces | done | React/Vite, admin, chat UI, node engine UI |
|
||||
| lot-4-bascule | done | Migration, parite, rollback |
|
||||
| lot-12-deep-audit | done | Pipeline/docs coherents, seams fermes |
|
||||
| lot-13-voice-mcp | done | XTTS valide, MCP SDK officiel |
|
||||
| lot-14-documents-search | done | SearXNG + BGE-M3 spike |
|
||||
| lot-15-hotspot-reduction | done | Chat.tsx 631→67 LOC, cookie secure, rate limit |
|
||||
| lot-16-minitel-ui | done | CSS phosphore, VIDEOTEX, F1-F7 |
|
||||
| lot-17-chat-fixes | done | nick WS, Pharmacius concis, qwen3:8b |
|
||||
| lot-18-media-tts | done | media-store, VoiceChat, 26 voices |
|
||||
| lot-19-infra | done | Dockerfile Bookworm, deploy.sh tmux |
|
||||
| lot-20-deep-audit-2 | done | 7 bugs, 6 fixes, Mermaid, OSS veille |
|
||||
| lot-21-chat-reactivity | done | Streaming chunks, web search, timestamps |
|
||||
| lot-22-chatterbox-tts | done | Chatterbox Docker GPU :9200 |
|
||||
| lot-23-graph-rag | done | LightRAG :9621 integre |
|
||||
| lot-24-deep-audit-3 | running | Admin fixes, compose timing, tests, ARCHITECTURE.md |
|
||||
|
||||
## lot-2-domaines
|
||||
- Status: done
|
||||
- Owner: Backend API
|
||||
- Execution: managed
|
||||
- Checks: npm run test:v2
|
||||
- Open tasks: none
|
||||
## Services (kxkm-ai)
|
||||
|
||||
## lot-3-surfaces
|
||||
- Status: done
|
||||
- Owner: Frontend
|
||||
- Execution: managed
|
||||
- Checks: npm run -w @kxkm/web check
|
||||
- Open tasks: none
|
||||
| Service | Port | Status |
|
||||
|---------|------|--------|
|
||||
| API V2 | :3333 | healthy |
|
||||
| PostgreSQL | :5432 | healthy |
|
||||
| SearXNG | :8080 | healthy (JSON enabled) |
|
||||
| Chatterbox TTS | :9200 | GPU Docker |
|
||||
| TTS Sidecar | :9100 | chatterbox-remote |
|
||||
| LightRAG | :9621 | healthy |
|
||||
| Ollama | :11434 | natif (25 models) |
|
||||
| Worker | host | UP |
|
||||
|
||||
## lot-4-bascule
|
||||
- Status: done
|
||||
- Owner: Coordinateur
|
||||
- Execution: managed
|
||||
- Checks: npm run smoke:v2
|
||||
- Open tasks: none
|
||||
## Tests: 265 (248 pass, 6 fail → fix en cours)
|
||||
|
||||
## lot-12-deep-audit
|
||||
- Status: done
|
||||
- Owner: Coordinateur
|
||||
- Execution: manual
|
||||
- Checks: npm run check:v2, npm run test:v2, npm run -w @kxkm/web test, node ops/v2/deep-audit.js --json
|
||||
- Open tasks: none
|
||||
|
||||
## lot-13-voice-mcp
|
||||
- Status: done
|
||||
- Owner: Multimodal
|
||||
- Execution: manual
|
||||
- Checks: node scripts/mcp-server-smoke.js, npm run smoke:voice-mcp, npm run smoke:voice-clone, bash ops/v2/run-spike-checks.sh voice-clone --yes
|
||||
- Open tasks: none
|
||||
|
||||
## lot-14-documents-search
|
||||
- Status: done
|
||||
- Owner: Ops/TUI
|
||||
- Execution: manual
|
||||
- Checks: docker compose --profile v2 config --services, bash scripts/health-doc-search.sh search --strict, npm run smoke:documents-search, npm run smoke:embeddings, bash ops/v2/run-spike-checks.sh embeddings --yes
|
||||
- Open tasks: none
|
||||
|
||||
## lot-15-hotspot-reduction
|
||||
- Status: running
|
||||
- Owner: Coordinateur
|
||||
- Execution: manual
|
||||
- Checks: npm run -w @kxkm/persona-domain check, npm run test:v2, bash ops/v2/run-deep-cycle.sh run --yes
|
||||
- Open tasks:
|
||||
- node-engine-seams [pending] (P2, Worker/Engine)
|
||||
- storage-test-split [pending] (P3, Backend API)
|
||||
- web-chat-modularization [pending] (P2, Frontend)
|
||||
## Health Check: 19/19 pass, 1 warning (Chatterbox :9200 direct)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
{
|
||||
"security": [],
|
||||
"performance": [],
|
||||
"metrics": [
|
||||
{
|
||||
"file": "packages/node-engine/src/index.ts",
|
||||
"lines": 717,
|
||||
"sizeKB": 18,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "packages/storage/src/index.test.ts",
|
||||
"lines": 670,
|
||||
"sizeKB": 24,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "packages/node-engine/src/index.test.ts",
|
||||
"lines": 651,
|
||||
"sizeKB": 20,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/components/Chat.tsx",
|
||||
"lines": 617,
|
||||
"sizeKB": 21,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/components/ChatHistory.tsx",
|
||||
"lines": 602,
|
||||
"sizeKB": 17,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "apps/worker/src/worker-runtime.ts",
|
||||
"lines": 565,
|
||||
"sizeKB": 18,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "packages/storage/src/index.ts",
|
||||
"lines": 551,
|
||||
"sizeKB": 18,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/app.ts",
|
||||
"lines": 536,
|
||||
"sizeKB": 17,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/app.test.ts",
|
||||
"lines": 493,
|
||||
"sizeKB": 17,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/context-store.ts",
|
||||
"lines": 422,
|
||||
"sizeKB": 15,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "packages/persona-domain/src/index.ts",
|
||||
"lines": 422,
|
||||
"sizeKB": 11,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/components/VoiceChat.tsx",
|
||||
"lines": 421,
|
||||
"sizeKB": 14,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/personas-default.ts",
|
||||
"lines": 418,
|
||||
"sizeKB": 20,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/components/NodeEditor.tsx",
|
||||
"lines": 407,
|
||||
"sizeKB": 14,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"lines": 375,
|
||||
"sizeKB": 11,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/ws-conversation-router.ts",
|
||||
"lines": 355,
|
||||
"sizeKB": 11,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/api.ts",
|
||||
"lines": 328,
|
||||
"sizeKB": 9,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/routes/personas.ts",
|
||||
"lines": 323,
|
||||
"sizeKB": 11,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/ws-commands.ts",
|
||||
"lines": 316,
|
||||
"sizeKB": 10,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/ws-ollama.ts",
|
||||
"lines": 290,
|
||||
"sizeKB": 9,
|
||||
"flag": "ok"
|
||||
},
|
||||
{
|
||||
"file": "packages/chat-domain/src/index.test.ts",
|
||||
"lines": 279,
|
||||
"sizeKB": 9,
|
||||
"flag": "ok"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/components/PersonaDetail.tsx",
|
||||
"lines": 278,
|
||||
"sizeKB": 9,
|
||||
"flag": "ok"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/routes/chat-history.ts",
|
||||
"lines": 275,
|
||||
"sizeKB": 10,
|
||||
"flag": "ok"
|
||||
},
|
||||
{
|
||||
"file": "packages/chat-domain/src/index.ts",
|
||||
"lines": 263,
|
||||
"sizeKB": 8,
|
||||
"flag": "ok"
|
||||
},
|
||||
{
|
||||
"file": "packages/persona-domain/src/index.test.ts",
|
||||
"lines": 260,
|
||||
"sizeKB": 9,
|
||||
"flag": "ok"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/components/Collectif.tsx",
|
||||
"lines": 252,
|
||||
"sizeKB": 8,
|
||||
"flag": "ok"
|
||||
},
|
||||
{
|
||||
"file": "packages/node-engine/src/training.ts",
|
||||
"lines": 250,
|
||||
"sizeKB": 7,
|
||||
"flag": "ok"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/components/PersonaList.tsx",
|
||||
"lines": 243,
|
||||
"sizeKB": 8,
|
||||
"flag": "ok"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/ws-conversation-router.test.ts",
|
||||
"lines": 242,
|
||||
"sizeKB": 9,
|
||||
"flag": "ok"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/App.tsx",
|
||||
"lines": 236,
|
||||
"sizeKB": 8,
|
||||
"flag": "ok"
|
||||
},
|
||||
{
|
||||
"file": "apps/worker/src/worker-runtime.test.ts",
|
||||
"lines": 231,
|
||||
"sizeKB": 7,
|
||||
"flag": "ok"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/routes/session.ts",
|
||||
"lines": 208,
|
||||
"sizeKB": 7,
|
||||
"flag": "ok"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/components/TrainingDashboard.tsx",
|
||||
"lines": 203,
|
||||
"sizeKB": 7,
|
||||
"flag": "ok"
|
||||
}
|
||||
],
|
||||
"deps": [
|
||||
{
|
||||
"package": "@discordjs/voice",
|
||||
"current": "0.19.1",
|
||||
"wanted": "0.19.2",
|
||||
"latest": "0.19.2"
|
||||
},
|
||||
{
|
||||
"package": "express"
|
||||
},
|
||||
{
|
||||
"package": "pdf-parse",
|
||||
"current": "1.1.4",
|
||||
"wanted": "1.1.4",
|
||||
"latest": "2.4.5"
|
||||
}
|
||||
],
|
||||
"typescript": [
|
||||
{
|
||||
"dir": "apps/api",
|
||||
"status": "ok",
|
||||
"errors": 0,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"dir": "apps/web",
|
||||
"status": "ok",
|
||||
"errors": 0,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"dir": "apps/worker",
|
||||
"status": "ok",
|
||||
"errors": 0,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"dir": "packages/core",
|
||||
"status": "ok",
|
||||
"errors": 0,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"dir": "packages/auth",
|
||||
"status": "ok",
|
||||
"errors": 0,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"dir": "packages/chat-domain",
|
||||
"status": "ok",
|
||||
"errors": 0,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"dir": "packages/persona-domain",
|
||||
"status": "ok",
|
||||
"errors": 0,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"dir": "packages/node-engine",
|
||||
"status": "ok",
|
||||
"errors": 0,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"dir": "packages/storage",
|
||||
"status": "ok",
|
||||
"errors": 0,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"dir": "packages/ui",
|
||||
"status": "ok",
|
||||
"errors": 0,
|
||||
"detail": ""
|
||||
},
|
||||
{
|
||||
"dir": "packages/tui",
|
||||
"status": "ok",
|
||||
"errors": 0,
|
||||
"detail": ""
|
||||
}
|
||||
],
|
||||
"debt": {
|
||||
"score": 38,
|
||||
"level": "medium",
|
||||
"components": {
|
||||
"security": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 0
|
||||
},
|
||||
"performance": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 0
|
||||
},
|
||||
"tsErrors": 0,
|
||||
"largeFiles": 8,
|
||||
"mediumFiles": 11,
|
||||
"deps": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"timestamp": "20260317-230529",
|
||||
"generated_at": "2026-03-17T22:05:38Z",
|
||||
"files": {
|
||||
"audit": "/home/kxkm/KXKM_Clown/ops/v2/logs/deep-audit-20260317-230529.json",
|
||||
"check": "/home/kxkm/KXKM_Clown/ops/v2/logs/check-v2-20260317-230529.log",
|
||||
"test": "/home/kxkm/KXKM_Clown/ops/v2/logs/test-v2-20260317-230529.log",
|
||||
"summary": "/home/kxkm/KXKM_Clown/ops/v2/outputs/deep-cycles/summary-20260317-230529.md"
|
||||
},
|
||||
"checks": {
|
||||
"check:v2": "ok",
|
||||
"test:v2": "ok"
|
||||
},
|
||||
"retained_logs": [
|
||||
"/home/kxkm/KXKM_Clown/ops/v2/logs/deep-audit-20260317-230529.json",
|
||||
"/home/kxkm/KXKM_Clown/ops/v2/logs/check-v2-20260317-230529.log",
|
||||
"/home/kxkm/KXKM_Clown/ops/v2/logs/test-v2-20260317-230529.log"
|
||||
],
|
||||
"counts": {
|
||||
"security": 0,
|
||||
"performance": 0,
|
||||
"large_files": 9
|
||||
},
|
||||
"debt": {
|
||||
"score": 40,
|
||||
"level": "medium",
|
||||
"components": {
|
||||
"security": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 0
|
||||
},
|
||||
"performance": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 0
|
||||
},
|
||||
"tsErrors": 0,
|
||||
"largeFiles": 9,
|
||||
"mediumFiles": 10,
|
||||
"deps": 3
|
||||
}
|
||||
},
|
||||
"hotspots": [
|
||||
"packages/persona-domain/src/index.ts",
|
||||
"packages/node-engine/src/index.ts",
|
||||
"packages/storage/src/index.test.ts",
|
||||
"packages/node-engine/src/index.test.ts",
|
||||
"apps/web/src/components/Chat.tsx"
|
||||
],
|
||||
"open_findings": [],
|
||||
"backlog_links": [
|
||||
"lot-12-deep-audit/pipeline-canonique",
|
||||
"lot-12-deep-audit/deep-cycle-operator",
|
||||
"lot-12-deep-audit/ws-chat-seams",
|
||||
"lot-12-deep-audit/ui-contract",
|
||||
"lot-12-deep-audit/web-shell-convergence"
|
||||
],
|
||||
"retention_days": 7,
|
||||
"hashes": {
|
||||
"audit_sha256": "f407098d6b9eefa41e87481fce8160d6c61a56c07b52ffc4ec2cd676aad1dda5",
|
||||
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
|
||||
"test_sha256": "b294f58e5e15644959d8eb76fbd180b6e28468006fd5f49cf98cfaeca90569c9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"timestamp": "20260317-230603",
|
||||
"generated_at": "2026-03-17T22:06:11Z",
|
||||
"files": {
|
||||
"audit": "/home/kxkm/KXKM_Clown/ops/v2/logs/deep-audit-20260317-230603.json",
|
||||
"check": "/home/kxkm/KXKM_Clown/ops/v2/logs/check-v2-20260317-230603.log",
|
||||
"test": "/home/kxkm/KXKM_Clown/ops/v2/logs/test-v2-20260317-230603.log",
|
||||
"summary": "/home/kxkm/KXKM_Clown/ops/v2/outputs/deep-cycles/summary-20260317-230603.md"
|
||||
},
|
||||
"checks": {
|
||||
"check:v2": "ok",
|
||||
"test:v2": "ok"
|
||||
},
|
||||
"retained_logs": [
|
||||
"/home/kxkm/KXKM_Clown/ops/v2/logs/deep-audit-20260317-230603.json",
|
||||
"/home/kxkm/KXKM_Clown/ops/v2/logs/check-v2-20260317-230603.log",
|
||||
"/home/kxkm/KXKM_Clown/ops/v2/logs/test-v2-20260317-230603.log"
|
||||
],
|
||||
"counts": {
|
||||
"security": 0,
|
||||
"performance": 0,
|
||||
"large_files": 9
|
||||
},
|
||||
"debt": {
|
||||
"score": 40,
|
||||
"level": "medium",
|
||||
"components": {
|
||||
"security": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 0
|
||||
},
|
||||
"performance": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 0
|
||||
},
|
||||
"tsErrors": 0,
|
||||
"largeFiles": 9,
|
||||
"mediumFiles": 10,
|
||||
"deps": 3
|
||||
}
|
||||
},
|
||||
"hotspots": [
|
||||
"packages/persona-domain/src/index.ts",
|
||||
"packages/node-engine/src/index.ts",
|
||||
"packages/storage/src/index.test.ts",
|
||||
"packages/node-engine/src/index.test.ts",
|
||||
"apps/web/src/components/Chat.tsx"
|
||||
],
|
||||
"open_findings": [],
|
||||
"backlog_links": [
|
||||
"lot-12-deep-audit/pipeline-canonique",
|
||||
"lot-12-deep-audit/deep-cycle-operator",
|
||||
"lot-12-deep-audit/ws-chat-seams",
|
||||
"lot-12-deep-audit/ui-contract",
|
||||
"lot-12-deep-audit/web-shell-convergence"
|
||||
],
|
||||
"retention_days": 7,
|
||||
"hashes": {
|
||||
"audit_sha256": "f407098d6b9eefa41e87481fce8160d6c61a56c07b52ffc4ec2cd676aad1dda5",
|
||||
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
|
||||
"test_sha256": "ee0bf24524cdd085a6a0eeee1339aa6924311be226819e7c195471bc7abf8e38"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Deep Cycle Summary 20260317-230529
|
||||
|
||||
- Generated: 2026-03-17T22:05:38Z
|
||||
- Retention policy: 7 days
|
||||
- check:v2: ok
|
||||
- test:v2: ok
|
||||
- security findings: 0
|
||||
- performance findings: 0
|
||||
- debt score: 40/100 (medium)
|
||||
|
||||
## Hotspots
|
||||
- packages/persona-domain/src/index.ts
|
||||
- packages/node-engine/src/index.ts
|
||||
- packages/storage/src/index.test.ts
|
||||
- packages/node-engine/src/index.test.ts
|
||||
- apps/web/src/components/Chat.tsx
|
||||
|
||||
## Open Findings
|
||||
- none
|
||||
@@ -0,0 +1,19 @@
|
||||
# Deep Cycle Summary 20260317-230603
|
||||
|
||||
- Generated: 2026-03-17T22:06:11Z
|
||||
- Retention policy: 7 days
|
||||
- check:v2: ok
|
||||
- test:v2: ok
|
||||
- security findings: 0
|
||||
- performance findings: 0
|
||||
- debt score: 40/100 (medium)
|
||||
|
||||
## Hotspots
|
||||
- packages/persona-domain/src/index.ts
|
||||
- packages/node-engine/src/index.ts
|
||||
- packages/storage/src/index.test.ts
|
||||
- packages/node-engine/src/index.test.ts
|
||||
- apps/web/src/components/Chat.tsx
|
||||
|
||||
## Open Findings
|
||||
- none
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"timestamp": "20260317-230523",
|
||||
"mode": "all",
|
||||
"generated_at": "2026-03-17T22:05:24Z",
|
||||
"checks": {
|
||||
"voice-mcp": "ok",
|
||||
"voice-clone": "ok",
|
||||
"documents-search": "ok",
|
||||
"embeddings": "ok"
|
||||
},
|
||||
"logs": {
|
||||
"voice-mcp": "/home/kxkm/KXKM_Clown/ops/v2/logs/voice-mcp-smoke-20260317-230523.log",
|
||||
"voice-clone": "/home/kxkm/KXKM_Clown/ops/v2/logs/voice-clone-smoke-20260317-230523.log",
|
||||
"documents-search": "/home/kxkm/KXKM_Clown/ops/v2/logs/documents-search-smoke-20260317-230523.log",
|
||||
"embeddings": "/home/kxkm/KXKM_Clown/ops/v2/logs/embeddings-smoke-20260317-230523.log"
|
||||
},
|
||||
"summary": "/home/kxkm/KXKM_Clown/ops/v2/outputs/spikes/summary-all-20260317-230523.md"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Spike Summary all 20260317-230523
|
||||
|
||||
- Generated: 2026-03-17T22:05:24Z
|
||||
- Voice / MCP smoke: ok
|
||||
- Voice cloning smoke: ok
|
||||
- Documents / Search smoke: ok
|
||||
- Embeddings smoke: ok
|
||||
|
||||
## Logs
|
||||
- voice-mcp: /home/kxkm/KXKM_Clown/ops/v2/logs/voice-mcp-smoke-20260317-230523.log
|
||||
- voice-clone: /home/kxkm/KXKM_Clown/ops/v2/logs/voice-clone-smoke-20260317-230523.log
|
||||
- documents-search: /home/kxkm/KXKM_Clown/ops/v2/logs/documents-search-smoke-20260317-230523.log
|
||||
- embeddings: /home/kxkm/KXKM_Clown/ops/v2/logs/embeddings-smoke-20260317-230523.log
|
||||
Generated
+482
-5
@@ -52,7 +52,15 @@
|
||||
"@kxkm/node-engine": "*",
|
||||
"@kxkm/persona-domain": "*",
|
||||
"@kxkm/storage": "*",
|
||||
"express": "^4.22.1"
|
||||
"express": "^4.22.1",
|
||||
"file-type": "^21.3.3",
|
||||
"p-limit": "^7.3.0",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pino": "^7.0.4"
|
||||
}
|
||||
},
|
||||
"apps/web": {
|
||||
@@ -61,12 +69,16 @@
|
||||
"dependencies": {
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
"react-dom": "^19.2.4",
|
||||
"react-virtualized-auto-sizer": "^2.0.3",
|
||||
"react-window": "^2.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.4",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"jsdom": "^29.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
@@ -166,6 +178,16 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@borewit/text-codec": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz",
|
||||
"integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
@@ -1465,6 +1487,12 @@
|
||||
"@noble/hashes": "^1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
|
||||
@@ -2109,6 +2137,52 @@
|
||||
"@testing-library/dom": ">=7.21.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tokenizer/inflate": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
|
||||
"integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
"token-types": "^6.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/@tokenizer/inflate/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@tokenizer/inflate/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tokenizer/token": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
|
||||
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
@@ -2289,6 +2363,16 @@
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pino": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pino/-/pino-7.0.4.tgz",
|
||||
"integrity": "sha512-yKw1UbZOTe7vP1xMQT+oz3FexwgIpBTrM+AC62vWgAkNRULgLTJWfYX+H5/sKPm8VXFbIcXkC3VZPyuaNioZFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pino": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
||||
@@ -2323,6 +2407,26 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-virtualized-auto-sizer": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.4.tgz",
|
||||
"integrity": "sha512-nhYwlFiYa8M3S+O2T9QO/e1FQUYMr/wJENUdf/O0dhRi1RS/93rjrYQFYdbUqtdFySuhrtnEDX29P6eKOttY+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-window": {
|
||||
"version": "1.8.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz",
|
||||
"integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
@@ -2728,6 +2832,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/b4a": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz",
|
||||
@@ -2989,6 +3102,12 @@
|
||||
"color-support": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
"version": "2.0.20",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
||||
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -3252,6 +3371,15 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dateformat": {
|
||||
"version": "4.6.3",
|
||||
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
|
||||
"integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
@@ -3421,6 +3549,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
@@ -3658,6 +3795,12 @@
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-copy": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
|
||||
"integrity": "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -3674,7 +3817,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
@@ -3711,6 +3853,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/file-type": {
|
||||
"version": "21.3.3",
|
||||
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.3.tgz",
|
||||
"integrity": "sha512-pNwbwz8c3aZ+GvbJnIsCnDjKvgCZLHxkFWLEFxU3RMa+Ey++ZSEfisvsWQMcdys6PpxQjWUOIDi1fifXsW3YRg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tokenizer/inflate": "^0.4.1",
|
||||
"strtok3": "^10.3.4",
|
||||
"token-types": "^6.1.1",
|
||||
"uint8array-extras": "^1.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
@@ -3986,6 +4146,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/help-me": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
|
||||
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.8",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz",
|
||||
@@ -4076,6 +4242,26 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/indent-string": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
|
||||
@@ -4158,6 +4344,15 @@
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/joycon": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
|
||||
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -4651,6 +4846,15 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
|
||||
@@ -4857,6 +5061,15 @@
|
||||
"whatwg-fetch": "^3.6.20"
|
||||
}
|
||||
},
|
||||
"node_modules/on-exit-leak-free": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
@@ -4878,6 +5091,21 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz",
|
||||
"integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"yocto-queue": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
|
||||
@@ -5056,6 +5284,67 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pino": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
|
||||
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pinojs/redact": "^0.4.0",
|
||||
"atomic-sleep": "^1.0.0",
|
||||
"on-exit-leak-free": "^2.1.0",
|
||||
"pino-abstract-transport": "^3.0.0",
|
||||
"pino-std-serializers": "^7.0.0",
|
||||
"process-warning": "^5.0.0",
|
||||
"quick-format-unescaped": "^4.0.3",
|
||||
"real-require": "^0.2.0",
|
||||
"safe-stable-stringify": "^2.3.1",
|
||||
"sonic-boom": "^4.0.1",
|
||||
"thread-stream": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"pino": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-abstract-transport": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
|
||||
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-pretty": {
|
||||
"version": "13.1.3",
|
||||
"resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz",
|
||||
"integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"colorette": "^2.0.7",
|
||||
"dateformat": "^4.6.3",
|
||||
"fast-copy": "^4.0.0",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"help-me": "^5.0.0",
|
||||
"joycon": "^3.1.1",
|
||||
"minimist": "^1.2.6",
|
||||
"on-exit-leak-free": "^2.1.0",
|
||||
"pino-abstract-transport": "^3.0.0",
|
||||
"pump": "^3.0.0",
|
||||
"secure-json-parse": "^4.0.0",
|
||||
"sonic-boom": "^4.0.1",
|
||||
"strip-json-comments": "^5.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"pino-pretty": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-std-serializers": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pkce-challenge": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
||||
@@ -5175,6 +5464,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -5188,6 +5493,16 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -5213,6 +5528,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/quick-format-unescaped": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
@@ -5266,6 +5587,26 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-virtualized-auto-sizer": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-2.0.3.tgz",
|
||||
"integrity": "sha512-nonmCSUIh5HtbzazGcQ1NhnMFps/ZBu/UKJyhCt0Fhi7ondLAUZNETtRCWM8RWYZDzVlMYOQGgBmIxUutIhqgw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-window": {
|
||||
"version": "2.2.7",
|
||||
"resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.7.tgz",
|
||||
"integrity": "sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
@@ -5280,6 +5621,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/real-require": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
@@ -5451,6 +5801,15 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-stable-stringify": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
@@ -5476,6 +5835,22 @@
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/secure-json-parse": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
|
||||
"integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
@@ -5665,6 +6040,15 @@
|
||||
"bare": ">=1.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sonic-boom": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"atomic-sleep": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -5766,6 +6150,34 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
|
||||
"integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strtok3": {
|
||||
"version": "10.3.4",
|
||||
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz",
|
||||
"integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tokenizer/token": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/superagent": {
|
||||
"version": "10.3.0",
|
||||
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
|
||||
@@ -5893,6 +6305,18 @@
|
||||
"b4a": "^1.6.4"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
|
||||
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"real-require": "^0.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -5966,6 +6390,24 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/token-types": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
|
||||
"integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@borewit/text-codec": "^0.2.1",
|
||||
"@tokenizer/token": "^0.3.0",
|
||||
"ieee754": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
|
||||
@@ -6153,6 +6595,18 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/uint8array-extras": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
|
||||
"integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.24.3",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.3.tgz",
|
||||
@@ -6532,6 +6986,18 @@
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
|
||||
"integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
@@ -6600,7 +7066,17 @@
|
||||
"name": "@kxkm/node-engine",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@kxkm/core": "*"
|
||||
"@kxkm/core": "*",
|
||||
"zod": "^3.23.0"
|
||||
}
|
||||
},
|
||||
"packages/node-engine/node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"packages/persona-domain": {
|
||||
@@ -6617,7 +7093,8 @@
|
||||
"@kxkm/core": "*",
|
||||
"@kxkm/node-engine": "*",
|
||||
"@kxkm/persona-domain": "*",
|
||||
"pg": "^8.20.0"
|
||||
"pg": "^8.20.0",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
},
|
||||
"packages/tui": {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export const USER_ROLES = ["admin", "editor", "operator", "viewer"] as const;
|
||||
export type UserRole = (typeof USER_ROLES)[number];
|
||||
|
||||
@@ -70,11 +72,9 @@ export function createIsoTimestamp(date = new Date()): string {
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
export function createId(prefix: string): string {
|
||||
const bytes = globalThis.crypto?.getRandomValues
|
||||
? globalThis.crypto.getRandomValues(new Uint8Array(8))
|
||||
: require("node:crypto").randomBytes(8);
|
||||
return `${prefix}_${Buffer.from(bytes).toString("hex")}`;
|
||||
export function createId(prefix = ""): string {
|
||||
const sep = prefix ? "_" : "";
|
||||
return `${prefix}${sep}${Date.now().toString(36)}_${crypto.randomBytes(4).toString("hex")}`;
|
||||
}
|
||||
|
||||
export function isUserRole(value: string): value is UserRole {
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
".": "./dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kxkm/core": "*"
|
||||
"@kxkm/core": "*",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"scripts": {
|
||||
"status": "node ../../scripts/workspace-package.js node-engine status",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Training adapter configuration — pure module (no I/O, no child_process)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -24,6 +26,26 @@ export const DEFAULT_HYPERPARAMS: TrainingHyperparams = {
|
||||
maxSeqLength: 2048,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zod bounds validation for hyperparameters (lot-60)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Coerce non-number values to undefined so they fall through to defaults. */
|
||||
const numOrUndef = z.preprocess(
|
||||
(v) => (typeof v === "number" && Number.isFinite(v) ? v : undefined),
|
||||
z.number().optional(),
|
||||
);
|
||||
|
||||
export const hyperparamsSchema = z.object({
|
||||
learningRate: numOrUndef.pipe(z.number().min(1e-7).max(1).optional()),
|
||||
epochs: numOrUndef.pipe(z.number().int().min(1).max(100).optional()),
|
||||
batchSize: numOrUndef.pipe(z.number().int().min(1).max(256).optional()),
|
||||
warmupSteps: numOrUndef.pipe(z.number().int().min(0).max(10000).optional()),
|
||||
maxSeqLength: numOrUndef.pipe(z.number().int().min(32).max(8192).optional()),
|
||||
loraRank: numOrUndef.pipe(z.number().int().min(1).max(256).optional()),
|
||||
loraAlpha: numOrUndef.pipe(z.number().int().min(1).max(512).optional()),
|
||||
}).passthrough();
|
||||
|
||||
export interface TrainingJobSpec {
|
||||
type: TrainingJobType;
|
||||
baseModel: string;
|
||||
@@ -157,20 +179,28 @@ export function validateJobSpec(spec: unknown): TrainingJobSpec {
|
||||
throw new Error("TrainingJobSpec.outputDir must be a non-empty string");
|
||||
}
|
||||
|
||||
// hyperparams — merge with defaults
|
||||
// hyperparams — validate bounds with Zod, then merge with defaults
|
||||
const rawHp =
|
||||
raw.hyperparams && typeof raw.hyperparams === "object"
|
||||
? (raw.hyperparams as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const parsed = hyperparamsSchema.safeParse(rawHp);
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
`Invalid hyperparams: ${parsed.error.issues.map((i) => i.message).join(", ")}`,
|
||||
);
|
||||
}
|
||||
const hp = parsed.data;
|
||||
|
||||
const hyperparams: TrainingHyperparams = {
|
||||
learningRate: validNumber(rawHp.learningRate, DEFAULT_HYPERPARAMS.learningRate),
|
||||
epochs: validNumber(rawHp.epochs, DEFAULT_HYPERPARAMS.epochs),
|
||||
batchSize: validNumber(rawHp.batchSize, DEFAULT_HYPERPARAMS.batchSize),
|
||||
loraRank: validNumber(rawHp.loraRank, DEFAULT_HYPERPARAMS.loraRank),
|
||||
loraAlpha: validNumber(rawHp.loraAlpha, DEFAULT_HYPERPARAMS.loraAlpha),
|
||||
warmupSteps: validNumber(rawHp.warmupSteps, DEFAULT_HYPERPARAMS.warmupSteps),
|
||||
maxSeqLength: validNumber(rawHp.maxSeqLength, DEFAULT_HYPERPARAMS.maxSeqLength),
|
||||
learningRate: validNumber(hp.learningRate, DEFAULT_HYPERPARAMS.learningRate),
|
||||
epochs: validNumber(hp.epochs, DEFAULT_HYPERPARAMS.epochs),
|
||||
batchSize: validNumber(hp.batchSize, DEFAULT_HYPERPARAMS.batchSize),
|
||||
loraRank: validNumber((hp as Record<string, unknown>).loraRank, DEFAULT_HYPERPARAMS.loraRank),
|
||||
loraAlpha: validNumber((hp as Record<string, unknown>).loraAlpha, DEFAULT_HYPERPARAMS.loraAlpha),
|
||||
warmupSteps: validNumber(hp.warmupSteps, DEFAULT_HYPERPARAMS.warmupSteps),
|
||||
maxSeqLength: validNumber(hp.maxSeqLength, DEFAULT_HYPERPARAMS.maxSeqLength),
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -101,5 +101,11 @@ export function extractDPOPairs(
|
||||
});
|
||||
}
|
||||
|
||||
if (pairs.length === 0 && feedback.length > 0) {
|
||||
console.warn(
|
||||
`[DPO] extractDPOPairs returned 0 pairs for persona "${persona.id}" from ${feedback.length} feedback items — check vote polarity signals`,
|
||||
);
|
||||
}
|
||||
|
||||
return pairs;
|
||||
}
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@kxkm/core": "*",
|
||||
"@kxkm/persona-domain": "*",
|
||||
"@kxkm/node-engine": "*",
|
||||
"pg": "^8.20.0"
|
||||
"@kxkm/persona-domain": "*",
|
||||
"pg": "^8.20.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"scripts": {
|
||||
"status": "node ../../scripts/workspace-package.js storage status",
|
||||
|
||||
+220
-89
@@ -1,4 +1,5 @@
|
||||
import { Pool } from "pg";
|
||||
import { z } from "zod";
|
||||
import type { AuthSession, UserRole } from "@kxkm/core";
|
||||
import { createId, createIsoTimestamp } from "@kxkm/core";
|
||||
import type { PersonaRecord, PersonaSourceRecord, PersonaFeedbackRecord, PersonaProposalRecord } from "@kxkm/persona-domain";
|
||||
@@ -129,6 +130,74 @@ export async function runMigrations(pool: Pool): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation logger (lightweight, no external dep)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const validationLogger = {
|
||||
warn(ctx: { repo: string; errors: z.ZodIssue[] }, msg: string) {
|
||||
console.warn(`[storage] ${msg}`, JSON.stringify({ repo: ctx.repo, errors: ctx.errors }));
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zod schemas for DB row validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const sessionRowSchema = z.object({
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
role: z.string(),
|
||||
created_at: z.coerce.date(),
|
||||
expires_at: z.coerce.date(),
|
||||
});
|
||||
|
||||
const personaRowSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
model: z.string(),
|
||||
summary: z.string(),
|
||||
editable: z.union([z.boolean(), z.number()]).transform(Boolean),
|
||||
});
|
||||
|
||||
const nodeGraphRowSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
});
|
||||
|
||||
const nodeRunRowSchema = z.object({
|
||||
id: z.string(),
|
||||
graph_id: z.string(),
|
||||
status: z.string(),
|
||||
created_at: z.union([z.date(), z.string()]),
|
||||
});
|
||||
|
||||
const personaSourceRowSchema = z.object({
|
||||
persona_id: z.string(),
|
||||
subject_name: z.string(),
|
||||
summary: z.string(),
|
||||
references_: z.unknown().transform((v) => (Array.isArray(v) ? v : [])),
|
||||
});
|
||||
|
||||
const personaFeedbackRowSchema = z.object({
|
||||
id: z.string(),
|
||||
persona_id: z.string(),
|
||||
kind: z.string(),
|
||||
message: z.string(),
|
||||
created_at: z.union([z.date(), z.string()]),
|
||||
});
|
||||
|
||||
const personaProposalRowSchema = z.object({
|
||||
id: z.string(),
|
||||
persona_id: z.string(),
|
||||
before_snapshot: z.unknown().nullable(),
|
||||
after_snapshot: z.unknown().nullable(),
|
||||
reason: z.string(),
|
||||
applied: z.union([z.boolean(), z.number()]).transform(Boolean),
|
||||
created_at: z.union([z.date(), z.string()]),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session input type
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -139,6 +208,126 @@ export interface SessionCreateInput {
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validated row mappers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mapSessionRow(row: unknown): AuthSession | null {
|
||||
const result = sessionRowSchema.safeParse(row);
|
||||
if (!result.success) {
|
||||
validationLogger.warn({ repo: "session", errors: result.error.issues }, "Invalid session row");
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: result.data.id,
|
||||
username: result.data.username,
|
||||
role: result.data.role as UserRole,
|
||||
createdAt: result.data.created_at.toISOString(),
|
||||
expiresAt: result.data.expires_at.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function mapPersonaRow(row: unknown): PersonaRecord | null {
|
||||
const result = personaRowSchema.safeParse(row);
|
||||
if (!result.success) {
|
||||
validationLogger.warn({ repo: "persona", errors: result.error.issues }, "Invalid persona row");
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: result.data.id,
|
||||
name: result.data.name,
|
||||
model: result.data.model,
|
||||
summary: result.data.summary,
|
||||
editable: result.data.editable,
|
||||
};
|
||||
}
|
||||
|
||||
function mapNodeGraphRow(row: unknown): NodeGraphRecord | null {
|
||||
const result = nodeGraphRowSchema.safeParse(row);
|
||||
if (!result.success) {
|
||||
validationLogger.warn({ repo: "nodeGraph", errors: result.error.issues }, "Invalid node_graph row");
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: result.data.id,
|
||||
name: result.data.name,
|
||||
description: result.data.description,
|
||||
};
|
||||
}
|
||||
|
||||
function mapNodeRunRow(row: unknown): NodeRunRecord | null {
|
||||
const result = nodeRunRowSchema.safeParse(row);
|
||||
if (!result.success) {
|
||||
validationLogger.warn({ repo: "nodeRun", errors: result.error.issues }, "Invalid node_run row");
|
||||
return null;
|
||||
}
|
||||
const ca = result.data.created_at;
|
||||
return {
|
||||
id: result.data.id,
|
||||
graphId: result.data.graph_id,
|
||||
status: result.data.status as RunStatus,
|
||||
createdAt: ca instanceof Date ? ca.toISOString() : String(ca),
|
||||
};
|
||||
}
|
||||
|
||||
function mapPersonaSourceRow(row: unknown): PersonaSourceRecord | null {
|
||||
const result = personaSourceRowSchema.safeParse(row);
|
||||
if (!result.success) {
|
||||
validationLogger.warn({ repo: "personaSource", errors: result.error.issues }, "Invalid persona_source row");
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
personaId: result.data.persona_id,
|
||||
subjectName: result.data.subject_name,
|
||||
summary: result.data.summary,
|
||||
references: result.data.references_ as string[],
|
||||
};
|
||||
}
|
||||
|
||||
function mapPersonaFeedbackRow(row: unknown): PersonaFeedbackRecord | null {
|
||||
const result = personaFeedbackRowSchema.safeParse(row);
|
||||
if (!result.success) {
|
||||
validationLogger.warn({ repo: "personaFeedback", errors: result.error.issues }, "Invalid persona_feedback row");
|
||||
return null;
|
||||
}
|
||||
const ca = result.data.created_at;
|
||||
return {
|
||||
id: result.data.id,
|
||||
personaId: result.data.persona_id,
|
||||
kind: result.data.kind as PersonaFeedbackRecord["kind"],
|
||||
message: result.data.message,
|
||||
createdAt: ca instanceof Date ? ca.toISOString() : String(ca),
|
||||
};
|
||||
}
|
||||
|
||||
function mapPersonaProposalRow(row: unknown): PersonaProposalRecord | null {
|
||||
const result = personaProposalRowSchema.safeParse(row);
|
||||
if (!result.success) {
|
||||
validationLogger.warn({ repo: "personaProposal", errors: result.error.issues }, "Invalid persona_proposal row");
|
||||
return null;
|
||||
}
|
||||
const ca = result.data.created_at;
|
||||
return {
|
||||
id: result.data.id,
|
||||
personaId: result.data.persona_id,
|
||||
before: result.data.before_snapshot as PersonaProposalRecord["before"],
|
||||
after: result.data.after_snapshot as PersonaProposalRecord["after"],
|
||||
reason: result.data.reason,
|
||||
applied: result.data.applied,
|
||||
createdAt: ca instanceof Date ? ca.toISOString() : String(ca),
|
||||
};
|
||||
}
|
||||
|
||||
/** Filter null results from validated row mapping */
|
||||
function filterValid<T>(rows: unknown[], mapper: (row: unknown) => T | null): T[] {
|
||||
const results: T[] = [];
|
||||
for (const row of rows) {
|
||||
const mapped = mapper(row);
|
||||
if (mapped !== null) results.push(mapped);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session repository
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -165,14 +354,7 @@ export function createSessionRepo(pool: Pool) {
|
||||
[id],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
role: row.role as UserRole,
|
||||
createdAt: (row.created_at as Date).toISOString(),
|
||||
expiresAt: (row.expires_at as Date).toISOString(),
|
||||
};
|
||||
return mapSessionRow(result.rows[0]);
|
||||
},
|
||||
|
||||
async deleteById(id: string): Promise<void> {
|
||||
@@ -198,7 +380,7 @@ export function createPersonaRepo(pool: Pool) {
|
||||
const result = await pool.query(
|
||||
`SELECT id, name, model, summary, editable FROM personas ORDER BY name`,
|
||||
);
|
||||
return result.rows.map(rowToPersona);
|
||||
return filterValid(result.rows, mapPersonaRow);
|
||||
},
|
||||
|
||||
async findById(id: string): Promise<PersonaRecord | null> {
|
||||
@@ -207,7 +389,7 @@ export function createPersonaRepo(pool: Pool) {
|
||||
[id],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
return rowToPersona(result.rows[0]);
|
||||
return mapPersonaRow(result.rows[0]);
|
||||
},
|
||||
|
||||
async upsert(persona: PersonaRecord): Promise<PersonaRecord> {
|
||||
@@ -223,7 +405,9 @@ export function createPersonaRepo(pool: Pool) {
|
||||
RETURNING id, name, model, summary, editable`,
|
||||
[persona.id, persona.name, persona.model, persona.summary, persona.editable],
|
||||
);
|
||||
return rowToPersona(result.rows[0]);
|
||||
const mapped = mapPersonaRow(result.rows[0]);
|
||||
if (!mapped) throw new Error("Persona upsert returned invalid row");
|
||||
return mapped;
|
||||
},
|
||||
|
||||
async seedCatalog(catalog: PersonaRecord[]): Promise<void> {
|
||||
@@ -250,16 +434,6 @@ export function createPersonaRepo(pool: Pool) {
|
||||
};
|
||||
}
|
||||
|
||||
function rowToPersona(row: Record<string, unknown>): PersonaRecord {
|
||||
return {
|
||||
id: row.id as string,
|
||||
name: row.name as string,
|
||||
model: row.model as string,
|
||||
summary: row.summary as string,
|
||||
editable: Boolean(row.editable),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node Graph repository
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -270,7 +444,7 @@ export function createNodeGraphRepo(pool: Pool) {
|
||||
const result = await pool.query(
|
||||
`SELECT id, name, description FROM node_graphs ORDER BY created_at DESC`,
|
||||
);
|
||||
return result.rows.map(rowToNodeGraph);
|
||||
return filterValid(result.rows, mapNodeGraphRow);
|
||||
},
|
||||
|
||||
async findById(id: string): Promise<NodeGraphRecord | null> {
|
||||
@@ -279,7 +453,7 @@ export function createNodeGraphRepo(pool: Pool) {
|
||||
[id],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
return rowToNodeGraph(result.rows[0]);
|
||||
return mapNodeGraphRow(result.rows[0]);
|
||||
},
|
||||
|
||||
async create(graph: NodeGraphRecord): Promise<NodeGraphRecord> {
|
||||
@@ -289,11 +463,12 @@ export function createNodeGraphRepo(pool: Pool) {
|
||||
RETURNING id, name, description`,
|
||||
[graph.id, graph.name, graph.description],
|
||||
);
|
||||
return rowToNodeGraph(result.rows[0]);
|
||||
const mapped = mapNodeGraphRow(result.rows[0]);
|
||||
if (!mapped) throw new Error("NodeGraph create returned invalid row");
|
||||
return mapped;
|
||||
},
|
||||
|
||||
async update(id: string, patch: Partial<NodeGraphRecord>): Promise<NodeGraphRecord | null> {
|
||||
// Build SET clause dynamically from provided fields
|
||||
const setClauses: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
@@ -320,19 +495,11 @@ export function createNodeGraphRepo(pool: Pool) {
|
||||
values,
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
return rowToNodeGraph(result.rows[0]);
|
||||
return mapNodeGraphRow(result.rows[0]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rowToNodeGraph(row: Record<string, unknown>): NodeGraphRecord {
|
||||
return {
|
||||
id: row.id as string,
|
||||
name: row.name as string,
|
||||
description: row.description as string,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node Run repository
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -343,7 +510,7 @@ export function createNodeRunRepo(pool: Pool) {
|
||||
const result = await pool.query(
|
||||
`SELECT id, graph_id, status, created_at FROM node_runs ORDER BY created_at DESC`,
|
||||
);
|
||||
return result.rows.map(rowToNodeRun);
|
||||
return filterValid(result.rows, mapNodeRunRow);
|
||||
},
|
||||
|
||||
async findById(id: string): Promise<NodeRunRecord | null> {
|
||||
@@ -352,7 +519,7 @@ export function createNodeRunRepo(pool: Pool) {
|
||||
[id],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
return rowToNodeRun(result.rows[0]);
|
||||
return mapNodeRunRow(result.rows[0]);
|
||||
},
|
||||
|
||||
async create(run: NodeRunRecord): Promise<NodeRunRecord> {
|
||||
@@ -362,7 +529,9 @@ export function createNodeRunRepo(pool: Pool) {
|
||||
RETURNING id, graph_id, status, created_at`,
|
||||
[run.id, run.graphId, run.status, run.createdAt],
|
||||
);
|
||||
return rowToNodeRun(result.rows[0]);
|
||||
const mapped = mapNodeRunRow(result.rows[0]);
|
||||
if (!mapped) throw new Error("NodeRun create returned invalid row");
|
||||
return mapped;
|
||||
},
|
||||
|
||||
async updateStatus(id: string, status: RunStatus): Promise<void> {
|
||||
@@ -372,7 +541,6 @@ export function createNodeRunRepo(pool: Pool) {
|
||||
);
|
||||
},
|
||||
|
||||
/** Mark a run as cancel-requested (worker checks this during execution) */
|
||||
async requestCancel(id: string): Promise<void> {
|
||||
await pool.query(
|
||||
`UPDATE node_runs SET status = 'cancelled', updated_at = NOW() WHERE id = $1 AND status IN ('queued', 'running')`,
|
||||
@@ -380,26 +548,23 @@ export function createNodeRunRepo(pool: Pool) {
|
||||
);
|
||||
},
|
||||
|
||||
/** Recover runs that were running when the worker crashed → re-queue them */
|
||||
async recoverStaleRuns(): Promise<NodeRunRecord[]> {
|
||||
const result = await pool.query(
|
||||
`UPDATE node_runs SET status = 'queued', updated_at = NOW()
|
||||
WHERE status = 'running'
|
||||
RETURNING id, graph_id, status, created_at`,
|
||||
);
|
||||
return result.rows.map(rowToNodeRun);
|
||||
return filterValid(result.rows, mapNodeRunRow);
|
||||
},
|
||||
|
||||
/** List runs by status */
|
||||
async listByStatus(status: RunStatus, limit = 50): Promise<NodeRunRecord[]> {
|
||||
const result = await pool.query(
|
||||
`SELECT id, graph_id, status, created_at FROM node_runs WHERE status = $1 ORDER BY created_at ASC LIMIT $2`,
|
||||
[status, limit],
|
||||
);
|
||||
return result.rows.map(rowToNodeRun);
|
||||
return filterValid(result.rows, mapNodeRunRow);
|
||||
},
|
||||
|
||||
/** Delete completed/failed/cancelled runs older than the given ISO date */
|
||||
async deleteOlderThan(date: string): Promise<number> {
|
||||
const result = await pool.query(
|
||||
`DELETE FROM node_runs WHERE status IN ('completed', 'failed', 'cancelled') AND created_at < $1`,
|
||||
@@ -410,15 +575,6 @@ export function createNodeRunRepo(pool: Pool) {
|
||||
};
|
||||
}
|
||||
|
||||
function rowToNodeRun(row: Record<string, unknown>): NodeRunRecord {
|
||||
return {
|
||||
id: row.id as string,
|
||||
graphId: row.graph_id as string,
|
||||
status: row.status as RunStatus,
|
||||
createdAt: (row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at)),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persona Source repository
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -431,7 +587,7 @@ export function createPersonaSourceRepo(pool: Pool) {
|
||||
[personaId],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
return rowToPersonaSource(result.rows[0]);
|
||||
return mapPersonaSourceRow(result.rows[0]);
|
||||
},
|
||||
|
||||
async upsert(source: PersonaSourceRecord): Promise<PersonaSourceRecord> {
|
||||
@@ -445,20 +601,13 @@ export function createPersonaSourceRepo(pool: Pool) {
|
||||
RETURNING persona_id, subject_name, summary, references_`,
|
||||
[source.personaId, source.subjectName, source.summary, JSON.stringify(source.references)],
|
||||
);
|
||||
return rowToPersonaSource(result.rows[0]);
|
||||
const mapped = mapPersonaSourceRow(result.rows[0]);
|
||||
if (!mapped) throw new Error("PersonaSource upsert returned invalid row");
|
||||
return mapped;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rowToPersonaSource(row: Record<string, unknown>): PersonaSourceRecord {
|
||||
return {
|
||||
personaId: row.persona_id as string,
|
||||
subjectName: row.subject_name as string,
|
||||
summary: row.summary as string,
|
||||
references: (row.references_ as string[]) || [],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persona Feedback repository
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -470,7 +619,7 @@ export function createPersonaFeedbackRepo(pool: Pool) {
|
||||
`SELECT id, persona_id, kind, message, created_at FROM persona_feedback WHERE persona_id = $1 ORDER BY created_at`,
|
||||
[personaId],
|
||||
);
|
||||
return result.rows.map(rowToPersonaFeedback);
|
||||
return filterValid(result.rows, mapPersonaFeedbackRow);
|
||||
},
|
||||
|
||||
async create(record: PersonaFeedbackRecord): Promise<PersonaFeedbackRecord> {
|
||||
@@ -480,21 +629,13 @@ export function createPersonaFeedbackRepo(pool: Pool) {
|
||||
RETURNING id, persona_id, kind, message, created_at`,
|
||||
[record.id, record.personaId, record.kind, record.message, record.createdAt],
|
||||
);
|
||||
return rowToPersonaFeedback(result.rows[0]);
|
||||
const mapped = mapPersonaFeedbackRow(result.rows[0]);
|
||||
if (!mapped) throw new Error("PersonaFeedback create returned invalid row");
|
||||
return mapped;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rowToPersonaFeedback(row: Record<string, unknown>): PersonaFeedbackRecord {
|
||||
return {
|
||||
id: row.id as string,
|
||||
personaId: row.persona_id as string,
|
||||
kind: row.kind as PersonaFeedbackRecord["kind"],
|
||||
message: row.message as string,
|
||||
createdAt: (row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at)),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persona Proposal repository
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -507,7 +648,7 @@ export function createPersonaProposalRepo(pool: Pool) {
|
||||
FROM persona_proposals WHERE persona_id = $1 ORDER BY created_at`,
|
||||
[personaId],
|
||||
);
|
||||
return result.rows.map(rowToPersonaProposal);
|
||||
return filterValid(result.rows, mapPersonaProposalRow);
|
||||
},
|
||||
|
||||
async create(record: PersonaProposalRecord): Promise<PersonaProposalRecord> {
|
||||
@@ -525,7 +666,9 @@ export function createPersonaProposalRepo(pool: Pool) {
|
||||
record.createdAt,
|
||||
],
|
||||
);
|
||||
return rowToPersonaProposal(result.rows[0]);
|
||||
const mapped = mapPersonaProposalRow(result.rows[0]);
|
||||
if (!mapped) throw new Error("PersonaProposal create returned invalid row");
|
||||
return mapped;
|
||||
},
|
||||
|
||||
async markApplied(id: string): Promise<void> {
|
||||
@@ -536,15 +679,3 @@ export function createPersonaProposalRepo(pool: Pool) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rowToPersonaProposal(row: Record<string, unknown>): PersonaProposalRecord {
|
||||
return {
|
||||
id: row.id as string,
|
||||
personaId: row.persona_id as string,
|
||||
before: row.before_snapshot as PersonaProposalRecord["before"],
|
||||
after: row.after_snapshot as PersonaProposalRecord["after"],
|
||||
reason: row.reason as string,
|
||||
applied: Boolean(row.applied),
|
||||
createdAt: (row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at)),
|
||||
};
|
||||
}
|
||||
|
||||
+83
-210
@@ -1,219 +1,92 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
SHELL_THEME,
|
||||
STATUS_COLORS,
|
||||
RUN_STATUS_COLORS,
|
||||
PERSONA_PALETTE,
|
||||
getPersonaColor,
|
||||
createUiCssVariables,
|
||||
UI_CSS_VARIABLES,
|
||||
RUN_STATUS_CLASSES,
|
||||
getRunStatusClass,
|
||||
publishUiCssVariables,
|
||||
createUiCssText,
|
||||
UI_THEME,
|
||||
SHELL_THEME, STATUS_COLORS, RUN_STATUS_COLORS, PERSONA_PALETTE,
|
||||
getPersonaColor, createUiCssVariables, getRunStatusClass,
|
||||
publishUiCssVariables, createUiCssText, UI_THEME, UI_CSS_VARIABLES,
|
||||
} from "./index.js";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Constants */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("SHELL_THEME", () => {
|
||||
it("has all expected keys", () => {
|
||||
const keys = [
|
||||
"background", "panel", "ink", "muted", "accent",
|
||||
"border", "borderLight", "fontMono", "fontDisplay", "gap", "radius",
|
||||
];
|
||||
for (const k of keys) {
|
||||
assert.ok(k in SHELL_THEME, `missing key: ${k}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("STATUS_COLORS", () => {
|
||||
it("has info, warn, danger", () => {
|
||||
assert.equal(typeof STATUS_COLORS.info, "string");
|
||||
assert.equal(typeof STATUS_COLORS.warn, "string");
|
||||
assert.equal(typeof STATUS_COLORS.danger, "string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RUN_STATUS_COLORS", () => {
|
||||
it("has 5 statuses", () => {
|
||||
const keys = ["running", "queued", "completed", "failed", "cancelled"];
|
||||
assert.deepEqual(Object.keys(RUN_STATUS_COLORS).sort(), keys.sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe("PERSONA_PALETTE", () => {
|
||||
it("has 8 elements", () => {
|
||||
assert.equal(PERSONA_PALETTE.length, 8);
|
||||
});
|
||||
|
||||
it("all elements are hex color strings", () => {
|
||||
for (const c of PERSONA_PALETTE) {
|
||||
assert.match(c, /^#[0-9a-f]{6}$/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* getPersonaColor */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("getPersonaColor", () => {
|
||||
it("returns a color from the palette for a nick", () => {
|
||||
const color = getPersonaColor("Merzbow");
|
||||
assert.ok(PERSONA_PALETTE.includes(color as typeof PERSONA_PALETTE[number]));
|
||||
});
|
||||
|
||||
it("is deterministic (same nick -> same color)", () => {
|
||||
const a = getPersonaColor("Pharmacius");
|
||||
const b = getPersonaColor("Pharmacius");
|
||||
assert.equal(a, b);
|
||||
});
|
||||
|
||||
it("wraps around the palette (9th persona maps back)", () => {
|
||||
// With a custom 3-color palette, hash % 3 always in range
|
||||
const palette = ["#aaa", "#bbb", "#ccc"];
|
||||
const color = getPersonaColor("anything", palette);
|
||||
assert.ok(palette.includes(color));
|
||||
});
|
||||
|
||||
it("returns accent if palette is empty", () => {
|
||||
assert.equal(getPersonaColor("test", []), SHELL_THEME.accent);
|
||||
});
|
||||
|
||||
it("handles short nicks (single char)", () => {
|
||||
const color = getPersonaColor("X");
|
||||
assert.ok(PERSONA_PALETTE.includes(color as typeof PERSONA_PALETTE[number]));
|
||||
});
|
||||
|
||||
it("handles long nicks", () => {
|
||||
const long = "A".repeat(500);
|
||||
const color = getPersonaColor(long);
|
||||
assert.ok(PERSONA_PALETTE.includes(color as typeof PERSONA_PALETTE[number]));
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* createUiCssVariables */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("createUiCssVariables", () => {
|
||||
const vars = createUiCssVariables();
|
||||
|
||||
it("returns an object with --kxkm-* keys", () => {
|
||||
for (const key of Object.keys(vars)) {
|
||||
assert.ok(key.startsWith("--kxkm-"), `key ${key} missing --kxkm- prefix`);
|
||||
}
|
||||
});
|
||||
|
||||
it("contains shell, status, and persona variables", () => {
|
||||
assert.ok("--kxkm-shell-background" in vars);
|
||||
assert.ok("--kxkm-status-info" in vars);
|
||||
assert.ok("--kxkm-persona-1" in vars);
|
||||
});
|
||||
|
||||
it("has 27 variables (19 base + 8 persona)", () => {
|
||||
// 11 shell + 3 status + 5 run-status + 8 persona = 27
|
||||
assert.equal(Object.keys(vars).length, 27);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* UI_CSS_VARIABLES */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("UI_CSS_VARIABLES", () => {
|
||||
it("equals a fresh createUiCssVariables() call", () => {
|
||||
assert.deepEqual(UI_CSS_VARIABLES, createUiCssVariables());
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* getRunStatusClass */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("getRunStatusClass", () => {
|
||||
for (const [status, cls] of Object.entries(RUN_STATUS_CLASSES)) {
|
||||
it(`returns "${cls}" for "${status}"`, () => {
|
||||
assert.equal(getRunStatusClass(status), cls);
|
||||
describe("@kxkm/ui", () => {
|
||||
describe("SHELL_THEME", () => {
|
||||
it("has all required keys", () => {
|
||||
assert.ok(SHELL_THEME.background);
|
||||
assert.ok(SHELL_THEME.ink);
|
||||
assert.ok(SHELL_THEME.accent);
|
||||
assert.ok(SHELL_THEME.fontMono);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('returns "status-muted" for unknown status', () => {
|
||||
assert.equal(getRunStatusClass("unknown"), "status-muted");
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* publishUiCssVariables */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("publishUiCssVariables", () => {
|
||||
it("calls setProperty for each variable", () => {
|
||||
const calls: [string, string][] = [];
|
||||
const target = { setProperty: (n: string, v: string) => calls.push([n, v]) };
|
||||
const vars = { "--kxkm-test-a": "red", "--kxkm-test-b": "blue" };
|
||||
publishUiCssVariables(target, vars);
|
||||
assert.deepEqual(calls, [["--kxkm-test-a", "red"], ["--kxkm-test-b", "blue"]]);
|
||||
});
|
||||
|
||||
it("uses default UI_CSS_VARIABLES when none specified", () => {
|
||||
const calls: [string, string][] = [];
|
||||
const target = { setProperty: (n: string, v: string) => calls.push([n, v]) };
|
||||
publishUiCssVariables(target);
|
||||
assert.equal(calls.length, Object.keys(UI_CSS_VARIABLES).length);
|
||||
assert.deepEqual(calls[0][0], Object.keys(UI_CSS_VARIABLES)[0]);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* createUiCssText */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("createUiCssText", () => {
|
||||
it("generates a CSS block with :root by default", () => {
|
||||
const css = createUiCssText();
|
||||
assert.ok(css.startsWith(":root {"));
|
||||
assert.ok(css.endsWith("}"));
|
||||
});
|
||||
|
||||
it("uses a custom selector if provided", () => {
|
||||
const css = createUiCssText(".my-app");
|
||||
assert.ok(css.startsWith(".my-app {"));
|
||||
});
|
||||
|
||||
it("contains --kxkm-* variables", () => {
|
||||
const css = createUiCssText();
|
||||
assert.ok(css.includes("--kxkm-shell-background:"));
|
||||
assert.ok(css.includes("--kxkm-persona-1:"));
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* UI_THEME aggregate */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("UI_THEME", () => {
|
||||
it("aggregates all sub-objects", () => {
|
||||
assert.equal(UI_THEME.shell, SHELL_THEME);
|
||||
assert.equal(UI_THEME.status, STATUS_COLORS);
|
||||
assert.equal(UI_THEME.runStatus, RUN_STATUS_COLORS);
|
||||
assert.equal(UI_THEME.personaPalette, PERSONA_PALETTE);
|
||||
assert.equal(UI_THEME.runStatusClasses, RUN_STATUS_CLASSES);
|
||||
});
|
||||
|
||||
it("has typography from shell fonts", () => {
|
||||
assert.equal(UI_THEME.typography.mono, SHELL_THEME.fontMono);
|
||||
assert.equal(UI_THEME.typography.display, SHELL_THEME.fontDisplay);
|
||||
});
|
||||
|
||||
it("has spacing from shell gap/radius", () => {
|
||||
assert.equal(UI_THEME.spacing.gap, SHELL_THEME.gap);
|
||||
assert.equal(UI_THEME.spacing.radius, SHELL_THEME.radius);
|
||||
describe("getPersonaColor", () => {
|
||||
it("returns a string from PERSONA_PALETTE", () => {
|
||||
const color = getPersonaColor("Pharmacius");
|
||||
assert.ok(PERSONA_PALETTE.includes(color as any));
|
||||
});
|
||||
it("is deterministic", () => {
|
||||
assert.equal(getPersonaColor("Sherlock"), getPersonaColor("Sherlock"));
|
||||
});
|
||||
it("returns accent for empty palette", () => {
|
||||
assert.equal(getPersonaColor("test", []), SHELL_THEME.accent);
|
||||
});
|
||||
it("distributes across palette", () => {
|
||||
const colors = new Set(["A","B","C","D","E","F","G","H"].map(n => getPersonaColor(n)));
|
||||
assert.ok(colors.size >= 3, `Expected >=3 distinct colors, got ${colors.size}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createUiCssVariables", () => {
|
||||
it("returns object with CSS custom properties", () => {
|
||||
const vars = createUiCssVariables();
|
||||
assert.ok(vars["--kxkm-shell-background"]);
|
||||
assert.ok(vars["--kxkm-shell-accent"]);
|
||||
assert.ok(vars["--kxkm-persona-1"]);
|
||||
});
|
||||
it("includes all persona palette entries", () => {
|
||||
const vars = createUiCssVariables();
|
||||
for (let i = 1; i <= PERSONA_PALETTE.length; i++) {
|
||||
assert.ok(vars[`--kxkm-persona-${i}`]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRunStatusClass", () => {
|
||||
it("maps known statuses", () => {
|
||||
assert.equal(getRunStatusClass("running"), "status-running");
|
||||
assert.equal(getRunStatusClass("failed"), "status-failed");
|
||||
});
|
||||
it("returns status-muted for unknown", () => {
|
||||
assert.equal(getRunStatusClass("unknown"), "status-muted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("publishUiCssVariables", () => {
|
||||
it("calls setProperty for each variable", () => {
|
||||
const calls: [string, string][] = [];
|
||||
const target = { setProperty: (n: string, v: string) => calls.push([n, v]) };
|
||||
publishUiCssVariables(target);
|
||||
assert.ok(calls.length > 10);
|
||||
assert.ok(calls.some(([n]) => n === "--kxkm-shell-accent"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("createUiCssText", () => {
|
||||
it("generates valid CSS block", () => {
|
||||
const css = createUiCssText();
|
||||
assert.ok(css.startsWith(":root {"));
|
||||
assert.ok(css.includes("--kxkm-shell-background"));
|
||||
assert.ok(css.endsWith("}"));
|
||||
});
|
||||
it("accepts custom selector", () => {
|
||||
const css = createUiCssText(".minitel");
|
||||
assert.ok(css.startsWith(".minitel {"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("UI_THEME", () => {
|
||||
it("aggregates all theme objects", () => {
|
||||
assert.ok(UI_THEME.shell);
|
||||
assert.ok(UI_THEME.status);
|
||||
assert.ok(UI_THEME.personaPalette);
|
||||
assert.ok(UI_THEME.typography);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
KXKM_DIR=/home/kxkm/KXKM_Clown
|
||||
find $KXKM_DIR/data/chat-logs -name "*.jsonl" -mtime +30 -delete 2>/dev/null
|
||||
for f in $KXKM_DIR/data/persona-memory/*.json; do
|
||||
size=$(stat -c%s "$f" 2>/dev/null || echo 0)
|
||||
[ "$size" -gt 102400 ] && python3 -c "
|
||||
import json
|
||||
with open(\"$f\") as fh: d = json.load(fh)
|
||||
d[\"facts\"] = d.get(\"facts\", [])[-20:]
|
||||
with open(\"$f\", \"w\") as fh: json.dump(d, fh, indent=2)
|
||||
" && echo "[cleanup] Trimmed $f"
|
||||
done
|
||||
echo "[cleanup] Done $(date)"
|
||||
+34
-12
@@ -59,31 +59,53 @@ $SSH "cd $REMOTE_DIR && \
|
||||
docker restart kxkm_clown-api-1" || fail "Docker deploy failed"
|
||||
log "Docker restarted"
|
||||
|
||||
# ─── Step 5: Restart TTS server (chatterbox-remote + piper fallback) ──
|
||||
# ─── Step 5: Restart TTS server (systemd user unit) ───────
|
||||
if [[ "$MODE" == "--full" || "$MODE" == "--tts" ]]; then
|
||||
log "Restarting TTS server..."
|
||||
$SSH "tmux kill-session -t tts 2>/dev/null || true; \
|
||||
sleep 1; \
|
||||
tmux new-session -d -s tts \
|
||||
'source /home/kxkm/venv/bin/activate && cd $REMOTE_DIR && CHATTERBOX_URL=http://127.0.0.1:9200 python3 scripts/tts-server.py --port 9100 --backend chatterbox-remote 2>&1 | tee /tmp/tts-server.log'; \
|
||||
log "Restarting TTS server (systemd)..."
|
||||
$SSH "systemctl --user restart kxkm-tts.service; \
|
||||
sleep 3; \
|
||||
curl -sf http://127.0.0.1:9100/health && echo ' TTS OK' || echo ' TTS FAIL'"
|
||||
fi
|
||||
|
||||
# ─── Step 5b: Restart LightRAG server ─────────────────────
|
||||
# ─── Step 5b: Restart LightRAG server (systemd user unit) ─
|
||||
if [[ "$MODE" == "--full" ]]; then
|
||||
log "Restarting LightRAG server..."
|
||||
$SSH "tmux kill-session -t lightrag 2>/dev/null || true; \
|
||||
sleep 1; \
|
||||
tmux new-session -d -s lightrag \
|
||||
'source /home/kxkm/venv/bin/activate && cd $REMOTE_DIR && EMBEDDING_DIM=768 LLM_MODEL=qwen3:8b EMBEDDING_MODEL=nomic-embed-text OLLAMA_HOST=http://localhost:11434 lightrag-server --host 0.0.0.0 --port 9621 --working-dir $REMOTE_DIR/data/lightrag --llm-binding ollama --embedding-binding ollama 2>&1 | tee /tmp/lightrag-server.log'; \
|
||||
log "Restarting LightRAG server (systemd)..."
|
||||
$SSH "systemctl --user restart kxkm-lightrag.service; \
|
||||
sleep 5; \
|
||||
curl -sf http://127.0.0.1:9621/health | head -c 30 && echo ' LightRAG OK' || echo ' LightRAG FAIL'"
|
||||
fi
|
||||
|
||||
# ─── Step 5c: Restart Reranker server (systemd user unit) ─
|
||||
if [[ "$MODE" == "--full" ]]; then
|
||||
log "Restarting Reranker server (systemd)..."
|
||||
$SSH "systemctl --user restart kxkm-reranker.service; \
|
||||
sleep 3; \
|
||||
curl -sf http://127.0.0.1:9500/health && echo ' Reranker OK' || echo ' Reranker FAIL'"
|
||||
fi
|
||||
|
||||
# ─── Step 5d: Restart Qwen3-TTS server (on-demand, GPU-heavy) ─
|
||||
if [[ "$MODE" == "--full" ]]; then
|
||||
log "Checking Qwen3-TTS server (on-demand)..."
|
||||
$SSH "if systemctl --user is-active kxkm-qwen3-tts.service >/dev/null 2>&1; then \
|
||||
systemctl --user restart kxkm-qwen3-tts.service; \
|
||||
sleep 5; \
|
||||
curl -sf http://127.0.0.1:9300/health && echo ' Qwen3-TTS OK' || echo ' Qwen3-TTS FAIL'; \
|
||||
else \
|
||||
echo ' Qwen3-TTS not active (on-demand, skipped)'; \
|
||||
fi"
|
||||
fi
|
||||
|
||||
# ─── Step 6: Health check ──────────────────────────────────
|
||||
log "Health check..."
|
||||
sleep 3
|
||||
$SSH "curl -sf http://localhost:3333/api/v2/health | head -c 50" && echo " API OK" || echo " API FAIL"
|
||||
|
||||
# Docker container health
|
||||
log "Docker containers status..."
|
||||
$SSH "docker compose --profile v2 ps --format 'table {{.Name}}\t{{.Status}}' 2>/dev/null"
|
||||
|
||||
# Journal disk usage
|
||||
log "Journal disk usage..."
|
||||
$SSH "journalctl --user --disk-usage 2>/dev/null"
|
||||
|
||||
log "═══ Deploy complete ═══"
|
||||
|
||||
@@ -1,6 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract text from PDF using Docling (tables, layout, OCR)."""
|
||||
import argparse, json, sys, time
|
||||
"""Extract text from PDF using Docling (HTTP API → local library → PyMuPDF fallback)."""
|
||||
import argparse, json, os, sys, time
|
||||
|
||||
|
||||
def try_docling_http(filepath: str, max_chars: int) -> dict | None:
|
||||
"""Try Docling-serve HTTP API if DOCLING_URL is set."""
|
||||
docling_url = os.environ.get("DOCLING_URL", "").rstrip("/")
|
||||
if not docling_url:
|
||||
return None
|
||||
import urllib.request, urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
url = f"{docling_url}/v1/convert/file"
|
||||
filename = Path(filepath).name
|
||||
|
||||
# Build multipart form data manually (no requests dependency)
|
||||
boundary = f"----DoclingBoundary{int(time.time()*1000)}"
|
||||
file_data = Path(filepath).read_bytes()
|
||||
|
||||
body = (
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="files"; filename="{filename}"\r\n'
|
||||
f"Content-Type: application/pdf\r\n\r\n"
|
||||
).encode() + file_data + f"\r\n--{boundary}--\r\n".encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
headers={
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
result = json.loads(resp.read())
|
||||
# docling-serve response: {document: {md_content, filename}, status, processing_time}
|
||||
text = ""
|
||||
if isinstance(result, dict):
|
||||
doc = result.get("document", {})
|
||||
if isinstance(doc, dict):
|
||||
text = doc.get("md_content", "") or doc.get("text_content", "") or ""
|
||||
if not text:
|
||||
text = result.get("text", "") or result.get("markdown", "")
|
||||
if not text:
|
||||
text = json.dumps(result, ensure_ascii=False)
|
||||
text = text[:max_chars]
|
||||
status = result.get("status", "unknown") if isinstance(result, dict) else "unknown"
|
||||
return {"status": "completed", "text": text, "pages": "?", "backend": "docling-serve",
|
||||
"docling_status": status}
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, OSError, json.JSONDecodeError) as e:
|
||||
print(f"[docling-http] fallback: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def try_docling_local(filepath: str, max_chars: int) -> dict | None:
|
||||
"""Try local Docling Python library."""
|
||||
try:
|
||||
from docling.document_converter import DocumentConverter
|
||||
converter = DocumentConverter()
|
||||
doc = converter.convert(filepath)
|
||||
text = doc.document.export_to_markdown()[:max_chars]
|
||||
pages = len(doc.document.pages) if hasattr(doc.document, "pages") else 0
|
||||
return {"status": "completed", "text": text, "pages": pages, "backend": "docling-local"}
|
||||
except ImportError:
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[docling-local] fallback: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def try_pymupdf(filepath: str, max_chars: int) -> dict | None:
|
||||
"""Fallback to PyMuPDF."""
|
||||
try:
|
||||
import fitz
|
||||
doc = fitz.open(filepath)
|
||||
text = ""
|
||||
for page in doc:
|
||||
text += page.get_text()
|
||||
text = text[:max_chars]
|
||||
return {"status": "completed", "text": text, "pages": len(doc), "backend": "pymupdf"}
|
||||
except ImportError:
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[pymupdf] error: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
@@ -8,29 +94,17 @@ def main():
|
||||
p.add_argument("--max-chars", type=int, default=12000)
|
||||
args = p.parse_args()
|
||||
start = time.time()
|
||||
result = {"status": "failed", "text": "", "error": None}
|
||||
try:
|
||||
from docling.document_converter import DocumentConverter
|
||||
converter = DocumentConverter()
|
||||
doc = converter.convert(args.input)
|
||||
text = doc.document.export_to_markdown()[:args.max_chars]
|
||||
pages = len(doc.document.pages) if hasattr(doc.document, 'pages') else 0
|
||||
result = {"status": "completed", "text": text, "pages": pages, "duration": round(time.time()-start, 2)}
|
||||
except ImportError:
|
||||
# Fallback to pdf-parse style
|
||||
try:
|
||||
import fitz # PyMuPDF
|
||||
doc = fitz.open(args.input)
|
||||
text = ""
|
||||
for page in doc:
|
||||
text += page.get_text()
|
||||
text = text[:args.max_chars]
|
||||
result = {"status": "completed", "text": text, "pages": len(doc), "duration": round(time.time()-start, 2)}
|
||||
except ImportError:
|
||||
result["error"] = "Neither docling nor PyMuPDF installed"
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
print(json.dumps(result))
|
||||
|
||||
# Try backends in order: HTTP API → local library → PyMuPDF
|
||||
for backend_fn in [try_docling_http, try_docling_local, try_pymupdf]:
|
||||
result = backend_fn(args.input, args.max_chars)
|
||||
if result:
|
||||
result["duration"] = round(time.time() - start, 2)
|
||||
print(json.dumps(result))
|
||||
return
|
||||
|
||||
print(json.dumps({"status": "failed", "error": "No PDF backend available (docling-serve, docling, PyMuPDF)"}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
# ─── KXKM_Clown Health Check TUI ───────────────────────────
|
||||
# Usage: bash scripts/health-check.sh [--remote kxkm@kxkm-ai]
|
||||
set -uo pipefail
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||
PASS="${GREEN}✓${NC}"; FAIL="${RED}✗${NC}"; WARN="${YELLOW}⚠${NC}"
|
||||
|
||||
HOST="localhost"
|
||||
SSH=""
|
||||
if [[ "${1:-}" == "--remote" && -n "${2:-}" ]]; then
|
||||
SSH="ssh $2"
|
||||
HOST="localhost"
|
||||
echo -e "${CYAN}═══ KXKM_Clown Health Check (remote: $2) ═══${NC}"
|
||||
else
|
||||
echo -e "${CYAN}═══ KXKM_Clown Health Check (local) ═══${NC}"
|
||||
fi
|
||||
|
||||
run() { if [[ -n "$SSH" ]]; then $SSH "$@"; else eval "$@"; fi; }
|
||||
ok=0; fail=0; warn=0
|
||||
|
||||
check() {
|
||||
local label="$1"; shift
|
||||
if result=$(run "$@" 2>/dev/null); then
|
||||
echo -e " ${PASS} ${label}: ${result}"
|
||||
((ok++))
|
||||
else
|
||||
echo -e " ${FAIL} ${label}: FAILED"
|
||||
((fail++))
|
||||
fi
|
||||
}
|
||||
|
||||
checkwarn() {
|
||||
local label="$1"; shift
|
||||
if result=$(run "$@" 2>/dev/null); then
|
||||
echo -e " ${PASS} ${label}: ${result}"
|
||||
((ok++))
|
||||
else
|
||||
echo -e " ${WARN} ${label}: NOT AVAILABLE"
|
||||
((warn++))
|
||||
fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}── Services ──${NC}"
|
||||
check "API V2 (:3333)" "curl -sf http://${HOST}:3333/api/v2/health | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get(\"data\",{}).get(\"app\",\"?\"))'"
|
||||
check "PostgreSQL" "docker exec kxkm_clown-postgres-1 pg_isready -q && echo 'ready'"
|
||||
check "Ollama (:11434)" "curl -sf http://${HOST}:11434/api/tags | python3 -c 'import sys,json; d=json.load(sys.stdin); print(len(d.get(\"models\",[])),\"models\")'"
|
||||
checkwarn "SearXNG (:8080)" "curl -sf 'http://${HOST}:8080/search?q=test&format=json' -H 'Accept: application/json' | python3 -c 'import sys,json; d=json.load(sys.stdin); print(len(d.get(\"results\",[])),\"results\")'"
|
||||
checkwarn "TTS Sidecar (:9100)" "curl -sf http://${HOST}:9100/health | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get(\"backend\",\"?\"))'"
|
||||
checkwarn "Chatterbox (:9200)" "curl -sf http://${HOST}:9200/health | python3 -c 'import sys,json; d=json.load(sys.stdin); print(\"GPU docker\")'"
|
||||
checkwarn "LightRAG (:9621)" "curl -sf http://${HOST}:9621/health | head -c 50"
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}── Docker Containers ──${NC}"
|
||||
check "Containers" "docker ps --format '{{.Names}}: {{.Status}}' | grep -c 'Up' | xargs -I{} echo '{} running'"
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}── Data ──${NC}"
|
||||
check "Chat logs" "ls -1 data/chat-logs/v2-*.jsonl 2>/dev/null | wc -l | xargs -I{} echo '{} log files'"
|
||||
check "Context store" "ls -1 data/context/*.jsonl 2>/dev/null | wc -l | xargs -I{} echo '{} channels'"
|
||||
check "Media images" "ls -1 data/media/images/*.png 2>/dev/null | wc -l | xargs -I{} echo '{} images'"
|
||||
check "Media audio" "ls -1 data/media/audio/*.wav 2>/dev/null | wc -l | xargs -I{} echo '{} audio files'"
|
||||
check "Persona memory" "ls -1 data/persona-memory/*.json 2>/dev/null | wc -l | xargs -I{} echo '{} personas with memory'"
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}── API Endpoints ──${NC}"
|
||||
check "Session login" "curl -sf -X POST http://${HOST}:3333/api/session/login -H 'Content-Type: application/json' -d '{\"username\":\"healthcheck\",\"role\":\"viewer\"}' | python3 -c 'import sys,json; print(json.load(sys.stdin).get(\"ok\",False))'"
|
||||
check "Personas list" "curl -sf -c /tmp/hc.txt -X POST http://${HOST}:3333/api/session/login -H 'Content-Type: application/json' -d '{\"username\":\"hc\"}' > /dev/null && curl -sf -b /tmp/hc.txt http://${HOST}:3333/api/personas | python3 -c 'import sys,json; d=json.load(sys.stdin); print(len(d.get(\"data\",[])),\"personas\")'"
|
||||
check "Media images API" "curl -sf -b /tmp/hc.txt http://${HOST}:3333/api/v2/media/images | python3 -c 'import sys,json; d=json.load(sys.stdin); print(len(d.get(\"data\",[])),\"images\")'"
|
||||
check "Node Engine" "curl -sf -c /tmp/hcadm.txt -X POST http://${HOST}:3333/api/session/login -H 'Content-Type: application/json' -d '{\"username\":\"admin\",\"role\":\"admin\",\"token\":\"kxkm\"}' > /dev/null && curl -sf -b /tmp/hcadm.txt http://${HOST}:3333/api/admin/node-engine/overview | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d[\"data\"][\"registry\"][\"models\"],\"models\")'"
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}── GPU ──${NC}"
|
||||
checkwarn "NVIDIA GPU" "nvidia-smi --query-gpu=name,memory.used,memory.total --format=csv,noheader,nounits | head -1"
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}── Disk ──${NC}"
|
||||
check "Disk usage" "df -h / | tail -1 | awk '{print \$3\"/\"\$2\" used (\"\$5\")\"}'"
|
||||
check "Data dir size" "du -sh data/ 2>/dev/null | cut -f1 | xargs -I{} echo '{} total'"
|
||||
|
||||
echo ""
|
||||
echo -e "═══════════════════════════════════════"
|
||||
echo -e " ${GREEN}${ok} passed${NC} ${RED}${fail} failed${NC} ${YELLOW}${warn} warnings${NC}"
|
||||
echo -e "═══════════════════════════════════════"
|
||||
|
||||
[[ $fail -eq 0 ]] && exit 0 || exit 1
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Preload Ollama models into VRAM after boot/restart
|
||||
# Run via: systemctl --user start kxkm-ollama-warmup
|
||||
OLLAMA=http://localhost:11434
|
||||
|
||||
echo "[warmup] Loading models..."
|
||||
for model in qwen3:8b mistral:7b nomic-embed-text; do
|
||||
curl -sf $OLLAMA/api/chat -d "{\"model\":\"\",\"messages\":[{\"role\":\"user\",\"content\":\".\"}],\"stream\":false,\"options\":{\"num_predict\":1,\"num_ctx\":8192},\"keep_alive\":\"30m\"}" -o /dev/null 2>/dev/null
|
||||
echo " : loaded"
|
||||
done
|
||||
|
||||
echo "[warmup] Done. Models in VRAM:"
|
||||
curl -sf $OLLAMA/api/ps 2>/dev/null | python3 -c 'import sys,json; [print(f" {m[\"name\"]}: {m[\"size\"]//1e9:.1f}GB {m[\"details\"][\"family\"]}") for m in json.load(sys.stdin).get(\"models\",[])]' 2>/dev/null || ollama ps
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start Qwen3-TTS on demand, stop after 5 min idle
|
||||
# Usage: called by tts-server.py when qwen3 backend is requested
|
||||
|
||||
PORT=9300
|
||||
SERVICE=kxkm-qwen3-tts.service
|
||||
IDLE_TIMEOUT=300 # 5 minutes
|
||||
|
||||
# Check if already running
|
||||
if systemctl --user is-active $SERVICE >/dev/null 2>&1; then
|
||||
# Already running, just check health
|
||||
curl -sf http://localhost:$PORT/health >/dev/null && exit 0
|
||||
fi
|
||||
|
||||
# Start service
|
||||
systemctl --user start $SERVICE
|
||||
echo "[qwen3-tts] Starting on-demand..." >&2
|
||||
|
||||
# Wait for health (max 30s)
|
||||
for i in $(seq 1 30); do
|
||||
curl -sf http://localhost:$PORT/health >/dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Schedule auto-stop after idle timeout
|
||||
(sleep $IDLE_TIMEOUT && systemctl --user stop $SERVICE && echo "[qwen3-tts] Auto-stopped after ${IDLE_TIMEOUT}s idle" >&2) &
|
||||
disown
|
||||
|
||||
echo "[qwen3-tts] Ready on :$PORT" >&2
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user