feat: Add MCP server and smoke test scripts
- Introduced `mcp-server.js` to expose KXKM personas as MCP tools, supporting chat, persona listing, web search, and status checks. - Implemented `mcp-server-smoke.js` for testing the MCP server functionality, ensuring compatibility with both new and legacy message formats. - Created `setup-voice-clone.sh` for managing voice cloning environment setup, including bootstrapping, sample generation, and smoke testing. - Added `state.json` to track project status and task outputs for various batches. - Generated summary files for deep cycle and overall project status, capturing performance and security findings.
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(ssh kxkm@kxkm-ai \"cd /home/kxkm/KXKM_Clown && docker compose --profile v2 up -d api 2>&1 && sleep 5 && docker exec kxkm_clown-api-1 python3 -c ''import transformers; print\\(\"\"transformers\"\", transformers.__version__\\)'' 2>&1 && curl -s http://localhost:3333/api/v2/health | head -c 50\")",
|
||||
"Bash(ssh kxkm@kxkm-ai \"docker exec kxkm_clown-api-1 pip3 install --break-system-packages transformers accelerate 2>&1 | tail -5\")",
|
||||
"Bash(ssh kxkm@kxkm-ai \"docker commit kxkm_clown-api-1 kxkm_clown-api:with-ml 2>&1 && docker tag kxkm_clown-api:with-ml kxkm_clown-api:latest 2>&1 && echo IMAGE SAVED\")",
|
||||
"Bash(ssh kxkm@kxkm-ai \"docker exec kxkm_clown-api-1 python3 -c ''import transformers; print\\(\"\"OK\"\", transformers.__version__\\)'' 2>&1 && curl -s http://localhost:3333/api/v2/health | head -c 50\")",
|
||||
"Bash(sort -k2 -rn)",
|
||||
"Bash(find packages/*/src -name \"*.ts\" -exec wc -l {} +)",
|
||||
"Bash(find apps/*/src -name \"*.ts\" -exec wc -l {} +)",
|
||||
"Bash(ssh kxkm@kxkm-ai \"source ~/.nvm/nvm.sh && cd /home/kxkm/KXKM_Clown && git checkout -- . 2>/dev/null; git pull origin main 2>&1 | tail -5 && npm run -w @kxkm/web build 2>&1 | tail -3 && npm run -w @kxkm/api build 2>&1 | tail -3 && npm run build 2>&1 | tail -3 && docker compose --profile v2 build api 2>&1 | tail -3 && docker compose --profile v2 up -d api 2>&1 && echo DEPLOYED\")",
|
||||
"Bash(ssh kxkm@kxkm-ai 'source ~/.nvm/nvm.sh && cd /home/kxkm/KXKM_Clown && timeout 25 node -e \"\":*)",
|
||||
"Bash(ssh kxkm@kxkm-ai \"docker exec kxkm_clown-api-1 sh -c ''grep -o \"\"F6\\\\|F7\\\\|Media\\\\|media-explorer\\\\|vtx-progress\\\\|vtx-track\\\\|vtx-viewer\\\\|MEDIATHEQUE\"\" /app/apps/web/dist/assets/index-W6N2scKN.js | sort -u''\")",
|
||||
"Bash(ssh kxkm@kxkm-ai \"docker exec kxkm_clown-api-1 sh -c ''grep -o \"\"media-explorer\\\\|media-grid\\\\|media-playlist\\\\|vtx-progress-bar\\\\|vtx-viewer\\\\|voice-level-bar\\\\|compose-page\\\\|imagine-page\"\" /app/apps/web/dist/assets/index-DsjUsnfb.css | sort -u''\")",
|
||||
"Bash(ssh kxkm@kxkm-ai \"docker exec kxkm_clown-api-1 grep -c ''media-store\\\\|media\\\\.js\\\\|/api/v2/media'' /app/apps/api/dist/app.js /app/apps/api/dist/server.js 2>&1\")",
|
||||
"Bash(ssh kxkm@kxkm-ai \"docker exec kxkm_clown-api-1 ls /app/apps/api/dist/routes/ 2>&1 && docker exec kxkm_clown-api-1 grep ''stripThinking\\\\|<think>'' /app/apps/api/dist/ws-ollama.js | head -5\")",
|
||||
"Bash(ssh kxkm@kxkm-ai \"source ~/.nvm/nvm.sh && cd /home/kxkm/KXKM_Clown && echo ''=== GIT ==='' && git log --oneline -3 && echo ''=== DIFF ==='' && git diff --stat HEAD | head -10 && echo ''=== BUILD CHECK ==='' && grep -c ''F6\\\\|F7\\\\|MEDIATHEQUE\\\\|media-explorer\\\\|vtx-progress'' apps/web/dist/assets/*.js 2>/dev/null && echo ''=== CONTAINER ==='' && docker exec kxkm_clown-api-1 grep -c ''F6\\\\|F7\\\\|MEDIATHEQUE'' /app/apps/web/dist/assets/*.js 2>/dev/null\")",
|
||||
"Bash(ssh kxkm@kxkm-ai \"cd /home/kxkm/KXKM_Clown && git stash 2>/dev/null; git fetch origin main && git reset --hard origin/main 2>&1 | tail -5\")",
|
||||
"Bash(ssh kxkm@kxkm-ai \"source ~/.nvm/nvm.sh && cd /home/kxkm/KXKM_Clown && npx tsc -b tsconfig.v2.json 2>&1 | tail -3 && npm run -w @kxkm/web build 2>&1 | tail -5 && npm run -w @kxkm/api build 2>&1 | tail -3 && npm run build 2>&1 | tail -3\")",
|
||||
"Bash(ssh kxkm@kxkm-ai \"cd /home/kxkm/KXKM_Clown && grep -o ''F6\\\\|F7\\\\|MEDIATHEQUE\\\\|media-explorer\\\\|vtx-progress\\\\|vtx-viewer\\\\|vtx-track'' apps/web/dist/assets/index-D7Uj4-gG.js | sort -u\")",
|
||||
"Bash(ssh kxkm@kxkm-ai \"docker exec kxkm_clown-api-1 ls /app/apps/web/dist/assets/ && docker exec kxkm_clown-api-1 sh -c ''grep -c MEDIATHEQUE /app/apps/web/dist/assets/index-D7Uj4-gG.js''\")",
|
||||
"Bash(ssh kxkm@kxkm-ai \"docker exec kxkm_clown-api-1 python3 -c ''import transformers; print\\(\"\"OK\"\"\\)'' 2>&1 || docker exec kxkm_clown-api-1 pip3 install --break-system-packages --no-cache-dir transformers accelerate 2>&1 | tail -3\")",
|
||||
"Bash(ssh kxkm@kxkm-ai \"docker exec kxkm_clown-api-1 pip3 install --break-system-packages --no-cache-dir scipy 2>&1 | tail -3\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
Submodule
+1
Submodule .claude/worktrees/agent-aaaeff51 added at 06b2e96a43
@@ -4,6 +4,8 @@ dist/
|
||||
|
||||
.DS_Store
|
||||
*.log
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Secrets
|
||||
.env
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# EXECUTION STATUS (kxkm-clown-v2)
|
||||
|
||||
Updated: 2026-03-17T22:06:31Z
|
||||
|
||||
## lot-0-cadrage
|
||||
- Status: done
|
||||
- Owner: Coordinateur
|
||||
- Execution: managed
|
||||
- Checks: docs-reviewed
|
||||
- Open tasks: none
|
||||
|
||||
## lot-1-socle
|
||||
- Status: done
|
||||
- Owner: Coordinateur
|
||||
- Execution: managed
|
||||
- Checks: npm run check:v2, npm run test:v2
|
||||
- Open tasks: none
|
||||
|
||||
## lot-2-domaines
|
||||
- Status: done
|
||||
- Owner: Backend API
|
||||
- Execution: managed
|
||||
- Checks: npm run test:v2
|
||||
- Open tasks: none
|
||||
|
||||
## lot-3-surfaces
|
||||
- Status: done
|
||||
- Owner: Frontend
|
||||
- Execution: managed
|
||||
- Checks: npm run -w @kxkm/web check
|
||||
- Open tasks: none
|
||||
|
||||
## lot-4-bascule
|
||||
- Status: done
|
||||
- Owner: Coordinateur
|
||||
- Execution: managed
|
||||
- Checks: npm run smoke:v2
|
||||
- Open tasks: none
|
||||
|
||||
## lot-12-deep-audit
|
||||
- Status: done
|
||||
- Owner: Coordinateur
|
||||
- Execution: manual
|
||||
- Checks: npm run check:v2, npm run test:v2, npm run -w @kxkm/web test, node ops/v2/deep-audit.js --json
|
||||
- Open tasks: none
|
||||
|
||||
## lot-13-voice-mcp
|
||||
- Status: done
|
||||
- Owner: Multimodal
|
||||
- Execution: manual
|
||||
- Checks: node scripts/mcp-server-smoke.js, npm run smoke:voice-mcp, npm run smoke:voice-clone, bash ops/v2/run-spike-checks.sh voice-clone --yes
|
||||
- Open tasks: none
|
||||
|
||||
## lot-14-documents-search
|
||||
- Status: done
|
||||
- Owner: Ops/TUI
|
||||
- Execution: manual
|
||||
- Checks: docker compose --profile v2 config --services, bash scripts/health-doc-search.sh search --strict, npm run smoke:documents-search, npm run smoke:embeddings, bash ops/v2/run-spike-checks.sh embeddings --yes
|
||||
- Open tasks: none
|
||||
@@ -48,11 +48,11 @@ Par defaut, Ollama est attendu en natif sur le host (port 11434).
|
||||
### Chat multimodal
|
||||
|
||||
- **Interface Minitel** — Animation modem 3615 ULLA → login → chat (esthetique phosphore CRT)
|
||||
- **Chat temps reel** — WebSocket `/ws`, streaming LLM, 26 personas
|
||||
- **Chat temps reel** — WebSocket `/ws`, streaming LLM, 33 personas
|
||||
- **RAG local** — Embeddings Ollama (`nomic-embed-text`), contexte manifeste
|
||||
- **Vision** — Analyse d'images via `qwen3-vl:8b` (upload dans le chat)
|
||||
- **STT** — Transcription audio via `faster-whisper` (upload audio)
|
||||
- **TTS** — Piper-tts (fallback rapide) + XTTS-v2 (voice cloning per persona)
|
||||
- **TTS** — Piper-tts + Chatterbox (dual backend via TTS sidecar HTTP :9100)
|
||||
- **PDF** — Extraction via Docling/PyMuPDF (tables, layout, OCR)
|
||||
- **Recherche web** — SearXNG self-hosted + DuckDuckGo fallback
|
||||
- **Generation musicale** — `/compose` via ACE-Step 1.5 / MusicGen
|
||||
@@ -80,10 +80,11 @@ Par defaut, Ollama est attendu en natif sur le host (port 11434).
|
||||
|
||||
### Personas
|
||||
|
||||
- 26 personas (musique, arts, sciences, philosophie, ecologie, tech, cinema)
|
||||
- 33 personas (musique, arts, sciences, philosophie, ecologie, tech, cinema)
|
||||
- Pipeline editorial: source → feedback → proposals → apply/revert
|
||||
- Pharmacius: orchestrateur editorial automatique (mistral:7b)
|
||||
- Vue arborescente par modele (refermee par defaut)
|
||||
- Pharmacius: routeur principal (qwen3:8b, maxTokens:600, think-strip)
|
||||
- Inter-persona @mention depth 3, 2s delay
|
||||
- Modeles: qwen3:8b x21, mistral:7b x7, gemma3:4b x4, qwen3-vl:8b (vision)
|
||||
|
||||
## Variables d'environnement
|
||||
|
||||
@@ -99,7 +100,7 @@ Par defaut, Ollama est attendu en natif sur le host (port 11434).
|
||||
| `ADMIN_SUBNET` | (vide) | CIDR autorise pour admin V2 |
|
||||
| `MAX_GENERAL_RESPONDERS` | `4` | Nombre max de personas repondant dans #general |
|
||||
| `OWNER_NICK` | (vide) | Pseudo du proprietaire |
|
||||
| `VISION_MODEL` | `minicpm-v` | Modele Ollama pour analyse d'images |
|
||||
| `VISION_MODEL` | `qwen3-vl:8b` | Modele Ollama pour analyse d'images |
|
||||
| `TTS_ENABLED` | `0` | Activer la synthese vocale (`1` pour activer) |
|
||||
| `WEB_SEARCH_API_BASE` | (vide) | Endpoint API de recherche web custom |
|
||||
| `PYTHON_BIN` | `python3` | Python avec libs ML (PyTorch, faster-whisper, piper-tts) |
|
||||
@@ -244,7 +245,7 @@ kxkm_clown/
|
||||
| --- | --- | --- |
|
||||
| Chat temps reel | operationnel | operationnel |
|
||||
| RAG local | n/a | operationnel |
|
||||
| Vision (minicpm-v) | n/a | operationnel |
|
||||
| Vision (qwen3-vl:8b) | n/a | operationnel |
|
||||
| STT (faster-whisper) | n/a | operationnel |
|
||||
| TTS (piper-tts) | n/a | operationnel |
|
||||
| PDF extraction | n/a | operationnel |
|
||||
@@ -260,7 +261,11 @@ kxkm_clown/
|
||||
| RBAC | n/a | operationnel |
|
||||
| Frontend React | n/a | operationnel |
|
||||
| Training (TRL/Unsloth) | n/a | operationnel |
|
||||
| Tests (135+) | smoke | unit + component + smoke |
|
||||
| Tests (204) | smoke | unit + component + smoke |
|
||||
| VoiceChat push-to-talk | n/a | operationnel |
|
||||
| Mediatheque gallery/playlist | n/a | operationnel |
|
||||
| UI Minitel VIDEOTEX | n/a | operationnel |
|
||||
| Deploy tmux (deploy.sh) | n/a | operationnel |
|
||||
|
||||
Notes runtime V2:
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# EXECUTION STATUS (kxkm-clown-v2)
|
||||
|
||||
Updated: 2026-03-17T22:06:31Z
|
||||
|
||||
## lot-0-cadrage
|
||||
- Status: done
|
||||
- Owner: Coordinateur
|
||||
- Execution: managed
|
||||
- Checks: docs-reviewed
|
||||
- Open tasks: none
|
||||
|
||||
## lot-1-socle
|
||||
- Status: done
|
||||
- Owner: Coordinateur
|
||||
- Execution: managed
|
||||
- Checks: npm run check:v2, npm run test:v2
|
||||
- Open tasks: none
|
||||
|
||||
## lot-2-domaines
|
||||
- Status: done
|
||||
- Owner: Backend API
|
||||
- Execution: managed
|
||||
- Checks: npm run test:v2
|
||||
- Open tasks: none
|
||||
|
||||
## lot-3-surfaces
|
||||
- Status: done
|
||||
- Owner: Frontend
|
||||
- Execution: managed
|
||||
- Checks: npm run -w @kxkm/web check
|
||||
- Open tasks: none
|
||||
|
||||
## lot-4-bascule
|
||||
- Status: done
|
||||
- Owner: Coordinateur
|
||||
- Execution: managed
|
||||
- Checks: npm run smoke:v2
|
||||
- Open tasks: none
|
||||
|
||||
## lot-12-deep-audit
|
||||
- Status: done
|
||||
- Owner: Coordinateur
|
||||
- Execution: manual
|
||||
- Checks: npm run check:v2, npm run test:v2, npm run -w @kxkm/web test, node ops/v2/deep-audit.js --json
|
||||
- Open tasks: none
|
||||
|
||||
## lot-13-voice-mcp
|
||||
- Status: done
|
||||
- Owner: Multimodal
|
||||
- Execution: manual
|
||||
- Checks: node scripts/mcp-server-smoke.js, npm run smoke:voice-mcp, npm run smoke:voice-clone, bash ops/v2/run-spike-checks.sh voice-clone --yes
|
||||
- Open tasks: none
|
||||
|
||||
## lot-14-documents-search
|
||||
- Status: done
|
||||
- Owner: Ops/TUI
|
||||
- Execution: manual
|
||||
- Checks: docker compose --profile v2 config --services, bash scripts/health-doc-search.sh search --strict, npm run smoke:documents-search, npm run smoke:embeddings, bash ops/v2/run-spike-checks.sh embeddings --yes
|
||||
- Open tasks: none
|
||||
@@ -1,54 +1,217 @@
|
||||
# TODO (kxkm-clown-v2)
|
||||
# TODO
|
||||
|
||||
Updated: 2026-03-18T21:30:00Z
|
||||
## P0 Critical (sécurité & stabilité)
|
||||
|
||||
## Lots termines
|
||||
- [x] Fix bash injection dans `node-engine-runtimes.js` — validation runtimeId/nodeType + timeout 30min
|
||||
- [x] Ajouter timeout sur les appels Ollama — 15s metadata, 5min chat streaming
|
||||
- [x] Validation des entrées sur les messages WebSocket — 64KB max frame, 8192 chars text, type checks
|
||||
- [x] Rate limiting par user/IP sur chat — `rate-limit.js` + 30 msg/min par IP dans WebSocket
|
||||
- [x] `escapeHtml` dédupliqué → `utils.js`
|
||||
- [x] `normalizeAuth` consolidé dans `admin-api.js`
|
||||
- [x] `ensureSeedGraphs` guard flag ajouté
|
||||
- [x] `finishRun` comptage d'artifacts sans JSON parse
|
||||
- [x] `recoverRunnableRuns` double-read corrigé
|
||||
|
||||
- [x] lot-0 cadrage
|
||||
- [x] lot-1 socle
|
||||
- [x] lot-2 domaines
|
||||
- [x] lot-3 surfaces
|
||||
- [x] lot-4 bascule
|
||||
- [x] lot-12 deep-audit
|
||||
- [x] lot-13 voice-mcp
|
||||
- [x] lot-14 documents-search
|
||||
- [x] lot-16 minitel-ui
|
||||
- [x] lot-17 chat-fixes
|
||||
- [x] lot-18 media-tts
|
||||
- [x] lot-19 infra
|
||||
- [x] lot-20 deep-audit-2
|
||||
## P1 V1 Quality
|
||||
|
||||
## lot-21-chatterbox-tts (P1)
|
||||
- [x] Migrer vers le SDK officiel `ollama-js` — fait en P7 (`ollama.js` réécrit)
|
||||
- [x] Ajouter un audit logging pour les actions admin — `audit-log.js` + intégré dans `http-api.js` et `server.js`
|
||||
- [x] Implémenter l'analyse image/audio dans `attachment-pipeline.js` — stubs factory avec adapter slot
|
||||
- [x] Corriger la validation d'origine `postMessage` — déjà en place (personas.js:1476)
|
||||
- [x] Ajouter la déduplication de requêtes dans `admin-api.js` — fait en P7 (`deduplicatedFetch`)
|
||||
- [x] Node Engine : validation de tri topologique — déjà en place (cycle detection dans runner)
|
||||
- [x] Node Engine : timeout d'exécution par nœud — 10min default via `NODE_ENGINE_STEP_TIMEOUT_MS`
|
||||
|
||||
- [ ] Installer Chatterbox sur kxkm-ai | owner: Multimodal
|
||||
- [ ] Adapter tts-server.py backend Chatterbox | owner: Multimodal
|
||||
- [ ] Tester qualite vocale 33 personas | owner: Multimodal
|
||||
- [ ] Benchmark latence <500ms/100chars | owner: Multimodal
|
||||
## P2 V2 Domaines
|
||||
|
||||
## lot-22-graph-rag (P2)
|
||||
- [x] Schéma Postgres + migrations + repos typés (`packages/storage`) — session, persona, graph, run repos
|
||||
- [x] Module auth réel (`packages/auth`) — crypto.scrypt, token gen, extractSessionId, validateLoginInput
|
||||
- [x] Logique domaine chat (`packages/chat-domain`) — ChatMessage, ChatSession, compactHistory, channel validation
|
||||
- [x] Logique domaine persona (`packages/persona-domain`) — validatePersonaUpdate, aggregateFeedback, computePersonaDiff
|
||||
- [x] Brancher les repos Postgres dans `apps/api` — async `createApp()`, fallback in-memory si pas de DATABASE_URL
|
||||
|
||||
- [ ] Evaluer LightRAG vs txtai vs RAGatouille | owner: Backend API
|
||||
- [ ] Integrer dans rag.ts | owner: Backend API
|
||||
- [ ] Indexer manifeste + lore personas | owner: Backend API
|
||||
- [ ] Benchmark recall vs baseline cosine | owner: Backend API
|
||||
## P3 Node Engine V2
|
||||
|
||||
## lot-23-crt-webgl (P3)
|
||||
- [x] Porter registry → `packages/node-engine` (15 node types, 7 familles)
|
||||
- [x] Porter graph ops (topologicalSort, validateEdgeContracts, collectNodeInputs)
|
||||
- [x] Porter run state machine (createRun, RunStep, resolveFinalStatus)
|
||||
- [x] Porter queue logic (createQueueState, enqueue, dequeue, canDequeue)
|
||||
- [x] Runtime definitions (5 runtimes)
|
||||
- [x] Isoler les runtimes avec sandboxing approprié — fait en P8 (`sandbox.ts`)
|
||||
- [x] Adaptateurs d'entraînement réels (LoRA, QLoRA, SFT) — fait en P8 (`training.ts`)
|
||||
- [x] Brancher le runner V2 dans `apps/worker` — poll loop, stub executors, graceful shutdown
|
||||
|
||||
- [ ] Evaluer vault66-crt-effect vs shaders custom | owner: Frontend
|
||||
- [ ] Integrer dans MinitelFrame | owner: Frontend
|
||||
- [ ] Tester perf mobile FPS 30+ | owner: Frontend
|
||||
## P4 Frontend V2
|
||||
|
||||
## lot-24-tests-integration (P2)
|
||||
- [x] API client centralisé (`api.ts`)
|
||||
- [x] 9 composants React (Header, Login, Nav, PersonaList, PersonaDetail, NodeEngineOverview, GraphDetail, RunStatus, ChannelList)
|
||||
- [x] Routing hash-based + responsive CSS
|
||||
- [x] Interface chat React (WebSocket live) — fait en P7 (`Chat.tsx` + `useWebSocket.ts`)
|
||||
- [x] Éditeur visuel Node Engine (React Flow) — fait en P7 (`NodeEditor.tsx` + `EngineNode.tsx`)
|
||||
|
||||
- [ ] Mock HTTP Ollama (streaming + tools) | owner: Backend API
|
||||
- [ ] Mock ComfyUI workflow + polling | owner: Backend API
|
||||
- [ ] Mock SearXNG + DuckDuckGo fallback | owner: Backend API
|
||||
- [ ] Mock TTS sidecar HTTP | owner: Backend API
|
||||
- [ ] Test context-store concurrent writes | owner: Backend API
|
||||
- [ ] Test media-store path traversal | owner: Backend API
|
||||
## P5 TUI & Ops
|
||||
|
||||
## Bugs restants (P3)
|
||||
- [x] TUI health-check (V1+V2+Ollama+disk+memory)
|
||||
- [x] TUI queue-viewer (runs, statuses)
|
||||
- [x] TUI persona-manager (overview)
|
||||
- [x] Log rotation (--dry-run, --max-age-days)
|
||||
- [x] Packages/tui: ansi, statusDot, formatTable, drawBox
|
||||
|
||||
- [ ] Bug #7: token comparison timing-attack (crypto.timingSafeEqual) | owner: Backend API
|
||||
- [ ] Merzbow 0 chars (think tokens consomment tout num_predict) | owner: Backend API
|
||||
- [ ] Docker build torch timeout (layer trop lourd) | owner: Ops
|
||||
## P6 Migration
|
||||
|
||||
- [x] Matrice de parité V1 → V2 — `scripts/parity-check.js` (persona, graph, channel, API shape checks)
|
||||
- [x] Scripts de migration de données — `scripts/migrate-v1-to-v2.js` (personas, graphs, runs → Postgres, --dry-run support)
|
||||
- [x] Smoke tests pour V2 — `scripts/smoke-v2.js` (22 tests, 5 catégories, `npm run smoke:v2`)
|
||||
- [x] Procédure de rollback — `scripts/rollback-v2.js` (drop/truncate tables with confirmation, --yes, --tables filter)
|
||||
|
||||
## P0+ Sécurité V1 (deep analyse 2026-03-16)
|
||||
|
||||
- [x] Path traversal dans `storage.js` — sanitisation session IDs + boundary check memory paths
|
||||
- [x] Path traversal dans `persona-registry.js` / `persona-store.js` — `safeFsId()` helper
|
||||
- [x] Path traversal dans `attachment-store.js` — sanitisation IDs + boundary check
|
||||
- [x] SSRF dans `web-tools.js` — blocage localhost, IPs privées, .local/.internal
|
||||
- [x] Response body limit dans `web-tools.js` — truncation 2 MB
|
||||
- [x] Log injection dans `storage.js` — sanitisation paramètre `role`
|
||||
- [x] Crash JSONL corrompu dans `storage.js` — try/catch par ligne
|
||||
- [x] Map mutation during iteration dans `sessions.js` — collect then process
|
||||
- [x] Session leak `/msg` dans `commands.js` — clé stable au lieu de Date.now()
|
||||
- [x] Unbounded userRateLimits dans `chat-routing.js` — pruning > 200 entries
|
||||
- [x] Session pruning O(n) dans `admin-session.js` — throttle 60s
|
||||
|
||||
## P7 Intégration avancée
|
||||
|
||||
- [x] Migrer vers ollama-js SDK officiel — `ollama.js` réécrit avec `Ollama` class, même interface
|
||||
- [x] Chat WebSocket React live — hook `useWebSocket` + composant Chat IRC, auto-reconnect
|
||||
- [x] Éditeur visuel Node Engine avec React Flow — `NodeEditor.tsx` + `EngineNode.tsx`, 7 familles colorées
|
||||
- [x] Déduplication requêtes GET dans `admin-api.js` — `deduplicatedFetch` transparent
|
||||
- [x] Repos Postgres pour persona sources/feedback/proposals — 3 tables + repos + fallback in-memory
|
||||
- [x] CI/CD GitHub Actions — `.github/workflows/ci.yml` (check V1+V2)
|
||||
- [x] Deep analyse finale V1+V2 — 14 modules V1 vérifiés, 3 fixes TS V2, intégrité confirmée
|
||||
|
||||
## P8 Production Readiness
|
||||
|
||||
- [x] Adaptateurs training réels (TRL + Unsloth pour LoRA/DPO) — `packages/node-engine/src/training.ts` + worker intégré
|
||||
- [x] Sandboxing runtimes Node Engine (containers/VM) — `packages/node-engine/src/sandbox.ts` (none/subprocess/container)
|
||||
- [x] Turborepo pour build orchestration monorepo — `turbo.json` + scripts alignés + CI mis à jour
|
||||
- [x] Tests unitaires V2 avec node:test + supertest — 102 tests, 46 suites, 6 packages + API integration
|
||||
- [x] Tests React avec Vitest + RTL — 33 tests, 6 composants (Header, Login, Nav, PersonaList, RunStatus, ChannelList)
|
||||
- [x] Créer le repo GitHub privé — https://github.com/electron-rare/kxkm_clown
|
||||
|
||||
## P9 Code Quality (simplify review)
|
||||
|
||||
- [x] Triple filter → single-pass loop dans `node-engine.js:deriveAsyncMeta`
|
||||
- [x] Duplicate sanitization extraite dans `attachment-store.js:sanitizeId`
|
||||
- [x] Double `loadModelIndex()` éliminé dans `node-engine-store.js:registerDeployment`
|
||||
|
||||
## P10 Lot 11 — Consolidation & Feature Parity
|
||||
|
||||
### Phase A — Analyse & Recherche
|
||||
- [x] Deep analyse code V1+V2 (agent en cours)
|
||||
- [x] Veille OSS mise à jour (agent en cours)
|
||||
- [x] Recherche HuggingFace (agent en cours)
|
||||
|
||||
### Phase B — Correctifs sécurité (deep analyse)
|
||||
- [x] **P0 SEC-01** Path traversal `node-engine-runner.js` — reject absolute paths + rootDir boundary check
|
||||
- [x] **P0 SEC-04** V2 login role self-assignment — viewer par défaut, admin via ADMIN_TOKEN
|
||||
- [x] **P1 BUG-06** Health endpoint leaking DATABASE_URL — remplacé par storageMode string
|
||||
- [x] **P1 BUG-02** Timeout promise leak `node-engine-runner.js` — AbortSignal cancel
|
||||
- [x] **P1 SEC-03** Attachments sans auth — ajout requireAdminNetwork middleware
|
||||
- [x] Compilation + 119 tests OK après correctifs
|
||||
|
||||
### Phase C — Feature Parity V2
|
||||
- [x] Recovery on crash worker — `recoverStaleRuns()` + worker startup recovery
|
||||
- [x] Cancel support — `requestCancel()` repo + `shouldCancel` callback worker + API endpoint
|
||||
- [ ] Tab completion chat V2
|
||||
- [x] Commandes slash V2 — `parseSlashCommand`, `resolveCommand`, `generateHelpText` + 11 commandes + 17 tests
|
||||
- [x] Mémoire conversationnelle V2 — `ConversationMemory`, `addToMemory`, `buildLlmContext`, `clearMemory`
|
||||
- [x] Status strip admin V2 — GET `/api/v2/status` (personas, graphs, runs, queue)
|
||||
- [x] Subnet gate V2 — CIDR middleware `/api/v2/admin/*` avec ADMIN_SUBNET env
|
||||
- [x] Retention sweep V2 — `deleteOlderThan()` repo + POST `/api/v2/admin/retention-sweep`
|
||||
- [x] Export HTML V2 — GET `/api/v2/export/html` avec download attachment
|
||||
- [x] Upload fichiers V2 — bouton upload base64 dans Chat.tsx, accept image/audio/text/pdf/json/csv
|
||||
- [x] Tab completion chat V2 — nicks + commandes slash, cycling Tab, reset auto
|
||||
|
||||
### Phase D — Déploiement & Docs
|
||||
- [x] Docker — `Dockerfile` (multi-stage Node 22 alpine) + `docker-compose.yml` (5 services) + `.dockerignore`
|
||||
- [ ] Documentation utilisateur
|
||||
- [ ] Performance profiling
|
||||
|
||||
## P11 Lot 17 — Deep Audit & Refactoring
|
||||
|
||||
### Phase A — Analyse & documentation
|
||||
|
||||
- [x] Script TUI deep-audit.js (security, perf, complexity, deps) — `ops/v2/deep-audit.js`
|
||||
- [x] Veille OSS enrichie 2026-03-17 (10 nouvelles catégories) — `docs/OSS_WATCH_2026-03-16.md`
|
||||
- [x] Diagrammes Mermaid (Context Store, Docker, Inter-persona) — `docs/ARCHITECTURE.md`
|
||||
- [x] AGENTS.md refondu (matrice 10 agents, Mermaid routing, pipeline) — `docs/AGENTS.md`
|
||||
- [x] PLAN.md consolidé avec lots 17-19
|
||||
- [x] TODO.md consolidé avec backlog Phase 6+
|
||||
- [ ] Deep analyse code agents (api, web, packages, mascarade, v1+worker) — en cours
|
||||
|
||||
### Phase B — Refactoring code
|
||||
|
||||
- [ ] **P1** ws-chat.ts: extraction modules (1449 LOC → ~4×350 LOC)
|
||||
- [ ] Extraire `ws-multimodal.ts` (vision, STT, TTS, PDF handlers)
|
||||
- [ ] Extraire `ws-persona-router.ts` (pickResponders, inter-persona, memory)
|
||||
- [ ] Extraire `ws-commands.ts` (slash commands, /web, /imagine)
|
||||
- [ ] Garder `ws-chat.ts` core (WebSocket lifecycle, broadcast, rate limit)
|
||||
- [ ] **P1** app.ts: extraction routes (1292 LOC → routes/ + middleware/)
|
||||
- [ ] Extraire `routes/personas.ts`
|
||||
- [ ] Extraire `routes/node-engine.ts`
|
||||
- [ ] Extraire `routes/chat.ts`
|
||||
- [ ] Extraire `middleware/auth.ts`
|
||||
- [ ] **P2** writeFileSync → appendFile async dans ws-chat.ts (3 occurrences)
|
||||
- [ ] **P2** console.log → logger structuré (apps/api, apps/worker)
|
||||
- [ ] **P2** React.memo sur Chat, ChatHistory, VoiceChat, NodeEditor
|
||||
- [ ] **P2** Lazy load: React.lazy + Suspense pour routes lourdes
|
||||
|
||||
### Phase C — Infrastructure
|
||||
|
||||
- [ ] SearXNG dans docker-compose (service searxng:8080, remplacer DuckDuckGo)
|
||||
- [ ] MinerU/Docling dans docker-compose (remplacer pdf-parse)
|
||||
- [ ] Spike BGE-M3 embeddings (upgrade nomic-embed-text)
|
||||
- [ ] Déployer deep-audit.js sur kxkm-ai (cron quotidien)
|
||||
- [ ] Créer utilisateur Discord **Pharmacius** (bot orchestrateur, bridge chat Discord ↔ KXKM)
|
||||
|
||||
### Phase D — Nouveaux node types
|
||||
|
||||
- [ ] `music_generation` node (ACE-Step 1.5, <4GB VRAM)
|
||||
- [ ] `voice_clone` node (XTTS-v2, zero-shot 6s reference)
|
||||
- [ ] `document_extraction` node (MinerU/Docling)
|
||||
|
||||
## P12 Lot 18 — Voice & MCP (futur)
|
||||
|
||||
- [ ] XTTS-v2 voice cloning par persona
|
||||
- [ ] LLMRTC WebRTC streaming (TypeScript, VAD, barge-in)
|
||||
- [ ] MCP SDK integration (personas = MCP servers)
|
||||
- [ ] PCL + OpenCharacter pipeline fine-tune
|
||||
- [ ] Chatterbox TTS evaluation
|
||||
|
||||
## P13 Lot 19 — Music & Creative (futur)
|
||||
|
||||
- [ ] ACE-Step 1.5 production
|
||||
- [ ] `/compose` command (prompt → musique)
|
||||
- [ ] Flux 2 dans ComfyUI
|
||||
- [ ] A2A Protocol evaluation
|
||||
|
||||
## Lot 20 - Deep Analyse Continue & Execution Chainee `[en cours]`
|
||||
|
||||
A faire maintenant:
|
||||
- [ ] Poursuivre extraction modulaire de `ws-chat.ts` (router, commandes, core).
|
||||
- [ ] Decouper `app.ts` en routes + middleware sans regression.
|
||||
- [ ] Ajouter instrumentation perf API/WS (latence/debit/memoire).
|
||||
- [ ] Integrer SearXNG + Docling et valider le pipeline.
|
||||
|
||||
Fait sur ce lot:
|
||||
- [x] Extraction modulaire du bloc upload/analyse de `ws-chat.ts` (`ws-upload-handler.ts`).
|
||||
- [x] Refonte UI Minitel depuis la racine du site (`public/index.html`, `public/styles.css`, `public/app.js`).
|
||||
- [x] Deep audit execute et relance apres correctifs.
|
||||
- [x] Corrections context-store appliquees et validees.
|
||||
- [x] check:v2 et test:v2 au vert.
|
||||
- [x] Correctif anti-decrement TTS negatif (`ttsActive`).
|
||||
- [x] Cleanup opportuniste des sessions expirees (mode memory).
|
||||
- [x] Purge des logs vides/obsoletes `ops/v2/logs`.
|
||||
- [x] Script `ops/v2/run-deep-cycle.sh` ajoute et execute.
|
||||
- [x] Tests `apps/api/src/context-store.test.ts` ajoutes et valides.
|
||||
- [x] Scoring de dette technique integre dans `ops/v2/deep-audit.js`.
|
||||
- [x] Verification JSON dette: score 78/100 (niveau high).
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
loadDatabaseConfig,
|
||||
createPostgresPool,
|
||||
runMigrations,
|
||||
createSessionRepo,
|
||||
createPersonaRepo,
|
||||
createNodeGraphRepo,
|
||||
createNodeRunRepo,
|
||||
createPersonaSourceRepo,
|
||||
createPersonaFeedbackRepo,
|
||||
createPersonaProposalRepo,
|
||||
} from "@kxkm/storage";
|
||||
import { PERSONA_SEED_CATALOG, clonePersona, type PersonaRecord } from "@kxkm/persona-domain";
|
||||
|
||||
interface MemoryFactories<SessionRepo, PersonaRepo, GraphRepo, RunRepo, SourceRepo, FeedbackRepo, ProposalRepo> {
|
||||
createSessionRepo: () => SessionRepo;
|
||||
createPersonaRepo: () => PersonaRepo;
|
||||
createGraphRepo: () => GraphRepo;
|
||||
createRunRepo: () => RunRepo;
|
||||
createSourceRepo: () => SourceRepo;
|
||||
createFeedbackRepo: () => FeedbackRepo;
|
||||
createProposalRepo: () => ProposalRepo;
|
||||
}
|
||||
|
||||
interface SeedablePersonaRepo {
|
||||
seedCatalog(catalog: PersonaRecord[]): Promise<void>;
|
||||
}
|
||||
|
||||
export async function bootstrapRepositories<
|
||||
SessionRepo,
|
||||
PersonaRepo extends SeedablePersonaRepo,
|
||||
GraphRepo,
|
||||
RunRepo,
|
||||
SourceRepo,
|
||||
FeedbackRepo,
|
||||
ProposalRepo,
|
||||
>(
|
||||
factories: MemoryFactories<SessionRepo, PersonaRepo, GraphRepo, RunRepo, SourceRepo, FeedbackRepo, ProposalRepo>,
|
||||
): Promise<{
|
||||
sessionRepo: SessionRepo;
|
||||
personaRepo: PersonaRepo;
|
||||
graphRepo: GraphRepo;
|
||||
runRepo: RunRepo;
|
||||
sourceRepo: SourceRepo;
|
||||
feedbackRepo: FeedbackRepo;
|
||||
proposalRepo: ProposalRepo;
|
||||
storageMode: "postgres" | "memory";
|
||||
}> {
|
||||
const isProduction = (process.env.NODE_ENV || "").toLowerCase() === "production";
|
||||
if (!process.env.DATABASE_URL && isProduction) {
|
||||
throw new Error("DATABASE_URL is required when NODE_ENV=production");
|
||||
}
|
||||
|
||||
if (process.env.DATABASE_URL) {
|
||||
const dbConfig = loadDatabaseConfig();
|
||||
const pool = createPostgresPool(dbConfig);
|
||||
await runMigrations(pool);
|
||||
|
||||
const sessionRepo = createSessionRepo(pool) as SessionRepo;
|
||||
const personaRepo = createPersonaRepo(pool) as unknown as PersonaRepo;
|
||||
const graphRepo = createNodeGraphRepo(pool) as GraphRepo;
|
||||
const runRepo = createNodeRunRepo(pool) as RunRepo;
|
||||
const sourceRepo = createPersonaSourceRepo(pool) as SourceRepo;
|
||||
const feedbackRepo = createPersonaFeedbackRepo(pool) as FeedbackRepo;
|
||||
const proposalRepo = createPersonaProposalRepo(pool) as ProposalRepo;
|
||||
|
||||
await personaRepo.seedCatalog(PERSONA_SEED_CATALOG.map(clonePersona));
|
||||
|
||||
return {
|
||||
sessionRepo,
|
||||
personaRepo,
|
||||
graphRepo,
|
||||
runRepo,
|
||||
sourceRepo,
|
||||
feedbackRepo,
|
||||
proposalRepo,
|
||||
storageMode: "postgres",
|
||||
};
|
||||
}
|
||||
|
||||
console.warn("[kxkm/api] DATABASE_URL not set — using local persona storage + in-memory runtime stores");
|
||||
|
||||
return {
|
||||
sessionRepo: factories.createSessionRepo(),
|
||||
personaRepo: factories.createPersonaRepo(),
|
||||
graphRepo: factories.createGraphRepo(),
|
||||
runRepo: factories.createRunRepo(),
|
||||
sourceRepo: factories.createSourceRepo(),
|
||||
feedbackRepo: factories.createFeedbackRepo(),
|
||||
proposalRepo: factories.createProposalRepo(),
|
||||
storageMode: "memory",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import express, { type Request, type Response, type NextFunction } from "express";
|
||||
import net from "node:net";
|
||||
import { extractSessionId, hasPermission } from "@kxkm/auth";
|
||||
import type { AuthSession, Permission } from "@kxkm/core";
|
||||
|
||||
export interface SessionRequest extends Request {
|
||||
session?: AuthSession;
|
||||
}
|
||||
|
||||
export function createSessionMiddleware(sessionRepo: { findById(id: string): Promise<AuthSession | null> }): express.RequestHandler {
|
||||
return (req: SessionRequest, _res: Response, next: NextFunction) => {
|
||||
const sessionId = extractSessionId(req as unknown as { cookies?: Record<string, string>; headers?: Record<string, string> });
|
||||
if (!sessionId) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
sessionRepo.findById(sessionId)
|
||||
.then((session) => {
|
||||
if (session) req.session = session;
|
||||
next();
|
||||
})
|
||||
.catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
export function createRequireSession(): express.RequestHandler {
|
||||
return (req: SessionRequest, res: Response, next: NextFunction) => {
|
||||
if (!req.session) {
|
||||
res.status(401).json({ ok: false, error: "session_required" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
export function createRequirePermission(permission: Permission): express.RequestHandler {
|
||||
return (req: SessionRequest, res: Response, next: NextFunction) => {
|
||||
if (!req.session) {
|
||||
res.status(401).json({ ok: false, error: "session_required" });
|
||||
return;
|
||||
}
|
||||
if (!hasPermission(req.session.role, permission)) {
|
||||
res.status(403).json({ ok: false, error: "permission_denied" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
interface ParsedSubnet {
|
||||
version: number;
|
||||
mask: bigint;
|
||||
network: bigint;
|
||||
}
|
||||
|
||||
function normalizeIp(value: string): string {
|
||||
let ip = value.trim();
|
||||
const zoneIndex = ip.indexOf("%");
|
||||
if (zoneIndex >= 0) ip = ip.slice(0, zoneIndex);
|
||||
if (ip.startsWith("::ffff:") && net.isIP(ip.slice(7)) === 4) {
|
||||
return ip.slice(7);
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
|
||||
function ipv4ToBigInt(ip: string): bigint {
|
||||
return ip.split(".").reduce((r, o) => (r << 8n) + BigInt(Number.parseInt(o, 10)), 0n);
|
||||
}
|
||||
|
||||
function ipv6ToBigInt(ip: string): bigint {
|
||||
const parts = ip.split("::");
|
||||
const head = parts[0] ? parts[0].split(":").filter(Boolean) : [];
|
||||
const tail = parts[1] ? parts[1].split(":").filter(Boolean) : [];
|
||||
const missing = 8 - (head.length + tail.length);
|
||||
const groups = [...head, ...Array.from({ length: missing }, () => "0"), ...tail];
|
||||
return groups.reduce((r, g) => (r << 16n) + BigInt(Number.parseInt(g || "0", 16)), 0n);
|
||||
}
|
||||
|
||||
function parseSubnet(entry: string): ParsedSubnet | null {
|
||||
const raw = entry.trim();
|
||||
if (!raw) return null;
|
||||
const [addressPart, prefixPart] = raw.split("/");
|
||||
const address = normalizeIp(addressPart);
|
||||
const version = net.isIP(address);
|
||||
if (!version) return null;
|
||||
|
||||
const totalBits = version === 4 ? 32 : 128;
|
||||
const prefix = prefixPart === undefined ? totalBits : Number.parseInt(prefixPart, 10);
|
||||
if (!Number.isInteger(prefix) || prefix < 0 || prefix > totalBits) return null;
|
||||
|
||||
const bits = BigInt(totalBits);
|
||||
const hostBits = BigInt(totalBits - prefix);
|
||||
const allOnes = (1n << bits) - 1n;
|
||||
const mask = prefix === 0 ? 0n : (allOnes << hostBits) & allOnes;
|
||||
const value = version === 4 ? ipv4ToBigInt(address) : ipv6ToBigInt(address);
|
||||
|
||||
return { version, mask, network: value & mask };
|
||||
}
|
||||
|
||||
function isIpInSubnet(ip: string, subnet: ParsedSubnet): boolean {
|
||||
const normalized = normalizeIp(ip);
|
||||
const version = net.isIP(normalized);
|
||||
if (!version || version !== subnet.version) return false;
|
||||
const value = version === 4 ? ipv4ToBigInt(normalized) : ipv6ToBigInt(normalized);
|
||||
return (value & subnet.mask) === subnet.network;
|
||||
}
|
||||
|
||||
export function createAdminSubnetMiddleware(adminSubnet: string | undefined): express.RequestHandler | null {
|
||||
if (!adminSubnet) {
|
||||
return null;
|
||||
}
|
||||
const subnet = parseSubnet(adminSubnet);
|
||||
if (!subnet) {
|
||||
return null;
|
||||
}
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const ip = normalizeIp(req.ip || req.socket?.remoteAddress || "");
|
||||
if (!isIpInSubnet(ip, subnet)) {
|
||||
res.status(403).json({ ok: false, error: "subnet_denied" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
export function createPerfTracker() {
|
||||
const perfStats = {
|
||||
requestCount: 0,
|
||||
totalLatencyMs: 0,
|
||||
maxLatencyMs: 0,
|
||||
statusCodes: new Map<number, number>(),
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
|
||||
const middleware: express.RequestHandler = (_req: Request, res: Response, next: NextFunction) => {
|
||||
const start = performance.now();
|
||||
res.on("finish", () => {
|
||||
const latency = performance.now() - start;
|
||||
perfStats.requestCount++;
|
||||
perfStats.totalLatencyMs += latency;
|
||||
if (latency > perfStats.maxLatencyMs) perfStats.maxLatencyMs = latency;
|
||||
perfStats.statusCodes.set(res.statusCode, (perfStats.statusCodes.get(res.statusCode) || 0) + 1);
|
||||
});
|
||||
next();
|
||||
};
|
||||
|
||||
const route: express.RequestHandler = (_req: Request, res: Response) => {
|
||||
const uptimeMs = Date.now() - perfStats.startedAt;
|
||||
const avgLatency = perfStats.requestCount > 0 ? perfStats.totalLatencyMs / perfStats.requestCount : 0;
|
||||
const mem = process.memoryUsage();
|
||||
res.json({
|
||||
ok: true,
|
||||
data: {
|
||||
uptime_ms: uptimeMs,
|
||||
uptime_human: `${Math.floor(uptimeMs / 3600000)}h${Math.floor((uptimeMs % 3600000) / 60000)}m`,
|
||||
requests: perfStats.requestCount,
|
||||
avg_latency_ms: Math.round(avgLatency * 100) / 100,
|
||||
max_latency_ms: Math.round(perfStats.maxLatencyMs * 100) / 100,
|
||||
status_codes: Object.fromEntries(perfStats.statusCodes),
|
||||
memory: {
|
||||
rss_mb: Math.round(mem.rss / 1048576),
|
||||
heap_used_mb: Math.round(mem.heapUsed / 1048576),
|
||||
heap_total_mb: Math.round(mem.heapTotal / 1048576),
|
||||
external_mb: Math.round(mem.external / 1048576),
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return { middleware, route };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
import supertest from "supertest";
|
||||
import { createApp } from "./app.js";
|
||||
|
||||
@@ -230,6 +230,47 @@ describe("V2 API", () => {
|
||||
assert.ok(Array.isArray(res.body.data));
|
||||
});
|
||||
|
||||
it("uploads, reports and deletes a voice sample using the local data dir", async () => {
|
||||
const audio = Buffer.from("RIFF-test-voice-sample").toString("base64");
|
||||
|
||||
const uploadRes = await request
|
||||
.post("/api/admin/personas/schaeffer/voice-sample")
|
||||
.set("Cookie", cookie)
|
||||
.send({ audio })
|
||||
.expect(200);
|
||||
|
||||
assert.equal(uploadRes.body.ok, true);
|
||||
assert.equal(uploadRes.body.data.samplePath, path.join(".tmp-test-v2-local", "voice-samples", "schaeffer.wav"));
|
||||
|
||||
const persisted = await readFile(path.join(TEST_LOCAL_DIR, "voice-samples", "schaeffer.wav"));
|
||||
assert.equal(persisted.toString("utf8"), "RIFF-test-voice-sample");
|
||||
|
||||
const statusRes = await request
|
||||
.get("/api/admin/personas/schaeffer/voice-sample")
|
||||
.set("Cookie", cookie)
|
||||
.expect(200);
|
||||
|
||||
assert.equal(statusRes.body.ok, true);
|
||||
assert.equal(statusRes.body.data.hasVoiceSample, true);
|
||||
assert.equal(statusRes.body.data.samplePath, path.join(".tmp-test-v2-local", "voice-samples", "schaeffer.wav"));
|
||||
|
||||
const deleteRes = await request
|
||||
.delete("/api/admin/personas/schaeffer/voice-sample")
|
||||
.set("Cookie", cookie)
|
||||
.expect(200);
|
||||
|
||||
assert.equal(deleteRes.body.ok, true);
|
||||
assert.equal(deleteRes.body.data.deleted, true);
|
||||
|
||||
const missingStatusRes = await request
|
||||
.get("/api/admin/personas/schaeffer/voice-sample")
|
||||
.set("Cookie", cookie)
|
||||
.expect(200);
|
||||
|
||||
assert.equal(missingStatusRes.body.ok, true);
|
||||
assert.equal(missingStatusRes.body.data.hasVoiceSample, false);
|
||||
});
|
||||
|
||||
it("persists local persona updates across app recreation", async () => {
|
||||
const { app: app2 } = await createApp();
|
||||
const request2 = supertest(app2);
|
||||
|
||||
+4
-2
@@ -396,11 +396,13 @@ function escapeForHtml(text: string): string {
|
||||
}
|
||||
|
||||
function setSessionCookie(res: Response, sessionId: string): void {
|
||||
res.setHeader("Set-Cookie", `${COOKIE_NAME}=${sessionId}; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`);
|
||||
const secure = process.env.NODE_ENV === "production" ? "Secure; " : "";
|
||||
res.setHeader("Set-Cookie", `${COOKIE_NAME}=${sessionId}; HttpOnly; ${secure}SameSite=Strict; Path=/; Max-Age=3600`);
|
||||
}
|
||||
|
||||
function clearSessionCookie(res: Response): void {
|
||||
res.setHeader("Set-Cookie", `${COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`);
|
||||
const secure = process.env.NODE_ENV === "production" ? "Secure; " : "";
|
||||
res.setHeader("Set-Cookie", `${COOKIE_NAME}=; HttpOnly; ${secure}SameSite=Strict; Path=/; Max-Age=0`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { mkdtemp, readFile, rm, writeFile, stat } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import { ContextStore } from "./context-store.js";
|
||||
|
||||
@@ -18,6 +18,11 @@ async function makeStore(overrides: Record<string, unknown> = {}): Promise<{ sto
|
||||
return { store, dir };
|
||||
}
|
||||
|
||||
/** Build a valid JSONL entry as ContextStore would write it. */
|
||||
function jsonlLine(nick: string, text: string, type = "message"): string {
|
||||
return JSON.stringify({ ts: new Date().toISOString(), nick, text, type });
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
while (tmpDirs.length > 0) {
|
||||
const dir = tmpDirs.pop();
|
||||
@@ -27,6 +32,10 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("ContextStore", () => {
|
||||
// -----------------------------------------------------------------------
|
||||
// Existing tests (preserved as-is)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it("keeps returned context under requested char limit", async () => {
|
||||
const { store } = await makeStore({ maxContextChars: 200 });
|
||||
|
||||
@@ -79,4 +88,404 @@ describe("ContextStore", () => {
|
||||
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// init()
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it("init() creates the data directory", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "kxkm-context-store-"));
|
||||
tmpDirs.push(dir);
|
||||
const nested = path.join(dir, "sub", "deep");
|
||||
const store = new ContextStore({ dataDir: nested });
|
||||
await store.init();
|
||||
|
||||
const s = await stat(nested);
|
||||
assert.ok(s.isDirectory());
|
||||
});
|
||||
|
||||
it("init() is idempotent (calling twice does not throw)", async () => {
|
||||
const { store } = await makeStore();
|
||||
// Second call should be a no-op
|
||||
await store.init();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// addEntry / append
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it("append() writes a JSONL line to the channel file", async () => {
|
||||
const { store, dir } = await makeStore();
|
||||
await store.append("general", "alice", "hello world");
|
||||
|
||||
const filePath = path.join(dir, "general.jsonl");
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
const lines = content.trim().split("\n").filter(Boolean);
|
||||
assert.equal(lines.length, 1);
|
||||
|
||||
const entry = JSON.parse(lines[0]);
|
||||
assert.equal(entry.nick, "alice");
|
||||
assert.equal(entry.text, "hello world");
|
||||
assert.equal(entry.type, "message");
|
||||
assert.ok(entry.ts, "entry should have a timestamp");
|
||||
});
|
||||
|
||||
it("append() supports type parameter", async () => {
|
||||
const { store, dir } = await makeStore();
|
||||
await store.append("chan", "bot", "system message", "system");
|
||||
|
||||
const filePath = path.join(dir, "chan.jsonl");
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
const entry = JSON.parse(content.trim());
|
||||
assert.equal(entry.type, "system");
|
||||
});
|
||||
|
||||
it("append() truncates text longer than 2000 chars", async () => {
|
||||
const { store, dir } = await makeStore();
|
||||
const longText = "x".repeat(5000);
|
||||
await store.append("chan", "alice", longText);
|
||||
|
||||
const filePath = path.join(dir, "chan.jsonl");
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
const entry = JSON.parse(content.trim());
|
||||
assert.equal(entry.text.length, 2000);
|
||||
});
|
||||
|
||||
it("multiple sequential append() calls accumulate entries", async () => {
|
||||
const { store, dir } = await makeStore();
|
||||
await store.append("room", "alice", "msg1");
|
||||
await store.append("room", "bob", "msg2");
|
||||
await store.append("room", "carol", "msg3");
|
||||
|
||||
const filePath = path.join(dir, "room.jsonl");
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
const lines = content.trim().split("\n").filter(Boolean);
|
||||
assert.equal(lines.length, 3);
|
||||
assert.equal(JSON.parse(lines[0]).nick, "alice");
|
||||
assert.equal(JSON.parse(lines[1]).nick, "bob");
|
||||
assert.equal(JSON.parse(lines[2]).nick, "carol");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Channel sanitization
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it("sanitizes channel names: #general becomes _general", async () => {
|
||||
const { store, dir } = await makeStore();
|
||||
await store.append("#general", "alice", "hi");
|
||||
|
||||
const filePath = path.join(dir, "_general.jsonl");
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
assert.ok(content.includes("alice"), "file should contain the entry");
|
||||
});
|
||||
|
||||
it("sanitizes channel names with special characters", async () => {
|
||||
const { store, dir } = await makeStore();
|
||||
await store.append("#café/lounge!", "alice", "bonjour");
|
||||
|
||||
const filePath = path.join(dir, "_caf__lounge_.jsonl");
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
assert.ok(content.includes("bonjour"));
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// getContext()
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it("getContext() returns empty string when no file exists", async () => {
|
||||
const { store } = await makeStore();
|
||||
const ctx = await store.getContext("nonexistent");
|
||||
assert.equal(ctx, "");
|
||||
});
|
||||
|
||||
it("getContext() returns formatted recent entries", async () => {
|
||||
const { store } = await makeStore();
|
||||
await store.append("room", "alice", "hello");
|
||||
await store.append("room", "bob", "world");
|
||||
|
||||
const ctx = await store.getContext("room");
|
||||
assert.ok(ctx.includes("[Échanges récents]"), "should have recent header");
|
||||
assert.ok(ctx.includes("alice: hello"));
|
||||
assert.ok(ctx.includes("bob: world"));
|
||||
});
|
||||
|
||||
it("getContext() respects maxChars and truncates oldest entries first", async () => {
|
||||
const { store } = await makeStore();
|
||||
// Add enough entries so total exceeds the limit
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await store.append("room", `user${i}`, `message number ${i} with padding ${"z".repeat(20)}`);
|
||||
}
|
||||
|
||||
const ctx = await store.getContext("room", 100);
|
||||
assert.ok(ctx.length <= 100, `expected <= 100 chars, got ${ctx.length}`);
|
||||
// Should contain the LAST entries since we read backwards
|
||||
assert.ok(ctx.includes("user9") || ctx.includes("user8"), "should include recent entries");
|
||||
});
|
||||
|
||||
it("getContext() includes summary when summary file exists", async () => {
|
||||
const { store, dir } = await makeStore();
|
||||
|
||||
// Write a summary file manually
|
||||
const summaryData = {
|
||||
channel: "room",
|
||||
summaryText: "Alice and Bob discussed the weather.",
|
||||
entriesCompacted: 50,
|
||||
lastCompactedAt: new Date().toISOString(),
|
||||
totalCompactions: 1,
|
||||
};
|
||||
await writeFile(path.join(dir, "room.summary.json"), JSON.stringify(summaryData), "utf-8");
|
||||
|
||||
// Add a recent entry
|
||||
await store.append("room", "carol", "any news?");
|
||||
|
||||
const ctx = await store.getContext("room");
|
||||
assert.ok(ctx.includes("[Résumé des conversations précédentes]"), "should include summary header");
|
||||
assert.ok(ctx.includes("Alice and Bob discussed the weather."), "should include summary text");
|
||||
assert.ok(ctx.includes("carol: any news?"), "should also include recent entries");
|
||||
});
|
||||
|
||||
it("getContext() returns only summary when entries file is missing but summary exists", async () => {
|
||||
const { store, dir } = await makeStore();
|
||||
|
||||
const summaryData = {
|
||||
channel: "room",
|
||||
summaryText: "Previously discussed topics.",
|
||||
entriesCompacted: 10,
|
||||
lastCompactedAt: new Date().toISOString(),
|
||||
totalCompactions: 1,
|
||||
};
|
||||
await writeFile(path.join(dir, "room.summary.json"), JSON.stringify(summaryData), "utf-8");
|
||||
|
||||
const ctx = await store.getContext("room");
|
||||
assert.ok(ctx.includes("[Résumé des conversations précédentes]"));
|
||||
assert.ok(ctx.includes("Previously discussed topics."));
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// readSummary (via getContext / internal access)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it("readSummary returns null when no summary file exists", async () => {
|
||||
const { store } = await makeStore();
|
||||
const anyStore = store as unknown as {
|
||||
readSummary: (channel: string) => Promise<unknown>;
|
||||
};
|
||||
const result = await anyStore.readSummary("nonexistent");
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
it("readSummary parses valid summary JSON", async () => {
|
||||
const { store, dir } = await makeStore();
|
||||
const summaryData = {
|
||||
channel: "test",
|
||||
summaryText: "A summary.",
|
||||
entriesCompacted: 5,
|
||||
lastCompactedAt: "2026-01-01T00:00:00.000Z",
|
||||
totalCompactions: 1,
|
||||
};
|
||||
await writeFile(path.join(dir, "test.summary.json"), JSON.stringify(summaryData), "utf-8");
|
||||
|
||||
const anyStore = store as unknown as {
|
||||
readSummary: (channel: string) => Promise<unknown>;
|
||||
};
|
||||
const result = await anyStore.readSummary("test") as typeof summaryData;
|
||||
assert.equal(result.summaryText, "A summary.");
|
||||
assert.equal(result.entriesCompacted, 5);
|
||||
assert.equal(result.totalCompactions, 1);
|
||||
});
|
||||
|
||||
it("readSummary returns null for corrupted summary file", async () => {
|
||||
const { store, dir } = await makeStore();
|
||||
await writeFile(path.join(dir, "bad.summary.json"), "not valid json {{{", "utf-8");
|
||||
|
||||
const anyStore = store as unknown as {
|
||||
readSummary: (channel: string) => Promise<unknown>;
|
||||
};
|
||||
const result = await anyStore.readSummary("bad");
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Write locks (concurrency)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it("concurrent append() calls do not corrupt the file", async () => {
|
||||
const { store, dir } = await makeStore();
|
||||
|
||||
// Fire multiple appends concurrently
|
||||
const promises = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
promises.push(store.append("concurrent", `user${i}`, `message ${i}`));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
const filePath = path.join(dir, "concurrent.jsonl");
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
const lines = content.trim().split("\n").filter(Boolean);
|
||||
assert.equal(lines.length, 20, "all 20 entries should be present");
|
||||
|
||||
// Verify each line is valid JSON
|
||||
for (const line of lines) {
|
||||
assert.doesNotThrow(() => JSON.parse(line), `line should be valid JSON: ${line.slice(0, 50)}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("concurrent append() on different channels writes to separate files", async () => {
|
||||
const { store, dir } = await makeStore();
|
||||
|
||||
await Promise.all([
|
||||
store.append("chan-a", "alice", "msg-a"),
|
||||
store.append("chan-b", "bob", "msg-b"),
|
||||
store.append("chan-c", "carol", "msg-c"),
|
||||
]);
|
||||
|
||||
for (const [chan, nick] of [["chan-a", "alice"], ["chan-b", "bob"], ["chan-c", "carol"]]) {
|
||||
const filePath = path.join(dir, `${chan}.jsonl`);
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
const entry = JSON.parse(content.trim());
|
||||
assert.equal(entry.nick, nick);
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// getStats()
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it("getStats() returns correct channel count and entry counts", async () => {
|
||||
const { store } = await makeStore();
|
||||
await store.append("room1", "alice", "hello");
|
||||
await store.append("room1", "bob", "hi");
|
||||
await store.append("room2", "carol", "hey");
|
||||
|
||||
const stats = await store.getStats();
|
||||
assert.equal(stats.channels, 2);
|
||||
assert.equal(stats.entries["room1"] ?? stats.entries["#room1"], 2);
|
||||
assert.equal(stats.entries["room2"] ?? stats.entries["#room2"], 1);
|
||||
assert.ok(stats.totalSizeMB > 0, "totalSizeMB should be > 0");
|
||||
});
|
||||
|
||||
it("getStats() returns zero for empty store", async () => {
|
||||
const { store } = await makeStore();
|
||||
const stats = await store.getStats();
|
||||
assert.equal(stats.channels, 0);
|
||||
assert.equal(stats.totalSizeMB, 0);
|
||||
assert.deepEqual(stats.entries, {});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// channelFile / summaryFile sanitization
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it("channelFile and summaryFile produce correct paths", async () => {
|
||||
const { store, dir } = await makeStore();
|
||||
const anyStore = store as unknown as {
|
||||
channelFile: (channel: string) => string;
|
||||
summaryFile: (channel: string) => string;
|
||||
};
|
||||
|
||||
assert.equal(anyStore.channelFile("general"), path.join(dir, "general.jsonl"));
|
||||
assert.equal(anyStore.summaryFile("general"), path.join(dir, "general.summary.json"));
|
||||
assert.equal(anyStore.channelFile("#room"), path.join(dir, "_room.jsonl"));
|
||||
assert.equal(anyStore.summaryFile("#room"), path.join(dir, "_room.summary.json"));
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// maybeCompact skips when file is small
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it("maybeCompact does not compact when file is under 1 MB", async () => {
|
||||
const { store } = await makeStore({ maxEntriesBeforeCompact: 2 });
|
||||
const anyStore = store as unknown as {
|
||||
maybeCompact: (channel: string) => Promise<void>;
|
||||
compact: (channel: string, lines: string[]) => Promise<void>;
|
||||
};
|
||||
|
||||
// Add a few entries (well under 1 MB)
|
||||
await store.append("small", "alice", "hello");
|
||||
await store.append("small", "bob", "world");
|
||||
await store.append("small", "carol", "test");
|
||||
|
||||
let compactCalled = false;
|
||||
anyStore.compact = async () => { compactCalled = true; };
|
||||
|
||||
await anyStore.maybeCompact("small");
|
||||
assert.equal(compactCalled, false, "compact should not be called for files < 1 MB");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// parseJson helper
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it("parseJson returns null for invalid JSON", async () => {
|
||||
const { store } = await makeStore();
|
||||
const anyStore = store as unknown as {
|
||||
parseJson: <T>(raw: string) => T | null;
|
||||
};
|
||||
|
||||
assert.equal(anyStore.parseJson("not json"), null);
|
||||
assert.equal(anyStore.parseJson("{bad:}"), null);
|
||||
assert.deepEqual(anyStore.parseJson('{"ok": true}'), { ok: true });
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Edge cases
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it("getContext handles malformed JSONL lines gracefully", async () => {
|
||||
const { store, dir } = await makeStore();
|
||||
|
||||
// Write a file with some valid and some invalid lines
|
||||
const validLine = jsonlLine("alice", "valid message");
|
||||
const content = `${validLine}\n{bad json}\n${jsonlLine("bob", "also valid")}\n`;
|
||||
await writeFile(path.join(dir, "mixed.jsonl"), content, "utf-8");
|
||||
|
||||
const ctx = await store.getContext("mixed");
|
||||
// Should include valid entries, skip bad ones
|
||||
assert.ok(ctx.includes("alice: valid message") || ctx.includes("bob: also valid"),
|
||||
"should include at least one valid entry");
|
||||
});
|
||||
|
||||
it("append() calls init() automatically if not yet initialized", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "kxkm-context-store-"));
|
||||
tmpDirs.push(dir);
|
||||
const nested = path.join(dir, "auto-init");
|
||||
const store = new ContextStore({ dataDir: nested });
|
||||
// Do NOT call init() manually
|
||||
await store.append("room", "alice", "hello");
|
||||
|
||||
// Verify the directory and file were created
|
||||
const filePath = path.join(nested, "room.jsonl");
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
assert.ok(content.includes("alice"));
|
||||
});
|
||||
|
||||
it("getContext() calls init() automatically if not yet initialized", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "kxkm-context-store-"));
|
||||
tmpDirs.push(dir);
|
||||
const nested = path.join(dir, "auto-init2");
|
||||
const store = new ContextStore({ dataDir: nested });
|
||||
// Should not throw, just return empty
|
||||
const ctx = await store.getContext("room");
|
||||
assert.equal(ctx, "");
|
||||
});
|
||||
|
||||
it("summary budget is capped so recent entries still have room", async () => {
|
||||
const { store, dir } = await makeStore({ maxContextChars: 500 });
|
||||
|
||||
// Write a long summary
|
||||
const summaryData = {
|
||||
channel: "room",
|
||||
summaryText: "S".repeat(1000),
|
||||
entriesCompacted: 100,
|
||||
lastCompactedAt: new Date().toISOString(),
|
||||
totalCompactions: 5,
|
||||
};
|
||||
await writeFile(path.join(dir, "room.summary.json"), JSON.stringify(summaryData), "utf-8");
|
||||
|
||||
await store.append("room", "alice", "recent msg");
|
||||
|
||||
const ctx = await store.getContext("room", 500);
|
||||
assert.ok(ctx.length <= 500, `expected <= 500 chars, got ${ctx.length}`);
|
||||
// Summary should be truncated to leave room (limit - 256 = 244 for summary budget)
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
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";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
// 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;
|
||||
|
||||
// Dynamic import so DATA_DIR is already set when the module evaluates.
|
||||
const { saveImage, saveAudio, listMedia, getMediaFilePath } = await import(
|
||||
"./media-store.js"
|
||||
);
|
||||
|
||||
const IMAGES_DIR = path.join(TEST_DATA_DIR, "media", "images");
|
||||
const AUDIO_DIR = path.join(TEST_DATA_DIR, "media", "audio");
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe("saveImage()", () => {
|
||||
it("creates an image file and a metadata JSON file", async () => {
|
||||
const meta = await saveImage({ ...baseOpts, base64: DUMMY_PNG_B64 });
|
||||
|
||||
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",
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe("saveAudio()", () => {
|
||||
it("creates an audio file and metadata", async () => {
|
||||
const meta = await saveAudio({ ...baseOpts, base64: DUMMY_WAV_B64 });
|
||||
|
||||
const audioPath = path.join(AUDIO_DIR, meta.filename);
|
||||
const jsonPath = path.join(AUDIO_DIR, `${meta.id}.json`);
|
||||
|
||||
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"));
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
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");
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe("getMediaFilePath()", () => {
|
||||
it("returns null when the file does not exist", () => {
|
||||
const result = getMediaFilePath("image", "nonexistent.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
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { afterEach, beforeEach, describe, it, mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { LocalRAG } from "./rag.js";
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
let fetchMock: ReturnType<typeof mock.fn>;
|
||||
|
||||
/** Build a fake Ollama /api/embed response that returns a fixed embedding */
|
||||
function ollamaEmbedResponse(embedding: number[]) {
|
||||
return new Response(JSON.stringify({ embeddings: [embedding] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function makeRAG(opts?: Partial<{ minSimilarity: number; embeddingModel: string }>) {
|
||||
return new LocalRAG({
|
||||
ollamaUrl: "http://localhost:11434",
|
||||
embeddingModel: opts?.embeddingModel,
|
||||
minSimilarity: opts?.minSimilarity,
|
||||
});
|
||||
}
|
||||
|
||||
// ── tests ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("LocalRAG", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock = mock.fn();
|
||||
(globalThis as any).fetch = fetchMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restoreAll();
|
||||
// Restore original fetch (may be undefined in test env)
|
||||
delete (globalThis as any).fetch;
|
||||
});
|
||||
|
||||
// ── constructor ──────────────────────────────────────────────────
|
||||
|
||||
it("constructor creates an empty RAG (size === 0)", () => {
|
||||
const rag = makeRAG();
|
||||
assert.equal(rag.size, 0);
|
||||
});
|
||||
|
||||
// ── embed() ──────────────────────────────────────────────────────
|
||||
|
||||
it("embed() calls fetch with correct URL and model", async () => {
|
||||
fetchMock.mock.mockImplementation(() =>
|
||||
Promise.resolve(ollamaEmbedResponse([0.1, 0.2, 0.3])),
|
||||
);
|
||||
|
||||
const rag = makeRAG({ embeddingModel: "bge-m3" });
|
||||
const result = await rag.embed("hello");
|
||||
|
||||
assert.deepEqual(result, [0.1, 0.2, 0.3]);
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
|
||||
const [url, opts] = fetchMock.mock.calls[0].arguments as [string, any];
|
||||
assert.equal(url, "http://localhost:11434/api/embed");
|
||||
assert.equal(opts.method, "POST");
|
||||
const body = JSON.parse(opts.body);
|
||||
assert.equal(body.model, "bge-m3");
|
||||
assert.equal(body.input, "hello");
|
||||
});
|
||||
|
||||
it("embed() uses default model nomic-embed-text", async () => {
|
||||
fetchMock.mock.mockImplementation(() =>
|
||||
Promise.resolve(ollamaEmbedResponse([1])),
|
||||
);
|
||||
|
||||
const rag = makeRAG();
|
||||
await rag.embed("test");
|
||||
|
||||
const body = JSON.parse((fetchMock.mock.calls[0].arguments[1] as any).body);
|
||||
assert.equal(body.model, "nomic-embed-text");
|
||||
});
|
||||
|
||||
it("embed() throws on non-ok response", async () => {
|
||||
fetchMock.mock.mockImplementation(() =>
|
||||
Promise.resolve(new Response("fail", { status: 500, statusText: "Internal Server Error" })),
|
||||
);
|
||||
|
||||
const rag = makeRAG();
|
||||
await assert.rejects(() => rag.embed("x"), /Ollama embed returned 500/);
|
||||
});
|
||||
|
||||
// ── addDocument() ────────────────────────────────────────────────
|
||||
|
||||
it("addDocument() splits text and adds chunks", async () => {
|
||||
let callCount = 0;
|
||||
fetchMock.mock.mockImplementation(() => {
|
||||
callCount++;
|
||||
return Promise.resolve(ollamaEmbedResponse([callCount, 0, 0]));
|
||||
});
|
||||
|
||||
const rag = makeRAG();
|
||||
// Each paragraph > 500 chars forces split into separate chunks
|
||||
const para = "X".repeat(300);
|
||||
const count = await rag.addDocument(`${para}\n\n${para}`, "src");
|
||||
|
||||
assert.equal(count, 2);
|
||||
assert.equal(rag.size, 2);
|
||||
});
|
||||
|
||||
it("addDocument() with long text splits into multiple chunks", async () => {
|
||||
fetchMock.mock.mockImplementation(() =>
|
||||
Promise.resolve(ollamaEmbedResponse([1, 0])),
|
||||
);
|
||||
|
||||
const rag = makeRAG();
|
||||
// Create text with paragraphs > 500 chars each to force multiple chunks
|
||||
const longParagraph = "A".repeat(300);
|
||||
const text = `${longParagraph}\n\n${longParagraph}\n\n${longParagraph}`;
|
||||
const count = await rag.addDocument(text, "long");
|
||||
|
||||
assert.ok(count >= 2, `Expected >= 2 chunks, got ${count}`);
|
||||
assert.equal(rag.size, count);
|
||||
});
|
||||
|
||||
// ── search() ─────────────────────────────────────────────────────
|
||||
|
||||
it("search() returns [] if empty", async () => {
|
||||
const rag = makeRAG();
|
||||
// Should not even call fetch
|
||||
const results = await rag.search("anything");
|
||||
assert.deepEqual(results, []);
|
||||
assert.equal(fetchMock.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
it("search() returns chunks ranked by cosine similarity", async () => {
|
||||
// We control embeddings to get known cosine similarities
|
||||
// chunk A embedding = [1,0,0], chunk B = [0.7,0.7,0]
|
||||
// query embedding = [1,0,0] → sim(A)=1.0, sim(B)≈0.707
|
||||
const embeddings: number[][] = [];
|
||||
fetchMock.mock.mockImplementation(() =>
|
||||
Promise.resolve(ollamaEmbedResponse(embeddings.shift()!)),
|
||||
);
|
||||
|
||||
embeddings.push([1, 0, 0]); // chunk A embed
|
||||
const rag = makeRAG({ minSimilarity: 0 });
|
||||
await rag.addDocument("alpha", "a");
|
||||
|
||||
embeddings.push([0.7, 0.7, 0]); // chunk B embed
|
||||
await rag.addDocument("beta", "b");
|
||||
|
||||
embeddings.push([1, 0, 0]); // query embed
|
||||
const results = await rag.search("alpha");
|
||||
assert.equal(results.length, 2);
|
||||
assert.equal(results[0].source, "a");
|
||||
assert.equal(results[0].score, 1.0);
|
||||
assert.equal(results[1].source, "b");
|
||||
assert.ok(results[1].score > 0.5); // ~0.707
|
||||
});
|
||||
|
||||
it("search() filters by minSimilarity", async () => {
|
||||
// chunk = [1,0,0], query = [0.7,0.7,0] → sim ≈ 0.707
|
||||
const embeddings = [
|
||||
[1, 0, 0],
|
||||
[0.7, 0.7, 0], // query
|
||||
];
|
||||
let idx = 0;
|
||||
fetchMock.mock.mockImplementation(() =>
|
||||
Promise.resolve(ollamaEmbedResponse(embeddings[idx++])),
|
||||
);
|
||||
|
||||
const rag = makeRAG({ minSimilarity: 0.8 });
|
||||
await rag.addDocument("doc", "src");
|
||||
|
||||
const results = await rag.search("q");
|
||||
assert.equal(results.length, 0);
|
||||
});
|
||||
|
||||
it("search() respects maxResults", async () => {
|
||||
const embeddings = [
|
||||
[1, 0, 0],
|
||||
[0.9, 0.1, 0],
|
||||
[0.8, 0.2, 0],
|
||||
[1, 0, 0], // query
|
||||
];
|
||||
let idx = 0;
|
||||
fetchMock.mock.mockImplementation(() =>
|
||||
Promise.resolve(ollamaEmbedResponse(embeddings[idx++])),
|
||||
);
|
||||
|
||||
const rag = makeRAG({ minSimilarity: 0 });
|
||||
await rag.addDocument("a", "s1");
|
||||
await rag.addDocument("b", "s2");
|
||||
await rag.addDocument("c", "s3");
|
||||
|
||||
const results = await rag.search("q", 2);
|
||||
assert.equal(results.length, 2);
|
||||
});
|
||||
|
||||
// ── size ──────────────────────────────────────────────────────────
|
||||
|
||||
it("size returns the number of chunks", async () => {
|
||||
fetchMock.mock.mockImplementation(() =>
|
||||
Promise.resolve(ollamaEmbedResponse([1])),
|
||||
);
|
||||
|
||||
const rag = makeRAG();
|
||||
assert.equal(rag.size, 0);
|
||||
|
||||
await rag.addDocument("single chunk", "s");
|
||||
assert.equal(rag.size, 1);
|
||||
|
||||
// Force two chunks by making paragraphs > 500 chars
|
||||
const para = "Y".repeat(300);
|
||||
await rag.addDocument(`${para}\n\n${para}`, "s2");
|
||||
assert.equal(rag.size, 3);
|
||||
});
|
||||
|
||||
// ── cosine similarity edge cases (tested via search) ─────────────
|
||||
|
||||
it("identical vectors yield score 1.0", async () => {
|
||||
const embeddings = [
|
||||
[0.5, 0.5, 0.5], // doc
|
||||
[0.5, 0.5, 0.5], // query (identical)
|
||||
];
|
||||
let idx = 0;
|
||||
fetchMock.mock.mockImplementation(() =>
|
||||
Promise.resolve(ollamaEmbedResponse(embeddings[idx++])),
|
||||
);
|
||||
|
||||
const rag = makeRAG({ minSimilarity: 0 });
|
||||
await rag.addDocument("x", "s");
|
||||
const results = await rag.search("x");
|
||||
|
||||
assert.equal(results.length, 1);
|
||||
assert.ok(Math.abs(results[0].score - 1.0) < 1e-9);
|
||||
});
|
||||
|
||||
it("orthogonal vectors are filtered out by default minSimilarity", async () => {
|
||||
// cosine([1,0,0], [0,1,0]) = 0.0, below default threshold 0.3
|
||||
const embeddings = [
|
||||
[1, 0, 0], // doc
|
||||
[0, 1, 0], // query (orthogonal)
|
||||
];
|
||||
let idx = 0;
|
||||
fetchMock.mock.mockImplementation(() =>
|
||||
Promise.resolve(ollamaEmbedResponse(embeddings[idx++])),
|
||||
);
|
||||
|
||||
const rag = makeRAG(); // default minSimilarity → 0.3
|
||||
await rag.addDocument("x", "s");
|
||||
const results = await rag.search("x");
|
||||
|
||||
// Score 0.0 < 0.3, so filtered out
|
||||
assert.equal(results.length, 0);
|
||||
});
|
||||
});
|
||||
+66
-3
@@ -16,6 +16,7 @@ interface RAGOptions {
|
||||
embeddingModel?: string; // default: "nomic-embed-text"
|
||||
maxChunks?: number; // max chunks to return
|
||||
minSimilarity?: number; // minimum cosine similarity threshold
|
||||
lightragUrl?: string; // e.g. "http://localhost:9621"
|
||||
}
|
||||
|
||||
export class LocalRAG {
|
||||
@@ -43,8 +44,28 @@ export class LocalRAG {
|
||||
return data.embeddings?.[0] || [];
|
||||
}
|
||||
|
||||
/** Add a document (split into chunks, embed each) */
|
||||
/** Add a document (split into chunks, embed each).
|
||||
* If LightRAG is configured, also pushes the full text there (dual write). */
|
||||
async addDocument(text: string, source: string): Promise<number> {
|
||||
// Dual-write to LightRAG if configured
|
||||
if (this.options.lightragUrl) {
|
||||
try {
|
||||
const res = await fetch(`${this.options.lightragUrl}/documents/text`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
if (res.ok) {
|
||||
console.log(`[rag:lightrag] DEBUG addDocument to LightRAG OK (source=${source})`);
|
||||
} else {
|
||||
console.warn(`[rag:lightrag] addDocument failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[rag:lightrag] addDocument error (continuing local):", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Always index locally
|
||||
const textChunks = splitIntoChunks(text, 500);
|
||||
for (const chunk of textChunks) {
|
||||
const embedding = await this.embed(chunk);
|
||||
@@ -58,11 +79,53 @@ export class LocalRAG {
|
||||
return textChunks.length;
|
||||
}
|
||||
|
||||
/** Search for relevant chunks */
|
||||
/** Search for relevant chunks.
|
||||
* If LightRAG is configured, queries it first; falls back to local on failure. */
|
||||
async search(
|
||||
query: string,
|
||||
maxResults = 3,
|
||||
): Promise<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)}"`);
|
||||
const res = await fetch(`${this.options.lightragUrl}/query`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query, mode: "hybrid" }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as {
|
||||
response?: string;
|
||||
references?: Array<{ content?: string; text?: string }>;
|
||||
};
|
||||
console.log(
|
||||
`[rag:lightrag] DEBUG search OK refs=${data.references?.length ?? 0}`,
|
||||
);
|
||||
const results: Array<{ text: string; source: string; score: number }> = [];
|
||||
if (data.references && data.references.length > 0) {
|
||||
for (const ref of data.references.slice(0, maxResults)) {
|
||||
results.push({
|
||||
text: ref.content || ref.text || "",
|
||||
source: "lightrag",
|
||||
score: 1.0,
|
||||
});
|
||||
}
|
||||
} else if (data.response) {
|
||||
// No structured references — use the full response as a single chunk
|
||||
results.push({ text: data.response, source: "lightrag", score: 1.0 });
|
||||
}
|
||||
if (results.length > 0) return results;
|
||||
// Empty results from LightRAG → fall through to local
|
||||
} else {
|
||||
console.warn(`[rag:lightrag] search failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[rag:lightrag] search error (falling back to local):", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Local in-memory RAG
|
||||
if (this.chunks.length === 0) return [];
|
||||
|
||||
const queryEmbedding = await this.embed(query);
|
||||
@@ -73,7 +136,7 @@ export class LocalRAG {
|
||||
}));
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
return scored
|
||||
.filter((s) => s.score >= (this.options.minSimilarity || 0.3))
|
||||
.filter((s) => s.score >= (this.options.minSimilarity ?? 0.3))
|
||||
.slice(0, maxResults);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type PersonaRecord,
|
||||
type PersonaSourceRecord,
|
||||
} from "@kxkm/persona-domain";
|
||||
import { resolveVoiceSamplePath, resolveVoiceSamplesRoot } from "../voice-samples.js";
|
||||
|
||||
interface SessionRequest extends Request {
|
||||
session?: AuthSession;
|
||||
@@ -256,24 +257,18 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
|
||||
return;
|
||||
}
|
||||
|
||||
const voiceSamplesDir = path.resolve(process.cwd(), "data", "voice-samples");
|
||||
const voiceSamplesDir = resolveVoiceSamplesRoot();
|
||||
await mkdir(voiceSamplesDir, { recursive: true });
|
||||
|
||||
const sampleName = path.basename(persona.name.toLowerCase().replace(/[^a-z0-9_-]/g, "_")).slice(0, 64);
|
||||
if (!sampleName || sampleName === "." || sampleName === "..") {
|
||||
const samplePath = resolveVoiceSamplePath(persona.name, voiceSamplesDir);
|
||||
if (!samplePath) {
|
||||
res.status(400).json({ ok: false, error: "invalid_persona_name" });
|
||||
return;
|
||||
}
|
||||
const samplePath = path.join(voiceSamplesDir, `${sampleName}.wav`);
|
||||
// Boundary check: ensure resolved path stays within voiceSamplesDir
|
||||
if (!path.resolve(samplePath).startsWith(voiceSamplesDir)) {
|
||||
res.status(400).json({ ok: false, error: "path_traversal_blocked" });
|
||||
return;
|
||||
}
|
||||
|
||||
await writeFile(samplePath, buffer);
|
||||
|
||||
res.json({ ok: true, data: { personaId, samplePath: `data/voice-samples/${sampleName}.wav`, size: buffer.length } });
|
||||
res.json({ ok: true, data: { personaId, samplePath: path.relative(process.cwd(), samplePath), size: buffer.length } });
|
||||
});
|
||||
|
||||
router.delete("/api/admin/personas/:id/voice-sample", requirePermission("persona:write"), async (req: SessionRequest, res) => {
|
||||
@@ -284,10 +279,9 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
|
||||
return;
|
||||
}
|
||||
|
||||
const voiceSamplesDir2 = path.resolve(process.cwd(), "data", "voice-samples");
|
||||
const sampleName = path.basename(persona.name.toLowerCase().replace(/[^a-z0-9_-]/g, "_")).slice(0, 64);
|
||||
const samplePath = path.join(voiceSamplesDir2, `${sampleName}.wav`);
|
||||
if (!sampleName || !path.resolve(samplePath).startsWith(voiceSamplesDir2)) {
|
||||
const voiceSamplesDir2 = resolveVoiceSamplesRoot();
|
||||
const samplePath = resolveVoiceSamplePath(persona.name, voiceSamplesDir2);
|
||||
if (!samplePath) {
|
||||
res.status(400).json({ ok: false, error: "invalid_persona_name" });
|
||||
return;
|
||||
}
|
||||
@@ -309,17 +303,16 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
|
||||
return;
|
||||
}
|
||||
|
||||
const voiceSamplesDir3 = path.resolve(process.cwd(), "data", "voice-samples");
|
||||
const sampleName2 = path.basename(persona.name.toLowerCase().replace(/[^a-z0-9_-]/g, "_")).slice(0, 64);
|
||||
const samplePath2 = path.join(voiceSamplesDir3, `${sampleName2}.wav`);
|
||||
if (!sampleName2 || !path.resolve(samplePath2).startsWith(voiceSamplesDir3)) {
|
||||
const voiceSamplesDir3 = resolveVoiceSamplesRoot();
|
||||
const samplePath2 = resolveVoiceSamplePath(persona.name, voiceSamplesDir3);
|
||||
if (!samplePath2) {
|
||||
res.json({ ok: true, data: { hasVoiceSample: false } });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await stat(samplePath2);
|
||||
res.json({ ok: true, data: { hasVoiceSample: true, samplePath: `data/voice-samples/${sampleName2}.wav` } });
|
||||
res.json({ ok: true, data: { hasVoiceSample: true, samplePath: path.relative(process.cwd(), samplePath2) } });
|
||||
} catch {
|
||||
res.json({ ok: true, data: { hasVoiceSample: false } });
|
||||
}
|
||||
|
||||
@@ -49,6 +49,23 @@ interface SessionRouteDeps {
|
||||
clearSessionCookie: (res: Response) => void;
|
||||
}
|
||||
|
||||
// Simple in-memory rate limiter for login attempts
|
||||
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
|
||||
const LOGIN_RATE_LIMIT = 5; // max attempts
|
||||
const LOGIN_RATE_WINDOW_MS = 60_000; // per minute
|
||||
|
||||
function checkLoginRateLimit(ip: string): boolean {
|
||||
if (process.env.NODE_ENV === "test") return true;
|
||||
const now = Date.now();
|
||||
const entry = loginAttempts.get(ip);
|
||||
if (!entry || now > entry.resetAt) {
|
||||
loginAttempts.set(ip, { count: 1, resetAt: now + LOGIN_RATE_WINDOW_MS });
|
||||
return true;
|
||||
}
|
||||
entry.count++;
|
||||
return entry.count <= LOGIN_RATE_LIMIT;
|
||||
}
|
||||
|
||||
export function createSessionRoutes(deps: SessionRouteDeps): Router {
|
||||
const {
|
||||
sessionRepo,
|
||||
@@ -104,6 +121,12 @@ export function createSessionRoutes(deps: SessionRouteDeps): Router {
|
||||
|
||||
router.post("/api/session/login", async (req, res) => {
|
||||
try {
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || "unknown";
|
||||
if (!checkLoginRateLimit(clientIp)) {
|
||||
res.status(429).json({ ok: false, error: "rate_limited" });
|
||||
return;
|
||||
}
|
||||
|
||||
const input = validateLoginInput(req.body);
|
||||
|
||||
// SEC-04 fix: Never trust client-supplied role — assign viewer by default.
|
||||
|
||||
@@ -41,7 +41,7 @@ async function main() {
|
||||
// -----------------------------------------------------------------------
|
||||
// Initialize local RAG (embeddings via Ollama)
|
||||
// -----------------------------------------------------------------------
|
||||
const rag = new LocalRAG({ ollamaUrl });
|
||||
const rag = new LocalRAG({ ollamaUrl, lightragUrl: process.env.LIGHTRAG_URL });
|
||||
|
||||
// Index manifeste files asynchronously (non-blocking)
|
||||
// Try multiple paths: relative to cwd (inside container /app) and absolute on host
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { resolvePreferredPythonBin, resolveVoiceSamplePath, resolveVoiceSamplesRoot, toVoiceSampleBasename } from "./voice-samples.js";
|
||||
|
||||
describe("voice sample helpers", () => {
|
||||
it("sanitizes persona names consistently for upload and runtime lookup", () => {
|
||||
assert.equal(toVoiceSampleBasename("Sun Ra"), "sun_ra");
|
||||
assert.equal(toVoiceSampleBasename("Batty!"), "batty_");
|
||||
});
|
||||
|
||||
it("resolves a stable wav path inside the voice-samples directory", () => {
|
||||
const rootDir = path.resolve("/tmp", "voice-samples");
|
||||
const samplePath = resolveVoiceSamplePath("Sun Ra", rootDir);
|
||||
assert.equal(samplePath, path.join(rootDir, "sun_ra.wav"));
|
||||
});
|
||||
|
||||
it("rejects empty persona names", () => {
|
||||
assert.equal(resolveVoiceSamplePath(""), null);
|
||||
});
|
||||
|
||||
it("prefers the local data dir when resolving the voice sample root", () => {
|
||||
const env = { KXKM_LOCAL_DATA_DIR: path.join("/tmp", "kxkm-local") };
|
||||
assert.equal(resolveVoiceSamplesRoot(env), path.join("/tmp", "kxkm-local", "voice-samples"));
|
||||
});
|
||||
|
||||
it("prefers an explicit PYTHON_BIN over fallbacks", () => {
|
||||
assert.equal(resolvePreferredPythonBin({ PYTHON_BIN: "/tmp/custom-python" }), "/tmp/custom-python");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export function toVoiceSampleBasename(value: string): string {
|
||||
return path.basename(value.toLowerCase().replace(/[^a-z0-9_-]/g, "_")).slice(0, 64);
|
||||
}
|
||||
|
||||
export function resolveVoiceSamplesRoot(env: NodeJS.ProcessEnv = process.env): string {
|
||||
if (env.KXKM_VOICE_SAMPLES_DIR && env.KXKM_VOICE_SAMPLES_DIR.trim().length > 0) {
|
||||
return path.resolve(env.KXKM_VOICE_SAMPLES_DIR);
|
||||
}
|
||||
if (env.KXKM_LOCAL_DATA_DIR && env.KXKM_LOCAL_DATA_DIR.trim().length > 0) {
|
||||
return path.resolve(env.KXKM_LOCAL_DATA_DIR, "voice-samples");
|
||||
}
|
||||
return path.resolve(process.cwd(), "data", "voice-samples");
|
||||
}
|
||||
|
||||
export function resolveVoiceSamplePath(personaName: string, rootDir = resolveVoiceSamplesRoot()): string | null {
|
||||
const basename = toVoiceSampleBasename(personaName);
|
||||
if (!basename || basename === "." || basename === "..") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolved = path.join(rootDir, `${basename}.wav`);
|
||||
if (!path.resolve(resolved).startsWith(rootDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function resolvePreferredPythonBin(env: NodeJS.ProcessEnv = process.env): string {
|
||||
if (env.PYTHON_BIN && env.PYTHON_BIN.trim().length > 0) {
|
||||
return env.PYTHON_BIN;
|
||||
}
|
||||
|
||||
const projectVenvPython = path.resolve(process.cwd(), ".venvs", "voice-clone", "bin", "python");
|
||||
if (existsSync(projectVenvPython)) {
|
||||
return projectVenvPython;
|
||||
}
|
||||
|
||||
const legacyPython = "/home/kxkm/venv/bin/python3";
|
||||
if (existsSync(legacyPython)) {
|
||||
return legacyPython;
|
||||
}
|
||||
|
||||
return "python3";
|
||||
}
|
||||
@@ -7,6 +7,10 @@ export async function searchWeb(query: string): Promise<string> {
|
||||
const searxngUrl = process.env.SEARXNG_URL || "http://localhost:8080";
|
||||
try {
|
||||
const response = await fetch(`${searxngUrl}/search?q=${encodeURIComponent(query)}&format=json&engines=google,bing,duckduckgo`, {
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "KXKM_Clown/2.0",
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (response.ok) {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { afterEach, describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
import { rm, rmdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { WebSocket } from "ws";
|
||||
import { attachWebSocketChat } from "./ws-chat.js";
|
||||
import type { OutboundMessage } from "./chat-types.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const CHAT_LOG_DIR = path.resolve(process.cwd(), "data/chat-logs");
|
||||
|
||||
async function wait(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
describe("ws-chat smoke", () => {
|
||||
let server: http.Server | undefined;
|
||||
let wss: ReturnType<typeof attachWebSocketChat> | undefined;
|
||||
let client: WebSocket | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (client && client.readyState === WebSocket.OPEN) {
|
||||
client.close();
|
||||
await new Promise((resolve) => client?.once("close", resolve));
|
||||
}
|
||||
if (wss) {
|
||||
wss.close();
|
||||
}
|
||||
if (server) {
|
||||
await new Promise<void>((resolve) => server?.close(() => resolve()));
|
||||
}
|
||||
client = undefined;
|
||||
wss = undefined;
|
||||
server = undefined;
|
||||
globalThis.fetch = originalFetch;
|
||||
await wait(50);
|
||||
await rm(CHAT_LOG_DIR, { recursive: true, force: true });
|
||||
try {
|
||||
await rmdir(path.dirname(CHAT_LOG_DIR));
|
||||
} catch {
|
||||
// Keep parent dir if something else lives there.
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores malformed JSON and rate limits command bursts", async () => {
|
||||
server = http.createServer();
|
||||
wss = attachWebSocketChat(server, {
|
||||
ollamaUrl: "http://ollama.test",
|
||||
loadPersonas: async () => [],
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server?.listen(0, resolve));
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address !== "string", "expected numeric server address");
|
||||
|
||||
client = new WebSocket(`ws://127.0.0.1:${address.port}/ws`);
|
||||
const messages: OutboundMessage[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
client.on("message", (data) => {
|
||||
try {
|
||||
messages.push(JSON.parse(data.toString()) as OutboundMessage);
|
||||
} catch {
|
||||
// Ignore non-JSON frames
|
||||
}
|
||||
});
|
||||
client.on("error", (err) => {
|
||||
errors.push(err.message);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => client?.once("open", resolve));
|
||||
const baseline = messages.length;
|
||||
|
||||
client.send("not-json");
|
||||
await wait(50);
|
||||
assert.equal(messages.length, baseline);
|
||||
|
||||
for (let i = 0; i < 16; i += 1) {
|
||||
client.send(JSON.stringify({ type: "command", text: "/help" }));
|
||||
}
|
||||
|
||||
await wait(300);
|
||||
assert.ok(messages.some((msg) => msg.type === "system" && /Commandes disponibles/.test(msg.text)));
|
||||
assert.ok(messages.some((msg) => msg.type === "system" && /Trop de messages/.test(msg.text)));
|
||||
assert.equal(errors.length, 0);
|
||||
assert.equal(client.readyState, WebSocket.OPEN);
|
||||
});
|
||||
|
||||
it("dispatches command, upload and chat messages to their dedicated seams", async () => {
|
||||
globalThis.fetch = (async (_input, init) => {
|
||||
let body: Record<string, unknown> = {};
|
||||
if (typeof init?.body === "string") {
|
||||
try {
|
||||
body = JSON.parse(init.body) as Record<string, unknown>;
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
}
|
||||
if (body.stream === false) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
message: {
|
||||
content: "reponse stub",
|
||||
tool_calls: [],
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return new Response('{"message":{"content":"reponse stub"},"done":true}\n', {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/x-ndjson" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
server = http.createServer();
|
||||
wss = attachWebSocketChat(server, {
|
||||
ollamaUrl: "http://ollama.test",
|
||||
loadPersonas: async () => [],
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server?.listen(0, resolve));
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address !== "string", "expected numeric server address");
|
||||
|
||||
client = new WebSocket(`ws://127.0.0.1:${address.port}/ws`);
|
||||
const messages: OutboundMessage[] = [];
|
||||
|
||||
client.on("message", (data) => {
|
||||
try {
|
||||
messages.push(JSON.parse(data.toString()) as OutboundMessage);
|
||||
} catch {
|
||||
// Ignore non-JSON frames
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => client?.once("open", resolve));
|
||||
await wait(100);
|
||||
messages.length = 0;
|
||||
|
||||
client.send(JSON.stringify({ type: "command", text: "/help" }));
|
||||
await wait(100);
|
||||
assert.ok(messages.some((msg) => msg.type === "system" && /Commandes disponibles/.test(msg.text)));
|
||||
|
||||
client.send(JSON.stringify({ type: "upload", filename: "empty.txt", mimeType: "text/plain", data: "", size: 0 }));
|
||||
await wait(100);
|
||||
assert.ok(messages.some((msg) => msg.type === "system" && /Upload rejeté/.test(msg.text)));
|
||||
|
||||
client.send(JSON.stringify({ type: "message", text: "bonjour pharmacius" }));
|
||||
await wait(200);
|
||||
assert.ok(messages.some((msg) => msg.type === "message" && /^user_/.test(msg.nick)));
|
||||
assert.ok(messages.some((msg) => msg.type === "message" && msg.nick === "Pharmacius" && msg.text === "reponse stub"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
import { describe, it, mock, beforeEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createCommandHandler } from "./ws-commands.js";
|
||||
import type { ClientInfo, ChatPersona, OutboundMessage } from "./chat-types.js";
|
||||
import type { WebSocket } from "ws";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function makeInfo(overrides?: Partial<ClientInfo>): ClientInfo {
|
||||
return {
|
||||
nick: "TestUser",
|
||||
channel: "#general",
|
||||
connectedAt: Date.now(),
|
||||
messageTimestamps: [],
|
||||
uploadBytesWindow: 0,
|
||||
lastUploadReset: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeps(overrides?: Partial<Record<string, unknown>>) {
|
||||
return {
|
||||
send: mock.fn<(ws: WebSocket, msg: OutboundMessage) => void>(),
|
||||
broadcast: mock.fn<(channel: string, msg: OutboundMessage, exclude?: WebSocket) => void>(),
|
||||
broadcastUserlist: mock.fn<(channel: string) => void>(),
|
||||
channelUsers: mock.fn<(channel: string) => string[]>(() => ["TestUser", "Alice"]),
|
||||
listConnectedNicks: mock.fn<() => string[]>(() => ["TestUser", "Alice"]),
|
||||
listChannelCounts: mock.fn<() => Map<string, number>>(() => new Map([["#general", 2], ["#random", 1]])),
|
||||
routeToPersonas: mock.fn<(channel: string, text: string) => Promise<void>>(async () => {}),
|
||||
logChatMessage: mock.fn(),
|
||||
getPersonas: mock.fn<() => ChatPersona[]>(() => [
|
||||
{ id: "p1", nick: "Merzbow", model: "llama3", systemPrompt: "Tu es un artiste noise japonais provocateur et expérimental...", color: "#ff0000" },
|
||||
{ id: "p2", nick: "Oracle", model: "mistral", systemPrompt: "Tu es un oracle mystique qui parle en énigmes et prophéties...", color: "#9900ff" },
|
||||
]),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const fakeWs = {} as WebSocket;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* /help */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("/help", () => {
|
||||
it("sends help text", async () => {
|
||||
const deps = makeDeps();
|
||||
const handle = createCommandHandler(deps);
|
||||
await handle({ ws: fakeWs, info: makeInfo(), text: "/help" });
|
||||
|
||||
assert.equal(deps.send.mock.callCount(), 1);
|
||||
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
|
||||
assert.equal(msg.type, "system");
|
||||
assert.ok(msg.text.includes("/help"));
|
||||
assert.ok(msg.text.includes("/nick"));
|
||||
assert.ok(msg.text.includes("/join"));
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* /nick */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("/nick", () => {
|
||||
it("changes nick and broadcasts", async () => {
|
||||
const deps = makeDeps();
|
||||
const handle = createCommandHandler(deps);
|
||||
const info = makeInfo({ nick: "OldNick" });
|
||||
await handle({ ws: fakeWs, info, text: "/nick NewNick" });
|
||||
|
||||
assert.equal(info.nick, "NewNick");
|
||||
assert.equal(deps.broadcast.mock.callCount(), 1);
|
||||
const bMsg = deps.broadcast.mock.calls[0].arguments[1] as { type: string; text: string };
|
||||
assert.ok(bMsg.text.includes("OldNick"));
|
||||
assert.ok(bMsg.text.includes("NewNick"));
|
||||
assert.equal(deps.broadcastUserlist.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
it("rejects nick too short", async () => {
|
||||
const deps = makeDeps();
|
||||
const handle = createCommandHandler(deps);
|
||||
const info = makeInfo();
|
||||
await handle({ ws: fakeWs, info, text: "/nick X" });
|
||||
|
||||
assert.equal(info.nick, "TestUser"); // unchanged
|
||||
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
|
||||
assert.ok(msg.text.includes("2-24"));
|
||||
});
|
||||
|
||||
it("rejects nick too long", async () => {
|
||||
const deps = makeDeps();
|
||||
const handle = createCommandHandler(deps);
|
||||
const info = makeInfo();
|
||||
await handle({ ws: fakeWs, info, text: "/nick " + "A".repeat(25) });
|
||||
|
||||
assert.equal(info.nick, "TestUser");
|
||||
});
|
||||
|
||||
it("rejects missing nick", async () => {
|
||||
const deps = makeDeps();
|
||||
const handle = createCommandHandler(deps);
|
||||
await handle({ ws: fakeWs, info: makeInfo(), text: "/nick" });
|
||||
|
||||
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
|
||||
assert.ok(msg.text.includes("2-24"));
|
||||
});
|
||||
|
||||
it("rejects duplicate nick (case-insensitive)", async () => {
|
||||
const deps = makeDeps({
|
||||
listConnectedNicks: mock.fn(() => ["TestUser", "Alice"]),
|
||||
});
|
||||
const handle = createCommandHandler(deps as any);
|
||||
const info = makeInfo({ nick: "Bob" });
|
||||
await handle({ ws: fakeWs, info, text: "/nick alice" });
|
||||
|
||||
assert.equal(info.nick, "Bob"); // unchanged
|
||||
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
|
||||
assert.ok(msg.text.includes("deja utilise"));
|
||||
});
|
||||
|
||||
it("allows re-casing own nick", async () => {
|
||||
const deps = makeDeps({
|
||||
listConnectedNicks: mock.fn(() => ["testuser", "Alice"]),
|
||||
});
|
||||
const handle = createCommandHandler(deps as any);
|
||||
const info = makeInfo({ nick: "testuser" });
|
||||
await handle({ ws: fakeWs, info, text: "/nick TestUser" });
|
||||
|
||||
assert.equal(info.nick, "TestUser");
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* /who */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("/who", () => {
|
||||
it("sends userlist for current channel", async () => {
|
||||
const deps = makeDeps();
|
||||
const handle = createCommandHandler(deps);
|
||||
await handle({ ws: fakeWs, info: makeInfo(), text: "/who" });
|
||||
|
||||
assert.equal(deps.send.mock.callCount(), 1);
|
||||
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; users: string[] };
|
||||
assert.equal(msg.type, "userlist");
|
||||
assert.deepEqual(msg.users, ["TestUser", "Alice"]);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* /personas */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("/personas", () => {
|
||||
it("sends persona list", async () => {
|
||||
const deps = makeDeps();
|
||||
const handle = createCommandHandler(deps);
|
||||
await handle({ ws: fakeWs, info: makeInfo(), text: "/personas" });
|
||||
|
||||
assert.equal(deps.send.mock.callCount(), 1);
|
||||
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
|
||||
assert.equal(msg.type, "system");
|
||||
assert.ok(msg.text.includes("Merzbow"));
|
||||
assert.ok(msg.text.includes("Oracle"));
|
||||
assert.ok(msg.text.includes("llama3"));
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* /join */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("/join", () => {
|
||||
it("joins a valid channel", async () => {
|
||||
const deps = makeDeps();
|
||||
const handle = createCommandHandler(deps);
|
||||
const info = makeInfo({ channel: "#general" });
|
||||
await handle({ ws: fakeWs, info, text: "/join #lounge" });
|
||||
|
||||
assert.equal(info.channel, "#lounge");
|
||||
// broadcast part on old channel, join on new
|
||||
assert.equal(deps.broadcast.mock.callCount(), 2);
|
||||
const partMsg = deps.broadcast.mock.calls[0].arguments[1] as { type: string; channel: string };
|
||||
assert.equal(partMsg.type, "part");
|
||||
assert.equal(partMsg.channel, "#general");
|
||||
const joinMsg = deps.broadcast.mock.calls[1].arguments[1] as { type: string; channel: string };
|
||||
assert.equal(joinMsg.type, "join");
|
||||
assert.equal(joinMsg.channel, "#lounge");
|
||||
// broadcastUserlist called for both old and new
|
||||
assert.equal(deps.broadcastUserlist.mock.callCount(), 2);
|
||||
// send channelInfo + system confirmation
|
||||
assert.equal(deps.send.mock.callCount(), 2);
|
||||
});
|
||||
|
||||
it("rejects channel without #", async () => {
|
||||
const deps = makeDeps();
|
||||
const handle = createCommandHandler(deps);
|
||||
const info = makeInfo();
|
||||
await handle({ ws: fakeWs, info, text: "/join lounge" });
|
||||
|
||||
assert.equal(info.channel, "#general"); // unchanged
|
||||
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
|
||||
assert.ok(msg.text.includes("#"));
|
||||
});
|
||||
|
||||
it("rejects channel with invalid characters", async () => {
|
||||
const deps = makeDeps();
|
||||
const handle = createCommandHandler(deps);
|
||||
await handle({ ws: fakeWs, info: makeInfo(), text: "/join #bad channel!" });
|
||||
|
||||
// /join only takes parts[1], so "#bad" is valid; but let's test actual invalid chars
|
||||
const deps2 = makeDeps();
|
||||
const handle2 = createCommandHandler(deps2);
|
||||
const info2 = makeInfo();
|
||||
await handle2({ ws: fakeWs, info: info2, text: "/join #café" });
|
||||
|
||||
assert.equal(info2.channel, "#general");
|
||||
const msg = deps2.send.mock.calls[0].arguments[1] as { type: string; text: string };
|
||||
assert.ok(msg.text.includes("invalide"));
|
||||
});
|
||||
|
||||
it("rejects missing channel name", async () => {
|
||||
const deps = makeDeps();
|
||||
const handle = createCommandHandler(deps);
|
||||
await handle({ ws: fakeWs, info: makeInfo(), text: "/join" });
|
||||
|
||||
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
|
||||
assert.ok(msg.text.includes("Usage"));
|
||||
});
|
||||
|
||||
it("rejects channel name too long", async () => {
|
||||
const deps = makeDeps();
|
||||
const handle = createCommandHandler(deps);
|
||||
await handle({ ws: fakeWs, info: makeInfo(), text: `/join #${"a".repeat(30)}` });
|
||||
|
||||
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
|
||||
assert.ok(msg.text.includes("2-30"));
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* /channels */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("/channels", () => {
|
||||
it("lists channels sorted by user count", async () => {
|
||||
const deps = makeDeps({
|
||||
listChannelCounts: mock.fn(() => new Map([["#random", 1], ["#general", 5]])),
|
||||
});
|
||||
const handle = createCommandHandler(deps as any);
|
||||
await handle({ ws: fakeWs, info: makeInfo(), text: "/channels" });
|
||||
|
||||
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
|
||||
assert.ok(msg.text.includes("#general"));
|
||||
assert.ok(msg.text.includes("#random"));
|
||||
// #general (5) should appear before #random (1)
|
||||
assert.ok(msg.text.indexOf("#general") < msg.text.indexOf("#random"));
|
||||
});
|
||||
|
||||
it("shows (aucun) when no channels", async () => {
|
||||
const deps = makeDeps({
|
||||
listChannelCounts: mock.fn(() => new Map()),
|
||||
});
|
||||
const handle = createCommandHandler(deps as any);
|
||||
await handle({ ws: fakeWs, info: makeInfo(), text: "/channels" });
|
||||
|
||||
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
|
||||
assert.ok(msg.text.includes("(aucun)"));
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Unknown command */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("unknown command", () => {
|
||||
it("sends error for unknown command", async () => {
|
||||
const deps = makeDeps();
|
||||
const handle = createCommandHandler(deps);
|
||||
await handle({ ws: fakeWs, info: makeInfo(), text: "/foobar" });
|
||||
|
||||
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
|
||||
assert.equal(msg.type, "system");
|
||||
assert.ok(msg.text.includes("inconnue"));
|
||||
assert.ok(msg.text.includes("/foobar"));
|
||||
assert.ok(msg.text.includes("/help"));
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* /web */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("/web", () => {
|
||||
it("rejects empty query", async () => {
|
||||
const deps = makeDeps();
|
||||
const handle = createCommandHandler(deps);
|
||||
await handle({ ws: fakeWs, info: makeInfo(), text: "/web" });
|
||||
|
||||
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
|
||||
assert.ok(msg.text.includes("Usage"));
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* /imagine */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("/imagine", () => {
|
||||
it("rejects empty prompt", async () => {
|
||||
const deps = makeDeps();
|
||||
const handle = createCommandHandler(deps);
|
||||
await handle({ ws: fakeWs, info: makeInfo(), text: "/imagine" });
|
||||
|
||||
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
|
||||
assert.ok(msg.text.includes("Usage"));
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* /compose */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("/compose", () => {
|
||||
it("rejects empty prompt", async () => {
|
||||
const deps = makeDeps();
|
||||
const handle = createCommandHandler(deps);
|
||||
await handle({ ws: fakeWs, info: makeInfo(), text: "/compose" });
|
||||
|
||||
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
|
||||
assert.ok(msg.text.includes("Usage"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { afterEach, describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildConversationInput, createConversationRouter, type ConversationRouterDeps } from "./ws-conversation-router.js";
|
||||
import type { ChatLogEntry, ChatPersona, OutboundMessage } from "./chat-types.js";
|
||||
|
||||
const PERSONAS: ChatPersona[] = [
|
||||
{
|
||||
id: "pharmacius",
|
||||
nick: "Pharmacius",
|
||||
model: "llama3",
|
||||
systemPrompt: "Tu es Pharmacius.",
|
||||
color: "#c84c0c",
|
||||
},
|
||||
{
|
||||
id: "sherlock",
|
||||
nick: "Sherlock",
|
||||
model: "llama3",
|
||||
systemPrompt: "Tu es Sherlock.",
|
||||
color: "#2c6e49",
|
||||
},
|
||||
];
|
||||
|
||||
type BroadcastRecord = { channel: string; msg: OutboundMessage };
|
||||
type MemoryUpdateRecord = { persona: ChatPersona; recentMessages: string[]; ollamaUrl: string };
|
||||
|
||||
interface TestHarness {
|
||||
deps: ConversationRouterDeps;
|
||||
broadcasts: BroadcastRecord[];
|
||||
logs: ChatLogEntry[];
|
||||
contexts: Array<{ channel: string; nick: string; text: string }>;
|
||||
plainCalls: Array<{ persona: ChatPersona; message: string }>;
|
||||
toolCalls: Array<{ persona: ChatPersona; message: string; tools: unknown[] }>;
|
||||
memoryUpdates: MemoryUpdateRecord[];
|
||||
ttsCalls: Array<{ nick: string; text: string; channel: string }>;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
const originalTtsEnabled = process.env.TTS_ENABLED;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalTtsEnabled === undefined) {
|
||||
delete process.env.TTS_ENABLED;
|
||||
} else {
|
||||
process.env.TTS_ENABLED = originalTtsEnabled;
|
||||
}
|
||||
});
|
||||
|
||||
function sleep(ms = 0): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function createHarness(overrides: Partial<ConversationRouterDeps> = {}): TestHarness {
|
||||
const broadcasts: BroadcastRecord[] = [];
|
||||
const logs: ChatLogEntry[] = [];
|
||||
const contexts: Array<{ channel: string; nick: string; text: string }> = [];
|
||||
const plainCalls: Array<{ persona: ChatPersona; message: string }> = [];
|
||||
const toolCalls: Array<{ persona: ChatPersona; message: string; tools: unknown[] }> = [];
|
||||
const memoryUpdates: MemoryUpdateRecord[] = [];
|
||||
const ttsCalls: Array<{ nick: string; text: string; channel: string }> = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
const deps: ConversationRouterDeps = {
|
||||
ollamaUrl: "http://ollama.test",
|
||||
getPersonas: () => PERSONAS,
|
||||
broadcast: (channel, msg) => {
|
||||
broadcasts.push({ channel, msg });
|
||||
},
|
||||
logChatMessage: (entry) => {
|
||||
logs.push(entry);
|
||||
},
|
||||
addToContext: (channel, nick, text) => {
|
||||
contexts.push({ channel, nick, text });
|
||||
},
|
||||
getContextString: async () => "",
|
||||
getToolsForPersona: () => [],
|
||||
loadPersonaMemory: async (nick) => ({ nick, facts: [], summary: "", lastUpdated: "" }),
|
||||
updatePersonaMemory: async (persona, recentMessages, ollamaUrl) => {
|
||||
memoryUpdates.push({ persona, recentMessages, ollamaUrl });
|
||||
},
|
||||
streamOllamaChat: async (_ollamaUrl, persona, message, _onChunk, onDone) => {
|
||||
plainCalls.push({ persona, message });
|
||||
onDone(`Reponse ${persona.nick}`);
|
||||
},
|
||||
streamOllamaChatWithTools: async (_ollamaUrl, persona, message, tools, _rag, _onChunk, onDone) => {
|
||||
toolCalls.push({ persona, message, tools });
|
||||
onDone(`Reponse outils ${persona.nick}`);
|
||||
},
|
||||
synthesizeTTS: async (nick, text, channel) => {
|
||||
ttsCalls.push({ nick, text, channel });
|
||||
},
|
||||
isTTSAvailable: () => true,
|
||||
acquireTTS: () => {},
|
||||
releaseTTS: () => {},
|
||||
interPersonaDelayMs: 0,
|
||||
logger: {
|
||||
error: (...args: unknown[]) => {
|
||||
errors.push(args.map((item) => String(item)).join(" "));
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return {
|
||||
deps,
|
||||
broadcasts,
|
||||
logs,
|
||||
contexts,
|
||||
plainCalls,
|
||||
toolCalls,
|
||||
memoryUpdates,
|
||||
ttsCalls,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ws-conversation-router", () => {
|
||||
it("combines context store and RAG results in the enriched input", async () => {
|
||||
const enriched = await buildConversationInput(
|
||||
"Question utilisateur",
|
||||
"#general",
|
||||
async () => "Historique compact",
|
||||
{
|
||||
size: 1,
|
||||
search: async () => [{ text: "Document A" }, { text: "Document B" }],
|
||||
},
|
||||
);
|
||||
|
||||
assert.match(enriched, /Question utilisateur/);
|
||||
assert.match(enriched, /\[Contexte conversationnel\]/);
|
||||
assert.match(enriched, /Historique compact/);
|
||||
assert.match(enriched, /\[Contexte pertinent\]/);
|
||||
assert.match(enriched, /Document A/);
|
||||
assert.match(enriched, /Document B/);
|
||||
});
|
||||
|
||||
it("routes a direct @mention only to the mentioned persona", async () => {
|
||||
const harness = createHarness();
|
||||
const routeToPersonas = createConversationRouter(harness.deps);
|
||||
|
||||
await routeToPersonas("#general", "@Sherlock analyse ceci");
|
||||
|
||||
const replies = harness.broadcasts
|
||||
.filter((entry) => entry.msg.type === "message")
|
||||
.map((entry) => (entry.msg.type === "message" ? entry.msg.nick : ""));
|
||||
assert.deepEqual(replies, ["Sherlock"]);
|
||||
});
|
||||
|
||||
it("falls back to Pharmacius without a direct mention", async () => {
|
||||
const harness = createHarness();
|
||||
const routeToPersonas = createConversationRouter(harness.deps);
|
||||
|
||||
await routeToPersonas("#general", "bonjour tout le monde");
|
||||
|
||||
const replies = harness.broadcasts
|
||||
.filter((entry) => entry.msg.type === "message")
|
||||
.map((entry) => (entry.msg.type === "message" ? entry.msg.nick : ""));
|
||||
assert.deepEqual(replies, ["Pharmacius"]);
|
||||
});
|
||||
|
||||
it("switches to the tool-calling path when a persona has tools", async () => {
|
||||
const harness = createHarness({
|
||||
getToolsForPersona: (nick) => (nick === "Pharmacius" ? [{ type: "function", function: { name: "web_search", description: "", parameters: { type: "object", properties: {}, required: [] } } }] : []),
|
||||
});
|
||||
const routeToPersonas = createConversationRouter(harness.deps);
|
||||
|
||||
await routeToPersonas("#general", "question sans mention");
|
||||
|
||||
assert.equal(harness.toolCalls.length, 1);
|
||||
assert.equal(harness.toolCalls[0]?.persona.nick, "Pharmacius");
|
||||
assert.equal(harness.plainCalls.length, 0);
|
||||
});
|
||||
|
||||
it("updates persona memory every five responses with serialized recent messages", async () => {
|
||||
const harness = createHarness();
|
||||
const routeToPersonas = createConversationRouter(harness.deps);
|
||||
|
||||
for (let index = 1; index <= 5; index += 1) {
|
||||
await routeToPersonas("#general", `message ${index}`);
|
||||
}
|
||||
await sleep();
|
||||
|
||||
assert.equal(harness.memoryUpdates.length, 1);
|
||||
assert.equal(harness.memoryUpdates[0]?.persona.nick, "Pharmacius");
|
||||
assert.equal(harness.memoryUpdates[0]?.recentMessages.length, 5);
|
||||
assert.match(harness.memoryUpdates[0]?.recentMessages[4] || "", /message 5/);
|
||||
});
|
||||
|
||||
it("triggers TTS only when the feature flag is enabled", async () => {
|
||||
process.env.TTS_ENABLED = "0";
|
||||
const disabledHarness = createHarness();
|
||||
const disabledRouter = createConversationRouter(disabledHarness.deps);
|
||||
await disabledRouter("#general", "premier message");
|
||||
|
||||
process.env.TTS_ENABLED = "1";
|
||||
const enabledHarness = createHarness();
|
||||
const enabledRouter = createConversationRouter(enabledHarness.deps);
|
||||
await enabledRouter("#general", "second message");
|
||||
await sleep();
|
||||
|
||||
assert.equal(disabledHarness.ttsCalls.length, 0);
|
||||
assert.equal(enabledHarness.ttsCalls.length, 1);
|
||||
assert.equal(enabledHarness.ttsCalls[0]?.nick, "Pharmacius");
|
||||
});
|
||||
|
||||
it("caps inter-persona rebounds at the configured depth", async () => {
|
||||
const harness = createHarness({
|
||||
streamOllamaChat: async (_ollamaUrl, persona, _message, _onChunk, onDone) => {
|
||||
harness.plainCalls.push({ persona, message: _message });
|
||||
if (persona.nick === "Pharmacius") {
|
||||
onDone("Sherlock, regarde ca. @Sherlock");
|
||||
return;
|
||||
}
|
||||
onDone("Je reponds a Pharmacius. @Pharmacius");
|
||||
},
|
||||
maxInterPersonaDepth: 1,
|
||||
});
|
||||
const routeToPersonas = createConversationRouter(harness.deps);
|
||||
|
||||
await routeToPersonas("#general", "ouvre le debat");
|
||||
await sleep();
|
||||
await sleep();
|
||||
|
||||
const replies = harness.broadcasts
|
||||
.filter((entry) => entry.msg.type === "message")
|
||||
.map((entry) => (entry.msg.type === "message" ? entry.msg.nick : ""));
|
||||
assert.deepEqual(replies, ["Pharmacius", "Sherlock"]);
|
||||
});
|
||||
|
||||
it("broadcasts a system error when streaming fails without throwing", async () => {
|
||||
const harness = createHarness({
|
||||
streamOllamaChat: async (_ollamaUrl, _persona, _message, _onChunk, _onDone, onError) => {
|
||||
onError(new Error("boom"));
|
||||
},
|
||||
});
|
||||
const routeToPersonas = createConversationRouter(harness.deps);
|
||||
|
||||
await assert.doesNotReject(() => routeToPersonas("#general", "message casse"));
|
||||
const systemMessages = harness.broadcasts.filter((entry) => entry.msg.type === "system");
|
||||
assert.ok(systemMessages.some((entry) => entry.msg.type === "system" && entry.msg.text.includes("erreur Ollama — boom")));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
import { afterEach, beforeEach, describe, it, mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
acquireFileProcessor,
|
||||
releaseFileProcessor,
|
||||
isTTSAvailable,
|
||||
acquireTTS,
|
||||
releaseTTS,
|
||||
synthesizeTTS,
|
||||
isOfficeDocument,
|
||||
analyzeImage,
|
||||
} from "./ws-multimodal.js";
|
||||
import type { OutboundMessage } from "./chat-types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
function mockFetch(impl: (...args: any[]) => any) {
|
||||
globalThis.fetch = impl as typeof fetch;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Semaphores — file processor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("file processor semaphore", () => {
|
||||
afterEach(() => {
|
||||
// drain semaphore back to 0
|
||||
for (let i = 0; i < 5; i++) releaseFileProcessor();
|
||||
});
|
||||
|
||||
it("acquireFileProcessor resolves immediately under the limit", async () => {
|
||||
// Should resolve without delay when count is 0
|
||||
await acquireFileProcessor();
|
||||
assert.ok(true, "acquired without blocking");
|
||||
});
|
||||
|
||||
it("releaseFileProcessor decrements the counter", async () => {
|
||||
await acquireFileProcessor();
|
||||
await acquireFileProcessor();
|
||||
releaseFileProcessor();
|
||||
// Should still be able to acquire (now at 1, limit 2)
|
||||
await acquireFileProcessor();
|
||||
assert.ok(true, "acquired after release");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Semaphores — TTS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("TTS semaphore", () => {
|
||||
afterEach(() => {
|
||||
for (let i = 0; i < 5; i++) releaseTTS();
|
||||
});
|
||||
|
||||
it("isTTSAvailable returns true under the limit", () => {
|
||||
assert.equal(isTTSAvailable(), true);
|
||||
});
|
||||
|
||||
it("isTTSAvailable returns false at the limit", () => {
|
||||
acquireTTS();
|
||||
acquireTTS();
|
||||
assert.equal(isTTSAvailable(), false);
|
||||
});
|
||||
|
||||
it("acquireTTS/releaseTTS increments and decrements", () => {
|
||||
acquireTTS();
|
||||
assert.equal(isTTSAvailable(), true); // 1 < 2
|
||||
acquireTTS();
|
||||
assert.equal(isTTSAvailable(), false); // 2 >= 2
|
||||
releaseTTS();
|
||||
assert.equal(isTTSAvailable(), true); // back to 1
|
||||
});
|
||||
|
||||
it("releaseTTS does not go below 0", () => {
|
||||
releaseTTS();
|
||||
releaseTTS();
|
||||
releaseTTS();
|
||||
// Should still be available (counter stays at 0)
|
||||
assert.equal(isTTSAvailable(), true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isOfficeDocument
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("isOfficeDocument", () => {
|
||||
it("returns true for office extensions", () => {
|
||||
for (const ext of ["docx", "xlsx", "pptx", "odt", "ods", "odp", "rtf", "epub", "doc", "xls", "ppt"]) {
|
||||
assert.equal(isOfficeDocument(`file.${ext}`, ""), true, `expected true for .${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns true for office MIME types", () => {
|
||||
const mimes = [
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.ms-powerpoint",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/rtf",
|
||||
"application/epub+zip",
|
||||
];
|
||||
for (const m of mimes) {
|
||||
assert.equal(isOfficeDocument("file.bin", m), true, `expected true for ${m}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false for non-office extensions", () => {
|
||||
for (const ext of ["txt", "pdf", "jpg", "png", "mp3"]) {
|
||||
assert.equal(isOfficeDocument(`file.${ext}`, ""), false, `expected false for .${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false for non-office MIME types", () => {
|
||||
assert.equal(isOfficeDocument("file.txt", "text/plain"), false);
|
||||
assert.equal(isOfficeDocument("file.jpg", "image/jpeg"), false);
|
||||
});
|
||||
|
||||
it("is case-insensitive on extension", () => {
|
||||
assert.equal(isOfficeDocument("file.DOCX", ""), true);
|
||||
assert.equal(isOfficeDocument("file.Xlsx", ""), true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// synthesizeTTS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("synthesizeTTS", () => {
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("does nothing if text is shorter than 10 chars", async () => {
|
||||
let fetchCalled = false;
|
||||
mockFetch(() => { fetchCalled = true; });
|
||||
await synthesizeTTS("Nick", "short", "ch1", () => {});
|
||||
assert.equal(fetchCalled, false);
|
||||
});
|
||||
|
||||
it("does nothing if text is empty", async () => {
|
||||
let fetchCalled = false;
|
||||
mockFetch(() => { fetchCalled = true; });
|
||||
await synthesizeTTS("Nick", "", "ch1", () => {});
|
||||
assert.equal(fetchCalled, false);
|
||||
});
|
||||
|
||||
it("calls fetch with the correct URL and body", async () => {
|
||||
let capturedUrl = "";
|
||||
let capturedBody: any = {};
|
||||
mockFetch(async (url: string, opts: any) => {
|
||||
capturedUrl = url;
|
||||
capturedBody = JSON.parse(opts.body);
|
||||
return {
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
};
|
||||
});
|
||||
|
||||
await synthesizeTTS("Pharmacius", "Hello world this is a test text", "ch1", () => {});
|
||||
assert.match(capturedUrl, /\/synthesize$/);
|
||||
assert.equal(capturedBody.text, "Hello world this is a test text");
|
||||
assert.equal(capturedBody.persona, "Pharmacius");
|
||||
});
|
||||
|
||||
it("truncates text to 1000 chars", async () => {
|
||||
let capturedBody: any = {};
|
||||
mockFetch(async (_url: string, opts: any) => {
|
||||
capturedBody = JSON.parse(opts.body);
|
||||
return {
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
};
|
||||
});
|
||||
|
||||
const longText = "a".repeat(2000);
|
||||
await synthesizeTTS("Nick", longText, "ch1", () => {});
|
||||
assert.equal(capturedBody.text.length, 1000);
|
||||
});
|
||||
|
||||
it("broadcasts audio message in base64", async () => {
|
||||
const audioBytes = Buffer.from("fake-audio-data");
|
||||
mockFetch(async () => ({
|
||||
ok: true,
|
||||
arrayBuffer: async () => audioBytes.buffer.slice(audioBytes.byteOffset, audioBytes.byteOffset + audioBytes.byteLength),
|
||||
}));
|
||||
|
||||
const broadcasts: { channel: string; msg: OutboundMessage }[] = [];
|
||||
await synthesizeTTS("Pharmacius", "This is enough text for TTS", "room1", (ch, msg) => {
|
||||
broadcasts.push({ channel: ch, msg });
|
||||
});
|
||||
|
||||
assert.equal(broadcasts.length, 1);
|
||||
assert.equal(broadcasts[0].channel, "room1");
|
||||
assert.equal(broadcasts[0].msg.type, "audio");
|
||||
assert.equal((broadcasts[0].msg as any).nick, "Pharmacius");
|
||||
assert.equal((broadcasts[0].msg as any).mimeType, "audio/wav");
|
||||
assert.equal(typeof (broadcasts[0].msg as any).data, "string");
|
||||
// Verify base64 round-trip
|
||||
const decoded = Buffer.from((broadcasts[0].msg as any).data, "base64");
|
||||
assert.deepEqual(decoded, audioBytes);
|
||||
});
|
||||
|
||||
it("does not throw on fetch error", async () => {
|
||||
mockFetch(async () => { throw new Error("network down"); });
|
||||
// Should complete without throwing
|
||||
await synthesizeTTS("Nick", "This is enough text for TTS", "ch1", () => {});
|
||||
assert.ok(true, "did not throw");
|
||||
});
|
||||
|
||||
it("does not throw on non-ok response", async () => {
|
||||
mockFetch(async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => "server error",
|
||||
}));
|
||||
await synthesizeTTS("Nick", "This is enough text for TTS", "ch1", () => {});
|
||||
assert.ok(true, "did not throw");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// analyzeImage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("analyzeImage", () => {
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("calls Ollama /api/chat with correct model and images", async () => {
|
||||
let capturedUrl = "";
|
||||
let capturedBody: any = {};
|
||||
const imgBuffer = Buffer.from("fake-image");
|
||||
|
||||
mockFetch(async (url: string, opts: any) => {
|
||||
capturedUrl = url;
|
||||
capturedBody = JSON.parse(opts.body);
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ message: { content: "Une photo" } }),
|
||||
};
|
||||
});
|
||||
|
||||
await analyzeImage(imgBuffer, "image/png", "photo.png", "http://localhost:11434");
|
||||
|
||||
assert.equal(capturedUrl, "http://localhost:11434/api/chat");
|
||||
assert.equal(capturedBody.model, "qwen3-vl:8b");
|
||||
assert.equal(capturedBody.stream, false);
|
||||
assert.equal(capturedBody.messages.length, 1);
|
||||
assert.equal(capturedBody.messages[0].role, "user");
|
||||
assert.equal(capturedBody.messages[0].images[0], imgBuffer.toString("base64"));
|
||||
});
|
||||
|
||||
it("returns '[Image: filename]\\ncaption' on success", async () => {
|
||||
mockFetch(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ message: { content: "Un beau paysage" } }),
|
||||
}));
|
||||
|
||||
const result = await analyzeImage(Buffer.from("img"), "image/png", "landscape.png", "http://localhost:11434");
|
||||
assert.equal(result, "[Image: landscape.png]\nUn beau paysage");
|
||||
});
|
||||
|
||||
it("returns default caption when response has no content", async () => {
|
||||
mockFetch(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ message: {} }),
|
||||
}));
|
||||
|
||||
const result = await analyzeImage(Buffer.from("img"), "image/png", "test.png", "http://localhost:11434");
|
||||
assert.equal(result, "[Image: test.png]\nPas de description disponible");
|
||||
});
|
||||
|
||||
it("returns error message on non-ok response", async () => {
|
||||
mockFetch(async () => ({
|
||||
ok: false,
|
||||
status: 503,
|
||||
text: async () => "model not loaded",
|
||||
}));
|
||||
|
||||
const result = await analyzeImage(Buffer.from("img"), "image/png", "test.png", "http://localhost:11434");
|
||||
assert.match(result, /analyse échouée/);
|
||||
assert.match(result, /503/);
|
||||
assert.match(result, /test\.png/);
|
||||
});
|
||||
|
||||
it("returns error message on fetch exception", async () => {
|
||||
mockFetch(async () => { throw new Error("connection refused"); });
|
||||
|
||||
const result = await analyzeImage(Buffer.from("img"), "image/png", "test.png", "http://localhost:11434");
|
||||
assert.match(result, /erreur d'analyse/);
|
||||
assert.match(result, /connection refused/);
|
||||
assert.match(result, /test\.png/);
|
||||
});
|
||||
|
||||
it("uses VISION_MODEL env var when set", async () => {
|
||||
const origModel = process.env.VISION_MODEL;
|
||||
process.env.VISION_MODEL = "llava:13b";
|
||||
|
||||
let capturedBody: any = {};
|
||||
mockFetch(async (_url: string, opts: any) => {
|
||||
capturedBody = JSON.parse(opts.body);
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ message: { content: "caption" } }),
|
||||
};
|
||||
});
|
||||
|
||||
await analyzeImage(Buffer.from("img"), "image/png", "test.png", "http://localhost:11434");
|
||||
assert.equal(capturedBody.model, "llava:13b");
|
||||
|
||||
// Restore
|
||||
if (origModel === undefined) delete process.env.VISION_MODEL;
|
||||
else process.env.VISION_MODEL = origModel;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,566 @@
|
||||
import { describe, it, beforeEach, afterEach, mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock dependencies BEFORE importing the module under test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Mock web-search
|
||||
const mockSearchWeb = mock.fn(async (_q: string) => "search result");
|
||||
mock.module("./web-search.js", {
|
||||
namedExports: { searchWeb: mockSearchWeb },
|
||||
});
|
||||
|
||||
// Mock comfyui
|
||||
const mockGenerateImage = mock.fn(async (_p: string) => ({ imageBase64: "abc", seed: 42 }));
|
||||
mock.module("./comfyui.js", {
|
||||
namedExports: { generateImage: mockGenerateImage },
|
||||
});
|
||||
|
||||
// Now import the module under test
|
||||
const {
|
||||
acquireOllama,
|
||||
releaseOllama,
|
||||
streamOllamaChat,
|
||||
executeToolCall,
|
||||
streamOllamaChatWithTools,
|
||||
} = await import("./ws-ollama.js");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makePersona() {
|
||||
return {
|
||||
id: "test-id",
|
||||
nick: "test",
|
||||
model: "test:7b",
|
||||
systemPrompt: "You are a test",
|
||||
color: "#fff",
|
||||
maxTokens: 100,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a ReadableStream that yields NDJSON lines like Ollama */
|
||||
function ollamaStream(chunks: string[]): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
const lines = chunks.map(
|
||||
(c) => JSON.stringify({ message: { content: c }, done: false }) + "\n",
|
||||
);
|
||||
lines.push(JSON.stringify({ message: { content: "" }, done: true }) + "\n");
|
||||
let idx = 0;
|
||||
return new ReadableStream({
|
||||
pull(controller) {
|
||||
if (idx < lines.length) {
|
||||
controller.enqueue(encoder.encode(lines[idx++]));
|
||||
} else {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a mock Response with a streaming body */
|
||||
function mockStreamResponse(chunks: string[], status = 200): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: status === 200 ? "OK" : "Error",
|
||||
body: ollamaStream(chunks),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
/** Create a mock Response with a JSON body (non-streaming) */
|
||||
function mockJsonResponse(data: unknown, status = 200): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: status === 200 ? "OK" : "Error",
|
||||
json: async () => data,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
// Store original fetch
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ws-ollama", () => {
|
||||
let fetchMock: ReturnType<typeof mock.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = mock.fn();
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
mockSearchWeb.mock.resetCalls();
|
||||
mockGenerateImage.mock.resetCalls();
|
||||
// Drain the semaphore state by releasing enough times
|
||||
// (we need a fresh state; simplest approach is to accept leaks for now)
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Semaphore tests
|
||||
// =========================================================================
|
||||
|
||||
describe("acquireOllama / releaseOllama", () => {
|
||||
it("resolves immediately when under the limit", async () => {
|
||||
// Should resolve without blocking
|
||||
await acquireOllama();
|
||||
releaseOllama();
|
||||
});
|
||||
|
||||
it("queues when limit is reached and unblocks on release", async () => {
|
||||
// Acquire 3 slots (default MAX_OLLAMA_CONCURRENT = 3)
|
||||
await acquireOllama();
|
||||
await acquireOllama();
|
||||
await acquireOllama();
|
||||
|
||||
let resolved = false;
|
||||
const pending = acquireOllama().then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
|
||||
// Should not have resolved yet
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(resolved, false, "4th acquire should be queued");
|
||||
|
||||
// Release one slot
|
||||
releaseOllama();
|
||||
await pending;
|
||||
assert.equal(resolved, true, "4th acquire should resolve after release");
|
||||
|
||||
// Cleanup: release remaining
|
||||
releaseOllama();
|
||||
releaseOllama();
|
||||
releaseOllama();
|
||||
});
|
||||
|
||||
it("unblocks in FIFO order", async () => {
|
||||
await acquireOllama();
|
||||
await acquireOllama();
|
||||
await acquireOllama();
|
||||
|
||||
const order: number[] = [];
|
||||
const p1 = acquireOllama().then(() => order.push(1));
|
||||
const p2 = acquireOllama().then(() => order.push(2));
|
||||
|
||||
releaseOllama();
|
||||
await p1;
|
||||
releaseOllama();
|
||||
await p2;
|
||||
|
||||
assert.deepEqual(order, [1, 2]);
|
||||
|
||||
// Cleanup
|
||||
releaseOllama();
|
||||
releaseOllama();
|
||||
releaseOllama();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// streamOllamaChat tests
|
||||
// =========================================================================
|
||||
|
||||
describe("streamOllamaChat", () => {
|
||||
it("calls fetch with correct URL and body", async () => {
|
||||
fetchMock.mock.mockImplementation(async () => mockStreamResponse(["hello"]));
|
||||
|
||||
const persona = makePersona();
|
||||
await streamOllamaChat(
|
||||
"http://localhost:11434",
|
||||
persona,
|
||||
"Hi",
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
const [url, opts] = fetchMock.mock.calls[0].arguments as [string, RequestInit & { body: string }];
|
||||
assert.equal(url, "http://localhost:11434/api/chat");
|
||||
assert.equal(opts.method, "POST");
|
||||
const body = JSON.parse(opts.body as string);
|
||||
assert.equal(body.model, "test:7b");
|
||||
assert.equal(body.stream, true);
|
||||
assert.equal(body.messages[0].role, "system");
|
||||
assert.equal(body.messages[0].content, "You are a test");
|
||||
assert.equal(body.messages[1].role, "user");
|
||||
assert.equal(body.messages[1].content, "Hi");
|
||||
assert.equal(body.options.num_predict, 100);
|
||||
});
|
||||
|
||||
it("streams chunks via onChunk and calls onDone with full text", async () => {
|
||||
fetchMock.mock.mockImplementation(async () => mockStreamResponse(["Hello", " world"]));
|
||||
|
||||
const chunks: string[] = [];
|
||||
let doneText = "";
|
||||
|
||||
await streamOllamaChat(
|
||||
"http://localhost:11434",
|
||||
makePersona(),
|
||||
"Hi",
|
||||
(t) => chunks.push(t),
|
||||
(t) => { doneText = t; },
|
||||
() => {},
|
||||
);
|
||||
|
||||
assert.deepEqual(chunks, ["Hello", " world"]);
|
||||
assert.equal(doneText, "Hello world");
|
||||
});
|
||||
|
||||
it("strips <think>...</think> blocks from onDone text", async () => {
|
||||
fetchMock.mock.mockImplementation(async () =>
|
||||
mockStreamResponse(["<think>reasoning</think>", "Answer"]),
|
||||
);
|
||||
|
||||
let doneText = "";
|
||||
await streamOllamaChat(
|
||||
"http://localhost:11434",
|
||||
makePersona(),
|
||||
"Hi",
|
||||
() => {},
|
||||
(t) => { doneText = t; },
|
||||
() => {},
|
||||
);
|
||||
|
||||
assert.equal(doneText, "Answer");
|
||||
});
|
||||
|
||||
it("suppresses <think> content from onChunk", async () => {
|
||||
fetchMock.mock.mockImplementation(async () =>
|
||||
mockStreamResponse(["<think>", "internal", "</think>", "visible"]),
|
||||
);
|
||||
|
||||
const chunks: string[] = [];
|
||||
await streamOllamaChat(
|
||||
"http://localhost:11434",
|
||||
makePersona(),
|
||||
"Hi",
|
||||
(t) => chunks.push(t),
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
|
||||
// Only "visible" should have been sent via onChunk
|
||||
assert.deepEqual(chunks, ["visible"]);
|
||||
});
|
||||
|
||||
it("calls onError on fetch failure", async () => {
|
||||
fetchMock.mock.mockImplementation(async () => {
|
||||
throw new Error("Network error");
|
||||
});
|
||||
|
||||
let caughtError: Error | null = null;
|
||||
await streamOllamaChat(
|
||||
"http://localhost:11434",
|
||||
makePersona(),
|
||||
"Hi",
|
||||
() => {},
|
||||
() => {},
|
||||
(err) => { caughtError = err; },
|
||||
);
|
||||
|
||||
assert.notEqual(caughtError, null);
|
||||
assert.match(caughtError!.message, /Network error/);
|
||||
});
|
||||
|
||||
it("calls onError when response is not ok", async () => {
|
||||
fetchMock.mock.mockImplementation(async () => mockStreamResponse([], 500));
|
||||
|
||||
let caughtError: Error | null = null;
|
||||
await streamOllamaChat(
|
||||
"http://localhost:11434",
|
||||
makePersona(),
|
||||
"Hi",
|
||||
() => {},
|
||||
() => {},
|
||||
(err) => { caughtError = err; },
|
||||
);
|
||||
|
||||
assert.notEqual(caughtError, null);
|
||||
assert.match(caughtError!.message, /Ollama returned 500/);
|
||||
});
|
||||
|
||||
it("releases semaphore on success", async () => {
|
||||
fetchMock.mock.mockImplementation(async () => mockStreamResponse(["ok"]));
|
||||
|
||||
// Fill the semaphore, do a stream, check we can still acquire after
|
||||
await streamOllamaChat(
|
||||
"http://localhost:11434",
|
||||
makePersona(),
|
||||
"Hi",
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
|
||||
// If semaphore was released, this should resolve
|
||||
await acquireOllama();
|
||||
releaseOllama();
|
||||
});
|
||||
|
||||
it("releases semaphore on error", async () => {
|
||||
fetchMock.mock.mockImplementation(async () => {
|
||||
throw new Error("fail");
|
||||
});
|
||||
|
||||
await streamOllamaChat(
|
||||
"http://localhost:11434",
|
||||
makePersona(),
|
||||
"Hi",
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
|
||||
// Semaphore should still be functional
|
||||
await acquireOllama();
|
||||
releaseOllama();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// executeToolCall tests
|
||||
// =========================================================================
|
||||
|
||||
describe("executeToolCall", () => {
|
||||
it("web_search returns searchWeb result", async () => {
|
||||
mockSearchWeb.mock.mockImplementation(async () => "web results");
|
||||
const result = await executeToolCall("web_search", { query: "test" }, undefined);
|
||||
assert.equal(result, "web results");
|
||||
assert.equal(mockSearchWeb.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
it("image_generate returns seed message on success", async () => {
|
||||
mockGenerateImage.mock.mockImplementation(async () => ({ imageBase64: "x", seed: 99 }));
|
||||
const result = await executeToolCall("image_generate", { prompt: "cat" }, undefined);
|
||||
assert.match(result, /seed 99/);
|
||||
});
|
||||
|
||||
it("image_generate returns error message on null", async () => {
|
||||
mockGenerateImage.mock.mockImplementation(async () => null as unknown as { imageBase64: string; seed: number });
|
||||
const result = await executeToolCall("image_generate", { prompt: "cat" }, undefined);
|
||||
assert.match(result, /Erreur/);
|
||||
});
|
||||
|
||||
it("rag_search returns joined results", async () => {
|
||||
const rag = {
|
||||
size: 5,
|
||||
search: mock.fn(async () => [{ text: "doc1" }, { text: "doc2" }]),
|
||||
};
|
||||
const result = await executeToolCall("rag_search", { query: "find" }, rag);
|
||||
assert.match(result, /doc1/);
|
||||
assert.match(result, /doc2/);
|
||||
assert.match(result, /---/);
|
||||
});
|
||||
|
||||
it("rag_search without RAG returns message", async () => {
|
||||
const result = await executeToolCall("rag_search", { query: "find" }, undefined);
|
||||
assert.match(result, /Pas de documents/);
|
||||
});
|
||||
|
||||
it("rag_search with empty RAG returns message", async () => {
|
||||
const rag = { size: 0, search: mock.fn(async () => []) };
|
||||
const result = await executeToolCall("rag_search", { query: "find" }, rag);
|
||||
assert.match(result, /Pas de documents/);
|
||||
});
|
||||
|
||||
it("unknown tool returns error message", async () => {
|
||||
const result = await executeToolCall("unknown_tool", {}, undefined);
|
||||
assert.match(result, /Outil inconnu.*unknown_tool/);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// streamOllamaChatWithTools tests
|
||||
// =========================================================================
|
||||
|
||||
describe("streamOllamaChatWithTools", () => {
|
||||
const sampleTool = {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "web_search",
|
||||
description: "Search the web",
|
||||
parameters: {
|
||||
type: "object" as const,
|
||||
properties: { query: { type: "string", description: "query" } },
|
||||
required: ["query"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("returns direct response when no tool calls", async () => {
|
||||
fetchMock.mock.mockImplementation(async () =>
|
||||
mockJsonResponse({ message: { role: "assistant", content: "Direct answer" } }),
|
||||
);
|
||||
|
||||
const chunks: string[] = [];
|
||||
let doneText = "";
|
||||
|
||||
await streamOllamaChatWithTools(
|
||||
"http://localhost:11434",
|
||||
makePersona(),
|
||||
"Hi",
|
||||
[sampleTool],
|
||||
undefined,
|
||||
(t) => chunks.push(t),
|
||||
(t) => { doneText = t; },
|
||||
() => {},
|
||||
);
|
||||
|
||||
assert.deepEqual(chunks, ["Direct answer"]);
|
||||
assert.equal(doneText, "Direct answer");
|
||||
});
|
||||
|
||||
it("strips thinking from direct response", async () => {
|
||||
fetchMock.mock.mockImplementation(async () =>
|
||||
mockJsonResponse({
|
||||
message: { role: "assistant", content: "<think>hmm</think>Real answer" },
|
||||
}),
|
||||
);
|
||||
|
||||
let doneText = "";
|
||||
await streamOllamaChatWithTools(
|
||||
"http://localhost:11434",
|
||||
makePersona(),
|
||||
"Hi",
|
||||
[sampleTool],
|
||||
undefined,
|
||||
() => {},
|
||||
(t) => { doneText = t; },
|
||||
() => {},
|
||||
);
|
||||
|
||||
assert.equal(doneText, "Real answer");
|
||||
});
|
||||
|
||||
it("executes tool calls then streams final response", async () => {
|
||||
let callCount = 0;
|
||||
fetchMock.mock.mockImplementation(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
// Probe call: respond with tool_calls
|
||||
return mockJsonResponse({
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{ function: { name: "web_search", arguments: { query: "test" } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
// Streaming call: final response
|
||||
return mockStreamResponse(["Final ", "answer"]);
|
||||
});
|
||||
|
||||
mockSearchWeb.mock.mockImplementation(async () => "web result");
|
||||
|
||||
const chunks: string[] = [];
|
||||
let doneText = "";
|
||||
|
||||
await streamOllamaChatWithTools(
|
||||
"http://localhost:11434",
|
||||
makePersona(),
|
||||
"Hi",
|
||||
[sampleTool],
|
||||
undefined,
|
||||
(t) => chunks.push(t),
|
||||
(t) => { doneText = t; },
|
||||
() => {},
|
||||
);
|
||||
|
||||
assert.equal(mockSearchWeb.mock.callCount(), 1);
|
||||
assert.deepEqual(chunks, ["Final ", "answer"]);
|
||||
assert.equal(doneText, "Final answer");
|
||||
// Two fetch calls: probe + stream
|
||||
assert.equal(fetchMock.mock.callCount(), 2);
|
||||
});
|
||||
|
||||
it("calls onError on fetch failure", async () => {
|
||||
fetchMock.mock.mockImplementation(async () => {
|
||||
throw new Error("connection refused");
|
||||
});
|
||||
|
||||
let caughtError: Error | null = null;
|
||||
await streamOllamaChatWithTools(
|
||||
"http://localhost:11434",
|
||||
makePersona(),
|
||||
"Hi",
|
||||
[sampleTool],
|
||||
undefined,
|
||||
() => {},
|
||||
() => {},
|
||||
(err) => { caughtError = err; },
|
||||
);
|
||||
|
||||
assert.notEqual(caughtError, null);
|
||||
assert.match(caughtError!.message, /connection refused/);
|
||||
});
|
||||
|
||||
it("calls onError when probe response is not ok", async () => {
|
||||
fetchMock.mock.mockImplementation(async () =>
|
||||
mockJsonResponse({}, 503),
|
||||
);
|
||||
|
||||
let caughtError: Error | null = null;
|
||||
await streamOllamaChatWithTools(
|
||||
"http://localhost:11434",
|
||||
makePersona(),
|
||||
"Hi",
|
||||
[sampleTool],
|
||||
undefined,
|
||||
() => {},
|
||||
() => {},
|
||||
(err) => { caughtError = err; },
|
||||
);
|
||||
|
||||
assert.notEqual(caughtError, null);
|
||||
assert.match(caughtError!.message, /503/);
|
||||
});
|
||||
|
||||
it("handles tool execution errors gracefully", async () => {
|
||||
let callCount = 0;
|
||||
fetchMock.mock.mockImplementation(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return mockJsonResponse({
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{ function: { name: "web_search", arguments: { query: "fail" } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
return mockStreamResponse(["recovered"]);
|
||||
});
|
||||
|
||||
mockSearchWeb.mock.mockImplementation(async () => {
|
||||
throw new Error("search failed");
|
||||
});
|
||||
|
||||
let doneText = "";
|
||||
await streamOllamaChatWithTools(
|
||||
"http://localhost:11434",
|
||||
makePersona(),
|
||||
"Hi",
|
||||
[sampleTool],
|
||||
undefined,
|
||||
() => {},
|
||||
(t) => { doneText = t; },
|
||||
() => {},
|
||||
);
|
||||
|
||||
// Should still complete - tool error is caught and passed as context
|
||||
assert.equal(doneText, "recovered");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import { afterEach, beforeEach, describe, it, mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { handleUpload } from "./ws-upload-handler.js";
|
||||
import type { WebSocket } from "ws";
|
||||
import type { InboundUpload, ClientInfo, OutboundMessage } from "./chat-types.js";
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function makeClientInfo(overrides?: Partial<ClientInfo>): ClientInfo {
|
||||
return {
|
||||
nick: "testuser",
|
||||
channel: "#test",
|
||||
connectedAt: Date.now(),
|
||||
messageTimestamps: [],
|
||||
uploadBytesWindow: 0,
|
||||
lastUploadReset: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function b64(str: string): string {
|
||||
return Buffer.from(str).toString("base64");
|
||||
}
|
||||
|
||||
interface MockDeps {
|
||||
ws: WebSocket;
|
||||
broadcast: ReturnType<typeof mock.fn>;
|
||||
logChatMessage: ReturnType<typeof mock.fn>;
|
||||
routeToPersonas: ReturnType<typeof mock.fn>;
|
||||
acquireFileProcessor: ReturnType<typeof mock.fn>;
|
||||
releaseFileProcessor: ReturnType<typeof mock.fn>;
|
||||
isOfficeDocument: ReturnType<typeof mock.fn>;
|
||||
analyzeImage: ReturnType<typeof mock.fn>;
|
||||
send: ReturnType<typeof mock.fn>;
|
||||
sentMessages: OutboundMessage[];
|
||||
}
|
||||
|
||||
function makeMocks(): MockDeps {
|
||||
const sentMessages: OutboundMessage[] = [];
|
||||
const send = mock.fn((_ws: WebSocket, msg: OutboundMessage) => {
|
||||
sentMessages.push(msg);
|
||||
});
|
||||
|
||||
return {
|
||||
ws: {} as WebSocket,
|
||||
broadcast: mock.fn(),
|
||||
logChatMessage: mock.fn(),
|
||||
routeToPersonas: mock.fn(() => Promise.resolve()),
|
||||
acquireFileProcessor: mock.fn(() => Promise.resolve()),
|
||||
releaseFileProcessor: mock.fn(),
|
||||
isOfficeDocument: mock.fn(() => false),
|
||||
analyzeImage: mock.fn(() => Promise.resolve("[image analysis]")),
|
||||
send,
|
||||
sentMessages,
|
||||
};
|
||||
}
|
||||
|
||||
function callUpload(
|
||||
parsed: InboundUpload,
|
||||
info: ClientInfo,
|
||||
deps: MockDeps,
|
||||
) {
|
||||
return handleUpload(
|
||||
deps.ws,
|
||||
info,
|
||||
parsed,
|
||||
"http://localhost:11434",
|
||||
deps.broadcast as any,
|
||||
deps.logChatMessage as any,
|
||||
deps.routeToPersonas as any,
|
||||
deps.acquireFileProcessor as any,
|
||||
deps.releaseFileProcessor as any,
|
||||
deps.isOfficeDocument as any,
|
||||
deps.analyzeImage as any,
|
||||
deps.send as any,
|
||||
);
|
||||
}
|
||||
|
||||
// ── tests ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ws-upload-handler", () => {
|
||||
it("rejects upload > 12 MB", async () => {
|
||||
const deps = makeMocks();
|
||||
const info = makeClientInfo();
|
||||
const parsed: InboundUpload = {
|
||||
type: "upload",
|
||||
filename: "big.bin",
|
||||
mimeType: "application/octet-stream",
|
||||
data: b64("x"),
|
||||
size: 13 * 1024 * 1024, // 13 MB
|
||||
};
|
||||
|
||||
await callUpload(parsed, info, deps);
|
||||
|
||||
assert.equal(deps.sentMessages.length, 1);
|
||||
assert.ok(deps.sentMessages[0].type === "system");
|
||||
assert.ok((deps.sentMessages[0] as any).text.includes("Upload rejeté"));
|
||||
assert.equal(deps.broadcast.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
it("rejects upload with empty data", async () => {
|
||||
const deps = makeMocks();
|
||||
const info = makeClientInfo();
|
||||
const parsed: InboundUpload = {
|
||||
type: "upload",
|
||||
filename: "empty.txt",
|
||||
mimeType: "text/plain",
|
||||
data: "",
|
||||
size: 100,
|
||||
};
|
||||
|
||||
await callUpload(parsed, info, deps);
|
||||
|
||||
assert.equal(deps.sentMessages.length, 1);
|
||||
assert.ok((deps.sentMessages[0] as any).text.includes("Upload rejeté"));
|
||||
assert.equal(deps.broadcast.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
it("rate limits: rejects if > 50 MB/min", async () => {
|
||||
const deps = makeMocks();
|
||||
const info = makeClientInfo({
|
||||
uploadBytesWindow: 49 * 1024 * 1024, // already uploaded 49 MB
|
||||
lastUploadReset: Date.now(), // within current window
|
||||
});
|
||||
const parsed: InboundUpload = {
|
||||
type: "upload",
|
||||
filename: "more.bin",
|
||||
mimeType: "application/octet-stream",
|
||||
data: b64("x"),
|
||||
size: 2 * 1024 * 1024, // 2 MB → total 51 MB > limit
|
||||
};
|
||||
|
||||
await callUpload(parsed, info, deps);
|
||||
|
||||
assert.equal(deps.sentMessages.length, 1);
|
||||
assert.ok((deps.sentMessages[0] as any).text.includes("limite de débit"));
|
||||
assert.equal(deps.broadcast.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
it("processes text/plain file", async () => {
|
||||
const deps = makeMocks();
|
||||
const info = makeClientInfo();
|
||||
const content = "Hello world content";
|
||||
const parsed: InboundUpload = {
|
||||
type: "upload",
|
||||
filename: "note.txt",
|
||||
mimeType: "text/plain",
|
||||
data: b64(content),
|
||||
size: Buffer.byteLength(content),
|
||||
};
|
||||
|
||||
await callUpload(parsed, info, deps);
|
||||
|
||||
// Should broadcast notification
|
||||
assert.equal(deps.broadcast.mock.callCount(), 1);
|
||||
// Should log
|
||||
assert.equal(deps.logChatMessage.mock.callCount(), 1);
|
||||
// Should route to personas with analysis containing the text
|
||||
assert.equal(deps.routeToPersonas.mock.callCount(), 1);
|
||||
const routeArgs = deps.routeToPersonas.mock.calls[0].arguments as any[];
|
||||
assert.equal(routeArgs[0], "#test");
|
||||
assert.ok(routeArgs[1].includes("Fichier texte"));
|
||||
assert.ok(routeArgs[1].includes(content));
|
||||
});
|
||||
|
||||
it("processes application/json file", async () => {
|
||||
const deps = makeMocks();
|
||||
const info = makeClientInfo();
|
||||
const content = '{"key": "value"}';
|
||||
const parsed: InboundUpload = {
|
||||
type: "upload",
|
||||
filename: "data.json",
|
||||
mimeType: "application/json",
|
||||
data: b64(content),
|
||||
size: Buffer.byteLength(content),
|
||||
};
|
||||
|
||||
await callUpload(parsed, info, deps);
|
||||
|
||||
assert.equal(deps.routeToPersonas.mock.callCount(), 1);
|
||||
const routeArgs = deps.routeToPersonas.mock.calls[0].arguments as any[];
|
||||
assert.ok(routeArgs[1].includes("Fichier texte"));
|
||||
assert.ok(routeArgs[1].includes(content));
|
||||
});
|
||||
|
||||
it("broadcasts upload notification", async () => {
|
||||
const deps = makeMocks();
|
||||
const info = makeClientInfo({ nick: "alice", channel: "#chat" });
|
||||
const parsed: InboundUpload = {
|
||||
type: "upload",
|
||||
filename: "pic.txt",
|
||||
mimeType: "text/plain",
|
||||
data: b64("hi"),
|
||||
size: 2,
|
||||
};
|
||||
|
||||
await callUpload(parsed, info, deps);
|
||||
|
||||
assert.equal(deps.broadcast.mock.callCount(), 1);
|
||||
const [channel, msg] = deps.broadcast.mock.calls[0].arguments;
|
||||
assert.equal(channel, "#chat");
|
||||
assert.ok((msg as any).text.includes("alice"));
|
||||
assert.ok((msg as any).text.includes("pic.txt"));
|
||||
});
|
||||
|
||||
it("logs the upload event", async () => {
|
||||
const deps = makeMocks();
|
||||
const info = makeClientInfo({ nick: "bob" });
|
||||
const parsed: InboundUpload = {
|
||||
type: "upload",
|
||||
filename: "doc.txt",
|
||||
mimeType: "text/plain",
|
||||
data: b64("data"),
|
||||
size: 4,
|
||||
};
|
||||
|
||||
await callUpload(parsed, info, deps);
|
||||
|
||||
assert.equal(deps.logChatMessage.mock.callCount(), 1);
|
||||
const entry = deps.logChatMessage.mock.calls[0].arguments[0] as any;
|
||||
assert.equal(entry.nick, "bob");
|
||||
assert.ok(entry.text.includes("doc.txt"));
|
||||
});
|
||||
|
||||
it("calls routeToPersonas with analysis", async () => {
|
||||
const deps = makeMocks();
|
||||
const info = makeClientInfo({ nick: "carol", channel: "#room" });
|
||||
const parsed: InboundUpload = {
|
||||
type: "upload",
|
||||
filename: "readme.txt",
|
||||
mimeType: "text/plain",
|
||||
data: b64("important text"),
|
||||
size: 14,
|
||||
};
|
||||
|
||||
await callUpload(parsed, info, deps);
|
||||
|
||||
assert.equal(deps.routeToPersonas.mock.callCount(), 1);
|
||||
const [ch, text] = deps.routeToPersonas.mock.calls[0].arguments as any[];
|
||||
assert.equal(ch, "#room");
|
||||
assert.ok(text.includes("carol"));
|
||||
assert.ok(text.includes("readme.txt"));
|
||||
assert.ok(text.includes("Analyse ce fichier"));
|
||||
});
|
||||
|
||||
it("handles unknown file type (returns metadata)", async () => {
|
||||
const deps = makeMocks();
|
||||
const info = makeClientInfo();
|
||||
const parsed: InboundUpload = {
|
||||
type: "upload",
|
||||
filename: "mystery.xyz",
|
||||
mimeType: "application/x-custom",
|
||||
data: b64("binary stuff"),
|
||||
size: 1234,
|
||||
};
|
||||
|
||||
await callUpload(parsed, info, deps);
|
||||
|
||||
assert.equal(deps.routeToPersonas.mock.callCount(), 1);
|
||||
const routeText = (deps.routeToPersonas.mock.calls[0].arguments as any[])[1];
|
||||
assert.ok(routeText.includes("mystery.xyz"));
|
||||
assert.ok(routeText.includes("application/x-custom"));
|
||||
});
|
||||
});
|
||||
@@ -1,519 +1,27 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { getPersonaColor } from "@kxkm/ui";
|
||||
import { useWebSocket } from "../hooks/useWebSocket";
|
||||
import { useMinitelSounds } from "../hooks/useMinitelSounds";
|
||||
import { resolveWebSocketUrl } from "../lib/websocket-url";
|
||||
|
||||
function buildWsUrl(): string {
|
||||
const base = resolveWebSocketUrl();
|
||||
const nick = typeof sessionStorage !== "undefined" ? sessionStorage.getItem("kxkm-nick") : null;
|
||||
if (!nick) return base;
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
return `${base}${sep}nick=${encodeURIComponent(nick)}`;
|
||||
}
|
||||
|
||||
interface ChatMsg {
|
||||
id: number;
|
||||
type: "system" | "message" | "join" | "part" | "persona" | "channelInfo" | "userlist" | "command" | "uploadCapability" | "audio" | "image" | "music";
|
||||
nick?: string;
|
||||
text?: string;
|
||||
color?: string;
|
||||
channel?: string;
|
||||
users?: string[];
|
||||
audioData?: string;
|
||||
audioMime?: string;
|
||||
imageData?: string;
|
||||
imageMime?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface PersonaColor {
|
||||
[nick: string]: string;
|
||||
}
|
||||
|
||||
const MAX_MESSAGES = 500;
|
||||
const MAX_HISTORY = 100;
|
||||
let msgIdCounter = 0;
|
||||
|
||||
interface ChatMessageProps {
|
||||
msg: ChatMsg;
|
||||
getNickColor: (nick: string) => string | undefined;
|
||||
channel: string;
|
||||
}
|
||||
|
||||
const ChatMessage = React.memo(function ChatMessage({ msg, getNickColor, channel }: ChatMessageProps) {
|
||||
switch (msg.type) {
|
||||
case "system":
|
||||
return (
|
||||
<div className="chat-msg chat-msg-system">
|
||||
{(msg.text || "").split("\n").map((line, i) => (
|
||||
<div key={i}>{line || "\u00A0"}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "join":
|
||||
return (
|
||||
<div className="chat-msg chat-msg-system">
|
||||
{"--> "}{msg.nick} a rejoint {msg.channel || channel}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "part":
|
||||
return (
|
||||
<div className="chat-msg chat-msg-system">
|
||||
{"<-- "}{msg.nick} a quitte {msg.channel || channel}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "audio": {
|
||||
const color = msg.nick ? getNickColor(msg.nick) : undefined;
|
||||
return (
|
||||
<div key={msg.id} className="chat-msg chat-msg-audio" style={color ? { color } : undefined}>
|
||||
<span className="chat-nick" style={color ? { color } : undefined}>
|
||||
{"<"}{msg.nick || "???"}{">"}{" "}
|
||||
</span>
|
||||
<span className="chat-audio-indicator">♫</span>
|
||||
<button className="chat-audio-play" onClick={() => {
|
||||
if (msg.audioData && msg.audioMime) {
|
||||
const a = new Audio(`data:${msg.audioMime};base64,${msg.audioData}`);
|
||||
a.volume = 0.7;
|
||||
a.play().catch(() => {});
|
||||
}
|
||||
}}>▶</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "image": {
|
||||
const color = msg.nick ? getNickColor(msg.nick) : undefined;
|
||||
return (
|
||||
<div className="chat-msg chat-msg-image" style={color ? { color } : undefined}>
|
||||
<span className="chat-nick" style={color ? { color } : undefined}>
|
||||
{"<"}{msg.nick || "???"}{">"}{" "}
|
||||
</span>
|
||||
<span className="chat-text">{msg.text}</span>
|
||||
{msg.imageData && msg.imageMime && (
|
||||
<img
|
||||
src={`data:${msg.imageMime};base64,${msg.imageData}`}
|
||||
alt={msg.text || "Image generee"}
|
||||
className="chat-generated-image"
|
||||
style={{ maxWidth: "512px", maxHeight: "512px", display: "block", marginTop: "4px", borderRadius: "4px" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "music": {
|
||||
const color = msg.nick ? getNickColor(msg.nick) : undefined;
|
||||
return (
|
||||
<div key={msg.id} className="chat-msg chat-msg-music" style={color ? { color } : undefined}>
|
||||
<span className="chat-nick" style={color ? { color } : undefined}>
|
||||
{"<"}{msg.nick || "???"}{">"}{" "}
|
||||
</span>
|
||||
<span className="chat-text">{msg.text}</span>
|
||||
{msg.audioData && msg.audioMime && (
|
||||
<audio
|
||||
controls
|
||||
src={`data:${msg.audioMime};base64,${msg.audioData}`}
|
||||
style={{ display: "block", marginTop: "4px", maxWidth: "400px" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "message":
|
||||
default: {
|
||||
const color = msg.nick ? getNickColor(msg.nick) : undefined;
|
||||
const className = color ? "chat-msg chat-msg-persona" : "chat-msg chat-msg-user";
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={color ? { color } : undefined}
|
||||
>
|
||||
<span className="chat-nick" style={color ? { color } : undefined}>
|
||||
{"<"}{msg.nick || "???"}{">"}{" "}
|
||||
</span>
|
||||
<span className="chat-text">{msg.text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
import React from "react";
|
||||
import { useChatState } from "../hooks/useChatState";
|
||||
import { ChatMessage } from "./ChatMessage";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
import { ChatSidebar } from "./ChatSidebar";
|
||||
|
||||
export default function Chat() {
|
||||
const [messages, setMessages] = useState<ChatMsg[]>([]);
|
||||
const [users, setUsers] = useState<string[]>([]);
|
||||
const [channel, setChannel] = useState("#general");
|
||||
const [input, setInput] = useState("");
|
||||
const [personaColors, setPersonaColors] = useState<PersonaColor>({});
|
||||
// showConnect removed — connection animation handled by App.tsx
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState({ personas: true, users: true });
|
||||
const [typingPersona, setTypingPersona] = useState<string | null>(null);
|
||||
const typingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
const historyRef = useRef<string[]>([]);
|
||||
const historyIndexRef = useRef(-1);
|
||||
const savedInputRef = useRef("");
|
||||
const keyPressCountRef = useRef(0);
|
||||
const ullaTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
|
||||
const sounds = useMinitelSounds();
|
||||
|
||||
// Clean up /ulla timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
ullaTimersRef.current.forEach((id) => clearTimeout(id));
|
||||
ullaTimersRef.current = [];
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMessage = useCallback((data: unknown) => {
|
||||
const msg = data as Record<string, unknown>;
|
||||
if (!msg || typeof msg !== "object" || typeof msg.type !== "string") return;
|
||||
|
||||
const type = msg.type as ChatMsg["type"];
|
||||
|
||||
switch (type) {
|
||||
case "persona":
|
||||
if (typeof msg.nick === "string") {
|
||||
const color = typeof msg.color === "string" && /^#[0-9a-fA-F]{3,8}$|^[a-z]{3,20}$/i.test(msg.color)
|
||||
? msg.color
|
||||
: getPersonaColor(msg.nick);
|
||||
setPersonaColors((prev) => ({ ...prev, [msg.nick as string]: color }));
|
||||
}
|
||||
return;
|
||||
|
||||
case "userlist":
|
||||
if (Array.isArray(msg.users)) {
|
||||
setUsers(msg.users as string[]);
|
||||
}
|
||||
return;
|
||||
|
||||
case "channelInfo":
|
||||
if (typeof msg.channel === "string") {
|
||||
setChannel(msg.channel as string);
|
||||
}
|
||||
return;
|
||||
|
||||
case "uploadCapability":
|
||||
// Silently ignore
|
||||
return;
|
||||
|
||||
case "image": {
|
||||
const chatMsg: ChatMsg = {
|
||||
id: ++msgIdCounter,
|
||||
type: "image",
|
||||
nick: typeof msg.nick === "string" ? msg.nick : undefined,
|
||||
text: typeof msg.text === "string" ? msg.text : undefined,
|
||||
imageData: typeof msg.imageData === "string" ? msg.imageData : undefined,
|
||||
imageMime: typeof msg.imageMime === "string" ? msg.imageMime : undefined,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setMessages((prev) => {
|
||||
const next = [...prev, chatMsg];
|
||||
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
case "music": {
|
||||
const chatMsg: ChatMsg = {
|
||||
id: ++msgIdCounter,
|
||||
type: "music",
|
||||
nick: typeof msg.nick === "string" ? msg.nick : undefined,
|
||||
text: typeof msg.text === "string" ? msg.text : undefined,
|
||||
audioData: typeof msg.audioData === "string" ? msg.audioData : undefined,
|
||||
audioMime: typeof msg.audioMime === "string" ? msg.audioMime : undefined,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setMessages((prev) => {
|
||||
const next = [...prev, chatMsg];
|
||||
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
case "audio": {
|
||||
if (typeof msg.data === "string" && typeof msg.mimeType === "string") {
|
||||
// Add to messages as a playable audio message
|
||||
const chatMsg: ChatMsg = {
|
||||
id: ++msgIdCounter,
|
||||
type: "audio",
|
||||
nick: typeof msg.nick === "string" ? msg.nick : undefined,
|
||||
text: "\u266A message vocal",
|
||||
audioData: msg.data as string,
|
||||
audioMime: msg.mimeType as string,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setMessages((prev) => {
|
||||
const next = [...prev, chatMsg];
|
||||
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
default: {
|
||||
// Intercept typing indicators — show in dedicated bar, don't add to messages
|
||||
if (type === "system" && typeof msg.text === "string") {
|
||||
const typingMatch = msg.text.match(/^(.+) est en train d'ecrire/);
|
||||
if (typingMatch) {
|
||||
setTypingPersona(typingMatch[1]);
|
||||
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
|
||||
typingTimerRef.current = setTimeout(() => setTypingPersona(null), 8000);
|
||||
return; // Don't add to messages
|
||||
}
|
||||
}
|
||||
|
||||
const chatMsg: ChatMsg = {
|
||||
id: ++msgIdCounter,
|
||||
type,
|
||||
nick: typeof msg.nick === "string" ? msg.nick : undefined,
|
||||
text: typeof msg.text === "string" ? msg.text : undefined,
|
||||
color: typeof msg.color === "string" ? msg.color : undefined,
|
||||
channel: typeof msg.channel === "string" ? msg.channel : undefined,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setMessages((prev) => {
|
||||
const next = [...prev, chatMsg];
|
||||
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
|
||||
});
|
||||
|
||||
// Clear typing indicator when persona actually responds
|
||||
if (type === "message" && chatMsg.nick) {
|
||||
setTypingPersona(null);
|
||||
}
|
||||
|
||||
// Minitel receive beep for persona messages
|
||||
if (type === "message" && chatMsg.nick && personaColors[chatMsg.nick]) {
|
||||
sounds.receive();
|
||||
}
|
||||
|
||||
// Update user list on join/part
|
||||
if (type === "join" && chatMsg.nick) {
|
||||
setUsers((prev) =>
|
||||
prev.includes(chatMsg.nick!) ? prev : [...prev, chatMsg.nick!]
|
||||
);
|
||||
} else if (type === "part" && chatMsg.nick) {
|
||||
setUsers((prev) => prev.filter((u) => u !== chatMsg.nick));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [sounds, personaColors]);
|
||||
|
||||
const [wsUrl] = useState(buildWsUrl);
|
||||
|
||||
const ws = useWebSocket({
|
||||
url: wsUrl,
|
||||
onMessage: handleMessage,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
// Track whether user has scrolled up
|
||||
useEffect(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
function onScroll() {
|
||||
if (!container) return;
|
||||
const atBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight < 40;
|
||||
autoScrollRef.current = atBottom;
|
||||
}
|
||||
|
||||
container.addEventListener("scroll", onScroll);
|
||||
return () => container.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
// Auto-scroll when new messages arrive
|
||||
useEffect(() => {
|
||||
if (autoScrollRef.current) {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
// Ref to always have current handleSend in the global keydown handler
|
||||
const handleSendRef = useRef<() => void>(() => {});
|
||||
|
||||
// Global F-key handler (Minitel bar: F1=Sommaire F2=Suite F3=Retour F4=Annul F5=Envoi)
|
||||
useEffect(() => {
|
||||
function handleGlobalKeyDown(e: KeyboardEvent) {
|
||||
switch (e.key) {
|
||||
case "F1":
|
||||
e.preventDefault();
|
||||
window.location.hash = "#/";
|
||||
break;
|
||||
case "F2":
|
||||
e.preventDefault();
|
||||
messagesContainerRef.current?.scrollBy({ top: 300, behavior: "smooth" });
|
||||
break;
|
||||
case "F3":
|
||||
e.preventDefault();
|
||||
history.back();
|
||||
break;
|
||||
case "F4":
|
||||
e.preventDefault();
|
||||
setInput("");
|
||||
break;
|
||||
case "F5":
|
||||
e.preventDefault();
|
||||
handleSendRef.current();
|
||||
break;
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleGlobalKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleGlobalKeyDown);
|
||||
}, []);
|
||||
|
||||
function handleSend() {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed || !ws.connected) return;
|
||||
|
||||
// Push to history
|
||||
historyRef.current.unshift(trimmed);
|
||||
if (historyRef.current.length > MAX_HISTORY) historyRef.current.pop();
|
||||
historyIndexRef.current = -1;
|
||||
savedInputRef.current = "";
|
||||
|
||||
// /ulla easter egg
|
||||
if (trimmed.toLowerCase() === "/ulla") {
|
||||
const ullaMessages = [
|
||||
"\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557",
|
||||
"\u2551 3615 ULLA \u2014 MESSAGERIE \u2551",
|
||||
"\u2551 \u2551",
|
||||
"\u2551 Salut beau gosse... \uD83D\uDE18 \u2551",
|
||||
"\u2551 Tu cherches quoi ce soir ? \u2551",
|
||||
"\u2551 Tape 1 pour RENCONTRE \u2551",
|
||||
"\u2551 Tape 2 pour DIALOGUE \u2551",
|
||||
"\u2551 Tape 3 pour MYSTERE \u2551",
|
||||
"\u2551 \u2551",
|
||||
"\u2551 0,34\u20AC/min \u2014 ah non, c'est \u2551",
|
||||
"\u2551 gratuit ici, c'est du LOCAL \uD83C\uDFF4\u200D\u2620\uFE0F \u2551",
|
||||
"\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D",
|
||||
];
|
||||
ullaTimersRef.current.forEach((id) => clearTimeout(id));
|
||||
ullaTimersRef.current = [];
|
||||
ullaMessages.forEach((line, i) => {
|
||||
const timerId = setTimeout(() => {
|
||||
setMessages(prev => [...prev, {
|
||||
id: ++msgIdCounter,
|
||||
type: "system",
|
||||
text: line,
|
||||
timestamp: Date.now(),
|
||||
}]);
|
||||
}, i * 200);
|
||||
ullaTimersRef.current.push(timerId);
|
||||
});
|
||||
setInput("");
|
||||
return;
|
||||
}
|
||||
|
||||
sounds.send();
|
||||
|
||||
if (trimmed.startsWith("/")) {
|
||||
ws.send({ type: "command", text: trimmed });
|
||||
} else {
|
||||
ws.send({ type: "message", text: trimmed });
|
||||
}
|
||||
setInput("");
|
||||
}
|
||||
|
||||
// Keep handleSendRef in sync
|
||||
useEffect(() => { handleSendRef.current = handleSend; });
|
||||
|
||||
const [tabIndex, setTabIndex] = useState(-1);
|
||||
const [tabPrefix, setTabPrefix] = useState("");
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
// Debounced Minitel keyPress sound (every 3rd key)
|
||||
if (e.key.length === 1) {
|
||||
keyPressCountRef.current++;
|
||||
if (keyPressCountRef.current % 3 === 0) {
|
||||
sounds.keyPress();
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
return;
|
||||
}
|
||||
|
||||
// Tab completion for nicks and slash commands
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const text = input;
|
||||
|
||||
// Slash command completion
|
||||
if (text.startsWith("/") && !text.includes(" ")) {
|
||||
const slashCommands = ["/help", "/clear", "/nick", "/join", "/channels", "/msg", "/web", "/imagine", "/compose", "/status", "/model", "/persona", "/reload", "/export"];
|
||||
const prefix = tabPrefix || text;
|
||||
const matches = slashCommands.filter((c) => c.startsWith(prefix.toLowerCase()));
|
||||
if (matches.length === 0) return;
|
||||
const nextIdx = (tabIndex + 1) % matches.length;
|
||||
setInput(matches[nextIdx] + " ");
|
||||
setTabIndex(nextIdx);
|
||||
if (!tabPrefix) setTabPrefix(prefix);
|
||||
return;
|
||||
}
|
||||
|
||||
// Nick completion
|
||||
const words = text.split(" ");
|
||||
const lastWord = words[words.length - 1];
|
||||
const prefix = tabPrefix || lastWord;
|
||||
const matches = users.filter((u) =>
|
||||
u.toLowerCase().startsWith(prefix.toLowerCase()),
|
||||
);
|
||||
if (matches.length === 0) return;
|
||||
const nextIdx = (tabIndex + 1) % matches.length;
|
||||
words[words.length - 1] = matches[nextIdx] + (words.length === 1 ? ": " : " ");
|
||||
setInput(words.join(" "));
|
||||
setTabIndex(nextIdx);
|
||||
if (!tabPrefix) setTabPrefix(prefix);
|
||||
return;
|
||||
}
|
||||
|
||||
// ArrowUp — navigate back through message history
|
||||
if (e.key === "ArrowUp") {
|
||||
const history = historyRef.current;
|
||||
if (history.length === 0) return;
|
||||
e.preventDefault();
|
||||
if (historyIndexRef.current < history.length - 1) {
|
||||
if (historyIndexRef.current === -1) savedInputRef.current = input;
|
||||
historyIndexRef.current++;
|
||||
setInput(history[historyIndexRef.current]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ArrowDown — navigate forward through message history
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (historyIndexRef.current > 0) {
|
||||
historyIndexRef.current--;
|
||||
setInput(historyRef.current[historyIndexRef.current]);
|
||||
} else if (historyIndexRef.current === 0) {
|
||||
historyIndexRef.current = -1;
|
||||
setInput(savedInputRef.current);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset tab state on any other key
|
||||
if (tabIndex >= 0) {
|
||||
setTabIndex(-1);
|
||||
setTabPrefix("");
|
||||
}
|
||||
}
|
||||
|
||||
const getNickColor = useCallback((nick: string): string | undefined => {
|
||||
return personaColors[nick];
|
||||
}, [personaColors]);
|
||||
const {
|
||||
messages,
|
||||
users,
|
||||
channel,
|
||||
input,
|
||||
setInput,
|
||||
personaColors,
|
||||
sidebarCollapsed,
|
||||
toggleSidebar,
|
||||
typingPersona,
|
||||
ws,
|
||||
messagesEndRef,
|
||||
messagesContainerRef,
|
||||
getNickColor,
|
||||
handleSend,
|
||||
handleKeyDown,
|
||||
} = useChatState();
|
||||
|
||||
return (
|
||||
<div className="chat-container">
|
||||
@@ -532,48 +40,12 @@ export default function Chat() {
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="chat-sidebar">
|
||||
<div className="chat-sidebar-section">
|
||||
<div className="chat-sidebar-title" onClick={() => setSidebarCollapsed(p => ({ ...p, personas: !p.personas }))}>
|
||||
{sidebarCollapsed.personas ? "+" : "-"} Personas
|
||||
</div>
|
||||
{!sidebarCollapsed.personas && (
|
||||
<div className="chat-sidebar-personas">
|
||||
{Object.entries(
|
||||
users.filter(u => personaColors[u]).reduce((acc, u) => {
|
||||
// Group by first letter as simple grouping
|
||||
const key = personaColors[u] ? "active" : "idle";
|
||||
(acc[key] = acc[key] || []).push(u);
|
||||
return acc;
|
||||
}, {} as Record<string, string[]>)
|
||||
).map(([, group]) =>
|
||||
group.map(u => (
|
||||
<div
|
||||
key={u}
|
||||
className="chat-sidebar-persona"
|
||||
style={{ color: personaColors[u] }}
|
||||
onClick={() => {
|
||||
const input = document.querySelector<HTMLInputElement>(".chat-input input");
|
||||
if (input) { input.value = `@${u} `; input.focus(); }
|
||||
}}
|
||||
title={`@${u}`}
|
||||
>
|
||||
● {u}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="chat-sidebar-section">
|
||||
<div className="chat-sidebar-title" onClick={() => setSidebarCollapsed(p => ({ ...p, users: !p.users }))}>
|
||||
{sidebarCollapsed.users ? "+" : "-"} Connectes ({users.filter(u => !personaColors[u]).length})
|
||||
</div>
|
||||
{!sidebarCollapsed.users && users.filter(u => !personaColors[u]).map((u) => (
|
||||
<div key={u} className="chat-user">{u}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ChatSidebar
|
||||
personaColors={personaColors}
|
||||
users={users}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
toggleSidebar={toggleSidebar}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{typingPersona && (
|
||||
@@ -582,50 +54,14 @@ export default function Chat() {
|
||||
<span className="chat-typing-dots">...</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-input">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
|
||||
<ChatInput
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
onSend={handleSend}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={ws.connected ? "Message ou /commande... (Tab pour compléter)" : "Connexion en cours..."}
|
||||
disabled={!ws.connected}
|
||||
autoFocus
|
||||
ws={ws}
|
||||
/>
|
||||
<label className="btn btn-secondary chat-upload-btn" title="Joindre un fichier">
|
||||
+
|
||||
<input
|
||||
type="file"
|
||||
style={{ display: "none" }}
|
||||
accept="image/*,audio/*,text/*,.pdf,.json,.jsonl,.csv,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.odt,.ods,.odp,.rtf,.epub,.html,.xml,.yaml,.yml,.toml,.ini,.log,.sh,.py,.js,.ts,.c,.cpp,.rs,.go,.java,.sql"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !ws.connected) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const base64 = (reader.result as string).split(",")[1];
|
||||
ws.send({
|
||||
type: "upload",
|
||||
filename: file.name,
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
data: base64,
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
e.target.value = "";
|
||||
}}
|
||||
disabled={!ws.connected}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSend}
|
||||
disabled={!ws.connected || !input.trim()}
|
||||
>
|
||||
Envoyer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
import type { UseWebSocketReturn } from "../hooks/useWebSocket";
|
||||
|
||||
function mockWs(connected: boolean): UseWebSocketReturn {
|
||||
return {
|
||||
connected,
|
||||
send: vi.fn(),
|
||||
lastMessage: null,
|
||||
} as unknown as UseWebSocketReturn;
|
||||
}
|
||||
|
||||
function renderInput(overrides: Partial<{
|
||||
input: string;
|
||||
setInput: (v: string) => void;
|
||||
onSend: () => void;
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
ws: UseWebSocketReturn;
|
||||
}> = {}) {
|
||||
const props = {
|
||||
input: overrides.input ?? "",
|
||||
setInput: overrides.setInput ?? vi.fn(),
|
||||
onSend: overrides.onSend ?? vi.fn(),
|
||||
onKeyDown: overrides.onKeyDown ?? vi.fn(),
|
||||
ws: overrides.ws ?? mockWs(true),
|
||||
};
|
||||
return { ...render(<ChatInput {...props} />), props };
|
||||
}
|
||||
|
||||
describe("ChatInput", () => {
|
||||
it("affiche l'input et le bouton envoyer", () => {
|
||||
renderInput();
|
||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Envoyer" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("le bouton est disabled quand ws non connecte", () => {
|
||||
renderInput({ ws: mockWs(false) });
|
||||
expect(screen.getByRole("button", { name: "Envoyer" })).toBeDisabled();
|
||||
expect(screen.getByRole("textbox")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("le bouton est disabled quand l'input est vide", () => {
|
||||
renderInput({ input: "", ws: mockWs(true) });
|
||||
expect(screen.getByRole("button", { name: "Envoyer" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("le bouton est enabled quand input non vide et ws connecte", () => {
|
||||
renderInput({ input: "hello", ws: mockWs(true) });
|
||||
expect(screen.getByRole("button", { name: "Envoyer" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("appelle onSend quand le bouton est clique", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSend = vi.fn();
|
||||
renderInput({ input: "test", onSend, ws: mockWs(true) });
|
||||
await user.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
expect(onSend).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("appelle onKeyDown quand une touche est pressee", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onKeyDown = vi.fn();
|
||||
renderInput({ onKeyDown, ws: mockWs(true) });
|
||||
const input = screen.getByRole("textbox");
|
||||
await user.click(input);
|
||||
await user.keyboard("a");
|
||||
expect(onKeyDown).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("affiche le bouton upload avec file input", () => {
|
||||
const { container } = renderInput();
|
||||
const fileInput = container.querySelector('input[type="file"]');
|
||||
expect(fileInput).toBeTruthy();
|
||||
expect(screen.getByText("+")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
import type { UseWebSocketReturn } from "../hooks/useWebSocket";
|
||||
|
||||
export interface ChatInputProps {
|
||||
input: string;
|
||||
setInput: (value: string) => void;
|
||||
onSend: () => void;
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
ws: UseWebSocketReturn;
|
||||
}
|
||||
|
||||
export function ChatInput({ input, setInput, onSend, onKeyDown, ws }: ChatInputProps) {
|
||||
return (
|
||||
<div className="chat-input">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={ws.connected ? "Message ou /commande... (Tab pour compléter)" : "Connexion en cours..."}
|
||||
disabled={!ws.connected}
|
||||
autoFocus
|
||||
/>
|
||||
<label className="btn btn-secondary chat-upload-btn" title="Joindre un fichier">
|
||||
+
|
||||
<input
|
||||
type="file"
|
||||
style={{ display: "none" }}
|
||||
accept="image/*,audio/*,text/*,.pdf,.json,.jsonl,.csv,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.odt,.ods,.odp,.rtf,.epub,.html,.xml,.yaml,.yml,.toml,.ini,.log,.sh,.py,.js,.ts,.c,.cpp,.rs,.go,.java,.sql"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !ws.connected) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const base64 = (reader.result as string).split(",")[1];
|
||||
ws.send({
|
||||
type: "upload",
|
||||
filename: file.name,
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
data: base64,
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
e.target.value = "";
|
||||
}}
|
||||
disabled={!ws.connected}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={onSend}
|
||||
disabled={!ws.connected || !input.trim()}
|
||||
>
|
||||
Envoyer
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ChatMessage } from "./ChatMessage";
|
||||
import type { ChatMsg } from "./chat-types";
|
||||
|
||||
const baseMsg: Omit<ChatMsg, "type"> = { id: 1, timestamp: Date.now() };
|
||||
const getNickColor = vi.fn((nick: string) => {
|
||||
if (nick === "Arlequin") return "#ff00ff";
|
||||
return undefined;
|
||||
});
|
||||
const channel = "#salon";
|
||||
|
||||
describe("ChatMessage", () => {
|
||||
it("affiche un message system avec les lignes", () => {
|
||||
const msg: ChatMsg = { ...baseMsg, type: "system", text: "Ligne 1\nLigne 2\nLigne 3" };
|
||||
render(<ChatMessage msg={msg} getNickColor={getNickColor} channel={channel} />);
|
||||
expect(screen.getByText("Ligne 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Ligne 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Ligne 3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("affiche un message join avec le nick et le channel", () => {
|
||||
const msg: ChatMsg = { ...baseMsg, type: "join", nick: "Bob", channel: "#test" };
|
||||
render(<ChatMessage msg={msg} getNickColor={getNickColor} channel={channel} />);
|
||||
expect(screen.getByText(/Bob/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/a rejoint/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/#test/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("affiche un message part", () => {
|
||||
const msg: ChatMsg = { ...baseMsg, type: "part", nick: "Bob" };
|
||||
render(<ChatMessage msg={msg} getNickColor={getNickColor} channel={channel} />);
|
||||
expect(screen.getByText(/Bob/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/a quitte/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/#salon/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("affiche un message texte avec la couleur du nick (persona)", () => {
|
||||
const msg: ChatMsg = { ...baseMsg, type: "message", nick: "Arlequin", text: "Bonjour le monde" };
|
||||
render(<ChatMessage msg={msg} getNickColor={getNickColor} channel={channel} />);
|
||||
const container = screen.getByText("Bonjour le monde").closest(".chat-msg");
|
||||
expect(container).toHaveClass("chat-msg-persona");
|
||||
expect(container).toHaveStyle({ color: "#ff00ff" });
|
||||
});
|
||||
|
||||
it("affiche un message texte user (sans couleur)", () => {
|
||||
const msg: ChatMsg = { ...baseMsg, type: "message", nick: "Jean", text: "Salut" };
|
||||
render(<ChatMessage msg={msg} getNickColor={getNickColor} channel={channel} />);
|
||||
const container = screen.getByText("Salut").closest(".chat-msg");
|
||||
expect(container).toHaveClass("chat-msg-user");
|
||||
expect(container).not.toHaveStyle({ color: "#ff00ff" });
|
||||
});
|
||||
|
||||
it("affiche un message audio avec le bouton play", () => {
|
||||
const msg: ChatMsg = {
|
||||
...baseMsg,
|
||||
type: "audio",
|
||||
nick: "Arlequin",
|
||||
audioData: "AAAA",
|
||||
audioMime: "audio/wav",
|
||||
};
|
||||
render(<ChatMessage msg={msg} getNickColor={getNickColor} channel={channel} />);
|
||||
expect(screen.getByText(/Arlequin/)).toBeInTheDocument();
|
||||
const playBtn = screen.getByRole("button");
|
||||
expect(playBtn).toBeInTheDocument();
|
||||
expect(playBtn).toHaveClass("chat-audio-play");
|
||||
});
|
||||
|
||||
it("affiche un message image avec l'image", () => {
|
||||
const msg: ChatMsg = {
|
||||
...baseMsg,
|
||||
type: "image",
|
||||
nick: "Arlequin",
|
||||
text: "Un paysage",
|
||||
imageData: "AAAA",
|
||||
imageMime: "image/png",
|
||||
};
|
||||
render(<ChatMessage msg={msg} getNickColor={getNickColor} channel={channel} />);
|
||||
expect(screen.getByText("Un paysage")).toBeInTheDocument();
|
||||
const img = screen.getByRole("img");
|
||||
expect(img).toHaveAttribute("src", "data:image/png;base64,AAAA");
|
||||
expect(img).toHaveAttribute("alt", "Un paysage");
|
||||
});
|
||||
|
||||
it("affiche un message music avec le player audio", () => {
|
||||
const msg: ChatMsg = {
|
||||
...baseMsg,
|
||||
type: "music",
|
||||
nick: "Arlequin",
|
||||
text: "Ma composition",
|
||||
audioData: "BBBB",
|
||||
audioMime: "audio/mpeg",
|
||||
};
|
||||
const { container } = render(<ChatMessage msg={msg} getNickColor={getNickColor} channel={channel} />);
|
||||
expect(screen.getByText("Ma composition")).toBeInTheDocument();
|
||||
const audio = container.querySelector("audio");
|
||||
expect(audio).toBeTruthy();
|
||||
expect(audio?.getAttribute("src")).toBe("data:audio/mpeg;base64,BBBB");
|
||||
expect(audio?.hasAttribute("controls")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from "react";
|
||||
import type { ChatMsg } from "./chat-types";
|
||||
|
||||
export interface ChatMessageProps {
|
||||
msg: ChatMsg;
|
||||
getNickColor: (nick: string) => string | undefined;
|
||||
channel: string;
|
||||
}
|
||||
|
||||
export const ChatMessage = React.memo(function ChatMessage({ msg, getNickColor, channel }: ChatMessageProps) {
|
||||
switch (msg.type) {
|
||||
case "system":
|
||||
return (
|
||||
<div className="chat-msg chat-msg-system">
|
||||
{(msg.text || "").split("\n").map((line, i) => (
|
||||
<div key={i}>{line || "\u00A0"}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "join":
|
||||
return (
|
||||
<div className="chat-msg chat-msg-system">
|
||||
{"--> "}{msg.nick} a rejoint {msg.channel || channel}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "part":
|
||||
return (
|
||||
<div className="chat-msg chat-msg-system">
|
||||
{"<-- "}{msg.nick} a quitte {msg.channel || channel}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "audio": {
|
||||
const color = msg.nick ? getNickColor(msg.nick) : undefined;
|
||||
return (
|
||||
<div key={msg.id} className="chat-msg chat-msg-audio" style={color ? { color } : undefined}>
|
||||
<span className="chat-nick" style={color ? { color } : undefined}>
|
||||
{"<"}{msg.nick || "???"}{">"}{" "}
|
||||
</span>
|
||||
<span className="chat-audio-indicator">♫</span>
|
||||
<button className="chat-audio-play" onClick={() => {
|
||||
if (msg.audioData && msg.audioMime) {
|
||||
const a = new Audio(`data:${msg.audioMime};base64,${msg.audioData}`);
|
||||
a.volume = 0.7;
|
||||
a.play().catch(() => {});
|
||||
}
|
||||
}}>▶</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "image": {
|
||||
const color = msg.nick ? getNickColor(msg.nick) : undefined;
|
||||
return (
|
||||
<div className="chat-msg chat-msg-image" style={color ? { color } : undefined}>
|
||||
<span className="chat-nick" style={color ? { color } : undefined}>
|
||||
{"<"}{msg.nick || "???"}{">"}{" "}
|
||||
</span>
|
||||
<span className="chat-text">{msg.text}</span>
|
||||
{msg.imageData && msg.imageMime && (
|
||||
<img
|
||||
src={`data:${msg.imageMime};base64,${msg.imageData}`}
|
||||
alt={msg.text || "Image generee"}
|
||||
className="chat-generated-image"
|
||||
style={{ maxWidth: "512px", maxHeight: "512px", display: "block", marginTop: "4px", borderRadius: "4px" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "music": {
|
||||
const color = msg.nick ? getNickColor(msg.nick) : undefined;
|
||||
return (
|
||||
<div key={msg.id} className="chat-msg chat-msg-music" style={color ? { color } : undefined}>
|
||||
<span className="chat-nick" style={color ? { color } : undefined}>
|
||||
{"<"}{msg.nick || "???"}{">"}{" "}
|
||||
</span>
|
||||
<span className="chat-text">{msg.text}</span>
|
||||
{msg.audioData && msg.audioMime && (
|
||||
<audio
|
||||
controls
|
||||
src={`data:${msg.audioMime};base64,${msg.audioData}`}
|
||||
style={{ display: "block", marginTop: "4px", maxWidth: "400px" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "message":
|
||||
default: {
|
||||
const color = msg.nick ? getNickColor(msg.nick) : undefined;
|
||||
const className = color ? "chat-msg chat-msg-persona" : "chat-msg chat-msg-user";
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={color ? { color } : undefined}
|
||||
>
|
||||
<span className="chat-nick" style={color ? { color } : undefined}>
|
||||
{"<"}{msg.nick || "???"}{">"}{" "}
|
||||
</span>
|
||||
<span className="chat-text">{msg.text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ChatSidebar } from "./ChatSidebar";
|
||||
import type { PersonaColor } from "./chat-types";
|
||||
|
||||
const personaColors: PersonaColor = {
|
||||
Arlequin: "#ff00ff",
|
||||
Pierrot: "#00ff00",
|
||||
};
|
||||
|
||||
const users = ["Arlequin", "Pierrot", "Jean", "Marie"];
|
||||
|
||||
function renderSidebar(overrides: Partial<{
|
||||
personaColors: PersonaColor;
|
||||
users: string[];
|
||||
sidebarCollapsed: { personas: boolean; users: boolean };
|
||||
toggleSidebar: (section: "personas" | "users") => void;
|
||||
}> = {}) {
|
||||
const props = {
|
||||
personaColors: overrides.personaColors ?? personaColors,
|
||||
users: overrides.users ?? users,
|
||||
sidebarCollapsed: overrides.sidebarCollapsed ?? { personas: false, users: false },
|
||||
toggleSidebar: overrides.toggleSidebar ?? vi.fn(),
|
||||
};
|
||||
return { ...render(<ChatSidebar {...props} />), props };
|
||||
}
|
||||
|
||||
describe("ChatSidebar", () => {
|
||||
it("affiche les sections personas et connectes", () => {
|
||||
renderSidebar();
|
||||
expect(screen.getByText(/Personas/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Connectes/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggle la section personas au clic", async () => {
|
||||
const user = userEvent.setup();
|
||||
const toggleSidebar = vi.fn();
|
||||
renderSidebar({ toggleSidebar });
|
||||
await user.click(screen.getByText(/Personas/));
|
||||
expect(toggleSidebar).toHaveBeenCalledWith("personas");
|
||||
});
|
||||
|
||||
it("toggle la section users au clic", async () => {
|
||||
const user = userEvent.setup();
|
||||
const toggleSidebar = vi.fn();
|
||||
renderSidebar({ toggleSidebar });
|
||||
await user.click(screen.getByText(/Connectes/));
|
||||
expect(toggleSidebar).toHaveBeenCalledWith("users");
|
||||
});
|
||||
|
||||
it("affiche les personas avec leurs couleurs", () => {
|
||||
renderSidebar();
|
||||
const arlequin = screen.getByText(/Arlequin/);
|
||||
expect(arlequin).toBeInTheDocument();
|
||||
expect(arlequin.closest(".chat-sidebar-persona")).toHaveStyle({ color: "#ff00ff" });
|
||||
const pierrot = screen.getByText(/Pierrot/);
|
||||
expect(pierrot).toBeInTheDocument();
|
||||
expect(pierrot.closest(".chat-sidebar-persona")).toHaveStyle({ color: "#00ff00" });
|
||||
});
|
||||
|
||||
it("affiche les users sans couleur", () => {
|
||||
renderSidebar();
|
||||
expect(screen.getByText("Jean")).toBeInTheDocument();
|
||||
expect(screen.getByText("Marie")).toBeInTheDocument();
|
||||
expect(screen.getByText("Jean").closest(".chat-user")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("masque les personas quand la section est collapsed", () => {
|
||||
renderSidebar({ sidebarCollapsed: { personas: true, users: false } });
|
||||
expect(screen.queryByText(/Arlequin/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Jean")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("masque les users quand la section est collapsed", () => {
|
||||
renderSidebar({ sidebarCollapsed: { personas: false, users: true } });
|
||||
expect(screen.getByText(/Arlequin/)).toBeInTheDocument();
|
||||
expect(screen.queryByText("Jean")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from "react";
|
||||
import type { PersonaColor } from "./chat-types";
|
||||
|
||||
export interface ChatSidebarProps {
|
||||
personaColors: PersonaColor;
|
||||
users: string[];
|
||||
sidebarCollapsed: { personas: boolean; users: boolean };
|
||||
toggleSidebar: (section: "personas" | "users") => void;
|
||||
}
|
||||
|
||||
export function ChatSidebar({ personaColors, users, sidebarCollapsed, toggleSidebar }: ChatSidebarProps) {
|
||||
return (
|
||||
<div className="chat-sidebar">
|
||||
<div className="chat-sidebar-section">
|
||||
<div className="chat-sidebar-title" onClick={() => toggleSidebar("personas")}>
|
||||
{sidebarCollapsed.personas ? "+" : "-"} Personas
|
||||
</div>
|
||||
{!sidebarCollapsed.personas && (
|
||||
<div className="chat-sidebar-personas">
|
||||
{Object.entries(
|
||||
users.filter(u => personaColors[u]).reduce((acc, u) => {
|
||||
const key = personaColors[u] ? "active" : "idle";
|
||||
(acc[key] = acc[key] || []).push(u);
|
||||
return acc;
|
||||
}, {} as Record<string, string[]>)
|
||||
).map(([, group]) =>
|
||||
group.map(u => (
|
||||
<div
|
||||
key={u}
|
||||
className="chat-sidebar-persona"
|
||||
style={{ color: personaColors[u] }}
|
||||
onClick={() => {
|
||||
const input = document.querySelector<HTMLInputElement>(".chat-input input");
|
||||
if (input) { input.value = `@${u} `; input.focus(); }
|
||||
}}
|
||||
title={`@${u}`}
|
||||
>
|
||||
● {u}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="chat-sidebar-section">
|
||||
<div className="chat-sidebar-title" onClick={() => toggleSidebar("users")}>
|
||||
{sidebarCollapsed.users ? "+" : "-"} Connectes ({users.filter(u => !personaColors[u]).length})
|
||||
</div>
|
||||
{!sidebarCollapsed.users && users.filter(u => !personaColors[u]).map((u) => (
|
||||
<div key={u} className="chat-user">{u}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,23 +28,30 @@ describe("PersonaList", () => {
|
||||
});
|
||||
|
||||
it("renders persona cards after loading", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.listPersonas).mockResolvedValue(mockPersonas);
|
||||
render(<PersonaList onSelect={vi.fn()} />);
|
||||
|
||||
const gpt4Group = await screen.findByText("gpt-4");
|
||||
await user.click(gpt4Group);
|
||||
expect(await screen.findByText("Clown Rouge")).toBeInTheDocument();
|
||||
expect(screen.getByText("Clown Bleu")).toBeInTheDocument();
|
||||
const claudeGroup = await screen.findByText("claude-3");
|
||||
await user.click(claudeGroup);
|
||||
expect(await screen.findByText("Clown Bleu")).toBeInTheDocument();
|
||||
expect(screen.getByText("gpt-4")).toBeInTheDocument();
|
||||
expect(screen.getByText("claude-3")).toBeInTheDocument();
|
||||
expect(screen.getByText("Un clown joyeux")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onSelect when a persona card is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.listPersonas).mockResolvedValue(mockPersonas);
|
||||
const onSelect = vi.fn();
|
||||
render(<PersonaList onSelect={onSelect} />);
|
||||
|
||||
const group = await screen.findByText("gpt-4");
|
||||
await user.click(group);
|
||||
const card = await screen.findByText("Clown Rouge");
|
||||
await userEvent.click(card);
|
||||
await user.click(card);
|
||||
expect(onSelect).toHaveBeenCalledWith("p1");
|
||||
});
|
||||
|
||||
@@ -59,6 +66,6 @@ describe("PersonaList", () => {
|
||||
vi.mocked(api.listPersonas).mockRejectedValue(new Error("Network error"));
|
||||
render(<PersonaList onSelect={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Network error")).toBeInTheDocument();
|
||||
expect(await screen.findByText(/ERREUR: Network error/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface ChatMsg {
|
||||
id: number;
|
||||
type: "system" | "message" | "join" | "part" | "persona" | "channelInfo" | "userlist" | "command" | "uploadCapability" | "audio" | "image" | "music";
|
||||
nick?: string;
|
||||
text?: string;
|
||||
color?: string;
|
||||
channel?: string;
|
||||
users?: string[];
|
||||
audioData?: string;
|
||||
audioMime?: string;
|
||||
imageData?: string;
|
||||
imageMime?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface PersonaColor {
|
||||
[nick: string]: string;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api, type SessionData } from "../api";
|
||||
|
||||
const NICK_KEY = "kxkm-nick";
|
||||
const EMAIL_KEY = "kxkm-email";
|
||||
|
||||
function readStorage(key: string): string | null {
|
||||
try {
|
||||
return typeof sessionStorage !== "undefined" ? sessionStorage.getItem(key) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorage(key: string, value: string): void {
|
||||
try {
|
||||
if (typeof sessionStorage !== "undefined") sessionStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Ignore storage failures in private mode / tests.
|
||||
}
|
||||
}
|
||||
|
||||
function removeStorage(key: string): void {
|
||||
try {
|
||||
if (typeof sessionStorage !== "undefined") sessionStorage.removeItem(key);
|
||||
} catch {
|
||||
// Ignore storage failures in private mode / tests.
|
||||
}
|
||||
}
|
||||
|
||||
export function useAppSession() {
|
||||
const [session, setSession] = useState<SessionData | null>(null);
|
||||
const [nick, setNickState] = useState<string | null>(() => readStorage(NICK_KEY));
|
||||
const [checkingSession, setCheckingSession] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
|
||||
api.getSession()
|
||||
.then((current) => {
|
||||
if (alive) setSession(current);
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setSession(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setCheckingSession(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [nick]);
|
||||
|
||||
const setNick = useCallback((username: string, email?: string) => {
|
||||
setNickState(username);
|
||||
writeStorage(NICK_KEY, username);
|
||||
if (email) writeStorage(EMAIL_KEY, email);
|
||||
else removeStorage(EMAIL_KEY);
|
||||
}, []);
|
||||
|
||||
const clearSessionState = useCallback(() => {
|
||||
setSession(null);
|
||||
setNickState(null);
|
||||
removeStorage(NICK_KEY);
|
||||
removeStorage(EMAIL_KEY);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
session,
|
||||
setSession,
|
||||
nick,
|
||||
setNick,
|
||||
clearSessionState,
|
||||
checkingSession,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { getPersonaColor } from "@kxkm/ui";
|
||||
import { useWebSocket } from "./useWebSocket";
|
||||
import type { UseWebSocketReturn } from "./useWebSocket";
|
||||
import { useMinitelSounds } from "./useMinitelSounds";
|
||||
import { resolveWebSocketUrl } from "../lib/websocket-url";
|
||||
import type { ChatMsg, PersonaColor } from "../components/chat-types";
|
||||
|
||||
const MAX_MESSAGES = 500;
|
||||
const MAX_HISTORY = 100;
|
||||
let msgIdCounter = 0;
|
||||
|
||||
function buildWsUrl(): string {
|
||||
const base = resolveWebSocketUrl();
|
||||
const nick = typeof sessionStorage !== "undefined" ? sessionStorage.getItem("kxkm-nick") : null;
|
||||
if (!nick) return base;
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
return `${base}${sep}nick=${encodeURIComponent(nick)}`;
|
||||
}
|
||||
|
||||
export interface UseChatStateReturn {
|
||||
messages: ChatMsg[];
|
||||
users: string[];
|
||||
channel: string;
|
||||
input: string;
|
||||
setInput: (value: string) => void;
|
||||
personaColors: PersonaColor;
|
||||
sidebarCollapsed: { personas: boolean; users: boolean };
|
||||
toggleSidebar: (section: "personas" | "users") => void;
|
||||
typingPersona: string | null;
|
||||
ws: UseWebSocketReturn;
|
||||
sounds: ReturnType<typeof useMinitelSounds>;
|
||||
messagesEndRef: React.RefObject<HTMLDivElement | null>;
|
||||
messagesContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
getNickColor: (nick: string) => string | undefined;
|
||||
handleSend: () => void;
|
||||
handleKeyDown: (e: React.KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
export function useChatState(): UseChatStateReturn {
|
||||
const [messages, setMessages] = useState<ChatMsg[]>([]);
|
||||
const [users, setUsers] = useState<string[]>([]);
|
||||
const [channel, setChannel] = useState("#general");
|
||||
const [input, setInput] = useState("");
|
||||
const [personaColors, setPersonaColors] = useState<PersonaColor>({});
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState({ personas: true, users: true });
|
||||
const [typingPersona, setTypingPersona] = useState<string | null>(null);
|
||||
const typingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
const historyRef = useRef<string[]>([]);
|
||||
const historyIndexRef = useRef(-1);
|
||||
const savedInputRef = useRef("");
|
||||
const keyPressCountRef = useRef(0);
|
||||
const ullaTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
|
||||
const [tabIndex, setTabIndex] = useState(-1);
|
||||
const [tabPrefix, setTabPrefix] = useState("");
|
||||
|
||||
const sounds = useMinitelSounds();
|
||||
|
||||
// Clean up /ulla timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
ullaTimersRef.current.forEach((id) => clearTimeout(id));
|
||||
ullaTimersRef.current = [];
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMessage = useCallback((data: unknown) => {
|
||||
const msg = data as Record<string, unknown>;
|
||||
if (!msg || typeof msg !== "object" || typeof msg.type !== "string") return;
|
||||
|
||||
const type = msg.type as ChatMsg["type"];
|
||||
|
||||
switch (type) {
|
||||
case "persona":
|
||||
if (typeof msg.nick === "string") {
|
||||
const color = typeof msg.color === "string" && /^#[0-9a-fA-F]{3,8}$|^[a-z]{3,20}$/i.test(msg.color)
|
||||
? msg.color
|
||||
: getPersonaColor(msg.nick);
|
||||
setPersonaColors((prev) => ({ ...prev, [msg.nick as string]: color }));
|
||||
}
|
||||
return;
|
||||
|
||||
case "userlist":
|
||||
if (Array.isArray(msg.users)) {
|
||||
setUsers(msg.users as string[]);
|
||||
}
|
||||
return;
|
||||
|
||||
case "channelInfo":
|
||||
if (typeof msg.channel === "string") {
|
||||
setChannel(msg.channel as string);
|
||||
}
|
||||
return;
|
||||
|
||||
case "uploadCapability":
|
||||
return;
|
||||
|
||||
case "image": {
|
||||
const chatMsg: ChatMsg = {
|
||||
id: ++msgIdCounter,
|
||||
type: "image",
|
||||
nick: typeof msg.nick === "string" ? msg.nick : undefined,
|
||||
text: typeof msg.text === "string" ? msg.text : undefined,
|
||||
imageData: typeof msg.imageData === "string" ? msg.imageData : undefined,
|
||||
imageMime: typeof msg.imageMime === "string" ? msg.imageMime : undefined,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setMessages((prev) => {
|
||||
const next = [...prev, chatMsg];
|
||||
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
case "music": {
|
||||
const chatMsg: ChatMsg = {
|
||||
id: ++msgIdCounter,
|
||||
type: "music",
|
||||
nick: typeof msg.nick === "string" ? msg.nick : undefined,
|
||||
text: typeof msg.text === "string" ? msg.text : undefined,
|
||||
audioData: typeof msg.audioData === "string" ? msg.audioData : undefined,
|
||||
audioMime: typeof msg.audioMime === "string" ? msg.audioMime : undefined,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setMessages((prev) => {
|
||||
const next = [...prev, chatMsg];
|
||||
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
case "audio": {
|
||||
if (typeof msg.data === "string" && typeof msg.mimeType === "string") {
|
||||
const chatMsg: ChatMsg = {
|
||||
id: ++msgIdCounter,
|
||||
type: "audio",
|
||||
nick: typeof msg.nick === "string" ? msg.nick : undefined,
|
||||
text: "\u266A message vocal",
|
||||
audioData: msg.data as string,
|
||||
audioMime: msg.mimeType as string,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setMessages((prev) => {
|
||||
const next = [...prev, chatMsg];
|
||||
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
default: {
|
||||
// Intercept typing indicators
|
||||
if (type === "system" && typeof msg.text === "string") {
|
||||
const typingMatch = msg.text.match(/^(.+) est en train d'ecrire/);
|
||||
if (typingMatch) {
|
||||
setTypingPersona(typingMatch[1]);
|
||||
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
|
||||
typingTimerRef.current = setTimeout(() => setTypingPersona(null), 8000);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const chatMsg: ChatMsg = {
|
||||
id: ++msgIdCounter,
|
||||
type,
|
||||
nick: typeof msg.nick === "string" ? msg.nick : undefined,
|
||||
text: typeof msg.text === "string" ? msg.text : undefined,
|
||||
color: typeof msg.color === "string" ? msg.color : undefined,
|
||||
channel: typeof msg.channel === "string" ? msg.channel : undefined,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setMessages((prev) => {
|
||||
const next = [...prev, chatMsg];
|
||||
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
|
||||
});
|
||||
|
||||
if (type === "message" && chatMsg.nick) {
|
||||
setTypingPersona(null);
|
||||
}
|
||||
|
||||
if (type === "message" && chatMsg.nick && personaColors[chatMsg.nick]) {
|
||||
sounds.receive();
|
||||
}
|
||||
|
||||
if (type === "join" && chatMsg.nick) {
|
||||
setUsers((prev) =>
|
||||
prev.includes(chatMsg.nick!) ? prev : [...prev, chatMsg.nick!]
|
||||
);
|
||||
} else if (type === "part" && chatMsg.nick) {
|
||||
setUsers((prev) => prev.filter((u) => u !== chatMsg.nick));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [sounds, personaColors]);
|
||||
|
||||
const [wsUrl] = useState(buildWsUrl);
|
||||
|
||||
const ws = useWebSocket({
|
||||
url: wsUrl,
|
||||
onMessage: handleMessage,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
// Track whether user has scrolled up
|
||||
useEffect(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
function onScroll() {
|
||||
if (!container) return;
|
||||
const atBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight < 40;
|
||||
autoScrollRef.current = atBottom;
|
||||
}
|
||||
|
||||
container.addEventListener("scroll", onScroll);
|
||||
return () => container.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
// Auto-scroll when new messages arrive
|
||||
useEffect(() => {
|
||||
if (autoScrollRef.current) {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
// Ref to always have current handleSend in the global keydown handler
|
||||
const handleSendRef = useRef<() => void>(() => {});
|
||||
|
||||
// Global F-key handler
|
||||
useEffect(() => {
|
||||
function handleGlobalKeyDown(e: KeyboardEvent) {
|
||||
switch (e.key) {
|
||||
case "F1":
|
||||
e.preventDefault();
|
||||
window.location.hash = "#/";
|
||||
break;
|
||||
case "F2":
|
||||
e.preventDefault();
|
||||
messagesContainerRef.current?.scrollBy({ top: 300, behavior: "smooth" });
|
||||
break;
|
||||
case "F3":
|
||||
e.preventDefault();
|
||||
history.back();
|
||||
break;
|
||||
case "F4":
|
||||
e.preventDefault();
|
||||
setInput("");
|
||||
break;
|
||||
case "F5":
|
||||
e.preventDefault();
|
||||
handleSendRef.current();
|
||||
break;
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleGlobalKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleGlobalKeyDown);
|
||||
}, []);
|
||||
|
||||
function handleSend() {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed || !ws.connected) return;
|
||||
|
||||
// Push to history
|
||||
historyRef.current.unshift(trimmed);
|
||||
if (historyRef.current.length > MAX_HISTORY) historyRef.current.pop();
|
||||
historyIndexRef.current = -1;
|
||||
savedInputRef.current = "";
|
||||
|
||||
// /ulla easter egg
|
||||
if (trimmed.toLowerCase() === "/ulla") {
|
||||
const ullaMessages = [
|
||||
"\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557",
|
||||
"\u2551 3615 ULLA \u2014 MESSAGERIE \u2551",
|
||||
"\u2551 \u2551",
|
||||
"\u2551 Salut beau gosse... \uD83D\uDE18 \u2551",
|
||||
"\u2551 Tu cherches quoi ce soir ? \u2551",
|
||||
"\u2551 Tape 1 pour RENCONTRE \u2551",
|
||||
"\u2551 Tape 2 pour DIALOGUE \u2551",
|
||||
"\u2551 Tape 3 pour MYSTERE \u2551",
|
||||
"\u2551 \u2551",
|
||||
"\u2551 0,34\u20AC/min \u2014 ah non, c'est \u2551",
|
||||
"\u2551 gratuit ici, c'est du LOCAL \uD83C\uDFF4\u200D\u2620\uFE0F \u2551",
|
||||
"\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D",
|
||||
];
|
||||
ullaTimersRef.current.forEach((id) => clearTimeout(id));
|
||||
ullaTimersRef.current = [];
|
||||
ullaMessages.forEach((line, i) => {
|
||||
const timerId = setTimeout(() => {
|
||||
setMessages(prev => [...prev, {
|
||||
id: ++msgIdCounter,
|
||||
type: "system",
|
||||
text: line,
|
||||
timestamp: Date.now(),
|
||||
}]);
|
||||
}, i * 200);
|
||||
ullaTimersRef.current.push(timerId);
|
||||
});
|
||||
setInput("");
|
||||
return;
|
||||
}
|
||||
|
||||
sounds.send();
|
||||
|
||||
if (trimmed.startsWith("/")) {
|
||||
ws.send({ type: "command", text: trimmed });
|
||||
} else {
|
||||
ws.send({ type: "message", text: trimmed });
|
||||
}
|
||||
setInput("");
|
||||
}
|
||||
|
||||
// Keep handleSendRef in sync
|
||||
useEffect(() => { handleSendRef.current = handleSend; });
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
// Debounced Minitel keyPress sound (every 3rd key)
|
||||
if (e.key.length === 1) {
|
||||
keyPressCountRef.current++;
|
||||
if (keyPressCountRef.current % 3 === 0) {
|
||||
sounds.keyPress();
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
return;
|
||||
}
|
||||
|
||||
// Tab completion for nicks and slash commands
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const text = input;
|
||||
|
||||
// Slash command completion
|
||||
if (text.startsWith("/") && !text.includes(" ")) {
|
||||
const slashCommands = ["/help", "/clear", "/nick", "/join", "/channels", "/msg", "/web", "/imagine", "/compose", "/status", "/model", "/persona", "/reload", "/export"];
|
||||
const prefix = tabPrefix || text;
|
||||
const matches = slashCommands.filter((c) => c.startsWith(prefix.toLowerCase()));
|
||||
if (matches.length === 0) return;
|
||||
const nextIdx = (tabIndex + 1) % matches.length;
|
||||
setInput(matches[nextIdx] + " ");
|
||||
setTabIndex(nextIdx);
|
||||
if (!tabPrefix) setTabPrefix(prefix);
|
||||
return;
|
||||
}
|
||||
|
||||
// Nick completion
|
||||
const words = text.split(" ");
|
||||
const lastWord = words[words.length - 1];
|
||||
const prefix = tabPrefix || lastWord;
|
||||
const matches = users.filter((u) =>
|
||||
u.toLowerCase().startsWith(prefix.toLowerCase()),
|
||||
);
|
||||
if (matches.length === 0) return;
|
||||
const nextIdx = (tabIndex + 1) % matches.length;
|
||||
words[words.length - 1] = matches[nextIdx] + (words.length === 1 ? ": " : " ");
|
||||
setInput(words.join(" "));
|
||||
setTabIndex(nextIdx);
|
||||
if (!tabPrefix) setTabPrefix(prefix);
|
||||
return;
|
||||
}
|
||||
|
||||
// ArrowUp — navigate back through message history
|
||||
if (e.key === "ArrowUp") {
|
||||
const history = historyRef.current;
|
||||
if (history.length === 0) return;
|
||||
e.preventDefault();
|
||||
if (historyIndexRef.current < history.length - 1) {
|
||||
if (historyIndexRef.current === -1) savedInputRef.current = input;
|
||||
historyIndexRef.current++;
|
||||
setInput(history[historyIndexRef.current]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ArrowDown — navigate forward through message history
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (historyIndexRef.current > 0) {
|
||||
historyIndexRef.current--;
|
||||
setInput(historyRef.current[historyIndexRef.current]);
|
||||
} else if (historyIndexRef.current === 0) {
|
||||
historyIndexRef.current = -1;
|
||||
setInput(savedInputRef.current);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset tab state on any other key
|
||||
if (tabIndex >= 0) {
|
||||
setTabIndex(-1);
|
||||
setTabPrefix("");
|
||||
}
|
||||
}
|
||||
|
||||
const getNickColor = useCallback((nick: string): string | undefined => {
|
||||
return personaColors[nick];
|
||||
}, [personaColors]);
|
||||
|
||||
const toggleSidebar = useCallback((section: "personas" | "users") => {
|
||||
setSidebarCollapsed(p => ({ ...p, [section]: !p[section] }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
messages,
|
||||
users,
|
||||
channel,
|
||||
input,
|
||||
setInput,
|
||||
personaColors,
|
||||
sidebarCollapsed,
|
||||
toggleSidebar,
|
||||
typingPersona,
|
||||
ws,
|
||||
sounds,
|
||||
messagesEndRef,
|
||||
messagesContainerRef,
|
||||
getNickColor,
|
||||
handleSend,
|
||||
handleKeyDown,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export interface HashRoute {
|
||||
page: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
function parseHash(hash: string, defaultPage = "chat"): HashRoute {
|
||||
const normalized = hash.replace(/^#\/?/, "");
|
||||
if (!normalized) return { page: defaultPage, id: "" };
|
||||
const parts = normalized.split("/");
|
||||
const page = parts[0] || defaultPage;
|
||||
const id = parts.slice(1).join("/");
|
||||
return { page, id };
|
||||
}
|
||||
|
||||
export function useHashRoute(defaultPage = "chat") {
|
||||
const [route, setRoute] = useState<HashRoute>(() =>
|
||||
typeof window !== "undefined" ? parseHash(window.location.hash, defaultPage) : { page: defaultPage, id: "" },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function onHashChange() {
|
||||
setRoute(parseHash(window.location.hash, defaultPage));
|
||||
}
|
||||
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
return () => window.removeEventListener("hashchange", onHashChange);
|
||||
}, [defaultPage]);
|
||||
|
||||
const navigate = useCallback((page: string, id?: string) => {
|
||||
const next = id ? `${page}/${id}` : page;
|
||||
setRoute(parseHash(`#${next}`, defaultPage));
|
||||
window.location.hash = next;
|
||||
}, [defaultPage]);
|
||||
|
||||
return { route, navigate };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export function resolveWebSocketUrl(path = "/ws"): string {
|
||||
const configured = import.meta.env.VITE_WS_URL;
|
||||
if (configured) return configured;
|
||||
|
||||
if (typeof window === "undefined") {
|
||||
return `ws://127.0.0.1:4180${path}`;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${window.location.host}${path}`;
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { publishUiCssVariables } from "@kxkm/ui";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
publishUiCssVariables(document.documentElement.style);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,6 +16,7 @@
|
||||
"status": "node ../../scripts/workspace-package.js worker status",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc -p tsconfig.json --pretty false",
|
||||
"check": "tsc -p tsconfig.json --noEmit --pretty false"
|
||||
"check": "tsc -p tsconfig.json --noEmit --pretty false",
|
||||
"test": "npm run build && node --test dist/*.test.js"
|
||||
}
|
||||
}
|
||||
|
||||
+53
-421
@@ -5,6 +5,9 @@
|
||||
* 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,
|
||||
@@ -15,34 +18,18 @@ import {
|
||||
import {
|
||||
createNodeEngineRegistry,
|
||||
createQueueState,
|
||||
canDequeue,
|
||||
dequeue,
|
||||
enqueue,
|
||||
markComplete,
|
||||
topologicalSort,
|
||||
validateEdgeContracts,
|
||||
collectNodeInputs,
|
||||
resolveFinalStatus,
|
||||
listDefaultRuntimes,
|
||||
createRun,
|
||||
validateJobSpec,
|
||||
buildTrlCommand,
|
||||
DEFAULT_HYPERPARAMS,
|
||||
type TrainingJobSpec,
|
||||
type NodeRun,
|
||||
type RunStep,
|
||||
type GraphNode,
|
||||
type NodeGraph,
|
||||
type StepStatus,
|
||||
type RunStatus,
|
||||
type NodeEngineRegistry,
|
||||
type QueueState,
|
||||
createNodeEngineOverview,
|
||||
} from "@kxkm/node-engine";
|
||||
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";
|
||||
import { formatOverviewLine } from "@kxkm/tui";
|
||||
import {
|
||||
createShutdownController,
|
||||
createNodeExecutor,
|
||||
executeRun,
|
||||
runPollCycle,
|
||||
waitForNextPollTick,
|
||||
type WorkerLogger,
|
||||
} from "./worker-runtime.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -67,327 +54,29 @@ const DEBUG = process.env.NODE_ENV !== "production" || process.env.DEBUG === "1"
|
||||
function log(msg: string): void {
|
||||
if (!DEBUG) return;
|
||||
const ts = new Date().toISOString();
|
||||
console.log(`[${ts}] ${msg}`);
|
||||
process.stdout.write(`[${ts}] ${msg}\n`);
|
||||
}
|
||||
|
||||
function logError(msg: string, err?: unknown): void {
|
||||
const ts = new Date().toISOString();
|
||||
const detail = err instanceof Error ? err.message : String(err ?? "");
|
||||
console.error(`[${ts}] ERROR: ${msg}${detail ? " — " + detail : ""}`);
|
||||
process.stderr.write(`[${ts}] ERROR: ${msg}${detail ? " — " + detail : ""}\n`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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,
|
||||
},
|
||||
const workerLogger: WorkerLogger = {
|
||||
log,
|
||||
error: logError,
|
||||
};
|
||||
} 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let shutdownRequested = false;
|
||||
const shutdown = createShutdownController();
|
||||
|
||||
function requestShutdown(): void {
|
||||
if (shutdownRequested) return;
|
||||
shutdownRequested = true;
|
||||
if (shutdown.isShutdownRequested()) return;
|
||||
shutdown.requestShutdown();
|
||||
log("Shutdown requested — finishing current work...");
|
||||
}
|
||||
|
||||
@@ -437,6 +126,18 @@ 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);
|
||||
@@ -444,107 +145,38 @@ async function main(): Promise<void> {
|
||||
// 7. Poll loop
|
||||
log(`Entering poll loop (interval=${POLL_INTERVAL_MS}ms)`);
|
||||
|
||||
while (!shutdownRequested) {
|
||||
while (!shutdown.isShutdownRequested()) {
|
||||
try {
|
||||
// Fetch queued runs from the DB
|
||||
const queuedDbRuns = await runRepo.listByStatus("queued", 20);
|
||||
|
||||
// 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;
|
||||
},
|
||||
const cycle = await runPollCycle({
|
||||
queueState,
|
||||
runRepo,
|
||||
graphRepo,
|
||||
registry,
|
||||
executeNode,
|
||||
shutdown,
|
||||
logger: workerLogger,
|
||||
});
|
||||
|
||||
// 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: {
|
||||
const overview = formatOverviewLine(
|
||||
createNodeEngineOverview({
|
||||
graphs: (await graphRepo.list()).length,
|
||||
models: 0,
|
||||
},
|
||||
storage: {
|
||||
backend: "postgres",
|
||||
artifacts: "filesystem",
|
||||
},
|
||||
});
|
||||
queuedRuns: queueState.queued.length,
|
||||
runningRuns: queueState.running.length,
|
||||
desiredWorkers: MAX_CONCURRENCY,
|
||||
activeWorkers: queueState.running.length,
|
||||
}),
|
||||
);
|
||||
|
||||
if (queuedDbRuns.length > 0) {
|
||||
if (cycle.queuedDbRuns > 0) {
|
||||
log(`Poll status: ${overview}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logError("Poll loop error", err);
|
||||
}
|
||||
|
||||
// Wait before next poll (check shutdown frequently)
|
||||
if (!shutdownRequested) {
|
||||
await delay(POLL_INTERVAL_MS);
|
||||
if (!shutdown.isShutdownRequested()) {
|
||||
await waitForNextPollTick(POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
createRun,
|
||||
createNodeEngineRegistry,
|
||||
createQueueState,
|
||||
type NodeRunRecord,
|
||||
type RunStatus,
|
||||
} from "@kxkm/node-engine";
|
||||
import {
|
||||
createNodeExecutor,
|
||||
createShutdownController,
|
||||
executeRun,
|
||||
parseLastJsonLine,
|
||||
runPollCycle,
|
||||
} from "./worker-runtime.js";
|
||||
|
||||
function createLogger() {
|
||||
const logs: string[] = [];
|
||||
const errors: Array<{ msg: string; err?: unknown }> = [];
|
||||
return {
|
||||
logs,
|
||||
errors,
|
||||
logger: {
|
||||
log(msg: string) {
|
||||
logs.push(msg);
|
||||
},
|
||||
error(msg: string, err?: unknown) {
|
||||
errors.push({ msg, err });
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type BenchmarkResult = {
|
||||
evaluation?: {
|
||||
kind?: string;
|
||||
score?: number;
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type DeployResult = {
|
||||
deployment?: {
|
||||
kind?: string;
|
||||
id?: string;
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
|
||||
test("parseLastJsonLine parses the final JSON line and rejects invalid output", () => {
|
||||
const parsed = parseLastJsonLine("noise\n{\"status\":\"ok\",\"score\":7}\n");
|
||||
assert.equal(parsed.ok, true);
|
||||
if (!parsed.ok) throw new Error("expected parsed json");
|
||||
assert.equal(parsed.value.status, "ok");
|
||||
assert.equal(parsed.value.score, 7);
|
||||
|
||||
const invalid = parseLastJsonLine("noise\nnot-json\n");
|
||||
assert.equal(invalid.ok, false);
|
||||
if (invalid.ok) throw new Error("expected parse failure");
|
||||
assert.equal(invalid.rawLine, "not-json");
|
||||
assert.equal(invalid.value && Object.keys(invalid.value).length, 0);
|
||||
});
|
||||
|
||||
test("createShutdownController is idempotent", () => {
|
||||
const controller = createShutdownController();
|
||||
assert.equal(controller.isShutdownRequested(), false);
|
||||
controller.requestShutdown();
|
||||
controller.requestShutdown();
|
||||
assert.equal(controller.isShutdownRequested(), true);
|
||||
});
|
||||
|
||||
test("executeRun completes steps in order and forwards upstream outputs", async () => {
|
||||
const registry = createNodeEngineRegistry();
|
||||
const graph = {
|
||||
id: "graph-1",
|
||||
name: "Graph",
|
||||
description: "Test graph",
|
||||
nodes: [
|
||||
{ id: "n1", type: "dataset_file", runtime: "local_cpu", params: {} },
|
||||
{ id: "n2", type: "clean_text", runtime: "local_cpu", params: {} },
|
||||
],
|
||||
edges: [
|
||||
{ from: { node: "n1", output: "dataset" }, to: { node: "n2", input: "dataset" } },
|
||||
],
|
||||
createdAt: "2026-03-17T00:00:00Z",
|
||||
updatedAt: "2026-03-17T00:00:00Z",
|
||||
};
|
||||
const run = createRun(graph, "worker");
|
||||
const seenInputs: Record<string, unknown>[] = [];
|
||||
const { logger } = createLogger();
|
||||
|
||||
await executeRun(run, registry, {
|
||||
executeNode: async (nodeType, inputs) => {
|
||||
seenInputs.push(inputs);
|
||||
if (nodeType === "dataset_file") return { dataset: { items: ["hello"], format: "stub" } };
|
||||
return { dataset: inputs.dataset };
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
assert.equal(run.status, "completed");
|
||||
assert.equal(run.steps[0]?.status, "completed");
|
||||
assert.equal(run.steps[1]?.status, "completed");
|
||||
assert.deepEqual(run.steps[0]?.outputs, ["dataset"]);
|
||||
assert.deepEqual(run.steps[1]?.outputs, ["dataset"]);
|
||||
assert.deepEqual(seenInputs[1]?.dataset, { items: ["hello"], format: "stub" });
|
||||
});
|
||||
|
||||
test("executeRun cancels before the second step when cancellation is requested", async () => {
|
||||
const registry = createNodeEngineRegistry();
|
||||
const graph = {
|
||||
id: "graph-2",
|
||||
name: "Graph",
|
||||
description: "Test graph",
|
||||
nodes: [
|
||||
{ id: "n1", type: "dataset_file", runtime: "local_cpu", params: {} },
|
||||
{ id: "n2", type: "clean_text", runtime: "local_cpu", params: {} },
|
||||
],
|
||||
edges: [
|
||||
{ from: { node: "n1", output: "dataset" }, to: { node: "n2", input: "dataset" } },
|
||||
],
|
||||
createdAt: "2026-03-17T00:00:00Z",
|
||||
updatedAt: "2026-03-17T00:00:00Z",
|
||||
};
|
||||
const run = createRun(graph, "worker");
|
||||
let executions = 0;
|
||||
const { logger } = createLogger();
|
||||
|
||||
await executeRun(run, registry, {
|
||||
executeNode: async () => {
|
||||
executions += 1;
|
||||
return { dataset: { items: [], format: "stub" } };
|
||||
},
|
||||
shouldCancel: () => executions > 0,
|
||||
logger,
|
||||
});
|
||||
|
||||
assert.equal(executions, 1);
|
||||
assert.equal(run.status, "cancelled");
|
||||
assert.equal(run.steps[0]?.status, "completed");
|
||||
assert.equal(run.steps[1]?.status, "pending");
|
||||
});
|
||||
|
||||
test("runPollCycle dequeues queued runs and persists the final status", async () => {
|
||||
const queueState = createQueueState({ maxConcurrency: 1 });
|
||||
const queuedRuns: NodeRunRecord[] = [
|
||||
{ id: "run-1", graphId: "graph-1", status: "queued", createdAt: "2026-03-17T00:00:00Z" },
|
||||
];
|
||||
const statusUpdates: Array<[string, string]> = [];
|
||||
const { logger } = createLogger();
|
||||
|
||||
const runRepo = {
|
||||
async listByStatus(status: RunStatus, limit: number) {
|
||||
assert.equal(status, "queued");
|
||||
assert.equal(limit, 20);
|
||||
return queuedRuns;
|
||||
},
|
||||
async findById(id: string) {
|
||||
return id === "run-1" ? queuedRuns[0] : null;
|
||||
},
|
||||
async updateStatus(id: string, status: RunStatus) {
|
||||
statusUpdates.push([id, status]);
|
||||
},
|
||||
};
|
||||
|
||||
const graphRepo = {
|
||||
async findById(id: string) {
|
||||
return id === "graph-1"
|
||||
? { id: "graph-1", name: "Graph", description: "Empty graph" }
|
||||
: null;
|
||||
},
|
||||
async list() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
const result = await runPollCycle({
|
||||
queueState,
|
||||
runRepo,
|
||||
graphRepo,
|
||||
registry: createNodeEngineRegistry(),
|
||||
executeNode: async () => ({}),
|
||||
shutdown: createShutdownController(),
|
||||
logger,
|
||||
});
|
||||
|
||||
assert.equal(result.queuedDbRuns, 1);
|
||||
assert.deepEqual(result.processedRunIds, ["run-1"]);
|
||||
assert.deepEqual(statusUpdates, [
|
||||
["run-1", "running"],
|
||||
["run-1", "completed"],
|
||||
]);
|
||||
assert.deepEqual(queueState.queued, []);
|
||||
assert.deepEqual(queueState.running, []);
|
||||
});
|
||||
|
||||
test("createNodeExecutor tolerates invalid JSON from subprocesses", async () => {
|
||||
const { logger, errors } = createLogger();
|
||||
const executor = createNodeExecutor(
|
||||
{
|
||||
dryRun: false,
|
||||
stepDelayMs: 0,
|
||||
pythonBin: "python3",
|
||||
scriptsDir: "/tmp/scripts",
|
||||
trainingTimeoutMs: 1000,
|
||||
},
|
||||
async () => ({ stdout: "garbage\n", stderr: "" }),
|
||||
logger,
|
||||
);
|
||||
|
||||
const benchmark = (await executor(
|
||||
"benchmark",
|
||||
{ model: { modelName: "demo-model", adapterPath: "/tmp/adapter" } },
|
||||
{ promptsPath: "/tmp/prompts.json" },
|
||||
)) as BenchmarkResult;
|
||||
|
||||
assert.equal(benchmark.evaluation?.kind, "real");
|
||||
assert.equal(benchmark.evaluation?.score, undefined);
|
||||
assert.ok(errors.some((entry) => entry.msg.includes("Failed to parse JSON output")));
|
||||
|
||||
const deploy = (await executor(
|
||||
"deploy_api",
|
||||
{ registered_model: { adapterPath: "/tmp/adapter" } },
|
||||
{ deployName: "demo-deploy" },
|
||||
)) as DeployResult;
|
||||
|
||||
assert.equal(deploy.deployment?.kind, "error");
|
||||
assert.equal(deploy.deployment?.error, "invalid_json_output");
|
||||
});
|
||||
@@ -0,0 +1,564 @@
|
||||
import type { ExecFileOptions } from "node:child_process";
|
||||
import * as path from "node:path";
|
||||
import { createIsoTimestamp } from "@kxkm/core";
|
||||
import {
|
||||
DEFAULT_HYPERPARAMS,
|
||||
buildTrlCommand,
|
||||
createRun,
|
||||
canDequeue,
|
||||
collectNodeInputs,
|
||||
dequeue,
|
||||
enqueue,
|
||||
markComplete,
|
||||
resolveFinalStatus,
|
||||
topologicalSort,
|
||||
validateEdgeContracts,
|
||||
validateJobSpec,
|
||||
type NodeEngineRegistry,
|
||||
type NodeGraph,
|
||||
type NodeRun,
|
||||
type NodeRunRecord,
|
||||
type QueueState,
|
||||
type RunStatus,
|
||||
type TrainingJobSpec,
|
||||
} from "@kxkm/node-engine";
|
||||
|
||||
export interface WorkerLogger {
|
||||
log(msg: string): void;
|
||||
error(msg: string, err?: unknown): void;
|
||||
}
|
||||
|
||||
export interface WorkerConfig {
|
||||
dryRun: boolean;
|
||||
stepDelayMs: number;
|
||||
pythonBin: string;
|
||||
scriptsDir: string;
|
||||
trainingTimeoutMs: number;
|
||||
}
|
||||
|
||||
export interface SubprocessRunner {
|
||||
(file: string, args: string[], options: ExecFileOptions): Promise<{ stdout: string; stderr: string }>;
|
||||
}
|
||||
|
||||
export interface ShutdownController {
|
||||
requestShutdown(): void;
|
||||
isShutdownRequested(): boolean;
|
||||
}
|
||||
|
||||
export interface GraphRecordLike {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface RunRepoLike {
|
||||
listByStatus(status: RunStatus, limit: number): Promise<NodeRunRecord[]>;
|
||||
findById(id: string): Promise<NodeRunRecord | null>;
|
||||
updateStatus(id: string, status: RunStatus): Promise<void>;
|
||||
}
|
||||
|
||||
export interface GraphRepoLike {
|
||||
findById(id: string): Promise<GraphRecordLike | null>;
|
||||
list(): Promise<GraphRecordLike[]>;
|
||||
}
|
||||
|
||||
export interface ExecuteNodeInputs {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ExecuteNodeParams {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type ExecuteNodeFn = (
|
||||
nodeType: string,
|
||||
inputs: ExecuteNodeInputs,
|
||||
params: ExecuteNodeParams,
|
||||
) => Promise<Record<string, unknown>>;
|
||||
|
||||
export interface RunPollCycleResult {
|
||||
queuedDbRuns: number;
|
||||
processedRunIds: string[];
|
||||
}
|
||||
|
||||
export interface ProcessDequeuedRunDeps {
|
||||
runId: string;
|
||||
runRepo: RunRepoLike;
|
||||
graphRepo: GraphRepoLike;
|
||||
registry: NodeEngineRegistry;
|
||||
executeNode: ExecuteNodeFn;
|
||||
logger: WorkerLogger;
|
||||
shouldCancel: () => boolean;
|
||||
}
|
||||
|
||||
export interface RunExecutionOptions {
|
||||
shouldCancel?: () => boolean;
|
||||
executeNode: ExecuteNodeFn;
|
||||
logger: WorkerLogger;
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function createShutdownController(): ShutdownController {
|
||||
let shutdownRequested = false;
|
||||
return {
|
||||
requestShutdown(): void {
|
||||
shutdownRequested = true;
|
||||
},
|
||||
isShutdownRequested(): boolean {
|
||||
return shutdownRequested;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function syncQueuedRuns(queueState: QueueState, queuedDbRuns: NodeRunRecord[]): number {
|
||||
let added = 0;
|
||||
for (const dbRun of queuedDbRuns) {
|
||||
const before = queueState.queued.length;
|
||||
enqueue(queueState, dbRun.id);
|
||||
if (queueState.queued.length > before) added++;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
export function buildWorkerGraph(graphRecord: GraphRecordLike): NodeGraph {
|
||||
const now = createIsoTimestamp();
|
||||
return {
|
||||
id: graphRecord.id,
|
||||
name: graphRecord.name,
|
||||
description: graphRecord.description,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
export function createWorkerRun(graph: NodeGraph, runId: string, actor = "worker"): NodeRun {
|
||||
const run = createRun(graph, actor);
|
||||
run.id = runId;
|
||||
return run;
|
||||
}
|
||||
|
||||
export type JsonParseResult =
|
||||
| { ok: true; rawLine: string; value: Record<string, unknown> }
|
||||
| { ok: false; rawLine: string; value: Record<string, never>; error: Error };
|
||||
|
||||
export function parseLastJsonLine(stdout: string): JsonParseResult {
|
||||
const rawLine = stdout.trim().split("\n").pop() || "{}";
|
||||
try {
|
||||
return { ok: true, rawLine, value: JSON.parse(rawLine) as Record<string, unknown> };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
rawLine,
|
||||
value: {},
|
||||
error: err instanceof Error ? err : new Error(String(err)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createNodeExecutor(
|
||||
config: WorkerConfig,
|
||||
runner: SubprocessRunner,
|
||||
logger: WorkerLogger,
|
||||
): ExecuteNodeFn {
|
||||
return async function executeNodeStub(
|
||||
nodeType: string,
|
||||
inputs: ExecuteNodeInputs,
|
||||
params: ExecuteNodeParams,
|
||||
): Promise<Record<string, unknown>> {
|
||||
await delay(config.stepDelayMs);
|
||||
|
||||
switch (nodeType) {
|
||||
case "dataset_file":
|
||||
case "dataset_folder":
|
||||
case "huggingface_dataset":
|
||||
case "web_scraper":
|
||||
return { dataset: { items: [], format: "stub" } };
|
||||
|
||||
case "clean_text":
|
||||
case "remove_duplicates":
|
||||
case "split_dataset":
|
||||
return { dataset: inputs.dataset ?? { items: [], format: "stub" } };
|
||||
|
||||
case "format_instruction_dataset":
|
||||
case "chat_dataset":
|
||||
return { dataset_ready: inputs.dataset ?? { items: [], format: "stub" } };
|
||||
|
||||
case "prompt_test":
|
||||
case "benchmark": {
|
||||
const evalModel = typeof params.model === "string" && params.model
|
||||
? params.model
|
||||
: ((inputs.model as Record<string, unknown> | undefined)?.modelName as string | undefined) || "unsloth/llama-3-8b";
|
||||
const adapterPath = (inputs.model as Record<string, unknown> | undefined)?.adapterPath as string | undefined;
|
||||
const promptsPath = typeof params.promptsPath === "string" ? params.promptsPath : "";
|
||||
const evalOutputPath = `/tmp/kxkm-eval-${Date.now()}.json`;
|
||||
|
||||
if (config.dryRun) {
|
||||
logger.log(` [dry-run] would evaluate model=${evalModel} adapter=${adapterPath || "none"}`);
|
||||
return { evaluation: { kind: "dry-run", score: 1 } };
|
||||
}
|
||||
|
||||
if (!promptsPath) {
|
||||
logger.log(" [eval] no promptsPath provided — returning stub evaluation");
|
||||
return { evaluation: { kind: "stub", score: 1 } };
|
||||
}
|
||||
|
||||
const scriptPath = path.join(config.scriptsDir, "eval_model.py");
|
||||
const args = [
|
||||
scriptPath,
|
||||
"--model",
|
||||
evalModel,
|
||||
"--prompts",
|
||||
promptsPath,
|
||||
"--output",
|
||||
evalOutputPath,
|
||||
];
|
||||
if (adapterPath) args.push("--adapter", adapterPath);
|
||||
|
||||
logger.log(` [eval] ${config.pythonBin} ${args.join(" ")}`);
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await runner(config.pythonBin, args, {
|
||||
timeout: config.trainingTimeoutMs,
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
});
|
||||
if (stderr) logger.log(` [eval] stderr: ${stderr.slice(-500)}`);
|
||||
|
||||
const parsed = parseLastJsonLine(stdout);
|
||||
if (!parsed.ok) logger.error(" [eval] Failed to parse JSON output", parsed.error);
|
||||
const evalResult = parsed.value;
|
||||
logger.log(` [eval] result: status=${evalResult.status} score=${evalResult.score}`);
|
||||
|
||||
return {
|
||||
evaluation: {
|
||||
kind: "real",
|
||||
score: evalResult.score,
|
||||
metrics: evalResult.metrics,
|
||||
outputFile: evalOutputPath,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error(" [eval] failed", err);
|
||||
return {
|
||||
evaluation: {
|
||||
kind: "error",
|
||||
score: 0,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
case "sft_training":
|
||||
case "lora_training":
|
||||
case "qlora_training": {
|
||||
const baseModel = typeof params.baseModel === "string" && params.baseModel
|
||||
? params.baseModel
|
||||
: "unsloth/llama-3-8b";
|
||||
const datasetPath = typeof params.datasetPath === "string" ? params.datasetPath : "";
|
||||
const outputDir = typeof params.outputDir === "string"
|
||||
? params.outputDir
|
||||
: `/tmp/kxkm-training-${Date.now()}`;
|
||||
const hp = {
|
||||
...DEFAULT_HYPERPARAMS,
|
||||
...(params.hyperparams && typeof params.hyperparams === "object" ? params.hyperparams : {}),
|
||||
};
|
||||
|
||||
if (config.dryRun) {
|
||||
const jobSpec = validateJobSpec({
|
||||
type: nodeType as TrainingJobSpec["type"],
|
||||
baseModel,
|
||||
datasetPath: datasetPath || "/data/dataset.jsonl",
|
||||
outputDir,
|
||||
hyperparams: hp,
|
||||
});
|
||||
logger.log(` [dry-run] would execute: ${buildTrlCommand(jobSpec)}`);
|
||||
return {
|
||||
model: {
|
||||
kind: "dry-run",
|
||||
modelName: `${baseModel}-finetuned`,
|
||||
jobSpec,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!datasetPath) {
|
||||
return { model: { kind: "error", error: "datasetPath is required for training" } };
|
||||
}
|
||||
|
||||
const scriptPath = path.join(config.scriptsDir, "train_unsloth.py");
|
||||
const method = params.dpo === true ? "dpo" : nodeType === "qlora_training" ? "qlora" : nodeType === "sft_training" ? "sft" : "lora";
|
||||
const args = [
|
||||
scriptPath,
|
||||
"--model",
|
||||
baseModel,
|
||||
"--data",
|
||||
datasetPath,
|
||||
"--output",
|
||||
outputDir,
|
||||
"--method",
|
||||
method,
|
||||
"--lr",
|
||||
String((hp.learningRate as number) ?? ""),
|
||||
"--epochs",
|
||||
String((hp.epochs as number) ?? ""),
|
||||
"--batch-size",
|
||||
String((hp.batchSize as number) ?? ""),
|
||||
"--lora-rank",
|
||||
String((hp.loraRank as number) ?? ""),
|
||||
"--lora-alpha",
|
||||
String((hp.loraAlpha as number) ?? ""),
|
||||
"--warmup-steps",
|
||||
String((hp.warmupSteps as number) ?? ""),
|
||||
"--max-seq-length",
|
||||
String((hp.maxSeqLength as number) ?? ""),
|
||||
];
|
||||
if (nodeType === "qlora_training") args.push("--quantize", "4bit");
|
||||
|
||||
logger.log(` [training] ${config.pythonBin} ${args.join(" ")}`);
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await runner(config.pythonBin, args, {
|
||||
timeout: config.trainingTimeoutMs,
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
});
|
||||
if (stderr) logger.log(` [training] stderr: ${stderr.slice(-500)}`);
|
||||
|
||||
const parsed = parseLastJsonLine(stdout);
|
||||
if (!parsed.ok) logger.error(" [training] Failed to parse JSON output", parsed.error);
|
||||
const trainResult = parsed.value;
|
||||
logger.log(
|
||||
` [training] result: status=${trainResult.status} loss=${(trainResult.metrics as Record<string, unknown> | undefined)?.trainLoss}`,
|
||||
);
|
||||
|
||||
return {
|
||||
model: {
|
||||
kind: "trained",
|
||||
modelName: `${baseModel}-finetuned`,
|
||||
adapterPath: (trainResult.adapterPath as string | undefined) || outputDir,
|
||||
metrics: trainResult.metrics,
|
||||
status: trainResult.status,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logger.error(" [training] failed", err);
|
||||
return { model: { kind: "error", error: msg } };
|
||||
}
|
||||
}
|
||||
|
||||
case "register_model":
|
||||
return { registered_model: { id: "stub" } };
|
||||
|
||||
case "deploy_api": {
|
||||
const modelInput = (inputs.registered_model as Record<string, unknown> | undefined) || (inputs.model as Record<string, unknown> | undefined) || {};
|
||||
const adapterPath = (modelInput.adapterPath as string | undefined) || (typeof params.adapterPath === "string" ? params.adapterPath : "");
|
||||
const baseOllamaModel = typeof params.baseOllamaModel === "string" ? params.baseOllamaModel : "llama3.2:1b";
|
||||
const deployName = typeof params.deployName === "string" ? params.deployName : `kxkm-${Date.now()}`;
|
||||
|
||||
if (config.dryRun || !adapterPath) {
|
||||
logger.log(` [deploy] dry-run or no adapter: base=${baseOllamaModel} name=${deployName}`);
|
||||
return { deployment: { kind: config.dryRun ? "dry-run" : "stub", id: deployName } };
|
||||
}
|
||||
|
||||
const scriptPath = path.join(config.scriptsDir, "ollama-import-adapter.sh");
|
||||
const args = [
|
||||
scriptPath,
|
||||
"--base-model",
|
||||
baseOllamaModel,
|
||||
"--adapter-path",
|
||||
adapterPath,
|
||||
"--name",
|
||||
deployName,
|
||||
];
|
||||
|
||||
logger.log(` [deploy] importing to Ollama: ${deployName} from ${baseOllamaModel} + ${adapterPath}`);
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await runner("/bin/bash", args, { timeout: 120_000 });
|
||||
if (stderr) logger.log(` [deploy] stderr: ${stderr.slice(-500)}`);
|
||||
const parsed = parseLastJsonLine(stdout);
|
||||
if (!parsed.ok) {
|
||||
logger.error(" [deploy] invalid JSON", parsed.error);
|
||||
return { deployment: { kind: "error", id: deployName, error: "invalid_json_output" } };
|
||||
}
|
||||
const result = parsed.value;
|
||||
logger.log(` [deploy] result: ${JSON.stringify(result)}`);
|
||||
return { deployment: { kind: "ollama", id: deployName, ...result } };
|
||||
} catch (err) {
|
||||
logger.error(" [deploy] failed", err);
|
||||
return {
|
||||
deployment: {
|
||||
kind: "error",
|
||||
id: deployName,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExecuteRunDeps {
|
||||
executeNode: ExecuteNodeFn;
|
||||
logger: WorkerLogger;
|
||||
shouldCancel?: () => boolean;
|
||||
}
|
||||
|
||||
export async function executeRun(
|
||||
run: NodeRun,
|
||||
registry: NodeEngineRegistry,
|
||||
deps: ExecuteRunDeps,
|
||||
): Promise<void> {
|
||||
const shouldCancel = deps.shouldCancel ?? (() => false);
|
||||
validateEdgeContracts(run.graphSnapshot, registry);
|
||||
|
||||
const sorted = topologicalSort(run.graphSnapshot);
|
||||
const outputsByNode = new Map<string, Record<string, unknown>>();
|
||||
|
||||
run.status = "running";
|
||||
run.startedAt = createIsoTimestamp();
|
||||
let cancelled = false;
|
||||
|
||||
deps.logger.log(` Executing ${sorted.length} node(s) in topological order`);
|
||||
|
||||
for (const node of sorted) {
|
||||
const step = run.steps.find((entry) => entry.id === node.id);
|
||||
if (step?.status === "completed") {
|
||||
deps.logger.log(` [${node.id}] ${node.type} — already completed (recovered)`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of sorted) {
|
||||
const step = run.steps.find((entry) => entry.id === node.id);
|
||||
if (!step) continue;
|
||||
if (step.status === "completed") continue;
|
||||
|
||||
if (shouldCancel()) {
|
||||
cancelled = true;
|
||||
deps.logger.log(` [${node.id}] ${node.type} — cancelled`);
|
||||
break;
|
||||
}
|
||||
|
||||
step.status = "running";
|
||||
step.startedAt = createIsoTimestamp();
|
||||
deps.logger.log(` [${node.id}] ${node.type} — running`);
|
||||
|
||||
try {
|
||||
const inputs = collectNodeInputs(run.graphSnapshot, node.id, outputsByNode);
|
||||
const result = await deps.executeNode(node.type, inputs, node.params);
|
||||
outputsByNode.set(node.id, result);
|
||||
step.status = "completed";
|
||||
step.finishedAt = createIsoTimestamp();
|
||||
step.outputs = Object.keys(result);
|
||||
deps.logger.log(` [${node.id}] ${node.type} — completed`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
step.status = "failed";
|
||||
step.finishedAt = createIsoTimestamp();
|
||||
step.error = message;
|
||||
deps.logger.error(` [${node.id}] ${node.type} — failed`, err);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const stepStatuses = run.steps.map((entry) => entry.status);
|
||||
run.status = resolveFinalStatus(stepStatuses, cancelled);
|
||||
run.finishedAt = createIsoTimestamp();
|
||||
}
|
||||
|
||||
export async function persistRunStatus(
|
||||
runRepo: RunRepoLike,
|
||||
runId: string,
|
||||
status: RunStatus,
|
||||
): Promise<void> {
|
||||
await runRepo.updateStatus(runId, status);
|
||||
}
|
||||
|
||||
export async function processDequeuedRun(
|
||||
deps: ProcessDequeuedRunDeps,
|
||||
): Promise<{ kind: "completed" | "missing-run" | "missing-graph" | "failed"; status?: RunStatus }> {
|
||||
await deps.runRepo.updateStatus(deps.runId, "running");
|
||||
|
||||
const dbRun = await deps.runRepo.findById(deps.runId);
|
||||
if (!dbRun) {
|
||||
deps.logger.error(`Run ${deps.runId} not found in DB — skipping`);
|
||||
return { kind: "missing-run" };
|
||||
}
|
||||
|
||||
const graphRecord = await deps.graphRepo.findById(dbRun.graphId);
|
||||
if (!graphRecord) {
|
||||
deps.logger.error(`Graph ${dbRun.graphId} for run ${deps.runId} not found — marking failed`);
|
||||
await persistRunStatus(deps.runRepo, deps.runId, "failed");
|
||||
return { kind: "missing-graph", status: "failed" };
|
||||
}
|
||||
|
||||
const graph = buildWorkerGraph(graphRecord);
|
||||
const nodeRun = createWorkerRun(graph, deps.runId, "worker");
|
||||
|
||||
try {
|
||||
await executeRun(nodeRun, deps.registry, {
|
||||
executeNode: deps.executeNode,
|
||||
logger: deps.logger,
|
||||
shouldCancel: deps.shouldCancel,
|
||||
});
|
||||
await persistRunStatus(deps.runRepo, deps.runId, nodeRun.status);
|
||||
return { kind: "completed", status: nodeRun.status };
|
||||
} catch (err) {
|
||||
deps.logger.error(`Run ${deps.runId} failed unexpectedly`, err);
|
||||
await persistRunStatus(deps.runRepo, deps.runId, "failed");
|
||||
return { kind: "failed", status: "failed" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPollCycle(
|
||||
params: {
|
||||
queueState: QueueState;
|
||||
runRepo: RunRepoLike;
|
||||
graphRepo: GraphRepoLike;
|
||||
registry: NodeEngineRegistry;
|
||||
executeNode: ExecuteNodeFn;
|
||||
shutdown: ShutdownController;
|
||||
logger: WorkerLogger;
|
||||
},
|
||||
): Promise<RunPollCycleResult> {
|
||||
const queuedDbRuns = await params.runRepo.listByStatus("queued", 20);
|
||||
syncQueuedRuns(params.queueState, queuedDbRuns);
|
||||
|
||||
const processedRunIds: string[] = [];
|
||||
while (canDequeue(params.queueState) && !params.shutdown.isShutdownRequested()) {
|
||||
const runId = dequeue(params.queueState);
|
||||
if (!runId) break;
|
||||
|
||||
processedRunIds.push(runId);
|
||||
params.logger.log(`Dequeued run: ${runId}`);
|
||||
|
||||
try {
|
||||
await processDequeuedRun({
|
||||
runId,
|
||||
runRepo: params.runRepo,
|
||||
graphRepo: params.graphRepo,
|
||||
registry: params.registry,
|
||||
executeNode: params.executeNode,
|
||||
logger: params.logger,
|
||||
shouldCancel: () => params.shutdown.isShutdownRequested(),
|
||||
});
|
||||
} finally {
|
||||
markComplete(params.queueState, runId);
|
||||
}
|
||||
}
|
||||
|
||||
return { queuedDbRuns: queuedDbRuns.length, processedRunIds };
|
||||
}
|
||||
|
||||
export async function waitForNextPollTick(ms: number): Promise<void> {
|
||||
await delay(ms);
|
||||
}
|
||||
+92
-2
@@ -45,7 +45,7 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3333/api/ping"]
|
||||
test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3333/api/ping').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))\""]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -78,11 +78,13 @@ services:
|
||||
PIPER_VOICE_DIR: "/app/data/piper-voices"
|
||||
COQUI_TOS_AGREED: "1"
|
||||
VISION_MODEL: "qwen3-vl:8b"
|
||||
LIGHTRAG_URL: "http://localhost:9621"
|
||||
TTS_URL: "http://localhost:9100"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3333/api/v2/health"]
|
||||
test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3333/api/v2/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))\""]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -220,6 +222,94 @@ services:
|
||||
api:
|
||||
condition: service_healthy
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Chatterbox TTS — GPU-accelerated voice synthesis (Docker)
|
||||
# Usage: docker compose --profile v2 up -d
|
||||
# API: POST /tts, POST /v1/audio/speech
|
||||
# -------------------------------------------------------------------------
|
||||
chatterbox:
|
||||
image: ghcr.io/devnen/chatterbox-tts-server:latest
|
||||
restart: unless-stopped
|
||||
profiles: [v2]
|
||||
ports:
|
||||
- "${CHATTERBOX_PORT:-9200}:8004"
|
||||
volumes:
|
||||
- ./data/voice-samples:/app/voices:ro
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:8004/get_predefined_voices')\""]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# TTS Sidecar — Proxy to Chatterbox + Piper fallback
|
||||
# Runs on host network, proxies /synthesize to Chatterbox :9200
|
||||
# -------------------------------------------------------------------------
|
||||
tts-sidecar:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
profiles: [v2]
|
||||
network_mode: host
|
||||
command: ["python3", "scripts/tts-server.py", "--port", "9100", "--backend", "chatterbox-remote"]
|
||||
environment:
|
||||
CHATTERBOX_URL: "http://127.0.0.1:9200"
|
||||
PIPER_VOICE_DIR: "/app/data/piper-voices"
|
||||
KXKM_VOICE_SAMPLES_DIR: "/app/data/voice-samples"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./scripts:/app/scripts:ro
|
||||
depends_on:
|
||||
chatterbox:
|
||||
condition: service_healthy
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# LightRAG — Graph RAG server (Ollama backend)
|
||||
# API: POST /query, POST /documents/text, GET /health
|
||||
# Web UI: http://localhost:9621
|
||||
# -------------------------------------------------------------------------
|
||||
lightrag:
|
||||
build:
|
||||
context: .
|
||||
dockerfile_inline: |
|
||||
FROM python:3.12-slim
|
||||
RUN pip install --no-cache-dir 'lightrag-hku[api]'
|
||||
EXPOSE 9621
|
||||
CMD ["lightrag-server", "--host", "0.0.0.0", "--port", "9621"]
|
||||
restart: unless-stopped
|
||||
profiles: [v2]
|
||||
network_mode: host
|
||||
environment:
|
||||
LLM_MODEL: "qwen3:8b"
|
||||
EMBEDDING_MODEL: "nomic-embed-text"
|
||||
EMBEDDING_DIM: "768"
|
||||
OLLAMA_HOST: "http://localhost:11434"
|
||||
LLM_BINDING: ollama
|
||||
EMBEDDING_BINDING: ollama
|
||||
RAG_DIR: "/data/lightrag"
|
||||
command: >
|
||||
lightrag-server
|
||||
--host 0.0.0.0
|
||||
--port 9621
|
||||
--working-dir /data/lightrag
|
||||
--llm-binding ollama
|
||||
--embedding-binding ollama
|
||||
volumes:
|
||||
- ./data/lightrag:/data/lightrag
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:9621/health')\""]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
volumes:
|
||||
app-data:
|
||||
pg-data:
|
||||
|
||||
+41
-22
@@ -67,57 +67,72 @@ flowchart TD
|
||||
OSSAgent --> |hf| OSSHF[HuggingFace models]
|
||||
```
|
||||
|
||||
## Todo agents (lot 17)
|
||||
## Todo agents (lot 17+ — mis a jour 2026-03-19)
|
||||
|
||||
### Coordinateur
|
||||
|
||||
- [ ] Consolider PLAN.md avec etat reel (lots 14-16 complets)
|
||||
- [ ] Mettre a jour TODO.md avec backlog Phase 6+
|
||||
- [ ] Synchroniser FEATURE_MAP.md matrice
|
||||
- [x] Consolider PLAN.md avec etat reel (lots 14-16 complets)
|
||||
- [x] Synchroniser FEATURE_MAP.md matrice
|
||||
- [ ] Mettre a jour TODO.md avec backlog Phase 7+
|
||||
- [ ] Documenter actions dans ops/v2/logs/
|
||||
|
||||
### Backend API
|
||||
|
||||
- [ ] Refactorer ws-chat.ts (1449 → <400 LOC par module)
|
||||
- [ ] Refactorer app.ts (1292 → routes + middleware + handlers)
|
||||
- [x] Extraire app-bootstrap.ts et app-middleware.ts de app.ts
|
||||
- [x] Extraire ws-conversation-router.ts de ws-chat.ts
|
||||
- [ ] Refactorer ws-chat.ts residuel (<400 LOC cible)
|
||||
- [ ] Remplacer writeFileSync par async dans ws-chat.ts
|
||||
- [ ] Ajouter error boundaries sur WebSocket handlers
|
||||
- [ ] Scinder tests storage volumineux (storage-test-split)
|
||||
- [ ] Decouper Chat.tsx en seams transport/liste/composer/media
|
||||
|
||||
### Node Engine
|
||||
|
||||
- [x] Extraire registry.ts du hotspot node-engine
|
||||
- [ ] Ajouter node type `music_generation` (ACE-Step 1.5)
|
||||
- [ ] Ajouter node type `voice_clone` (XTTS-v2)
|
||||
- [ ] Ajouter node type `voice_clone` (Chatterbox)
|
||||
- [ ] Tester pipeline DPO end-to-end sur kxkm-ai
|
||||
|
||||
### Personas
|
||||
|
||||
- [ ] Evaluer PCL (Persona-Aware Contrastive Learning) pour coherence
|
||||
- [ ] Evaluer OpenCharacter pour generation profils synthetiques
|
||||
- [ ] Ajouter `/compose` command (generation musicale)
|
||||
- [x] Ajouter `/compose` command (generation musicale)
|
||||
|
||||
### Frontend
|
||||
|
||||
- [ ] Implementer lot 16 UI Minitel rose (phosphore, VIDEOTEX)
|
||||
- [x] Implementer lot 16 UI Minitel rose (phosphore, VIDEOTEX)
|
||||
- [x] VoiceChat push-to-talk + level meter + silence auto
|
||||
- [x] Player audio + viewer image plein ecran
|
||||
- [x] Mediatheque gallery/playlist
|
||||
- [x] Progress bars animees Compose/Imagine
|
||||
- [ ] Ajouter memoization (React.memo) sur composants lourds
|
||||
- [ ] Lazy-load ChatHistory, VoiceChat, NodeEditor
|
||||
- [ ] vault66-crt-effect (React CRT WebGL)
|
||||
|
||||
### Ops/TUI
|
||||
|
||||
- [ ] Deployer deep-audit.js sur kxkm-ai
|
||||
- [ ] Ajouter SearXNG au docker-compose
|
||||
- [x] Deployer deep-audit.js sur kxkm-ai
|
||||
- [x] Ajouter SearXNG au docker-compose
|
||||
- [x] TTS sidecar HTTP (tts-server.py :9100, dual Chatterbox/Piper)
|
||||
- [x] deploy.sh tmux (build + deploy automatise)
|
||||
- [ ] Ajouter MinerU/Docling au docker-compose
|
||||
- [ ] Fix Docker transformers (rebuild propre avec torch)
|
||||
|
||||
### Training
|
||||
|
||||
- [ ] Spike BGE-M3 embeddings (remplacer nomic-embed-text)
|
||||
- [x] Spike BGE-M3 (resultat negatif sur Apple/Metal, baseline maintenue)
|
||||
- [x] TTS dual backend Chatterbox/Piper valide
|
||||
- [ ] Tester ACE-Step 1.5 sur RTX 4090
|
||||
- [ ] Evaluer Chatterbox TTS vs Piper
|
||||
- [ ] LightRAG graph RAG (personas/lore)
|
||||
|
||||
### Veille OSS
|
||||
|
||||
- [x] Veille mars 2026 complete (40+ projets analyses, top 10 recommandations)
|
||||
- [ ] Suivre LLMRTC (WebRTC voice TypeScript)
|
||||
- [ ] Suivre A2A Protocol (interop agents)
|
||||
- [ ] Suivre MCP SDK updates
|
||||
- [ ] Evaluer Kokoro TTS (82M params, ultra-leger)
|
||||
|
||||
## Pipeline d'intervention
|
||||
|
||||
@@ -165,13 +180,17 @@ stateDiagram-v2
|
||||
- Privilegier TUI et scripts avec logs lisibles, puis purge des logs obsoletes.
|
||||
- Conserver la V1 comme reference comportementale, V2 comme cible active.
|
||||
|
||||
### Etat de cycle (2026-03-17 10:30)
|
||||
- Audit/tests: vert (`deep-audit`, `check:v2`, `test:v2`).
|
||||
- Correctifs deployables: garde TTS + cleanup sessions memory-mode.
|
||||
- Logs: purge des fichiers vides et conservation des artefacts utiles.
|
||||
- Veille OSS: shortlist d'integration prioritaire = `SearXNG` + `Docling`.
|
||||
### Etat de cycle (2026-03-19)
|
||||
|
||||
### Micro-mission suivante
|
||||
1. Backend API: extraction du bloc `upload/analyse` de `ws-chat.ts` vers module dedie.
|
||||
2. Ops/TUI: ajouter score de dette technique pondere dans `deep-audit` JSON.
|
||||
3. Coordinateur: synchroniser PLAN/TODO apres chaque sous-lot (max 1h).
|
||||
- Audit/tests: en cours de verification.
|
||||
- Lots 0-4, 12-14, 16 complets. Lot 15 en cours (2/4 done).
|
||||
- TTS dual backend (Chatterbox/Piper) deploye avec deploy.sh tmux.
|
||||
- 33 personas (qwen3:8b x21, mistral:7b x7, gemma3:4b x4).
|
||||
- Veille OSS mars 2026: 40+ projets analyses.
|
||||
- Deep audit: 7 bugs HIGH/MEDIUM identifies, 6 corriges.
|
||||
|
||||
### Micro-missions lot 15 restantes
|
||||
|
||||
1. Backend API: scinder tests storage volumineux (storage-test-split).
|
||||
2. Frontend: decouper Chat.tsx en seams transport/liste/composer/media.
|
||||
3. Coordinateur: synchroniser PLAN/TODO/state apres cloture lot 15.
|
||||
|
||||
+21
-5
@@ -24,13 +24,15 @@ graph TB
|
||||
MULTI[ws-multimodal.ts — TTS HTTP + Vision]
|
||||
MSTORE[media-store.ts — Persistance media]
|
||||
CTX[context-store.ts — Contexte JSONL 4000ch]
|
||||
RAG[rag.ts — Embeddings cosine]
|
||||
RAG[rag.ts — LightRAG + local fallback]
|
||||
REST[Routes REST — session, personas, media]
|
||||
end
|
||||
|
||||
subgraph Services["Services"]
|
||||
OLLAMA[Ollama — qwen3:8b mistral gemma3]
|
||||
TTS[TTS Server piper :9100]
|
||||
TTS[TTS Sidecar :9100 — proxy]
|
||||
CBOX[Chatterbox GPU :9200 — Docker]
|
||||
LRAG[LightRAG :9621 — Graph RAG]
|
||||
COMFY[ComfyUI SDXL]
|
||||
SEARX[SearXNG :8080]
|
||||
PG[(PostgreSQL 16)]
|
||||
@@ -50,8 +52,9 @@ graph TB
|
||||
WS --> ROUTER --> LLM --> OLLAMA
|
||||
ROUTER --> CTX
|
||||
ROUTER --> RAG
|
||||
LLM -- "TTS" --> MULTI --> TTS
|
||||
LLM -- "TTS" --> MULTI --> TTS --> CBOX
|
||||
LLM -- "Vision" --> MULTI --> OLLAMA
|
||||
RAG -- "query hybrid" --> LRAG --> OLLAMA
|
||||
CMD -- "/imagine" --> COMFY
|
||||
CMD -- "/web" --> SEARX
|
||||
CMD -- "save" --> MSTORE
|
||||
@@ -148,10 +151,23 @@ mindmap
|
||||
| packages/persona-domain | 988 | 259 | Personas, feedback, editorial |
|
||||
| packages/node-engine | 1499 | 605 | DAG execution, training |
|
||||
| packages/storage | 1219 | 669 | PostgreSQL repos |
|
||||
| packages/ui | 134 | 0 | Theme, colors, CSS vars |
|
||||
| packages/ui | 134 | 29 | Theme, colors, CSS vars |
|
||||
| packages/tui | 209 | 108 | ANSI formatting, tables |
|
||||
| scripts | 37 fichiers | - | TTS, training, migration |
|
||||
| **Total** | **~15600** | **~3200** | |
|
||||
| **Total** | **~15600** | **417 tests** | |
|
||||
|
||||
## 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)
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Deep Audit 2026-03-19
|
||||
|
||||
> Analyse exhaustive par 3 agents paralleles (backend, frontend, packages) + veille OSS.
|
||||
|
||||
## Resultats validation
|
||||
|
||||
- **check:v2:** OK (TypeScript vert)
|
||||
- **test:v2:** 204 tests, 0 fail, 320ms
|
||||
|
||||
## Metriques globales
|
||||
|
||||
| Couche | LOC | Tests LOC | Couverture estimee |
|
||||
| --- | --- | --- | --- |
|
||||
| apps/api | 5 254 | 1 004 | ~45% |
|
||||
| apps/web | 6 454 | 374 | ~10% |
|
||||
| apps/worker | 986 | 230 | ~20% |
|
||||
| packages | 2 853 | 2 162 | ~72% |
|
||||
| **Total** | **~15 547** | **~3 770** | **~40%** |
|
||||
|
||||
## Hotspots identifies
|
||||
|
||||
### Backend (P1)
|
||||
|
||||
| Fichier | LOC | Complexite | Probleme |
|
||||
| --- | --- | --- | --- |
|
||||
| ws-conversation-router.ts | 358 | Tres haute | Race condition persona locks, memory leak maps unbounded |
|
||||
| ws-ollama.ts | 303 | Tres haute | Semaphore manuel, queue starvation, JSON streaming fragile |
|
||||
| context-store.ts | 435 | Haute | RAM unbounded en compaction, deadlock potentiel |
|
||||
| worker-runtime.ts | 564 | Tres haute | Subprocess timeout cascade, signal handling absent |
|
||||
| app.ts | 538 | Moyenne | 7 repo factories inline, couplage fort |
|
||||
|
||||
### Frontend (P1)
|
||||
|
||||
| Composant | LOC | Probleme |
|
||||
| --- | --- | --- |
|
||||
| Chat.tsx | 631 | Monolithique, 10+ useState, getNickColor re-render |
|
||||
| ChatHistory.tsx | 601 | Pagination complexe, pas de virtualisation |
|
||||
| NodeEditor.tsx | 406 | Logique metier melee au rendu |
|
||||
| VoiceChat.tsx | 388 | Memory leak recordingTimer, silenceTimer |
|
||||
| ComposePage/ImaginePage | 203/185 | WS pattern duplique, progress bar simulee |
|
||||
|
||||
### Packages (P2)
|
||||
|
||||
| Package | Probleme |
|
||||
| --- | --- |
|
||||
| @kxkm/core | createId race condition en worker threads |
|
||||
| @kxkm/node-engine | Hyperparams sans validation bounds, docker image hardcoded |
|
||||
| @kxkm/persona-domain | Extra keys non types, DPO pairs silent [] |
|
||||
| @kxkm/storage | Types unsafe sur row mapping (pas de zod) |
|
||||
| @kxkm/ui | Zero tests |
|
||||
|
||||
## Vulnerabilites securite
|
||||
|
||||
| # | Severite | Fichier | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| SEC-01 | CRITICAL | app-middleware.ts | Cookie session sans Secure flag (HTTPS) |
|
||||
| SEC-02 | HIGH | routes/session.ts | Admin token plaintext en env vars |
|
||||
| SEC-03 | MEDIUM | ws-upload-handler.ts | MIME type validation contournable (header, pas magic bytes) |
|
||||
| SEC-04 | MEDIUM | /api/session/login | Pas de rate limiting (brute force) |
|
||||
| SEC-05 | LOW | ws-ollama.ts | Tool execution sans whitelist / sandbox inputs |
|
||||
|
||||
## Refactoring prioritaire
|
||||
|
||||
### Tier 1 — Immediat
|
||||
|
||||
1. **Secure flag cookie** (5 min) — app-middleware.ts
|
||||
2. **Rate limit login** (1h) — routes/session.ts
|
||||
3. **MIME magic bytes validation** (30 min) — ws-upload-handler.ts, lib `file-type`
|
||||
4. **VoiceChat timer cleanup** (30 min) — recordingTimerRef + silenceTimerRef
|
||||
5. **Chat.tsx getNickColor useCallback** (15 min) — reduire re-renders 50%
|
||||
|
||||
### Tier 2 — Sprint suivant
|
||||
|
||||
1. **Decouper Chat.tsx** en ChatMessages + ChatInput + ChatSidebar (2-3j)
|
||||
2. **Remplacer semaphore manuel par p-limit** dans ws-ollama.ts (1h)
|
||||
3. **Input validation schemas (zod)** sur endpoints API (4h)
|
||||
4. **Storage row mapping runtime validation** (4h)
|
||||
5. **Tests Chat.tsx + VoiceChat.tsx** (2j)
|
||||
|
||||
### Tier 3 — Moyen terme
|
||||
|
||||
1. **Virtualisation chat messages** (react-window)
|
||||
2. **Worker strategy pattern** pour node executors
|
||||
3. **Error telemetry** (Sentry ou equivalent)
|
||||
4. **A11y pass WCAG 2.1 AA** (labels, focus, contrast)
|
||||
5. **Integration test suite end-to-end**
|
||||
|
||||
## Veille OSS — Nouvelles decouvertes mars 2026
|
||||
|
||||
| Projet | Pertinence | Notes |
|
||||
| --- | --- | --- |
|
||||
| **Qwen3-TTS** (Alibaba) | P2 | 0.6B-1.7B, clone voix 3s, 10 langues, Apache 2.0 |
|
||||
| **Pocket TTS** (Kyutai) | P3 | 100M params, CPU temps reel, voice cloning |
|
||||
| **NexusRAG** | P3 | Hybrid: vector + LightRAG + Docling + Ollama |
|
||||
| **AgoraAI** | P2 | Multi-persona voice-to-voice, dual-queue async |
|
||||
| **webgl-crt-shader** | P3 | GPU-accelere, plus authentique que vault66 CSS |
|
||||
| **LightRAG v1.4.11rc2** | P1 | Releases actives, Ollama natif, workspace isolation |
|
||||
|
||||
## Backlog mis a jour
|
||||
|
||||
### Lot 15 (en cours) — 2/4 done
|
||||
|
||||
- [x] persona-domain-seams
|
||||
- [x] node-engine-seams (registry.ts extrait)
|
||||
- [ ] storage-test-split
|
||||
- [ ] web-chat-modularization (Chat.tsx → seams)
|
||||
|
||||
### Backlog Phase 7+
|
||||
|
||||
1. Chatterbox TTS deploiement production (P1)
|
||||
2. LightRAG v1.4.11 integration (P1)
|
||||
3. Secure flag + rate limit login (P1 securite)
|
||||
4. Chat.tsx modularisation (lot 15)
|
||||
5. Tests couverture >60% (Chat, VoiceChat, ws-ollama)
|
||||
6. vault66 ou webgl-crt-shader (P3)
|
||||
7. AgoraAI etude architecture (P3)
|
||||
8. Qwen3-TTS evaluation (P2)
|
||||
@@ -0,0 +1,35 @@
|
||||
# BGE-M3 Benchmark - 2026-03-17
|
||||
|
||||
## Etat local confirme
|
||||
|
||||
- Le RAG courant utilise encore des embeddings locaux via Ollama dans `apps/api/src/rag.ts`, avec `nomic-embed-text` par defaut.
|
||||
- Le repo contient deja un bench local `scripts/bench-embeddings.js`.
|
||||
- Le health check `scripts/health-embeddings.sh` sonde Ollama et detecte la presence ou non de `bge-m3`.
|
||||
|
||||
## Etat machine observe
|
||||
|
||||
- `npm run -s smoke:embeddings` passe.
|
||||
- `ollama pull bge-m3` a ete execute avec succes; `bge-m3:latest` est maintenant present localement.
|
||||
- `bash scripts/health-embeddings.sh --strict` passe et confirme la presence de `bge-m3`.
|
||||
- Le benchmark local resout maintenant correctement les noms de modeles tagges `:latest` et remonte les erreurs Ollama par modele.
|
||||
- Sur cette machine Apple/Metal, `bge-m3:latest` echoue au chargement avec une erreur `ggml_metal_init` / `MTLLibraryErrorDomain`.
|
||||
- Sur cette meme machine, `nomic-embed-text:latest` et `qwen3-embedding:0.6b` retournent aussi des `500` Ollama de chargement de modele, donc le benchmark numerique ne peut pas etre compare localement ici.
|
||||
|
||||
## Commandes utiles
|
||||
|
||||
- `npm run -s smoke:embeddings`
|
||||
- `bash scripts/health-embeddings.sh --strict`
|
||||
- `node scripts/bench-embeddings.js --models bge-m3 --json-only`
|
||||
- `node scripts/bench-embeddings.js --models qwen3-embedding:0.6b,bge-m3 --json-only`
|
||||
|
||||
## Decision actuelle
|
||||
|
||||
1. Invalider `bge-m3` comme upgrade local sur cette machine macOS/Metal tant que le runner Ollama termine sur `ggml_metal_init`.
|
||||
2. Conserver la baseline applicative actuelle et ne pas changer `apps/api/src/rag.ts` sur la base de ce host.
|
||||
3. Si on veut requalifier `bge-m3`, le refaire sur une cible Linux/CPU ou Linux/CUDA, pas sur ce host Apple/Metal.
|
||||
|
||||
## Sources officielles
|
||||
|
||||
- BGE-M3 model card: https://huggingface.co/BAAI/bge-m3
|
||||
- FlagEmbedding repository: https://github.com/FlagOpen/FlagEmbedding
|
||||
- Ollama embedding models overview: https://ollama.com/blog/embedding-models
|
||||
@@ -0,0 +1,410 @@
|
||||
# Spike: Integration Chatterbox TTS — 2026-03-19
|
||||
|
||||
## 1. Resume de Chatterbox
|
||||
|
||||
[Chatterbox](https://github.com/resemble-ai/chatterbox) est un moteur TTS open-source (MIT) de Resemble AI, 23 700+ stars GitHub. Package PyPI `chatterbox-tts` v0.1.6, Python >= 3.10.
|
||||
|
||||
### Trois variantes
|
||||
|
||||
| Variante | Params | Langues | Diffusion steps | Voice cloning | Emotion control | Paralinguistics |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **Original** | 0.5B (LLaMA) | EN uniquement | 10 | Oui (zero-shot) | Oui (exaggeration) | Non |
|
||||
| **Multilingual** | 0.5B | 23 langues (FR, EN, DE, ES, JP...) | 10 | Oui (zero-shot) | Oui (exaggeration) | Non |
|
||||
| **Turbo** | 350M | EN uniquement | 1 (distille) | Oui (zero-shot) | Non | Oui: `[laugh]`, `[cough]`, `[chuckle]` |
|
||||
|
||||
Points cles :
|
||||
- Zero-shot voice cloning via audio prompt (WAV/MP3, 6-30s de reference)
|
||||
- Entraine sur 0.5M heures de donnees nettoyees
|
||||
- Hot-swap entre les trois moteurs sans redemarrage
|
||||
- Licence MIT (pas de CPML comme XTTS-v2/Coqui)
|
||||
|
||||
### API Python native
|
||||
|
||||
```python
|
||||
from chatterbox.tts import ChatterboxTTS
|
||||
model = ChatterboxTTS.from_pretrained(device="cuda")
|
||||
wav = model.generate("Bonjour le monde", audio_prompt_path="ref.wav")
|
||||
|
||||
# Multilingual
|
||||
from chatterbox.mtl_tts import ChatterboxMultilingualTTS
|
||||
model = ChatterboxMultilingualTTS.from_pretrained(device="cuda")
|
||||
wav = model.generate("Bonjour", audio_prompt_path="ref.wav", language_id="fr")
|
||||
|
||||
# Turbo
|
||||
from chatterbox.turbo_tts import ChatterboxTurboTTS
|
||||
model = ChatterboxTurboTTS.from_pretrained(device="cuda")
|
||||
wav = model.generate("Hello [laugh] world", audio_prompt_path="ref.wav")
|
||||
```
|
||||
|
||||
## 2. Comparaison avec Piper
|
||||
|
||||
| Critere | Piper | Chatterbox Multilingual | Chatterbox Turbo |
|
||||
|---|---|---|---|
|
||||
| **Qualite vocale** | Moyenne (synthe VITS) | Excellente (LLaMA + diffusion) | Tres bonne (distille) |
|
||||
| **Latence** | ~50ms (CPU) | ~2-5s (GPU, 10 steps) | ~0.5-1.5s (GPU, 1 step) |
|
||||
| **VRAM** | 0 (CPU pur, ONNX) | ~3-4 GB | ~2 GB |
|
||||
| **Voice cloning** | Non (voix pre-entrainee) | Oui (zero-shot, audio ref) | Oui (zero-shot, audio ref) |
|
||||
| **Francais** | Oui (fr_FR-siwis, fr_FR-gilles) | Oui (natif, 23 langues) | Non (EN uniquement) |
|
||||
| **Emotion/expressivite** | Non | Oui (exaggeration slider) | Oui (tags paralinguistiques) |
|
||||
| **Licence** | MIT | MIT | MIT |
|
||||
| **Deps** | onnxruntime (leger) | torch, torchaudio (~2 GB) | torch, torchaudio (~2 GB) |
|
||||
| **Deployment** | Trivial, CPU anywhere | GPU requis (CUDA) | GPU requis (CUDA) |
|
||||
|
||||
### Verdict
|
||||
|
||||
- **Piper** reste indispensable comme fallback CPU (Docker, CI, machines sans GPU)
|
||||
- **Chatterbox Multilingual** est le choix principal pour la prod GPU: francais natif + voice cloning
|
||||
- **Chatterbox Turbo** interessant pour les personas EN uniquement (Batty, Moorcock, Sherlock) grace aux paralinguistics
|
||||
|
||||
## 3. Serveurs OpenAI-compatibles existants
|
||||
|
||||
### Option A: devnen/Chatterbox-TTS-Server (recommande)
|
||||
|
||||
- FastAPI, support complet des 3 moteurs (Original + Multilingual + Turbo)
|
||||
- Hot-swap entre moteurs via API
|
||||
- Endpoint principal: `POST /tts` avec params: text, voice mode, reference audio, temperature, exaggeration, cfg_weight, seed, speed_factor, language
|
||||
- OpenAI-compatible: `POST /v1/audio/speech` (drop-in)
|
||||
- Docker support natif, CUDA 12.8 compatible
|
||||
- Voices predefinies + voice cloning
|
||||
- Chunking intelligent pour textes longs
|
||||
- MIT License
|
||||
|
||||
### Option B: travisvn/chatterbox-tts-api
|
||||
|
||||
- FastAPI, OpenAI-compatible (`POST /v1/audio/speech`)
|
||||
- Voice library management (`GET /voices`, upload/delete)
|
||||
- Multilingual support (22 langues)
|
||||
- Streaming SSE pour audio progressif
|
||||
- Docker + docker-compose avec GPU
|
||||
- Note: support Turbo "coming soon" (pas encore integre)
|
||||
|
||||
### Recommandation
|
||||
|
||||
**devnen/Chatterbox-TTS-Server** est le choix optimal:
|
||||
- Support complet des 3 moteurs
|
||||
- CUDA 12.8 teste (RTX 4090/5090)
|
||||
- Hot-swap sans redemarrage
|
||||
- Plus mature et actif
|
||||
|
||||
## 4. Plan de migration du TTS sidecar
|
||||
|
||||
### Etat actuel
|
||||
|
||||
```
|
||||
Docker (API) --HTTP :9100--> tts-server.py (host)
|
||||
|
|
||||
TTS_BACKEND="chatterbox" ?
|
||||
/ \
|
||||
Chatterbox MTL Piper (fallback)
|
||||
(from_pretrained) (ONNX, CPU)
|
||||
```
|
||||
|
||||
Le `tts-server.py` actuel charge deja Chatterbox Multilingual via `chatterbox.mtl_tts.ChatterboxMultilingualTTS`. L'integration est donc a mi-chemin.
|
||||
|
||||
### Phase 1: Deployer Chatterbox-TTS-Server standalone (1-2 jours)
|
||||
|
||||
**Objectif**: Avoir un serveur TTS OpenAI-compatible sur kxkm-ai, testable independamment.
|
||||
|
||||
1. Cloner `devnen/Chatterbox-TTS-Server` sur kxkm-ai
|
||||
2. Creer un venv dedie `/home/kxkm/.venvs/chatterbox-server`
|
||||
3. Installer avec CUDA 12.8: `pip install chatterbox-tts torch torchaudio --index-url https://download.pytorch.org/whl/cu128`
|
||||
4. Configurer `config.yaml`:
|
||||
- `server.host: 127.0.0.1`
|
||||
- `server.port: 9200` (a cote du :9100 existant)
|
||||
- `model.engine: multilingual` (defaut pour le francais)
|
||||
- `paths.predefined_voices: /home/kxkm/kxkm_clown/data/voice-samples`
|
||||
5. Lancer via tmux: `tmux new-session -d -s chatterbox-server 'python server.py'`
|
||||
6. Tester: `curl -X POST http://127.0.0.1:9200/tts -H 'Content-Type: application/json' -d '{"text":"Bonjour"}'`
|
||||
7. Tester OpenAI-compat: `curl -X POST http://127.0.0.1:9200/v1/audio/speech -d '{"model":"chatterbox","input":"Bonjour","voice":"default"}'`
|
||||
|
||||
**Validation**: health check vert, audio genere, latence < 5s pour 100 chars.
|
||||
|
||||
### Phase 2: Migrer tts-server.py vers Chatterbox-TTS-Server (1-2 jours)
|
||||
|
||||
**Objectif**: Router les requetes TTS du conteneur Docker vers Chatterbox par defaut, Piper en fallback.
|
||||
|
||||
Option A (proxy simple, recommande):
|
||||
```
|
||||
Docker (API) --HTTP :9100--> tts-server.py (inchange)
|
||||
|
|
||||
TTS_BACKEND="chatterbox" ?
|
||||
/ \
|
||||
HTTP :9200 Piper (fallback CPU)
|
||||
(Chatterbox-TTS-Server)
|
||||
```
|
||||
|
||||
- Modifier `synthesize_chatterbox()` dans tts-server.py pour faire un `requests.post("http://127.0.0.1:9200/tts", ...)` au lieu de charger le modele en processus
|
||||
- Avantage: le modele tourne dans un processus dedie, pas de conflit memoire
|
||||
- Le fallback Piper reste en local dans tts-server.py
|
||||
|
||||
Option B (remplacement direct):
|
||||
- Exposer directement le Chatterbox-TTS-Server sur :9100
|
||||
- Ajouter un endpoint `/synthesize` compatible avec le contrat actuel
|
||||
- Plus propre mais plus de travail d'adaptation
|
||||
|
||||
**Recommandation**: Option A d'abord, migration vers B si stable.
|
||||
|
||||
### Phase 3: Voice cloning per-persona (2-3 jours)
|
||||
|
||||
**Objectif**: Chaque persona a sa propre voix clonee via Chatterbox.
|
||||
|
||||
1. Generer des samples de reference de haute qualite pour chaque persona:
|
||||
- Utiliser les samples Piper existants (`data/voice-samples/*.wav`) comme point de depart
|
||||
- Re-generer avec Chatterbox Multilingual pour une meilleure qualite
|
||||
- Ajuster manuellement: intonation, debit, expressivite
|
||||
2. Uploader les samples dans la voice library du Chatterbox-TTS-Server:
|
||||
- `POST /voices` pour chaque persona
|
||||
- Nommer les voix par basename persona (schaeffer, batty, etc.)
|
||||
3. Mettre a jour le `VOICE_MAP` dans tts-server.py pour mapper persona -> voix Chatterbox
|
||||
4. Tester chaque persona avec un texte representatif
|
||||
5. Valider la coherence de voix entre requetes (seed fixe)
|
||||
|
||||
## 5. Configuration recommandee pour kxkm-ai
|
||||
|
||||
### Hardware
|
||||
|
||||
- GPU: RTX 4090 (24 GB VRAM)
|
||||
- CUDA: 12.8
|
||||
- OS: Ubuntu 24.04
|
||||
|
||||
### Budget VRAM estime
|
||||
|
||||
| Composant | VRAM |
|
||||
|---|---|
|
||||
| Chatterbox Multilingual (0.5B) | ~3-4 GB |
|
||||
| Chatterbox Turbo (350M) | ~2 GB |
|
||||
| Ollama (qwen3:8b, quantise) | ~6-8 GB |
|
||||
| Total en usage parallele | ~10-12 GB |
|
||||
| Marge disponible RTX 4090 | ~12-14 GB |
|
||||
|
||||
Le budget VRAM est confortable. Les deux modeles Chatterbox peuvent coexister avec Ollama.
|
||||
|
||||
### Modeles a pre-telecharger
|
||||
|
||||
```bash
|
||||
# Le premier appel from_pretrained() telecharge depuis HuggingFace Hub
|
||||
# Cache: ~/.cache/huggingface/hub/
|
||||
python3 -c "from chatterbox.mtl_tts import ChatterboxMultilingualTTS; ChatterboxMultilingualTTS.from_pretrained(device='cuda')"
|
||||
python3 -c "from chatterbox.turbo_tts import ChatterboxTurboTTS; ChatterboxTurboTTS.from_pretrained(device='cuda')"
|
||||
```
|
||||
|
||||
### Config serveur recommandee
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: 127.0.0.1
|
||||
port: 9200
|
||||
log_file_max_size_mb: 10
|
||||
|
||||
model:
|
||||
engine: multilingual # defaut FR
|
||||
device: cuda
|
||||
|
||||
generation_defaults:
|
||||
temperature: 0.7
|
||||
exaggeration: 0.3 # leger pour naturalite
|
||||
cfg_weight: 0.5
|
||||
speed_factor: 1.0
|
||||
seed: -1 # aleatoire par defaut
|
||||
|
||||
paths:
|
||||
predefined_voices: ./voices
|
||||
reference_audio: ./reference
|
||||
output: ./output
|
||||
```
|
||||
|
||||
## 6. Mapping des 33 personas vers des voix Chatterbox
|
||||
|
||||
### Strategie
|
||||
|
||||
- **FR personas (majorite)**: Chatterbox Multilingual, `language_id="fr"`
|
||||
- **EN personas**: Chatterbox Turbo (paralinguistics) ou Multilingual `language_id="en"`
|
||||
- Chaque persona utilise son propre sample de reference pour le voice cloning
|
||||
|
||||
### Mapping complet
|
||||
|
||||
| # | ID | Nick | Langue | Moteur recommande | Voice sample ref | Notes |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | schaeffer | Schaeffer | FR | Multilingual | schaeffer.wav | Precis, technique |
|
||||
| 2 | batty | Batty | FR | Multilingual | batty.wav | Lyrique, sombre |
|
||||
| 3 | radigue | Radigue | FR | Multilingual | radigue.wav | Calme, meditatif |
|
||||
| 4 | oliveros | Oliveros | FR | Multilingual | oliveros.wav | Profond, posee |
|
||||
| 5 | sunra | SunRa | FR | Multilingual | sunra.wav | Cosmique, mystique |
|
||||
| 6 | haraway | Haraway | FR | Multilingual | haraway.wav | Intellectuel, militant |
|
||||
| 7 | pharmacius | Pharmacius | FR | Multilingual | pharmacius.wav | Orchestrateur, neutre |
|
||||
| 8 | turing | Turing | FR | Multilingual | turing.wav | Logique, questionneur |
|
||||
| 9 | swartz | Swartz | FR | Multilingual | swartz.wav | Passionne, activiste |
|
||||
| 10 | merzbow | Merzbow | FR | Multilingual | merzbow.wav | Bruit, intense |
|
||||
| 11 | hypatia | Hypatia | FR | Multilingual | hypatia.wav | Savante, posee |
|
||||
| 12 | decroux | Decroux | FR | Multilingual | decroux.wav | Corps, tension |
|
||||
| 13 | mnouchkine | Mnouchkine | FR | Multilingual | mnouchkine.wav | Theatre, collectif |
|
||||
| 14 | royaldlx | RoyalDeLuxe | FR | Multilingual | royaldeluxe.wav | Rue, populaire |
|
||||
| 15 | ikeda | Ikeda | FR | Multilingual | ikeda.wav | Donnees, minimal |
|
||||
| 16 | teamlab | TeamLab | FR | Multilingual | teamlab.wav | Ecologie numerique |
|
||||
| 17 | demoscene | Demoscene | FR | Multilingual | demoscene.wav | Code, contrainte |
|
||||
| 18 | pina | Pina | FR | Multilingual | pina.wav | Danse, emotion |
|
||||
| 19 | grotowski | Grotowski | FR | Multilingual | grotowski.wav | Theatre, rituel |
|
||||
| 20 | cirque | Fratellini | FR | Multilingual | fratellini.wav | Cirque, joie |
|
||||
| 21 | curie | Curie | FR | Multilingual | curie.wav | Science, aventure |
|
||||
| 22 | foucault | Foucault | FR | Multilingual | foucault.wav | Pouvoir, analyse |
|
||||
| 23 | deleuze | Deleuze | FR | Multilingual | deleuze.wav | Rhizome, multiplicite |
|
||||
| 24 | bookchin | Bookchin | FR | Multilingual | bookchin.wav | Ecologie sociale |
|
||||
| 25 | leguin | LeGuin | FR | Multilingual | leguin.wav | Utopie, recit |
|
||||
| 26 | cage | Cage | FR | Multilingual | cage.wav | Silence, hasard |
|
||||
| 27 | bjork | Bjork | FR | Multilingual | bjork.wav | Voix, nature, machine |
|
||||
| 28 | fuller | Fuller | FR | Multilingual | fuller.wav | Geodesique, systeme |
|
||||
| 29 | tarkovski | Tarkovski | FR | Multilingual | tarkovski.wav | Temps, image |
|
||||
| 30 | oram | Oram | FR | Multilingual | oram.wav | Electronique, decouverte |
|
||||
| 31 | sherlock | Sherlock | FR | Multilingual | sherlock.wav | Indices, deduction |
|
||||
| 32 | picasso | Picasso | FR | Multilingual | picasso.wav | Image, creation |
|
||||
| 33 | eno | Eno | FR | Multilingual | eno.wav | Systemes, surprise |
|
||||
| - | moorcock | Moorcock | EN | Turbo | moorcock.wav | `[laugh]` possible, multivers |
|
||||
|
||||
Notes:
|
||||
- Toutes les personas parlent FR par defaut (systemPrompt en francais)
|
||||
- Moorcock est la seule persona avec `en_GB-alan-medium` dans le VOICE_MAP actuel, candidate naturelle pour Turbo EN
|
||||
- Les tags paralinguistiques (`[laugh]`, `[cough]`) sont a injecter dans le systemPrompt ou le post-processing pour les personas EN/Turbo
|
||||
- L'exaggeration slider permet de differencier les tonalites: faible pour Radigue (calme), fort pour Merzbow (intense)
|
||||
|
||||
### Parametres d'expressivite par persona (suggestion)
|
||||
|
||||
| Persona | exaggeration | temperature | Notes |
|
||||
|---|---|---|---|
|
||||
| Pharmacius | 0.1 | 0.5 | Neutre, orchestrateur |
|
||||
| Radigue | 0.1 | 0.4 | Calme, meditatif |
|
||||
| Merzbow | 0.8 | 0.9 | Intense, bruitiste |
|
||||
| Batty | 0.6 | 0.7 | Lyrique, emotif |
|
||||
| SunRa | 0.5 | 0.8 | Cosmique, exalte |
|
||||
| Schaeffer | 0.2 | 0.5 | Precis, technique |
|
||||
| Sherlock | 0.3 | 0.5 | Analytique |
|
||||
| Grotowski | 0.5 | 0.6 | Rituel, intense |
|
||||
| Autres | 0.3 | 0.6 | Defaut equilibre |
|
||||
|
||||
## 7. API endpoints necessaires
|
||||
|
||||
### Endpoints existants dans tts-server.py (contrat actuel)
|
||||
|
||||
```
|
||||
POST /synthesize { text, voice, persona } -> audio/wav
|
||||
POST /compose { prompt, duration } -> audio/wav
|
||||
GET /health -> { ok, backend }
|
||||
```
|
||||
|
||||
### Endpoints cibles (Chatterbox-TTS-Server)
|
||||
|
||||
```
|
||||
# OpenAI-compatible (drop-in pour clients OpenAI)
|
||||
POST /v1/audio/speech
|
||||
Body: { model, input, voice, response_format, speed, seed }
|
||||
Response: audio/wav | audio/mp3 | audio/opus
|
||||
|
||||
# Endpoint natif (controle fin)
|
||||
POST /tts
|
||||
Body: {
|
||||
text, voice_mode ("predefined"|"clone"),
|
||||
predefined_voice, reference_audio,
|
||||
split_text, chunk_size,
|
||||
temperature, exaggeration, cfg_weight,
|
||||
seed, speed_factor, language
|
||||
}
|
||||
Response: audio/wav
|
||||
|
||||
# Voice management
|
||||
GET /voices -> liste des voix
|
||||
POST /voices -> upload voice sample
|
||||
DELETE /voices/{name} -> supprimer voix
|
||||
GET /languages -> langues supportees
|
||||
|
||||
# Health
|
||||
GET /health -> status serveur
|
||||
GET /api/ui/initial-data -> status complet + config
|
||||
|
||||
# Engine switching (devnen)
|
||||
POST /api/engine/switch -> { engine: "multilingual"|"turbo"|"original" }
|
||||
```
|
||||
|
||||
### Adaptation du contrat /synthesize
|
||||
|
||||
Pour la Phase 2 (proxy), le `tts-server.py` doit mapper:
|
||||
|
||||
```python
|
||||
# Requete entrante (contrat actuel)
|
||||
{ "text": "Bonjour", "persona": "schaeffer", "voice": "fr_FR-siwis-medium" }
|
||||
|
||||
# Requete sortante vers Chatterbox-TTS-Server
|
||||
POST http://127.0.0.1:9200/tts
|
||||
{
|
||||
"text": "Bonjour",
|
||||
"voice_mode": "predefined",
|
||||
"predefined_voice": "schaeffer",
|
||||
"temperature": 0.5,
|
||||
"exaggeration": 0.2,
|
||||
"language": "fr"
|
||||
}
|
||||
```
|
||||
|
||||
## 8. Risques et mitigations
|
||||
|
||||
| Risque | Impact | Probabilite | Mitigation |
|
||||
|---|---|---|---|
|
||||
| **VRAM OOM** avec Ollama + Chatterbox simultanes | Service down | Moyenne | Budget VRAM valide (~12 GB sur 24). Monitor `nvidia-smi`. Unload moteur non utilise. |
|
||||
| **Latence Chatterbox** trop elevee pour chat temps reel | UX degradee | Moyenne | Utiliser Turbo (1-step) pour les cas sensibles a la latence. Piper fallback pour < 200ms. |
|
||||
| **Qualite voice cloning FR** insuffisante | Voix non naturelle | Faible | Chatterbox Multilingual entraine sur FR. Tester avec les 33 personas. Ajuster exaggeration/temperature. |
|
||||
| **Chatterbox Turbo** ne supporte pas le FR | Personas FR limitees | Certaine | Turbo = EN only. Utiliser Multilingual pour FR (toutes sauf Moorcock). |
|
||||
| **Regression API** lors de la migration | Breaking change | Faible | Phase 2 en proxy: le contrat /synthesize reste identique. Tests smoke avant deploy. |
|
||||
| **Deps Python** en conflit avec venv existant | Installation bloquee | Faible | Venv dedie `/home/kxkm/.venvs/chatterbox-server`, isole de `/home/kxkm/venv`. |
|
||||
| **Licence Coqui CPML** (XTTS-v2) vs MIT (Chatterbox) | Legal | N/A | Migration vers Chatterbox = sortie du regime CPML. Bonus juridique. |
|
||||
| **Modele HuggingFace** non disponible offline | Premiere utilisation | Faible | Pre-telecharger les modeles pendant le provisioning. Cache HF persistant. |
|
||||
| **Coexistence mascarade** sur kxkm-ai | Conflit ports/GPU | Faible | Ports distincts (9200 vs 3000+). VRAM suffisante pour coexister. |
|
||||
|
||||
## 9. Estimation d'effort
|
||||
|
||||
| Phase | Tache | Effort | Prerequis |
|
||||
|---|---|---|---|
|
||||
| **Phase 1** | Deployer Chatterbox-TTS-Server standalone | 1-2 jours | Acces SSH kxkm-ai |
|
||||
| | Configurer, tester, valider health | | |
|
||||
| **Phase 2** | Modifier tts-server.py (proxy HTTP) | 1-2 jours | Phase 1 validee |
|
||||
| | Tests smoke end-to-end | | |
|
||||
| **Phase 3** | Voice cloning 33 personas | 2-3 jours | Phase 2 validee |
|
||||
| | Upload samples, tuning expressivite | | |
|
||||
| | Validation qualite par persona | | |
|
||||
| **Total** | | **4-7 jours** | |
|
||||
|
||||
### Dependances externes
|
||||
|
||||
- Aucun cout cloud (tout on-prem sur kxkm-ai)
|
||||
- Modeles HuggingFace gratuits (MIT)
|
||||
- Pas de TOS/CPML a accepter (contrairement a XTTS-v2)
|
||||
|
||||
### Quick wins
|
||||
|
||||
1. Le `tts-server.py` charge deja `chatterbox.mtl_tts.ChatterboxMultilingualTTS` -- l'integration est a mi-chemin
|
||||
2. Les voice samples existent deja dans `data/voice-samples/`
|
||||
3. Le deploy script `scripts/deploy.sh --tts` est deja en place (tmux session)
|
||||
4. Le contrat API `/synthesize` n'a pas besoin de changer cote client
|
||||
|
||||
## 10. Decision et prochaines etapes
|
||||
|
||||
### Recommandation
|
||||
|
||||
Adopter **devnen/Chatterbox-TTS-Server** comme serveur TTS principal, en remplacement progressif de l'integration directe dans tts-server.py.
|
||||
|
||||
### Prochaines etapes
|
||||
|
||||
1. [ ] Valider le spike avec l'equipe
|
||||
2. [ ] Phase 1: deployer sur kxkm-ai (:9200)
|
||||
3. [ ] Benchmark latence/qualite sur 5 personas representatives
|
||||
4. [ ] Phase 2: modifier tts-server.py en mode proxy
|
||||
5. [ ] Phase 3: voice cloning complet des 33 personas
|
||||
6. [ ] Retirer la dependance XTTS-v2/Coqui (simplification, exit CPML)
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- https://github.com/resemble-ai/chatterbox (23 700+ stars, MIT)
|
||||
- https://pypi.org/project/chatterbox-tts/ (v0.1.6)
|
||||
- https://github.com/devnen/Chatterbox-TTS-Server (FastAPI, OpenAI-compat, 3 moteurs)
|
||||
- https://github.com/travisvn/chatterbox-tts-api (FastAPI, OpenAI-compat, voice library)
|
||||
- Code existant: `scripts/tts-server.py`, `scripts/generate-voice-samples.js`, `scripts/tts_clone_voice.py`
|
||||
- Validation precedente: `docs/VOICE_CLONING_VALIDATION_2026-03-17.md`
|
||||
@@ -0,0 +1,45 @@
|
||||
# Documents / Search Spike - 2026-03-17
|
||||
|
||||
## Etat local confirme
|
||||
|
||||
- `apps/api/src/web-search.ts` tente deja `SearXNG` en premier, puis `WEB_SEARCH_API_BASE`, puis DuckDuckGo.
|
||||
- `docker-compose.yml` expose deja un service `searxng` sous le profil `v2`.
|
||||
- `ops/v2/searxng/settings.yml` versionne maintenant la config locale pour autoriser `format=json`.
|
||||
- `apps/api/src/ws-upload-handler.ts` envoie deja les PDFs vers `scripts/extract_pdf_docling.py`.
|
||||
- `scripts/extract_pdf_docling.py` sait utiliser `Docling`, puis `PyMuPDF` en fallback.
|
||||
- `scripts/extract_document.py` couvre deja les formats bureautiques hors PDF.
|
||||
- `MinerU` n'est pas encore branche runtime; il reste un spike adjacent.
|
||||
|
||||
## Gaps reels
|
||||
|
||||
- Pas de check ops unifie pour distinguer `seam pret` et `service/dependance effectivement provisionne`.
|
||||
- Pas de branchement runtime MinerU dans le pipeline upload.
|
||||
- Pas de verification CI/ops legere pour la presence des deps `docling`, `fitz` ou `magic_pdf`.
|
||||
|
||||
## Recommandation minimale
|
||||
|
||||
1. Garder `SearXNG` comme backend prioritaire, avec fallback visible et explicite.
|
||||
2. Traiter `Docling` comme premier parseur PDF local, puis `PyMuPDF` en repli.
|
||||
3. Garder `MinerU` au stade spike jusqu'a preuve de valeur sur des PDFs complexes.
|
||||
4. Utiliser un health check ops non destructif avant toute activation stricte en runtime.
|
||||
|
||||
## Commandes utiles
|
||||
|
||||
- `npm run smoke:documents-search`
|
||||
- `bash scripts/health-doc-search.sh all --verbose`
|
||||
- `bash scripts/health-doc-search.sh search --strict`
|
||||
- `bash scripts/health-doc-search.sh docs`
|
||||
- `docker compose --profile v2 config --services`
|
||||
|
||||
## Etat machine observe
|
||||
|
||||
- `docker compose --profile v2 config --services` expose bien `searxng`, `api` et `worker`.
|
||||
- Sur cette machine, `SearXNG` tourne sur `http://localhost:8080` et `bash scripts/health-doc-search.sh search --strict` est vert.
|
||||
- Les modules Python `docling`, `fitz` et `magic_pdf` ne sont pas provisionnes actuellement.
|
||||
|
||||
## Sources officielles
|
||||
|
||||
- SearXNG docs: https://docs.searxng.org/
|
||||
- SearXNG GitHub: https://github.com/searxng/searxng
|
||||
- Docling docs: https://docling-project.github.io/docling/
|
||||
- MinerU repo: https://github.com/opendatalab/MinerU
|
||||
+12
-6
@@ -99,8 +99,10 @@ flowchart TD
|
||||
| --- | --- |
|
||||
| RAG local | Embeddings via Ollama (`nomic-embed-text`), cosine similarity, contexte manifeste injecte |
|
||||
| STT (Speech-to-Text) | `faster-whisper` (CTranslate2, int8) ou fallback `openai-whisper`, modeles tiny/base/small/medium/large |
|
||||
| TTS (Text-to-Speech) | `piper-tts` (fallback rapide) + XTTS-v2 (voice cloning zero-shot per persona, GPU) |
|
||||
| TTS (Text-to-Speech) | Dual backend: `piper-tts` (CPU) + `Chatterbox` (GPU, emotion tags, MIT) via TTS sidecar HTTP :9100 |
|
||||
| Vision | `qwen3-vl:8b` via Ollama, analyse d'images uploadees, description en francais |
|
||||
| VoiceChat | Push-to-talk (maintenir), level meter temps reel, silence auto 2s, 26 voix piper |
|
||||
| Mediatheque | Gallery images + playlist audio, persistance disk, API REST |
|
||||
| Extraction PDF | Docling (tables, layout, OCR) + fallback PyMuPDF |
|
||||
| Texte/CSV/JSON | Lecture directe des fichiers texte, CSV, JSONL uploades |
|
||||
| Generation musicale | `/compose` — ACE-Step 1.5 (GPU, <4GB VRAM) + fallback MusicGen |
|
||||
@@ -234,8 +236,10 @@ flowchart TD
|
||||
| **Multimodal** | | | |
|
||||
| RAG (embeddings locaux) | -- | OK | haute |
|
||||
| STT (faster-whisper) | -- | OK | haute |
|
||||
| TTS (piper-tts) | -- | OK | moyenne |
|
||||
| Vision (minicpm-v) | -- | OK | haute |
|
||||
| TTS (piper + Chatterbox) | -- | OK | moyenne |
|
||||
| VoiceChat push-to-talk | -- | OK | haute |
|
||||
| Mediatheque gallery/playlist | -- | OK | moyenne |
|
||||
| Vision (qwen3-vl:8b) | -- | OK | haute |
|
||||
| Extraction PDF | -- | OK | moyenne |
|
||||
| Memoire persona | -- | OK | haute |
|
||||
| **Admin** | | | |
|
||||
@@ -277,7 +281,9 @@ flowchart TD
|
||||
| Cookies HttpOnly | OK | OK | haute |
|
||||
| RBAC roles | -- | OK | haute |
|
||||
| **Tests** | | | |
|
||||
| Backend unit tests | -- | OK (102) | haute |
|
||||
| React component tests | -- | OK (33) | haute |
|
||||
| Smoke tests | OK | OK (22) | haute |
|
||||
| Backend unit tests | -- | OK (204) | haute |
|
||||
| React component tests | -- | OK | haute |
|
||||
| Smoke tests | OK | OK | haute |
|
||||
| UI Minitel VIDEOTEX | -- | OK | haute |
|
||||
| Deploy tmux (deploy.sh) | -- | OK | moyenne |
|
||||
| CI/CD GitHub Actions | -- | OK | haute |
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
# Spike: Integration LightRAG pour kxkm_clown
|
||||
|
||||
**Date**: 2026-03-19
|
||||
**Auteur**: Claude (spike automatise)
|
||||
**Statut**: DRAFT
|
||||
**Version cible LightRAG**: v1.4.11 (lightrag-hku sur PyPI)
|
||||
|
||||
---
|
||||
|
||||
## 1. Resume de LightRAG
|
||||
|
||||
### Qu'est-ce que LightRAG ?
|
||||
|
||||
LightRAG (HKU) est un framework de Graph RAG open-source (licence MIT) qui combine
|
||||
la recherche vectorielle classique avec un graphe de connaissances (knowledge graph)
|
||||
extrait automatiquement des documents. Contrairement au RAG naif (chunk + cosine similarity),
|
||||
LightRAG construit un graphe d'entites et de relations, ce qui permet des requetes
|
||||
multi-niveaux : local (contexte precis), global (themes generaux), hybrid, et mix
|
||||
(graphe + vecteurs combines).
|
||||
|
||||
### Fonctionnalites cles
|
||||
|
||||
| Fonctionnalite | Detail |
|
||||
|---|---|
|
||||
| **Graph RAG** | Extraction automatique d'entites/relations via LLM, stockage dans un graphe |
|
||||
| **Modes de requete** | `local`, `global`, `hybrid`, `naive`, `mix` (KG + vecteurs), `bypass` |
|
||||
| **Backends LLM** | OpenAI, Ollama, vLLM, Hugging Face, tout API compatible OpenAI |
|
||||
| **Backends Embedding** | OpenAI, Ollama, Sentence-Transformers, API compatible |
|
||||
| **Stockage vectoriel** | NanoVectorDB (defaut), PostgreSQL (pgvector), Milvus, Chroma, Faiss, Qdrant, MongoDB |
|
||||
| **Stockage graphe** | NetworkX (defaut), Neo4J, PostgreSQL (AGE), OpenSearch |
|
||||
| **Stockage KV** | JSON (defaut), PostgreSQL, Redis, MongoDB, OpenSearch |
|
||||
| **Reranker** | Support natif (bge-reranker-v2-m3, Jina), ameliore significativement le mode mix |
|
||||
| **API REST** | Serveur integre (lightrag-server) avec Web UI, API compatible Ollama |
|
||||
| **Docker** | docker-compose officiel inclus |
|
||||
| **Citations** | Support des citations/sources dans les reponses |
|
||||
| **Deletion** | Suppression de documents avec regeneration automatique du KG |
|
||||
| **Cache LLM** | Cache des reponses LLM pour eviter les appels redondants |
|
||||
|
||||
### Architecture interne
|
||||
|
||||
```
|
||||
Documents
|
||||
|
|
||||
v
|
||||
[Chunking (1200 tokens, overlap 100)]
|
||||
|
|
||||
v
|
||||
[Embedding via Ollama/OpenAI] --> [Vector Store (pgvector)]
|
||||
|
|
||||
v
|
||||
[Entity/Relation Extraction via LLM] --> [Knowledge Graph (NetworkX/PG)]
|
||||
|
|
||||
v
|
||||
[KV Store (metadonnees, doc status)] --> [PostgreSQL/JSON]
|
||||
|
||||
Query:
|
||||
User Query --> [Embedding] --> [Vector Search] \
|
||||
--> [Merge + Rerank] --> [LLM Generation]
|
||||
--> [KG Traversal (entites/relations)] /
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Comparaison avec le RAG actuel (LocalRAG)
|
||||
|
||||
### LocalRAG actuel (`apps/api/src/rag.ts`)
|
||||
|
||||
Le systeme actuel est un RAG minimal in-memory en TypeScript :
|
||||
|
||||
- **Chunking** : Split par paragraphes, fusion des chunks courts (max 500 chars)
|
||||
- **Embedding** : nomic-embed-text via Ollama `/api/embed`
|
||||
- **Stockage** : En memoire (array de `DocumentChunk[]`), perdu au restart
|
||||
- **Recherche** : Cosine similarity brute-force, seuil min 0.3
|
||||
- **Documents indexes** : `manifeste.md`, `manifeste_references_nouvelles.md`
|
||||
- **Integration** : Injecte via `ChatOptions.rag` dans ws-chat et ws-ollama
|
||||
|
||||
### Tableau comparatif
|
||||
|
||||
| Critere | LocalRAG (actuel) | LightRAG (cible) |
|
||||
|---|---|---|
|
||||
| **Type** | Vector RAG naif | Graph RAG + Vector RAG |
|
||||
| **Langage** | TypeScript | Python |
|
||||
| **Persistance** | Aucune (in-memory) | PostgreSQL / fichiers |
|
||||
| **Chunking** | 500 chars, paragraphes | 1200 tokens, overlap intelligent |
|
||||
| **Recherche** | Cosine brute-force | Multi-mode (local/global/hybrid/mix) |
|
||||
| **Graphe de connaissances** | Non | Oui (entites + relations) |
|
||||
| **Reranking** | Non | Oui (bge-reranker) |
|
||||
| **Scalabilite** | ~100 chunks max raisonnable | Datasets large-scale |
|
||||
| **Cache** | Non | Cache LLM integre |
|
||||
| **Web UI** | Non | Oui (lightrag-server) |
|
||||
| **API REST** | Non (code embarque) | Oui (compatible Ollama) |
|
||||
| **Maintenance** | Custom, fragile | Communaute active (22k+ stars) |
|
||||
|
||||
### Points forts de LocalRAG a conserver
|
||||
|
||||
- Simplicite d'integration (meme process Node.js)
|
||||
- Latence minimale (pas de round-trip reseau supplementaire)
|
||||
- Zero dependance externe
|
||||
|
||||
### Points faibles de LocalRAG justifiant la migration
|
||||
|
||||
- Pas de persistance : re-indexation a chaque restart
|
||||
- Pas de graphe de connaissances : les relations entre concepts du manifeste sont perdues
|
||||
- Chunking naif : decoupe par paragraphes sans respect des limites semantiques
|
||||
- Pas de reranking : les resultats sont parfois peu pertinents
|
||||
- Pas d'UI d'administration pour visualiser les documents indexes
|
||||
|
||||
---
|
||||
|
||||
## 3. Plan d'integration en 3 phases
|
||||
|
||||
### Phase 1 : Installation et configuration standalone
|
||||
|
||||
**Objectif** : LightRAG tourne sur kxkm-ai, accessible via API REST, valide avec les documents existants.
|
||||
|
||||
**Taches** :
|
||||
|
||||
1. Deployer LightRAG via Docker Compose sur kxkm-ai
|
||||
- Image officielle `ghcr.io/hkuds/lightrag`
|
||||
- Configurer `.env` pour utiliser Ollama local (localhost:11434)
|
||||
- Modele LLM : `qwen3:8b` (deja sur le serveur, 8B params suffisants pour extraction d'entites)
|
||||
- Modele embedding : `nomic-embed-text` (coherence avec le systeme actuel)
|
||||
- Stockage : PostgreSQL (meme instance que le projet, database separee `lightrag`)
|
||||
2. Verifier le fonctionnement :
|
||||
- Inserer un document test via API REST
|
||||
- Tester les 4 modes de requete (local, global, hybrid, mix)
|
||||
- Valider la Web UI
|
||||
3. Benchmarker les performances :
|
||||
- Temps d'indexation du manifeste (~10 KB)
|
||||
- Latence de requete par mode
|
||||
- Utilisation GPU (extraction d'entites via qwen3:8b)
|
||||
|
||||
**Criteres de succes** :
|
||||
- LightRAG repond aux requetes en < 5s
|
||||
- Le graphe de connaissances contient des entites pertinentes du manifeste
|
||||
- Pas d'impact sur les performances d'Ollama pour les autres usages
|
||||
|
||||
**Effort estime** : 0.5 jour
|
||||
|
||||
### Phase 2 : Migration des documents manifeste vers LightRAG
|
||||
|
||||
**Objectif** : Tous les documents de reference du projet sont indexes dans LightRAG avec un graphe de connaissances complet.
|
||||
|
||||
**Taches** :
|
||||
|
||||
1. Indexer les documents existants :
|
||||
- `data/manifeste.md`
|
||||
- `data/manifeste_references_nouvelles.md`
|
||||
- Descriptions des personas (system prompts)
|
||||
2. Enrichir le corpus :
|
||||
- Ajouter les documents de reference artistique
|
||||
- Ajouter les fiches personas detaillees
|
||||
3. Valider la qualite du graphe :
|
||||
- Visualiser le KG via la Web UI
|
||||
- Verifier les entites extraites (artistes, concepts, references)
|
||||
- Ajuster les parametres d'extraction si necessaire (`entity_types`, `language`)
|
||||
4. Configurer les parametres de requete optimaux :
|
||||
- Tester `addon_params.language: "French"` pour l'extraction
|
||||
- Ajuster `chunk_token_size` si necessaire
|
||||
- Tester le reranker si disponible
|
||||
|
||||
**Criteres de succes** :
|
||||
- Les requetes en francais retournent des resultats pertinents
|
||||
- Le graphe contient les entites cles : artistes, oeuvres, concepts esthetiques
|
||||
- Les relations entre concepts sont coherentes
|
||||
|
||||
**Effort estime** : 1 jour
|
||||
|
||||
### Phase 3 : Remplacement de LocalRAG par un adapter LightRAG
|
||||
|
||||
**Objectif** : Le code de l'API Node.js utilise LightRAG via son API REST au lieu de LocalRAG.
|
||||
|
||||
**Taches** :
|
||||
|
||||
1. Creer un adapter `LightRAGClient` dans `apps/api/src/` :
|
||||
```typescript
|
||||
// apps/api/src/lightrag-client.ts
|
||||
export class LightRAGClient {
|
||||
constructor(private baseUrl: string) {}
|
||||
|
||||
async search(query: string, mode: "hybrid" | "mix" = "mix", topK = 3): Promise<SearchResult[]>
|
||||
async insert(text: string, source: string): Promise<void>
|
||||
get ready(): boolean
|
||||
}
|
||||
```
|
||||
2. L'adapter doit implementer la meme interface que `LocalRAG` (methodes `search`, `addDocument`, `size`)
|
||||
3. Modifier `server.ts` pour instancier `LightRAGClient` au lieu de `LocalRAG`
|
||||
4. Mettre a jour `ChatOptions` dans `chat-types.ts` pour accepter le nouveau type
|
||||
5. Conserver `LocalRAG` comme fallback si LightRAG est indisponible
|
||||
6. Ajouter un health check LightRAG dans le monitoring
|
||||
|
||||
**Points d'integration** :
|
||||
- `apps/api/src/server.ts` : ligne 44 (`new LocalRAG(...)` -> `new LightRAGClient(...)`)
|
||||
- `apps/api/src/ws-conversation-router.ts` : ligne 95-97 (appel `rag.search`)
|
||||
- `apps/api/src/ws-ollama.ts` : ligne 145-146 (appel `rag.search`)
|
||||
- `apps/api/src/chat-types.ts` : ligne 34 (type `rag?: LocalRAG`)
|
||||
|
||||
**Criteres de succes** :
|
||||
- Les personas utilisent LightRAG pour le contexte sans changement d'UX
|
||||
- Fallback vers LocalRAG si LightRAG est down
|
||||
- Temps de reponse total < 8s (incluant RAG + LLM generation)
|
||||
|
||||
**Effort estime** : 1 jour
|
||||
|
||||
---
|
||||
|
||||
## 4. Configuration recommandee pour Ollama
|
||||
|
||||
### Modeles requis
|
||||
|
||||
| Usage | Modele | Taille | Deja installe? |
|
||||
|---|---|---|---|
|
||||
| **LLM (extraction KG)** | `qwen3:8b` | ~5 GB | A verifier |
|
||||
| **Embedding** | `nomic-embed-text` | ~274 MB | Oui (utilise par LocalRAG) |
|
||||
| **Reranker** (optionnel) | `bge-reranker-v2-m3` (via API) | ~1 GB | Non |
|
||||
|
||||
### Recommandations modeles
|
||||
|
||||
- **qwen3:8b** est recommande par LightRAG pour l'extraction d'entites/relations avec des LLM open-source.
|
||||
Le contexte de 32K tokens est suffisant. La RTX 4090 (24 GB VRAM) peut le charger confortablement.
|
||||
- **nomic-embed-text** : conserver le meme modele d'embedding garantit la coherence
|
||||
avec les embeddings existants et evite une re-indexation.
|
||||
- **Reranker** : Le reranker ameliore significativement le mode `mix`. A evaluer en Phase 2.
|
||||
|
||||
### Configuration Ollama
|
||||
|
||||
Pas de changement de config Ollama necessaire. LightRAG utilise les endpoints standard :
|
||||
- `POST /api/chat` (LLM generation)
|
||||
- `POST /api/embed` (embeddings)
|
||||
|
||||
### Storage
|
||||
|
||||
```
|
||||
data/lightrag/ # Working directory LightRAG
|
||||
├── graph_chunk_entity_relation.graphml # Export graphe (si NetworkX)
|
||||
└── kv_store_llm_response_cache.json # Cache LLM
|
||||
```
|
||||
|
||||
Pour PostgreSQL (recommande en production) :
|
||||
- Database : `lightrag` (separee de la DB principale du projet)
|
||||
- Extensions requises : `pgvector`, `age` (pour le graph storage)
|
||||
- Tables creees automatiquement par LightRAG
|
||||
|
||||
---
|
||||
|
||||
## 5. Risques et mitigations
|
||||
|
||||
| # | Risque | Impact | Probabilite | Mitigation |
|
||||
|---|---|---|---|---|
|
||||
| R1 | **Qualite extraction FR** : LightRAG est optimise pour l'anglais, l'extraction d'entites en francais peut etre moins precise | Moyen | Moyenne | Configurer `addon_params.language: "French"`, tester avec qwen3:8b qui a un bon support multilingue |
|
||||
| R2 | **Latence** : L'extraction d'entites via LLM ajoute de la latence a l'indexation | Faible | Haute | L'indexation est asynchrone (au demarrage), pas d'impact sur les requetes. Requetes < 5s en mode hybrid |
|
||||
| R3 | **Ressources GPU** : qwen3:8b pour l'extraction + modele persona en parallele | Moyen | Moyenne | Ollama gere le swapping de modeles. Indexation en batch hors peak. La 4090 a 24 GB VRAM |
|
||||
| R4 | **Complexite operationnelle** : Ajout d'un service Python (LightRAG server) a maintenir | Moyen | Faible | Docker Compose, health checks, monitoring existant. Service stateless |
|
||||
| R5 | **Lock-in embedding** : Changer de modele d'embedding necessite une re-indexation complete | Faible | Faible | Conserver nomic-embed-text, documenter la procedure de migration |
|
||||
| R6 | **Indisponibilite LightRAG** : Si le service tombe, les personas n'ont plus de contexte RAG | Moyen | Faible | Fallback vers LocalRAG in-memory. Health check avec alerte |
|
||||
| R7 | **Taille du graphe** : Avec peu de documents (~20 KB de manifeste), le graphe peut etre trop sparse | Faible | Moyenne | Enrichir le corpus en Phase 2. Le mode `naive` reste disponible comme fallback |
|
||||
|
||||
---
|
||||
|
||||
## 6. Estimation d'effort total
|
||||
|
||||
| Phase | Effort | Prerequis |
|
||||
|---|---|---|
|
||||
| Phase 1 : Standalone | 0.5 jour | Acces SSH kxkm-ai, Docker |
|
||||
| Phase 2 : Migration docs | 1 jour | Phase 1 validee |
|
||||
| Phase 3 : Adapter API | 1 jour | Phase 2 validee |
|
||||
| **Total** | **2.5 jours** | |
|
||||
|
||||
### Dependances externes
|
||||
|
||||
- Docker et Docker Compose sur kxkm-ai (deja present)
|
||||
- PostgreSQL avec extensions pgvector et age (a installer/activer)
|
||||
- Ollama avec qwen3:8b (a pull si absent)
|
||||
|
||||
### Definition of Done
|
||||
|
||||
- [ ] LightRAG server tourne sur kxkm-ai (Docker)
|
||||
- [ ] Documents manifeste indexes avec graphe de connaissances
|
||||
- [ ] Requetes en francais retournent des resultats pertinents (modes hybrid et mix)
|
||||
- [ ] Adapter LightRAGClient integre dans apps/api
|
||||
- [ ] Fallback LocalRAG fonctionnel
|
||||
- [ ] Health check et monitoring en place
|
||||
- [ ] Documentation operationnelle a jour
|
||||
|
||||
---
|
||||
|
||||
## 7. References
|
||||
|
||||
- LightRAG GitHub : https://github.com/HKUDS/LightRAG
|
||||
- Paper : https://arxiv.org/abs/2410.05779
|
||||
- PyPI : https://pypi.org/project/lightrag-hku/
|
||||
- Docker : https://github.com/HKUDS/LightRAG/pkgs/container/lightrag
|
||||
- Code actuel LocalRAG : `apps/api/src/rag.ts`
|
||||
- Points d'integration : `apps/api/src/server.ts`, `ws-conversation-router.ts`, `ws-ollama.ts`
|
||||
@@ -0,0 +1,69 @@
|
||||
# EXECUTION STATUS (kxkm-clown-v2)
|
||||
|
||||
Updated: 2026-03-17T22:13:03Z
|
||||
|
||||
## lot-0-cadrage
|
||||
- Status: done
|
||||
- Owner: Coordinateur
|
||||
- Execution: managed
|
||||
- Checks: docs-reviewed
|
||||
- Open tasks: none
|
||||
|
||||
## lot-1-socle
|
||||
- Status: done
|
||||
- Owner: Coordinateur
|
||||
- Execution: managed
|
||||
- Checks: npm run check:v2, npm run test:v2
|
||||
- Open tasks: none
|
||||
|
||||
## lot-2-domaines
|
||||
- Status: done
|
||||
- Owner: Backend API
|
||||
- Execution: managed
|
||||
- Checks: npm run test:v2
|
||||
- Open tasks: none
|
||||
|
||||
## lot-3-surfaces
|
||||
- Status: done
|
||||
- Owner: Frontend
|
||||
- Execution: managed
|
||||
- Checks: npm run -w @kxkm/web check
|
||||
- Open tasks: none
|
||||
|
||||
## lot-4-bascule
|
||||
- Status: done
|
||||
- Owner: Coordinateur
|
||||
- Execution: managed
|
||||
- Checks: npm run smoke:v2
|
||||
- Open tasks: none
|
||||
|
||||
## lot-12-deep-audit
|
||||
- Status: done
|
||||
- Owner: Coordinateur
|
||||
- Execution: manual
|
||||
- Checks: npm run check:v2, npm run test:v2, npm run -w @kxkm/web test, node ops/v2/deep-audit.js --json
|
||||
- Open tasks: none
|
||||
|
||||
## lot-13-voice-mcp
|
||||
- Status: done
|
||||
- Owner: Multimodal
|
||||
- Execution: manual
|
||||
- Checks: node scripts/mcp-server-smoke.js, npm run smoke:voice-mcp, npm run smoke:voice-clone, bash ops/v2/run-spike-checks.sh voice-clone --yes
|
||||
- Open tasks: none
|
||||
|
||||
## lot-14-documents-search
|
||||
- Status: done
|
||||
- Owner: Ops/TUI
|
||||
- Execution: manual
|
||||
- Checks: docker compose --profile v2 config --services, bash scripts/health-doc-search.sh search --strict, npm run smoke:documents-search, npm run smoke:embeddings, bash ops/v2/run-spike-checks.sh embeddings --yes
|
||||
- Open tasks: none
|
||||
|
||||
## lot-15-hotspot-reduction
|
||||
- Status: running
|
||||
- Owner: Coordinateur
|
||||
- Execution: manual
|
||||
- Checks: npm run -w @kxkm/persona-domain check, npm run test:v2, bash ops/v2/run-deep-cycle.sh run --yes
|
||||
- Open tasks:
|
||||
- node-engine-seams [pending] (P2, Worker/Engine)
|
||||
- storage-test-split [pending] (P3, Backend API)
|
||||
- web-chat-modularization [pending] (P2, Frontend)
|
||||
@@ -0,0 +1,55 @@
|
||||
# OSS Priorities - 2026-03-17
|
||||
|
||||
## Decision
|
||||
|
||||
Priorite d'integration pour le cycle actuel:
|
||||
|
||||
1. `SearXNG` comme backend de recherche self-hosted.
|
||||
2. `Docling` et `MinerU` comme spike document parsing adjacent.
|
||||
3. `MCP TypeScript SDK` comme standard d'outillage/personas.
|
||||
4. `LiveKit Agents JS` comme candidat principal pour le lot voice/WebRTC.
|
||||
|
||||
Projets gardes comme benchmarks produit, pas comme dependances embarquees:
|
||||
|
||||
- `Open WebUI`
|
||||
- `LibreChat`
|
||||
- `AnythingLLM`
|
||||
|
||||
## Shortlist
|
||||
|
||||
| Projet | Role retenu | Pourquoi maintenant | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| SearXNG | integration directe | moteur self-hosted, API JSON, alignement avec la recherche web locale | https://github.com/searxng/searxng , https://docs.searxng.org/ |
|
||||
| Docling | spike prioritaire | conversion multi-format, OCR, sorties Markdown/JSON, execution locale | https://docling-project.github.io/docling/ |
|
||||
| MinerU | spike prioritaire | PDF complexes, OCR CPU/GPU, oriente extraction LLM | https://github.com/opendatalab/MinerU |
|
||||
| MCP TypeScript SDK | integration directe | standardiser tools/resources/prompts cote personas et services | https://github.com/modelcontextprotocol/typescript-sdk |
|
||||
| LiveKit Agents JS | lot suivant | voice/WebRTC temps reel en Node, bon fit pour la suite voice-mcp | https://github.com/livekit/agents-js |
|
||||
|
||||
## Benchmarks produit
|
||||
|
||||
| Projet | Usage retenu | Source |
|
||||
| --- | --- | --- |
|
||||
| Open WebUI | benchmark UX local/Ollama/RAG | https://github.com/open-webui/open-webui |
|
||||
| LibreChat | benchmark multi-provider, auth, MCP, memory | https://www.librechat.ai/ , https://github.com/LibreChat-AI |
|
||||
| AnythingLLM | benchmark workspaces/RAG/agents | https://github.com/Mintplex-Labs/anything-llm |
|
||||
|
||||
## Notes d'adoption
|
||||
|
||||
- `SearXNG` est le seul candidat a brancher dans le cycle current sans attendre une refonte majeure.
|
||||
- `Docling` et `MinerU` doivent rester adjacents tant que la boucle `audit -> test -> resume -> sync-docs -> purge` n'est pas stabilisee.
|
||||
- `LiveKit Agents JS` ne doit pas entrer dans le cycle backend immediat; il reste assigne au lot voice-mcp.
|
||||
- `BGE-M3` reste un spike benchmark, pas une decision de remplacement immediate.
|
||||
|
||||
## Seams prets
|
||||
|
||||
| Zone | Etat actuel | Petite action utile maintenant |
|
||||
| --- | --- | --- |
|
||||
| Recherche web | `apps/api/src/web-search.ts` tente `SearXNG` puis `WEB_SEARCH_API_BASE` puis DuckDuckGo, et `scripts/mcp-server.js` a deja un fallback SearXNG. | Utiliser `scripts/health-doc-search.sh search --strict` pour valider le endpoint JSON et garder le fallback visible en ops. |
|
||||
| PDF/doc parsing | `apps/api/src/ws-upload-handler.ts` appelle deja `scripts/extract_pdf_docling.py` pour les PDFs et `scripts/extract_document.py` pour le reste. | Utiliser `scripts/health-doc-search.sh docs` pour verifier les deps `docling`/`PyMuPDF` et la presence du futur chemin `magic_pdf` MinerU. |
|
||||
| Docs de reference | `docs/DOCUMENT_AI_STATE_OF_ART_2026.md` recommande Docling d'abord, MinerU ensuite. | Laisser le produit inchangé et documenter l'ordre d'adoption avant tout branchement runtime. |
|
||||
|
||||
### Commande de preparation
|
||||
|
||||
```bash
|
||||
bash scripts/health-doc-search.sh all --verbose
|
||||
```
|
||||
@@ -0,0 +1,89 @@
|
||||
# Veille OSS — 2026-03-19
|
||||
|
||||
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 |
|
||||
|
||||
## Nouvelles decouvertes (mars 2026)
|
||||
|
||||
### 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).
|
||||
|
||||
### RAG
|
||||
|
||||
- **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.
|
||||
|
||||
### Multi-persona / Agents
|
||||
|
||||
- **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 |
|
||||
+1
-1
@@ -267,7 +267,7 @@ Les images uploadees sont analysees via un modele Ollama compatible vision.
|
||||
|
||||
| Parametre | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `VISION_MODEL` | `minicpm-v` | Modele Ollama avec capacite vision |
|
||||
| `VISION_MODEL` | `qwen3-vl:8b` | Modele Ollama avec capacite vision |
|
||||
| Timeout | 5 minutes | Timeout d'analyse |
|
||||
| Prompt | Fixe | "Analyse cette image en detail. Decris ce que tu vois..." (francais) |
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Voice Cloning Validation - 2026-03-17
|
||||
|
||||
## Etat local confirme
|
||||
|
||||
- Le runtime principal sait deja basculer vers XTTS-v2 quand un sample voix existe: `apps/api/src/ws-multimodal.ts`.
|
||||
- L'upload et le statut des samples voix existent deja cote admin: `apps/api/src/routes/personas.ts`, `apps/web/src/api.ts`, `apps/web/src/components/PersonaDetail.tsx`.
|
||||
- Le repo contient deja les scripts XTTS `scripts/tts_clone_voice.py` et `scripts/xtts_clone.py`, plus le fallback Piper `scripts/tts_synthesize.py`.
|
||||
- Le helper `apps/api/src/voice-samples.ts` unifie maintenant la resolution du nom de fichier entre upload admin et runtime TTS.
|
||||
- Le health check `scripts/health-voice-clone.sh` fournit un probe non destructif des deps XTTS et des samples.
|
||||
|
||||
## Etat machine observe
|
||||
|
||||
- `npm run -s smoke:voice-clone` passe et sonde maintenant le meme interpreteur que le runtime API.
|
||||
- Le runtime local `.venvs/voice-clone` est provisionne en `python3.12` avec `torch`, `coqui-tts`, `piper-tts` et `transformers<5`.
|
||||
- `scripts/generate-voice-samples.js` consomme maintenant le roster canonique de `apps/api/src/personas-default.ts` et le meme contrat de nommage que le runtime.
|
||||
- `data/voice-samples/pharmacius.wav` a ete genere avec Piper, et `data/piper-voices/fr_FR-gilles-low.onnx` est present localement.
|
||||
- Sur `kxkm-ai`, `ffmpeg` est present, `bash scripts/health-voice-clone.sh --json --verbose` remonte `torch=true`, `tts=true`, `piper_module=true`, `coqui_tos_agreed=true`, `cuda=true` et `persona_sample_present=true`.
|
||||
- Le smoke XTTS non interactif passe maintenant sur `kxkm-ai` avec `COQUI_TOS_AGREED=1 bash scripts/setup-voice-clone.sh all --persona pharmacius --yes --verbose`, et produit un rendu audio valide via `scripts/tts_clone_voice.py`.
|
||||
|
||||
## Commandes utiles
|
||||
|
||||
- `npm run -s smoke:voice-clone`
|
||||
- `bash scripts/health-voice-clone.sh --json --verbose`
|
||||
- `bash scripts/setup-voice-clone.sh bootstrap --yes`
|
||||
- `bash scripts/setup-voice-clone.sh sample --persona pharmacius --yes`
|
||||
- `COQUI_TOS_AGREED=1 bash scripts/setup-voice-clone.sh smoke --persona pharmacius --yes`
|
||||
- `node scripts/generate-voice-samples.js --dry-run --persona SunRa`
|
||||
|
||||
## Decision actuelle
|
||||
|
||||
1. Garder Piper comme fallback immediat.
|
||||
2. Considerer le runtime XTTS comme valide sur `kxkm-ai`, avec garde-fous scripts, sample local valide et smoke final vert sous `COQUI_TOS_AGREED=1`.
|
||||
3. Fermer `voice-cloning-validation` et `lot-13-voice-mcp`; garder Piper comme voie de repli operationnelle.
|
||||
|
||||
## Sources officielles
|
||||
|
||||
- Coqui XTTS-v2 model card: https://huggingface.co/coqui/XTTS-v2
|
||||
- Coqui TTS repository: https://github.com/coqui-ai/TTS
|
||||
- Coqui XTTS streaming server note on `COQUI_TOS_AGREED=1`: https://github.com/coqui-ai/xtts-streaming-server
|
||||
@@ -0,0 +1,42 @@
|
||||
# Voice / MCP Spike - 2026-03-17
|
||||
|
||||
## Etat local confirme
|
||||
|
||||
- `apps/web/src/components/VoiceChat.tsx` est un chat vocal browser-side base sur `MediaRecorder`, `WebSocket` et un flux upload STT.
|
||||
- `apps/api/src/ws-multimodal.ts` fournit deja le TTS, la synthese vocale par persona et les garde-fous de concurrence.
|
||||
- `scripts/discord-voice.js` est un rail voice externe distinct, centre Discord, STT Python et TTS Python.
|
||||
- `scripts/mcp-server.js` utilise maintenant le SDK MCP officiel sur stdio.
|
||||
- `@modelcontextprotocol/sdk` est actif dans le runtime local et valide par smoke autonome.
|
||||
- Aucun paquet `@livekit/*` n'est installe dans le workspace pour l'instant.
|
||||
|
||||
## Gaps reels
|
||||
|
||||
- Pas de runtime LiveKit agent dans le repo.
|
||||
- Pas de transport browser WebRTC/room/agent pour remplacer le chat vocal WebSocket.
|
||||
- Pas de health check dedicated pour valider le serveur MCP local de maniere autonome.
|
||||
|
||||
## Recommandation minimale
|
||||
|
||||
1. Garder `VoiceChat` comme experience browser actuelle tant que le spike LiveKit n'a pas montre un vrai gain.
|
||||
2. Introduire un agent LiveKit dans un script adjoint seulement apres preuve de valeur, sans toucher au chat WebSocket principal.
|
||||
3. Conserver le serveur MCP sur le SDK officiel et garder le smoke stdio comme garde-fou de protocole.
|
||||
4. N'ajouter LiveKit/voice-cloning qu'apres validation d'un vrai besoin runtime.
|
||||
|
||||
## Commandes utiles
|
||||
|
||||
- `node scripts/mcp-server-smoke.js`
|
||||
- `node scripts/mcp-server-smoke.js --with-tool-call`
|
||||
- `node scripts/mcp-server.js`
|
||||
- `npm run smoke:voice-mcp`
|
||||
|
||||
## Etat machine observe
|
||||
|
||||
- Le smoke MCP valide `initialize` + `tools/list` sans API locale demarree, avec le SDK officiel.
|
||||
- Le `tools/call` peut etre force avec `--with-tool-call`, mais il depend alors de `KXKM_API_URL`.
|
||||
|
||||
## Sources officielles
|
||||
|
||||
- LiveKit Agents JS: https://github.com/livekit/agents-js
|
||||
- LiveKit Agents docs: https://docs.livekit.io/agents/
|
||||
- MCP SDK officiel: https://github.com/modelcontextprotocol/typescript-sdk
|
||||
- MCP SDK docs: https://modelcontextprotocol.io/docs/sdk
|
||||
@@ -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,18 @@
|
||||
{
|
||||
"timestamp": "20260317-230529",
|
||||
"mode": "all",
|
||||
"generated_at": "2026-03-17T22:05:31Z",
|
||||
"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-230529.log",
|
||||
"voice-clone": "/home/kxkm/KXKM_Clown/ops/v2/logs/voice-clone-smoke-20260317-230529.log",
|
||||
"documents-search": "/home/kxkm/KXKM_Clown/ops/v2/logs/documents-search-smoke-20260317-230529.log",
|
||||
"embeddings": "/home/kxkm/KXKM_Clown/ops/v2/logs/embeddings-smoke-20260317-230529.log"
|
||||
},
|
||||
"summary": "/home/kxkm/KXKM_Clown/ops/v2/outputs/spikes/summary-all-20260317-230529.md"
|
||||
}
|
||||
+48
-59
@@ -1,86 +1,75 @@
|
||||
# PLAN (kxkm-clown-v2)
|
||||
|
||||
Updated: 2026-03-17T08:45:00Z
|
||||
Updated: 2026-03-19T08:20:00Z
|
||||
|
||||
## lot-0-cadrage [done]
|
||||
|
||||
- Description: Docs, architecture, feature map, agents, invariants, orchestration
|
||||
- Depends on: none
|
||||
- Owner: Coordinateur
|
||||
- Execution: managed
|
||||
- Checks: docs-reviewed
|
||||
- Summary: Cadrage historique clos, conserve comme base de référence.
|
||||
|
||||
## lot-1-socle [done]
|
||||
|
||||
- Description: Workspace V2, packages, scripts TUI, verification
|
||||
- Depends on: lot-0-cadrage
|
||||
- Owner: Coordinateur
|
||||
- Execution: managed
|
||||
- Checks: npm run check:v2, npm run test:v2
|
||||
- Summary: Socle monorepo en place, scripts TUI et vérifications disponibles.
|
||||
|
||||
## lot-2-domaines [done]
|
||||
|
||||
- Description: Auth, chat, storage, personas, node engine
|
||||
- Depends on: lot-1-socle
|
||||
- Owner: Backend API
|
||||
- Execution: managed
|
||||
- Checks: npm run test:v2
|
||||
- Summary: Domaines métier V2 en place, avec couverture de base et repos réels.
|
||||
|
||||
## lot-3-surfaces [done]
|
||||
|
||||
- Description: Shell React/Vite, admin, chat, node engine, ops
|
||||
- Depends on: lot-2-domaines
|
||||
- Owner: Frontend
|
||||
- Execution: managed
|
||||
- Checks: npm run -w @kxkm/web check
|
||||
- Summary: Surfaces V2 disponibles, avec dette UI et duplication admin encore présentes.
|
||||
|
||||
## lot-4-bascule [done]
|
||||
|
||||
- Description: Migration, parite, rollback, bascule
|
||||
- Description: Migration, parité, rollback, bascule
|
||||
- Depends on: lot-3-surfaces
|
||||
- Owner: Coordinateur
|
||||
- Execution: managed
|
||||
- Checks: npm run smoke:v2
|
||||
- Summary: Bascule initiale et outillage de migration réalisés.
|
||||
|
||||
## lot-5-production [done]
|
||||
|
||||
- Description: Training adapters, sandboxing, tests, turborepo, CI/CD, GitHub
|
||||
## lot-12-deep-audit [done]
|
||||
- Description: Deep analyse continue, refactoring, veille OSS, docs et infrastructure adjacente
|
||||
- Depends on: lot-4-bascule
|
||||
- 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
|
||||
- Summary: Boucle deep-audit stabilisee: pipeline/docs/logs coherents, seams backend/frontend fermes et cycle ops industrialise.
|
||||
|
||||
## lot-6-consolidation [done]
|
||||
|
||||
- Description: Deep analyse, correctifs securite, feature parity V2, deploy
|
||||
- Depends on: lot-5-production
|
||||
|
||||
## lot-7-training [done]
|
||||
|
||||
- Description: PyTorch, Unsloth, TRL, DPO pipeline, autoresearch, Ollama import
|
||||
- Depends on: lot-6-consolidation
|
||||
|
||||
## lot-8-multimodal [done]
|
||||
|
||||
- Description: RAG, STT, TTS, vision, PDF, recherche web, memoire persona
|
||||
- Depends on: lot-7-training
|
||||
|
||||
## lot-9-chat-avance [done]
|
||||
|
||||
- Description: Chat vocal, inter-persona, multi-channel, analytics, 26 personas
|
||||
- Depends on: lot-8-multimodal
|
||||
|
||||
## lot-10-generation [done]
|
||||
|
||||
- Description: ComfyUI, Sherlock, Picasso, diversification modeles, contexte 750MB
|
||||
- Depends on: lot-9-chat-avance
|
||||
|
||||
## lot-11-mcp-personas [done]
|
||||
|
||||
- Description: MCP tool-calling personas, pipeline fine-tune
|
||||
- Depends on: lot-10-generation
|
||||
|
||||
## lot-12-deep-audit [in-progress]
|
||||
|
||||
- Description: Deep audit code, refactoring, veille OSS, diagrammes, infrastructure
|
||||
- Depends on: lot-11-mcp-personas
|
||||
- Deliverables:
|
||||
- ops/v2/deep-audit.js (TUI security/perf/complexity)
|
||||
- docs/OSS_WATCH enrichi (voice, music, PDF, WebRTC, MCP, persona fine-tune)
|
||||
- docs/ARCHITECTURE.md (3 Mermaid ajoutes)
|
||||
- docs/AGENTS.md (matrice 10 agents, pipeline intervention)
|
||||
- PLAN.md + TODO.md consolides
|
||||
- Refactoring ws-chat.ts + app.ts
|
||||
- SearXNG + MinerU/Docling docker
|
||||
|
||||
## lot-13-voice-mcp [planned]
|
||||
|
||||
- Description: XTTS-v2 voice cloning, LLMRTC WebRTC, MCP SDK, Discord Pharmacius
|
||||
## lot-13-voice-mcp [done]
|
||||
- Description: Voice/WebRTC/MCP et convergence temps réel
|
||||
- Depends on: lot-12-deep-audit
|
||||
- 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
|
||||
- Summary: Spike voice/MCP stabilise: serveur MCP migre vers le SDK officiel, runtime XTTS valide sur kxkm-ai avec sample Piper genere, ffmpeg disponible et smoke non interactif vert sous COQUI_TOS_AGREED=1.
|
||||
|
||||
## lot-14-music-creative [planned]
|
||||
## lot-14-documents-search [done]
|
||||
- Description: Services adjacents de recherche et document parsing
|
||||
- Depends on: lot-12-deep-audit
|
||||
- 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
|
||||
- Summary: Seams search/doc consolides: SearXNG tourne localement avec format json actif et health check strict vert; le spike BGE-M3 est clos comme resultat negatif sur ce host Apple/Metal, avec maintien de la baseline actuelle.
|
||||
|
||||
- Description: ACE-Step 1.5, /compose, Flux 2, A2A Protocol
|
||||
- Depends on: lot-13-voice-mcp
|
||||
## lot-15-hotspot-reduction [done]
|
||||
- Description: Reduction chirurgicale des hotspots de domaine, moteur, tests storage et chat web
|
||||
- Depends on: lot-13-voice-mcp, lot-14-documents-search
|
||||
- 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
|
||||
- Summary: Lot 15 DONE: persona-domain seams, node-engine registry, storage-test-split (5 suites + helpers), web-chat-modularization (Chat.tsx 631→67 LOC, 5 modules). Cookie Secure flag + rate limit login. 210 tests, 0 fail.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# EXECUTION STATUS (kxkm-clown-v2)
|
||||
|
||||
Updated: 2026-03-17T22:13:03Z
|
||||
|
||||
## lot-0-cadrage
|
||||
- Status: done
|
||||
- Owner: Coordinateur
|
||||
- Execution: managed
|
||||
- Checks: docs-reviewed
|
||||
- Open tasks: none
|
||||
|
||||
## lot-1-socle
|
||||
- Status: done
|
||||
- Owner: Coordinateur
|
||||
- Execution: managed
|
||||
- Checks: npm run check:v2, npm run test:v2
|
||||
- Open tasks: none
|
||||
|
||||
## lot-2-domaines
|
||||
- Status: done
|
||||
- Owner: Backend API
|
||||
- Execution: managed
|
||||
- Checks: npm run test:v2
|
||||
- Open tasks: none
|
||||
|
||||
## lot-3-surfaces
|
||||
- Status: done
|
||||
- Owner: Frontend
|
||||
- Execution: managed
|
||||
- Checks: npm run -w @kxkm/web check
|
||||
- Open tasks: none
|
||||
|
||||
## lot-4-bascule
|
||||
- Status: done
|
||||
- Owner: Coordinateur
|
||||
- Execution: managed
|
||||
- Checks: npm run smoke:v2
|
||||
- Open tasks: none
|
||||
|
||||
## lot-12-deep-audit
|
||||
- Status: done
|
||||
- Owner: Coordinateur
|
||||
- Execution: manual
|
||||
- Checks: npm run check:v2, npm run test:v2, npm run -w @kxkm/web test, node ops/v2/deep-audit.js --json
|
||||
- Open tasks: none
|
||||
|
||||
## lot-13-voice-mcp
|
||||
- Status: done
|
||||
- Owner: Multimodal
|
||||
- Execution: manual
|
||||
- Checks: node scripts/mcp-server-smoke.js, npm run smoke:voice-mcp, npm run smoke:voice-clone, bash ops/v2/run-spike-checks.sh voice-clone --yes
|
||||
- Open tasks: none
|
||||
|
||||
## lot-14-documents-search
|
||||
- Status: done
|
||||
- Owner: Ops/TUI
|
||||
- Execution: manual
|
||||
- Checks: docker compose --profile v2 config --services, bash scripts/health-doc-search.sh search --strict, npm run smoke:documents-search, npm run smoke:embeddings, bash ops/v2/run-spike-checks.sh embeddings --yes
|
||||
- Open tasks: none
|
||||
|
||||
## lot-15-hotspot-reduction
|
||||
- Status: running
|
||||
- Owner: Coordinateur
|
||||
- Execution: manual
|
||||
- Checks: npm run -w @kxkm/persona-domain check, npm run test:v2, bash ops/v2/run-deep-cycle.sh run --yes
|
||||
- Open tasks:
|
||||
- node-engine-seams [pending] (P2, Worker/Engine)
|
||||
- storage-test-split [pending] (P3, Backend API)
|
||||
- web-chat-modularization [pending] (P2, Frontend)
|
||||
+36
-96
@@ -1,114 +1,54 @@
|
||||
# TODO (kxkm-clown-v2)
|
||||
|
||||
Updated: 2026-03-17T08:45:00Z
|
||||
Updated: 2026-03-19T08:20:00Z
|
||||
|
||||
## lot-0-cadrage
|
||||
|
||||
- [x] architecture (done)
|
||||
- [x] docs (done)
|
||||
- [x] agents (done)
|
||||
- [x] architecture (done) | owner: Coordinateur | severity: P2 | out: outputs/lot-0-cadrage/architecture.csv
|
||||
- [x] docs (done) | owner: Coordinateur | severity: P2 | out: outputs/lot-0-cadrage/docs.csv
|
||||
- [x] agents (done) | owner: Coordinateur | severity: P2 | out: outputs/lot-0-cadrage/agents.csv
|
||||
|
||||
## lot-1-socle
|
||||
|
||||
- [x] workspace (done)
|
||||
- [x] verification (done)
|
||||
- [x] ops-tui (done)
|
||||
- [x] workspace (done) | owner: Coordinateur | severity: P2 | out: outputs/lot-1-socle/workspace.csv
|
||||
- [x] verification (done) | owner: Coordinateur | severity: P2 | out: outputs/lot-1-socle/verification.csv
|
||||
- [x] ops-tui (done) | owner: Ops/TUI | severity: P2 | out: outputs/lot-1-socle/ops-tui.csv
|
||||
|
||||
## lot-2-domaines
|
||||
|
||||
- [x] auth-chat (done)
|
||||
- [x] personas (done)
|
||||
- [x] node-engine (done)
|
||||
- [x] auth-chat (done) | owner: Backend API | severity: P2 | out: ops/v2/outputs/lot-2-domaines/auth-chat.csv
|
||||
- [x] personas (done) | owner: Personas | severity: P2 | out: ops/v2/outputs/lot-2-domaines/personas.csv
|
||||
- [x] node-engine (done) | owner: Worker/Engine | severity: P2 | out: ops/v2/outputs/lot-2-domaines/node-engine.csv
|
||||
|
||||
## lot-3-surfaces
|
||||
|
||||
- [x] web-shell (done)
|
||||
- [x] persona-ui (done)
|
||||
- [x] engine-ui (done)
|
||||
- [x] web-shell (done) | owner: Frontend | severity: P2 | out: ops/v2/outputs/lot-3-surfaces/web-shell.csv
|
||||
- [x] persona-ui (done) | owner: Frontend | severity: P2 | out: ops/v2/outputs/lot-3-surfaces/persona-ui.csv
|
||||
- [x] engine-ui (done) | owner: Frontend | severity: P2 | out: ops/v2/outputs/lot-3-surfaces/engine-ui.csv
|
||||
|
||||
## lot-4-bascule
|
||||
|
||||
- [x] migration (done)
|
||||
- [x] parity (done)
|
||||
- [x] cutover (done)
|
||||
|
||||
## lot-5-production
|
||||
|
||||
- [x] training-adapters (done)
|
||||
- [x] sandboxing (done)
|
||||
- [x] tests (done)
|
||||
- [x] turborepo (done)
|
||||
- [x] ci-cd (done)
|
||||
|
||||
## lot-6-consolidation
|
||||
|
||||
- [x] deep-analyse (done)
|
||||
- [x] security-fixes (done)
|
||||
- [x] feature-parity (done)
|
||||
- [x] deploy (done)
|
||||
|
||||
## lot-7-training
|
||||
|
||||
- [x] pytorch-unsloth (done)
|
||||
- [x] dpo-pipeline (done)
|
||||
- [x] autoresearch (done)
|
||||
- [x] ollama-import (done)
|
||||
|
||||
## lot-8-multimodal
|
||||
|
||||
- [x] rag-local (done)
|
||||
- [x] stt-whisper (done)
|
||||
- [x] tts-piper (done)
|
||||
- [x] vision-analysis (done)
|
||||
- [x] pdf-extraction (done)
|
||||
- [x] web-search (done)
|
||||
- [x] persona-memory (done)
|
||||
|
||||
## lot-9-chat-avance
|
||||
|
||||
- [x] voice-chat (done)
|
||||
- [x] inter-persona (done)
|
||||
- [x] multi-channel (done)
|
||||
- [x] analytics (done)
|
||||
- [x] 26-personas (done)
|
||||
|
||||
## lot-10-generation
|
||||
|
||||
- [x] comfyui (done)
|
||||
- [x] sherlock-picasso (done)
|
||||
- [x] model-diversification (done)
|
||||
- [x] context-750mb (done)
|
||||
|
||||
## lot-11-mcp-personas
|
||||
|
||||
- [x] mcp-tool-calling (done)
|
||||
- [x] pipeline-finetune (done)
|
||||
- [x] migration (done) | owner: Coordinateur | severity: P2 | out: ops/v2/outputs/lot-4-bascule/migration.csv
|
||||
- [x] parity (done) | owner: Coordinateur | severity: P2 | out: ops/v2/outputs/lot-4-bascule/parity.csv
|
||||
- [x] cutover (done) | owner: Coordinateur | severity: P2 | out: ops/v2/outputs/lot-4-bascule/cutover.csv
|
||||
|
||||
## lot-12-deep-audit
|
||||
|
||||
- [x] deep-audit-tui (done) | ops/v2/deep-audit.js
|
||||
- [x] oss-watch-enriched (done) | docs/OSS_WATCH_2026-03-16.md
|
||||
- [x] mermaid-diagrams (done) | docs/ARCHITECTURE.md
|
||||
- [x] agents-matrix (done) | docs/AGENTS.md
|
||||
- [x] plan-todo-consolidation (done) | PLAN.md + TODO.md
|
||||
- [ ] deep-analyse-agents (in-progress) | 5 agents paralleles
|
||||
- [ ] refactor-ws-chat (pending) | 1449 LOC → 4 modules
|
||||
- [ ] refactor-app-ts (pending) | 1292 LOC → routes + middleware
|
||||
- [ ] searxng-docker (pending) | docker-compose service
|
||||
- [ ] mineru-docling (pending) | remplacer pdf-parse
|
||||
- [ ] discord-pharmacius (pending) | bot Discord orchestrateur
|
||||
- [ ] bge-m3-spike (pending) | upgrade embeddings RAG
|
||||
- [x] pipeline-canonique (done) | owner: Coordinateur | severity: P1
|
||||
- [x] deep-cycle-operator (done) | owner: Ops/TUI | severity: P1
|
||||
- [x] ws-chat-seams (done) | owner: Backend API | severity: P1
|
||||
- [x] app-bootstrap-extraction (done) | owner: Backend API | severity: P1
|
||||
- [x] worker-runtime-harness (done) | owner: Worker/Engine | severity: P1
|
||||
- [x] web-shell-convergence (done) | owner: Frontend | severity: P1
|
||||
- [x] ui-contract (done) | owner: Frontend | severity: P2
|
||||
- [x] oss-shortlist (done) | owner: Veille OSS | severity: P2
|
||||
|
||||
## lot-13-voice-mcp
|
||||
- [x] livekit-agents-spike (done) | owner: Multimodal | severity: P2
|
||||
- [x] mcp-sdk-integration (done) | owner: Backend API | severity: P2
|
||||
- [x] voice-cloning-validation (done) | owner: Multimodal | severity: P2
|
||||
|
||||
- [ ] xtts-v2-voice-cloning (planned)
|
||||
- [ ] llmrtc-webrtc (planned)
|
||||
- [ ] mcp-sdk-integration (planned)
|
||||
- [ ] pcl-opencharacter (planned)
|
||||
## lot-14-documents-search
|
||||
- [x] searxng-service (done) | owner: Ops/TUI | severity: P2
|
||||
- [x] docling-mineru-spike (done) | owner: Veille OSS | severity: P2
|
||||
- [x] bge-m3-benchmark (done) | owner: Veille OSS | severity: P3
|
||||
|
||||
## lot-14-music-creative
|
||||
|
||||
- [ ] ace-step-production (planned)
|
||||
- [ ] compose-command (planned)
|
||||
- [ ] flux2-comfyui (planned)
|
||||
- [ ] a2a-protocol (planned)
|
||||
## lot-15-hotspot-reduction
|
||||
- [x] persona-domain-seams (done) | owner: Backend API | severity: P2
|
||||
- [x] node-engine-seams (done) | owner: Worker/Engine | severity: P2
|
||||
- [x] storage-test-split (done) | owner: Backend API | severity: P3
|
||||
- [x] web-chat-modularization (done) | owner: Frontend | severity: P2
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
# =============================================================================
|
||||
# LightRAG Configuration Example for kxkm_clown
|
||||
# =============================================================================
|
||||
# Ce fichier sert de reference pour configurer LightRAG sur kxkm-ai.
|
||||
# Copier vers .env ou utiliser comme base pour docker-compose.yml
|
||||
# =============================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLM Configuration (via Ollama)
|
||||
# ---------------------------------------------------------------------------
|
||||
llm:
|
||||
provider: ollama
|
||||
model: qwen3:8b
|
||||
base_url: http://localhost:11434
|
||||
# Contexte max du modele (qwen3:8b supporte 32K)
|
||||
max_context_size: 32768
|
||||
# Nombre max d'appels LLM asynchrones simultanes
|
||||
max_async: 4
|
||||
# Parametres additionnels pour la generation
|
||||
kwargs:
|
||||
temperature: 0.0
|
||||
num_predict: 2048
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Embedding Configuration (via Ollama)
|
||||
# ---------------------------------------------------------------------------
|
||||
embedding:
|
||||
provider: ollama
|
||||
model: nomic-embed-text
|
||||
base_url: http://localhost:11434
|
||||
# Dimension des embeddings nomic-embed-text
|
||||
embedding_dim: 768
|
||||
max_token_size: 8192
|
||||
# Nombre de textes par batch d'embedding
|
||||
batch_num: 32
|
||||
max_async: 16
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Storage Configuration (PostgreSQL all-in-one)
|
||||
# ---------------------------------------------------------------------------
|
||||
storage:
|
||||
# KV Storage : metadonnees documents et chunks
|
||||
kv_storage: PGKVStorage
|
||||
# Vector Storage : embeddings (requiert extension pgvector)
|
||||
vector_storage: PGVectorStorage
|
||||
# Graph Storage : graphe de connaissances (requiert extension age)
|
||||
graph_storage: PGGraphStorage
|
||||
# Document Status Storage
|
||||
doc_status_storage: PGDocStatusStorage
|
||||
|
||||
postgres:
|
||||
host: localhost
|
||||
port: 5432
|
||||
database: lightrag
|
||||
user: kxkm
|
||||
password: "${LIGHTRAG_PG_PASSWORD}" # A definir dans .env
|
||||
# Creer la database si elle n'existe pas
|
||||
# CREATE DATABASE lightrag;
|
||||
# CREATE EXTENSION IF NOT EXISTS vector; -- pgvector
|
||||
# CREATE EXTENSION IF NOT EXISTS age; -- Apache AGE pour graph storage
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Working Directory
|
||||
# ---------------------------------------------------------------------------
|
||||
working_dir: data/lightrag
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RAG Parameters
|
||||
# ---------------------------------------------------------------------------
|
||||
rag:
|
||||
# Taille des chunks en tokens
|
||||
chunk_token_size: 1200
|
||||
# Overlap entre chunks
|
||||
chunk_overlap_token_size: 100
|
||||
# Nombre de boucles d'extraction d'entites (1 = standard, 2+ = plus precis mais plus lent)
|
||||
entity_extract_max_gleaning: 1
|
||||
# Langue pour l'extraction d'entites et la generation
|
||||
language: French
|
||||
# Types d'entites a extraire (adaptes au projet artistique)
|
||||
entity_types:
|
||||
- personne
|
||||
- artiste
|
||||
- oeuvre
|
||||
- concept
|
||||
- lieu
|
||||
- evenement
|
||||
- technique
|
||||
- instrument
|
||||
- mouvement_artistique
|
||||
# Cache des reponses LLM
|
||||
enable_llm_cache: true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Query Defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
query:
|
||||
# Mode par defaut pour les requetes
|
||||
default_mode: mix
|
||||
# Nombre de resultats top-k
|
||||
top_k: 30
|
||||
# Seuil de similarite cosine
|
||||
cosine_better_than_threshold: 0.2
|
||||
# Format de reponse
|
||||
response_type: "Multiple Paragraphs"
|
||||
# Activer le reranking (necessite un reranker configure)
|
||||
enable_rerank: false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server Configuration (lightrag-server)
|
||||
# ---------------------------------------------------------------------------
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 9621
|
||||
# Authentification (optionnel)
|
||||
# api_key: "${LIGHTRAG_API_KEY}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Docker Compose equivalent (.env)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pour deployer via Docker Compose, creer un fichier .env avec :
|
||||
#
|
||||
# # LLM
|
||||
# LLM_PROVIDER=ollama
|
||||
# LLM_MODEL=qwen3:8b
|
||||
# OLLAMA_HOST=http://host.docker.internal:11434
|
||||
#
|
||||
# # Embedding
|
||||
# EMBEDDING_PROVIDER=ollama
|
||||
# EMBEDDING_MODEL=nomic-embed-text
|
||||
# EMBEDDING_DIM=768
|
||||
#
|
||||
# # Storage (PostgreSQL)
|
||||
# KV_STORAGE=PGKVStorage
|
||||
# VECTOR_STORAGE=PGVectorStorage
|
||||
# GRAPH_STORAGE=PGGraphStorage
|
||||
# DOC_STATUS_STORAGE=PGDocStatusStorage
|
||||
# POSTGRES_HOST=host.docker.internal
|
||||
# POSTGRES_PORT=5432
|
||||
# POSTGRES_DATABASE=lightrag
|
||||
# POSTGRES_USER=kxkm
|
||||
# POSTGRES_PASSWORD=changeme
|
||||
#
|
||||
# # RAG
|
||||
# LANGUAGE=French
|
||||
# CHUNK_TOKEN_SIZE=1200
|
||||
# CHUNK_OVERLAP_TOKEN_SIZE=100
|
||||
#
|
||||
# # Server
|
||||
# PORT=9621
|
||||
@@ -0,0 +1,371 @@
|
||||
{
|
||||
"security": [],
|
||||
"performance": [
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/context-store.ts",
|
||||
"line": 245,
|
||||
"snippet": "console.log(`[context] Compacting ${channel}: ${lines.length} entries`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/context-store.ts",
|
||||
"line": 316,
|
||||
"snippet": "console.log(`[context] Compacted ${channel}: ${toSummarize.length} entries → summary, kept ${toKeep.length} recent`);"
|
||||
},
|
||||
{
|
||||
"severity": "P1",
|
||||
"pattern": "readFileSync in non-init",
|
||||
"file": "apps/api/src/server.ts",
|
||||
"line": 61,
|
||||
"snippet": "const text = fs.readFileSync(filePath, \"utf-8\");"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/server.ts",
|
||||
"line": 63,
|
||||
"snippet": "console.log(`[rag] Indexed ${filePath}: ${count} chunks`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/server.ts",
|
||||
"line": 67,
|
||||
"snippet": "console.log(`[rag] Ready: ${rag.size} total chunks`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/server.ts",
|
||||
"line": 83,
|
||||
"snippet": "console.log(\"[context] Persistent context store ready\");"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/server.ts",
|
||||
"line": 106,
|
||||
"snippet": "console.log(JSON.stringify({"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "JSON.parse without try/catch",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 147,
|
||||
"snippet": "const extracted = JSON.parse(data.message?.content || \"{}\") as {"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "JSON.parse without try/catch",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 497,
|
||||
"snippet": "const result = JSON.parse(stdout.trim().split(\"\\n\").pop() || \"{}\") as {"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "JSON.parse without try/catch",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 893,
|
||||
"snippet": "const result = JSON.parse(stdout.trim().split(\"\\n\").pop() || \"{}\") as {"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "JSON.parse without try/catch",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 1262,
|
||||
"snippet": "const result = JSON.parse(lastLine) as { status?: string; transcript?: string; error?: string };"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 379,
|
||||
"snippet": "console.log(`[mcp-tools] ${persona.nick} calling ${name}(${JSON.stringify(args)})`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 644,
|
||||
"snippet": "console.log(`[ws-chat] Refreshed personas: ${personas.map((p) => p.nick).join(\", \")}`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 891,
|
||||
"snippet": "if (stderr) console.log(`[compose] ${stderr.slice(-200)}`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 1258,
|
||||
"snippet": "if (stderr) console.log(`[ws-chat][audio] ${stderr.trim().slice(-200)}`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 1286,
|
||||
"snippet": "if (stderr) console.log(`[upload] pdf: ${stderr.slice(-200)}`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 1314,
|
||||
"snippet": "if (stderr) console.log(`[upload] doc extract: ${stderr.slice(-200)}`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 1453,
|
||||
"snippet": "console.log(`[ws-chat] WebSocket chat attached on /ws (Ollama: ${ollamaUrl})`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "JSON.parse without try/catch",
|
||||
"file": "apps/worker/src/index.ts",
|
||||
"line": 152,
|
||||
"snippet": "const evalResult = JSON.parse(jsonLine);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "JSON.parse without try/catch",
|
||||
"file": "apps/worker/src/index.ts",
|
||||
"line": 226,
|
||||
"snippet": "const trainResult = JSON.parse(jsonLine);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/worker/src/index.ts",
|
||||
"line": 67,
|
||||
"snippet": "console.log(`[${ts}] ${msg}`);"
|
||||
}
|
||||
],
|
||||
"metrics": [
|
||||
{
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"lines": 1456,
|
||||
"sizeKB": 48,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/app.ts",
|
||||
"lines": 1326,
|
||||
"sizeKB": 47,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"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": "apps/web/src/components/ChatHistory.tsx",
|
||||
"lines": 602,
|
||||
"sizeKB": 17,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/components/Chat.tsx",
|
||||
"lines": 567,
|
||||
"sizeKB": 19,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "packages/storage/src/index.ts",
|
||||
"lines": 551,
|
||||
"sizeKB": 18,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "apps/worker/src/index.ts",
|
||||
"lines": 548,
|
||||
"sizeKB": 18,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/app.test.ts",
|
||||
"lines": 452,
|
||||
"sizeKB": 15,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/components/VoiceChat.tsx",
|
||||
"lines": 424,
|
||||
"sizeKB": 14,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/context-store.ts",
|
||||
"lines": 420,
|
||||
"sizeKB": 15,
|
||||
"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/web/src/api.ts",
|
||||
"lines": 328,
|
||||
"sizeKB": 9,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "packages/node-engine/src/index.test.ts",
|
||||
"lines": 293,
|
||||
"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/web/src/App.tsx",
|
||||
"lines": 273,
|
||||
"sizeKB": 8,
|
||||
"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/TrainingDashboard.tsx",
|
||||
"lines": 212,
|
||||
"sizeKB": 7,
|
||||
"flag": "ok"
|
||||
}
|
||||
],
|
||||
"deps": [
|
||||
{
|
||||
"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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
{
|
||||
"security": [],
|
||||
"performance": [
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/context-store.ts",
|
||||
"line": 245,
|
||||
"snippet": "console.log(`[context] Compacting ${channel}: ${lines.length} entries`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/context-store.ts",
|
||||
"line": 316,
|
||||
"snippet": "console.log(`[context] Compacted ${channel}: ${toSummarize.length} entries → summary, kept ${toKeep.length} recent`);"
|
||||
},
|
||||
{
|
||||
"severity": "P1",
|
||||
"pattern": "readFileSync in non-init",
|
||||
"file": "apps/api/src/server.ts",
|
||||
"line": 61,
|
||||
"snippet": "const text = fs.readFileSync(filePath, \"utf-8\");"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/server.ts",
|
||||
"line": 63,
|
||||
"snippet": "console.log(`[rag] Indexed ${filePath}: ${count} chunks`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/server.ts",
|
||||
"line": 67,
|
||||
"snippet": "console.log(`[rag] Ready: ${rag.size} total chunks`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/server.ts",
|
||||
"line": 83,
|
||||
"snippet": "console.log(\"[context] Persistent context store ready\");"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/server.ts",
|
||||
"line": 106,
|
||||
"snippet": "console.log(JSON.stringify({"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "JSON.parse without try/catch",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 147,
|
||||
"snippet": "const extracted = JSON.parse(data.message?.content || \"{}\") as {"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "JSON.parse without try/catch",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 497,
|
||||
"snippet": "const result = JSON.parse(stdout.trim().split(\"\\n\").pop() || \"{}\") as {"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "JSON.parse without try/catch",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 893,
|
||||
"snippet": "const result = JSON.parse(stdout.trim().split(\"\\n\").pop() || \"{}\") as {"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "JSON.parse without try/catch",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 1262,
|
||||
"snippet": "const result = JSON.parse(lastLine) as { status?: string; transcript?: string; error?: string };"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 379,
|
||||
"snippet": "console.log(`[mcp-tools] ${persona.nick} calling ${name}(${JSON.stringify(args)})`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 644,
|
||||
"snippet": "console.log(`[ws-chat] Refreshed personas: ${personas.map((p) => p.nick).join(\", \")}`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 891,
|
||||
"snippet": "if (stderr) console.log(`[compose] ${stderr.slice(-200)}`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 1258,
|
||||
"snippet": "if (stderr) console.log(`[ws-chat][audio] ${stderr.trim().slice(-200)}`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 1286,
|
||||
"snippet": "if (stderr) console.log(`[upload] pdf: ${stderr.slice(-200)}`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 1314,
|
||||
"snippet": "if (stderr) console.log(`[upload] doc extract: ${stderr.slice(-200)}`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 1453,
|
||||
"snippet": "console.log(`[ws-chat] WebSocket chat attached on /ws (Ollama: ${ollamaUrl})`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "JSON.parse without try/catch",
|
||||
"file": "apps/worker/src/index.ts",
|
||||
"line": 152,
|
||||
"snippet": "const evalResult = JSON.parse(jsonLine);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "JSON.parse without try/catch",
|
||||
"file": "apps/worker/src/index.ts",
|
||||
"line": 226,
|
||||
"snippet": "const trainResult = JSON.parse(jsonLine);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/worker/src/index.ts",
|
||||
"line": 67,
|
||||
"snippet": "console.log(`[${ts}] ${msg}`);"
|
||||
}
|
||||
],
|
||||
"metrics": [
|
||||
{
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"lines": 1456,
|
||||
"sizeKB": 48,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/app.ts",
|
||||
"lines": 1326,
|
||||
"sizeKB": 47,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"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": "apps/web/src/components/ChatHistory.tsx",
|
||||
"lines": 602,
|
||||
"sizeKB": 17,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/components/Chat.tsx",
|
||||
"lines": 567,
|
||||
"sizeKB": 19,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "packages/storage/src/index.ts",
|
||||
"lines": 551,
|
||||
"sizeKB": 18,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "apps/worker/src/index.ts",
|
||||
"lines": 548,
|
||||
"sizeKB": 18,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/app.test.ts",
|
||||
"lines": 452,
|
||||
"sizeKB": 15,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/components/VoiceChat.tsx",
|
||||
"lines": 424,
|
||||
"sizeKB": 14,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/context-store.ts",
|
||||
"lines": 420,
|
||||
"sizeKB": 15,
|
||||
"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/web/src/api.ts",
|
||||
"lines": 328,
|
||||
"sizeKB": 9,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "packages/node-engine/src/index.test.ts",
|
||||
"lines": 293,
|
||||
"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/web/src/App.tsx",
|
||||
"lines": 273,
|
||||
"sizeKB": 8,
|
||||
"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/TrainingDashboard.tsx",
|
||||
"lines": 212,
|
||||
"sizeKB": 7,
|
||||
"flag": "ok"
|
||||
}
|
||||
],
|
||||
"deps": [
|
||||
{
|
||||
"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": 78,
|
||||
"level": "high",
|
||||
"components": {
|
||||
"security": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 0
|
||||
},
|
||||
"performance": {
|
||||
"P0": 0,
|
||||
"P1": 1,
|
||||
"P2": 20
|
||||
},
|
||||
"tsErrors": 0,
|
||||
"largeFiles": 8,
|
||||
"mediumFiles": 6,
|
||||
"deps": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
{
|
||||
"security": [],
|
||||
"performance": [
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/context-store.ts",
|
||||
"line": 245,
|
||||
"snippet": "console.log(`[context] Compacting ${channel}: ${lines.length} entries`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/context-store.ts",
|
||||
"line": 316,
|
||||
"snippet": "console.log(`[context] Compacted ${channel}: ${toSummarize.length} entries → summary, kept ${toKeep.length} recent`);"
|
||||
},
|
||||
{
|
||||
"severity": "P1",
|
||||
"pattern": "readFileSync in non-init",
|
||||
"file": "apps/api/src/server.ts",
|
||||
"line": 61,
|
||||
"snippet": "const text = fs.readFileSync(filePath, \"utf-8\");"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/server.ts",
|
||||
"line": 63,
|
||||
"snippet": "console.log(`[rag] Indexed ${filePath}: ${count} chunks`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/server.ts",
|
||||
"line": 67,
|
||||
"snippet": "console.log(`[rag] Ready: ${rag.size} total chunks`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/server.ts",
|
||||
"line": 83,
|
||||
"snippet": "console.log(\"[context] Persistent context store ready\");"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/server.ts",
|
||||
"line": 106,
|
||||
"snippet": "console.log(JSON.stringify({"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "JSON.parse without try/catch",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 147,
|
||||
"snippet": "const extracted = JSON.parse(data.message?.content || \"{}\") as {"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "JSON.parse without try/catch",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 497,
|
||||
"snippet": "const result = JSON.parse(stdout.trim().split(\"\\n\").pop() || \"{}\") as {"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "JSON.parse without try/catch",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 893,
|
||||
"snippet": "const result = JSON.parse(stdout.trim().split(\"\\n\").pop() || \"{}\") as {"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "JSON.parse without try/catch",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 1262,
|
||||
"snippet": "const result = JSON.parse(lastLine) as { status?: string; transcript?: string; error?: string };"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 379,
|
||||
"snippet": "console.log(`[mcp-tools] ${persona.nick} calling ${name}(${JSON.stringify(args)})`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 644,
|
||||
"snippet": "console.log(`[ws-chat] Refreshed personas: ${personas.map((p) => p.nick).join(\", \")}`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 891,
|
||||
"snippet": "if (stderr) console.log(`[compose] ${stderr.slice(-200)}`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 1258,
|
||||
"snippet": "if (stderr) console.log(`[ws-chat][audio] ${stderr.trim().slice(-200)}`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 1286,
|
||||
"snippet": "if (stderr) console.log(`[upload] pdf: ${stderr.slice(-200)}`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 1314,
|
||||
"snippet": "if (stderr) console.log(`[upload] doc extract: ${stderr.slice(-200)}`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 1453,
|
||||
"snippet": "console.log(`[ws-chat] WebSocket chat attached on /ws (Ollama: ${ollamaUrl})`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "JSON.parse without try/catch",
|
||||
"file": "apps/worker/src/index.ts",
|
||||
"line": 152,
|
||||
"snippet": "const evalResult = JSON.parse(jsonLine);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "JSON.parse without try/catch",
|
||||
"file": "apps/worker/src/index.ts",
|
||||
"line": 226,
|
||||
"snippet": "const trainResult = JSON.parse(jsonLine);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/worker/src/index.ts",
|
||||
"line": 67,
|
||||
"snippet": "console.log(`[${ts}] ${msg}`);"
|
||||
}
|
||||
],
|
||||
"metrics": [
|
||||
{
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"lines": 1456,
|
||||
"sizeKB": 48,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/app.ts",
|
||||
"lines": 1326,
|
||||
"sizeKB": 47,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"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": "apps/web/src/components/ChatHistory.tsx",
|
||||
"lines": 602,
|
||||
"sizeKB": 17,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/components/Chat.tsx",
|
||||
"lines": 567,
|
||||
"sizeKB": 19,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "packages/storage/src/index.ts",
|
||||
"lines": 551,
|
||||
"sizeKB": 18,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "apps/worker/src/index.ts",
|
||||
"lines": 548,
|
||||
"sizeKB": 18,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/app.test.ts",
|
||||
"lines": 452,
|
||||
"sizeKB": 15,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/components/VoiceChat.tsx",
|
||||
"lines": 424,
|
||||
"sizeKB": 14,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/context-store.ts",
|
||||
"lines": 420,
|
||||
"sizeKB": 15,
|
||||
"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/web/src/api.ts",
|
||||
"lines": 328,
|
||||
"sizeKB": 9,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "packages/node-engine/src/index.test.ts",
|
||||
"lines": 293,
|
||||
"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/web/src/App.tsx",
|
||||
"lines": 273,
|
||||
"sizeKB": 8,
|
||||
"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/TrainingDashboard.tsx",
|
||||
"lines": 212,
|
||||
"sizeKB": 7,
|
||||
"flag": "ok"
|
||||
}
|
||||
],
|
||||
"deps": [
|
||||
{
|
||||
"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": 78,
|
||||
"level": "high",
|
||||
"components": {
|
||||
"security": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 0
|
||||
},
|
||||
"performance": {
|
||||
"P0": 0,
|
||||
"P1": 1,
|
||||
"P2": 20
|
||||
},
|
||||
"tsErrors": 0,
|
||||
"largeFiles": 8,
|
||||
"mediumFiles": 6,
|
||||
"deps": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
{
|
||||
"security": [],
|
||||
"performance": [
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/context-store.ts",
|
||||
"line": 245,
|
||||
"snippet": "console.log(`[context] Compacting ${channel}: ${lines.length} entries`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/context-store.ts",
|
||||
"line": 316,
|
||||
"snippet": "console.log(`[context] Compacted ${channel}: ${toSummarize.length} entries → summary, kept ${toKeep.length} recent`);"
|
||||
},
|
||||
{
|
||||
"severity": "P1",
|
||||
"pattern": "readFileSync in non-init",
|
||||
"file": "apps/api/src/server.ts",
|
||||
"line": 61,
|
||||
"snippet": "const text = fs.readFileSync(filePath, \"utf-8\");"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/server.ts",
|
||||
"line": 63,
|
||||
"snippet": "console.log(`[rag] Indexed ${filePath}: ${count} chunks`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/server.ts",
|
||||
"line": 67,
|
||||
"snippet": "console.log(`[rag] Ready: ${rag.size} total chunks`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/server.ts",
|
||||
"line": 83,
|
||||
"snippet": "console.log(\"[context] Persistent context store ready\");"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/server.ts",
|
||||
"line": 106,
|
||||
"snippet": "console.log(JSON.stringify({"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "JSON.parse without try/catch",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 147,
|
||||
"snippet": "const extracted = JSON.parse(data.message?.content || \"{}\") as {"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "JSON.parse without try/catch",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 497,
|
||||
"snippet": "const result = JSON.parse(stdout.trim().split(\"\\n\").pop() || \"{}\") as {"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "JSON.parse without try/catch",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 893,
|
||||
"snippet": "const result = JSON.parse(stdout.trim().split(\"\\n\").pop() || \"{}\") as {"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 379,
|
||||
"snippet": "console.log(`[mcp-tools] ${persona.nick} calling ${name}(${JSON.stringify(args)})`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 644,
|
||||
"snippet": "console.log(`[ws-chat] Refreshed personas: ${personas.map((p) => p.nick).join(\", \")}`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 891,
|
||||
"snippet": "if (stderr) console.log(`[compose] ${stderr.slice(-200)}`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"line": 1315,
|
||||
"snippet": "console.log(`[ws-chat] WebSocket chat attached on /ws (Ollama: ${ollamaUrl})`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-upload-handler.ts",
|
||||
"line": 93,
|
||||
"snippet": "if (stderr) console.log(`[ws-chat][audio] ${stderr.trim().slice(-200)}`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-upload-handler.ts",
|
||||
"line": 118,
|
||||
"snippet": "if (stderr) console.log(`[upload] pdf: ${stderr.slice(-200)}`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-upload-handler.ts",
|
||||
"line": 145,
|
||||
"snippet": "if (stderr) console.log(`[upload] doc extract: ${stderr.slice(-200)}`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "JSON.parse without try/catch",
|
||||
"file": "apps/worker/src/index.ts",
|
||||
"line": 152,
|
||||
"snippet": "const evalResult = JSON.parse(jsonLine);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "JSON.parse without try/catch",
|
||||
"file": "apps/worker/src/index.ts",
|
||||
"line": 226,
|
||||
"snippet": "const trainResult = JSON.parse(jsonLine);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/worker/src/index.ts",
|
||||
"line": 67,
|
||||
"snippet": "console.log(`[${ts}] ${msg}`);"
|
||||
}
|
||||
],
|
||||
"metrics": [
|
||||
{
|
||||
"file": "apps/api/src/app.ts",
|
||||
"lines": 1326,
|
||||
"sizeKB": 47,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"lines": 1318,
|
||||
"sizeKB": 42,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"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": "apps/web/src/components/ChatHistory.tsx",
|
||||
"lines": 602,
|
||||
"sizeKB": 17,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/components/Chat.tsx",
|
||||
"lines": 567,
|
||||
"sizeKB": 19,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "packages/storage/src/index.ts",
|
||||
"lines": 551,
|
||||
"sizeKB": 18,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "apps/worker/src/index.ts",
|
||||
"lines": 548,
|
||||
"sizeKB": 18,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/app.test.ts",
|
||||
"lines": 452,
|
||||
"sizeKB": 15,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/components/VoiceChat.tsx",
|
||||
"lines": 424,
|
||||
"sizeKB": 14,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/context-store.ts",
|
||||
"lines": 420,
|
||||
"sizeKB": 15,
|
||||
"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/web/src/api.ts",
|
||||
"lines": 328,
|
||||
"sizeKB": 9,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "packages/node-engine/src/index.test.ts",
|
||||
"lines": 293,
|
||||
"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/web/src/App.tsx",
|
||||
"lines": 273,
|
||||
"sizeKB": 8,
|
||||
"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/TrainingDashboard.tsx",
|
||||
"lines": 212,
|
||||
"sizeKB": 7,
|
||||
"flag": "ok"
|
||||
}
|
||||
],
|
||||
"deps": [
|
||||
{
|
||||
"package": "express"
|
||||
},
|
||||
{
|
||||
"package": "pdf-parse",
|
||||
"current": "1.1.4",
|
||||
"wanted": "1.1.4",
|
||||
"latest": "2.4.5"
|
||||
}
|
||||
],
|
||||
"typescript": [
|
||||
{
|
||||
"dir": "apps/api",
|
||||
"status": "fail",
|
||||
"errors": 6,
|
||||
"detail": "src/ws-chat.ts(1187,28): error TS1308: 'await' expressions are only allowed within async functions and at the top levels of modules.\nsrc/ws-chat.ts(1188,3): error TS1308: 'await' expressions are only allowed within async functions and at the top levels of modules.\nsrc/ws-chat.ts(1189,5): error TS2304: Cannot find name 'ws'.\nsrc/ws-chat.ts(1190,5): error TS2304: Cannot find name 'info'.\nsrc/ws-chat.ts(1191,5): error TS2304: Cannot find name 'parsed'.\nsrc/ws-chat.ts(1276,15): error TS2554: Expecte"
|
||||
},
|
||||
{
|
||||
"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": 100,
|
||||
"level": "high",
|
||||
"components": {
|
||||
"security": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 0
|
||||
},
|
||||
"performance": {
|
||||
"P0": 0,
|
||||
"P1": 1,
|
||||
"P2": 19
|
||||
},
|
||||
"tsErrors": 6,
|
||||
"largeFiles": 8,
|
||||
"mediumFiles": 6,
|
||||
"deps": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
{
|
||||
"security": [],
|
||||
"performance": [
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/api/src/ws-commands.ts",
|
||||
"line": 221,
|
||||
"snippet": "console.log(`[compose] ${stderr.slice(-200)}`);"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "console.log in production",
|
||||
"file": "apps/worker/src/index.ts",
|
||||
"line": 57,
|
||||
"snippet": "console.log(`[${ts}] ${msg}`);"
|
||||
}
|
||||
],
|
||||
"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": 616,
|
||||
"sizeKB": 21,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/components/ChatHistory.tsx",
|
||||
"lines": 602,
|
||||
"sizeKB": 17,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"lines": 573,
|
||||
"sizeKB": 18,
|
||||
"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": 452,
|
||||
"sizeKB": 15,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/context-store.ts",
|
||||
"lines": 422,
|
||||
"sizeKB": 15,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/components/VoiceChat.tsx",
|
||||
"lines": 420,
|
||||
"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/routes/personas.ts",
|
||||
"lines": 330,
|
||||
"sizeKB": 12,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/api.ts",
|
||||
"lines": 328,
|
||||
"sizeKB": 9,
|
||||
"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/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/web/src/components/TrainingDashboard.tsx",
|
||||
"lines": 212,
|
||||
"sizeKB": 7,
|
||||
"flag": "ok"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/routes/session.ts",
|
||||
"lines": 208,
|
||||
"sizeKB": 7,
|
||||
"flag": "ok"
|
||||
}
|
||||
],
|
||||
"deps": [
|
||||
{
|
||||
"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": 44,
|
||||
"level": "medium",
|
||||
"components": {
|
||||
"security": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 0
|
||||
},
|
||||
"performance": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 2
|
||||
},
|
||||
"tsErrors": 0,
|
||||
"largeFiles": 10,
|
||||
"mediumFiles": 8,
|
||||
"deps": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
{
|
||||
"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": 616,
|
||||
"sizeKB": 21,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/components/ChatHistory.tsx",
|
||||
"lines": 602,
|
||||
"sizeKB": 17,
|
||||
"flag": "large"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/ws-chat.ts",
|
||||
"lines": 573,
|
||||
"sizeKB": 18,
|
||||
"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": 452,
|
||||
"sizeKB": 15,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/context-store.ts",
|
||||
"lines": 422,
|
||||
"sizeKB": 15,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/components/VoiceChat.tsx",
|
||||
"lines": 420,
|
||||
"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/routes/personas.ts",
|
||||
"lines": 330,
|
||||
"sizeKB": 12,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/api.ts",
|
||||
"lines": 328,
|
||||
"sizeKB": 9,
|
||||
"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/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/web/src/components/TrainingDashboard.tsx",
|
||||
"lines": 212,
|
||||
"sizeKB": 7,
|
||||
"flag": "ok"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/routes/session.ts",
|
||||
"lines": 208,
|
||||
"sizeKB": 7,
|
||||
"flag": "ok"
|
||||
}
|
||||
],
|
||||
"deps": [
|
||||
{
|
||||
"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": 10,
|
||||
"mediumFiles": 8,
|
||||
"deps": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
{
|
||||
"security": [],
|
||||
"performance": [
|
||||
{
|
||||
"severity": "P2",
|
||||
"pattern": "JSON.parse without try/catch",
|
||||
"file": "apps/api/src/ws-chat-smoke.test.ts",
|
||||
"line": 82,
|
||||
"snippet": "const body = typeof init?.body === \"string\" ? JSON.parse(init.body) : {};"
|
||||
}
|
||||
],
|
||||
"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": 452,
|
||||
"sizeKB": 15,
|
||||
"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": 372,
|
||||
"sizeKB": 10,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/ws-conversation-router.ts",
|
||||
"lines": 355,
|
||||
"sizeKB": 11,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/routes/personas.ts",
|
||||
"lines": 330,
|
||||
"sizeKB": 12,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/api.ts",
|
||||
"lines": 328,
|
||||
"sizeKB": 9,
|
||||
"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": 42,
|
||||
"level": "medium",
|
||||
"components": {
|
||||
"security": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 0
|
||||
},
|
||||
"performance": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 1
|
||||
},
|
||||
"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": 452,
|
||||
"sizeKB": 15,
|
||||
"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": 372,
|
||||
"sizeKB": 10,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/ws-conversation-router.ts",
|
||||
"lines": 355,
|
||||
"sizeKB": 11,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/api/src/routes/personas.ts",
|
||||
"lines": 330,
|
||||
"sizeKB": 12,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/api.ts",
|
||||
"lines": 328,
|
||||
"sizeKB": 9,
|
||||
"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": 452,
|
||||
"sizeKB": 15,
|
||||
"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/api/src/routes/personas.ts",
|
||||
"lines": 330,
|
||||
"sizeKB": 12,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/api.ts",
|
||||
"lines": 328,
|
||||
"sizeKB": 9,
|
||||
"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": 452,
|
||||
"sizeKB": 15,
|
||||
"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/api/src/routes/personas.ts",
|
||||
"lines": 330,
|
||||
"sizeKB": 12,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/api.ts",
|
||||
"lines": 328,
|
||||
"sizeKB": 9,
|
||||
"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": 452,
|
||||
"sizeKB": 15,
|
||||
"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/api/src/routes/personas.ts",
|
||||
"lines": 330,
|
||||
"sizeKB": 12,
|
||||
"flag": "medium"
|
||||
},
|
||||
{
|
||||
"file": "apps/web/src/api.ts",
|
||||
"lines": 328,
|
||||
"sizeKB": 9,
|
||||
"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": 452,
|
||||
"sizeKB": 15,
|
||||
"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": "fail",
|
||||
"errors": 1,
|
||||
"detail": "src/ws-multimodal.ts(79,24): error TS2322: Type 'string | null' is not assignable to type 'string'.\n Type 'null' is not assignable to type 'string'."
|
||||
},
|
||||
{
|
||||
"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": 45,
|
||||
"level": "medium",
|
||||
"components": {
|
||||
"security": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 0
|
||||
},
|
||||
"performance": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 0
|
||||
},
|
||||
"tsErrors": 1,
|
||||
"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": 452,
|
||||
"sizeKB": 15,
|
||||
"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": "fail",
|
||||
"errors": 1,
|
||||
"detail": "src/ws-multimodal.ts(79,24): error TS2322: Type 'string | null' is not assignable to type 'string'.\n Type 'null' is not assignable to type 'string'."
|
||||
},
|
||||
{
|
||||
"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": 45,
|
||||
"level": "medium",
|
||||
"components": {
|
||||
"security": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 0
|
||||
},
|
||||
"performance": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 0
|
||||
},
|
||||
"tsErrors": 1,
|
||||
"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": 452,
|
||||
"sizeKB": 15,
|
||||
"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": 452,
|
||||
"sizeKB": 15,
|
||||
"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": 452,
|
||||
"sizeKB": 15,
|
||||
"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,72 @@
|
||||
{
|
||||
"timestamp": "20260317-212918",
|
||||
"generated_at": "2026-03-17T20:29:31Z",
|
||||
"files": {
|
||||
"audit": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-212918.json",
|
||||
"check": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-212918.log",
|
||||
"test": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-212918.log",
|
||||
"summary": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/outputs/deep-cycles/summary-20260317-212918.md"
|
||||
},
|
||||
"checks": {
|
||||
"check:v2": "ok",
|
||||
"test:v2": "ok"
|
||||
},
|
||||
"counts": {
|
||||
"security": 0,
|
||||
"performance": 2,
|
||||
"large_files": 10
|
||||
},
|
||||
"debt": {
|
||||
"score": 44,
|
||||
"level": "medium",
|
||||
"components": {
|
||||
"security": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 0
|
||||
},
|
||||
"performance": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 2
|
||||
},
|
||||
"tsErrors": 0,
|
||||
"largeFiles": 10,
|
||||
"mediumFiles": 8,
|
||||
"deps": 2
|
||||
}
|
||||
},
|
||||
"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": [
|
||||
{
|
||||
"severity": "P2",
|
||||
"file": "apps/api/src/ws-commands.ts",
|
||||
"line": 221,
|
||||
"pattern": "console.log in production"
|
||||
},
|
||||
{
|
||||
"severity": "P2",
|
||||
"file": "apps/worker/src/index.ts",
|
||||
"line": 57,
|
||||
"pattern": "console.log in production"
|
||||
}
|
||||
],
|
||||
"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/web-shell-convergence"
|
||||
],
|
||||
"retention_days": 7,
|
||||
"hashes": {
|
||||
"audit_sha256": "77d53143f3bb6315a9d8b7d4eb0f2a25f5fa1e126e9a23b14fb855e587793bd7",
|
||||
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
|
||||
"test_sha256": "9f6ed983113a397e11cacd5a7c04e25571967eb570036b8171928eb252969a2b"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"timestamp": "20260317-213137",
|
||||
"generated_at": "2026-03-17T20:31:50Z",
|
||||
"files": {
|
||||
"audit": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-213137.json",
|
||||
"check": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-213137.log",
|
||||
"test": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-213137.log",
|
||||
"summary": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/outputs/deep-cycles/summary-20260317-213137.md"
|
||||
},
|
||||
"checks": {
|
||||
"check:v2": "ok",
|
||||
"test:v2": "ok"
|
||||
},
|
||||
"counts": {
|
||||
"security": 0,
|
||||
"performance": 0,
|
||||
"large_files": 10
|
||||
},
|
||||
"debt": {
|
||||
"score": 40,
|
||||
"level": "medium",
|
||||
"components": {
|
||||
"security": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 0
|
||||
},
|
||||
"performance": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 0
|
||||
},
|
||||
"tsErrors": 0,
|
||||
"largeFiles": 10,
|
||||
"mediumFiles": 8,
|
||||
"deps": 2
|
||||
}
|
||||
},
|
||||
"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/web-shell-convergence"
|
||||
],
|
||||
"retention_days": 7,
|
||||
"hashes": {
|
||||
"audit_sha256": "98de552279816ebd6143b0b03b320fd85e5359db6af6832ded8040624d4f81d0",
|
||||
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
|
||||
"test_sha256": "37da49bbbadcc5ccf6f2818169127ea142b86263db18246dc7502db796e40ac8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"timestamp": "20260317-214953",
|
||||
"generated_at": "2026-03-17T20:50:06Z",
|
||||
"files": {
|
||||
"audit": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-214953.json",
|
||||
"check": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-214953.log",
|
||||
"test": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-214953.log",
|
||||
"summary": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/outputs/deep-cycles/summary-20260317-214953.md"
|
||||
},
|
||||
"checks": {
|
||||
"check:v2": "ok",
|
||||
"test:v2": "ok"
|
||||
},
|
||||
"retained_logs": [
|
||||
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-214953.json",
|
||||
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-214953.log",
|
||||
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-214953.log"
|
||||
],
|
||||
"counts": {
|
||||
"security": 0,
|
||||
"performance": 1,
|
||||
"large_files": 9
|
||||
},
|
||||
"debt": {
|
||||
"score": 42,
|
||||
"level": "medium",
|
||||
"components": {
|
||||
"security": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 0
|
||||
},
|
||||
"performance": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 1
|
||||
},
|
||||
"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": [
|
||||
{
|
||||
"severity": "P2",
|
||||
"file": "apps/api/src/ws-chat-smoke.test.ts",
|
||||
"line": 82,
|
||||
"pattern": "JSON.parse without try/catch"
|
||||
}
|
||||
],
|
||||
"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": "ef7decd9bfc82325db46ba727791ea82ca23abacc25a4c5309ce83385ee379b7",
|
||||
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
|
||||
"test_sha256": "a1d9151d5c607a75a294d95d8df48bcd6dc719ebdf8c68682a88090d3483eaec"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"timestamp": "20260317-215042",
|
||||
"generated_at": "2026-03-17T20:50:55Z",
|
||||
"files": {
|
||||
"audit": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-215042.json",
|
||||
"check": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-215042.log",
|
||||
"test": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-215042.log",
|
||||
"summary": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/outputs/deep-cycles/summary-20260317-215042.md"
|
||||
},
|
||||
"checks": {
|
||||
"check:v2": "ok",
|
||||
"test:v2": "ok"
|
||||
},
|
||||
"retained_logs": [
|
||||
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-215042.json",
|
||||
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-215042.log",
|
||||
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-215042.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": "051b739fd91f9e33923d9a65403a6a8f33870e8e9c9b3d128f6c3fd8a56184c2",
|
||||
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
|
||||
"test_sha256": "5a556c1d63e4a10a8d4e1c6acebd6d953217b4c8954d1d5652b6f9379e09f3b2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"timestamp": "20260317-215230",
|
||||
"generated_at": "2026-03-17T20:52:43Z",
|
||||
"files": {
|
||||
"audit": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-215230.json",
|
||||
"check": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-215230.log",
|
||||
"test": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-215230.log",
|
||||
"summary": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/outputs/deep-cycles/summary-20260317-215230.md"
|
||||
},
|
||||
"checks": {
|
||||
"check:v2": "ok",
|
||||
"test:v2": "ok"
|
||||
},
|
||||
"retained_logs": [
|
||||
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-215230.json",
|
||||
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-215230.log",
|
||||
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-215230.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": "17eb052a14022141ae0ac300a51e7466686bd48cec717995f4e127af36553d1b",
|
||||
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
|
||||
"test_sha256": "0be1c56be0c436e44f739fe117793baf7b9b760c4efc93d2612253b3b8e5ce68"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"timestamp": "20260317-220132",
|
||||
"generated_at": "2026-03-17T21:01:46Z",
|
||||
"files": {
|
||||
"audit": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-220132.json",
|
||||
"check": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-220132.log",
|
||||
"test": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-220132.log",
|
||||
"summary": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/outputs/deep-cycles/summary-20260317-220132.md"
|
||||
},
|
||||
"checks": {
|
||||
"check:v2": "ok",
|
||||
"test:v2": "ok"
|
||||
},
|
||||
"retained_logs": [
|
||||
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-220132.json",
|
||||
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-220132.log",
|
||||
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-220132.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": "17eb052a14022141ae0ac300a51e7466686bd48cec717995f4e127af36553d1b",
|
||||
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
|
||||
"test_sha256": "6fa8efd92491cdb9dea87fe0d31662c2ed53bb70cb78d61943901efd872db1fb"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"timestamp": "20260317-221553",
|
||||
"generated_at": "2026-03-17T21:16:12Z",
|
||||
"files": {
|
||||
"audit": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-221553.json",
|
||||
"check": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-221553.log",
|
||||
"test": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-221553.log",
|
||||
"summary": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/outputs/deep-cycles/summary-20260317-221553.md"
|
||||
},
|
||||
"checks": {
|
||||
"check:v2": "ok",
|
||||
"test:v2": "ok"
|
||||
},
|
||||
"retained_logs": [
|
||||
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-221553.json",
|
||||
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-221553.log",
|
||||
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-221553.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": "17eb052a14022141ae0ac300a51e7466686bd48cec717995f4e127af36553d1b",
|
||||
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
|
||||
"test_sha256": "f30163615c053c34250dd5b59f8348878ee4e2bf7adac0547a06fcb529afec6c"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"timestamp": "20260317-222432",
|
||||
"generated_at": "2026-03-17T21:24:53Z",
|
||||
"files": {
|
||||
"audit": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-222432.json",
|
||||
"check": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-222432.log",
|
||||
"test": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-222432.log",
|
||||
"summary": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/outputs/deep-cycles/summary-20260317-222432.md"
|
||||
},
|
||||
"checks": {
|
||||
"check:v2": "ok",
|
||||
"test:v2": "ok"
|
||||
},
|
||||
"retained_logs": [
|
||||
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-222432.json",
|
||||
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-222432.log",
|
||||
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-222432.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": "6feb48cd179cfedbf33fc90f6a25ee9d7b2f6bb979e2f5be70d8390554eac17a",
|
||||
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
|
||||
"test_sha256": "226f2c6b4312d964a2d7f70d9f2ec562c22c5f55a89de4236e6db879316a6253"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"timestamp": "20260317-222623",
|
||||
"generated_at": "2026-03-17T21:26:36Z",
|
||||
"files": {
|
||||
"audit": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-222623.json",
|
||||
"check": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-222623.log",
|
||||
"test": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-222623.log",
|
||||
"summary": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/outputs/deep-cycles/summary-20260317-222623.md"
|
||||
},
|
||||
"checks": {
|
||||
"check:v2": "ok",
|
||||
"test:v2": "ok"
|
||||
},
|
||||
"retained_logs": [
|
||||
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-222623.json",
|
||||
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-222623.log",
|
||||
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-222623.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": "6feb48cd179cfedbf33fc90f6a25ee9d7b2f6bb979e2f5be70d8390554eac17a",
|
||||
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
|
||||
"test_sha256": "b5d3c9176eda959317a147cb2340a64a37225e5b5c5df1f7a1ff511bc6415326"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"timestamp": "20260317-223122",
|
||||
"generated_at": "2026-03-17T21:32:41Z",
|
||||
"files": {
|
||||
"audit": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-223122.json",
|
||||
"check": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-223122.log",
|
||||
"test": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-223122.log",
|
||||
"summary": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/outputs/deep-cycles/summary-20260317-223122.md"
|
||||
},
|
||||
"checks": {
|
||||
"check:v2": "ok",
|
||||
"test:v2": "ok"
|
||||
},
|
||||
"retained_logs": [
|
||||
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-223122.json",
|
||||
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-223122.log",
|
||||
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-223122.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": "6feb48cd179cfedbf33fc90f6a25ee9d7b2f6bb979e2f5be70d8390554eac17a",
|
||||
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
|
||||
"test_sha256": "9a90dcde00268c0fb47ede934dd6f3ce596901f8bde0f976106e95b226db9afe"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"timestamp": "20260317-224958",
|
||||
"generated_at": "2026-03-17T21:50:10Z",
|
||||
"files": {
|
||||
"audit": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-224958.json",
|
||||
"check": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-224958.log",
|
||||
"test": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-224958.log",
|
||||
"summary": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/outputs/deep-cycles/summary-20260317-224958.md"
|
||||
},
|
||||
"checks": {
|
||||
"check:v2": "ok",
|
||||
"test:v2": "ok"
|
||||
},
|
||||
"retained_logs": [
|
||||
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-224958.json",
|
||||
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-224958.log",
|
||||
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-224958.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": "728b305c4d165132e9f34b52b32a9eeab9480970dba9de96ef76cc49f3c51b59"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"timestamp": "20260317-230624",
|
||||
"generated_at": "2026-03-17T22:06:31Z",
|
||||
"files": {
|
||||
"audit": "/home/kxkm/KXKM_Clown/ops/v2/logs/deep-audit-20260317-230624.json",
|
||||
"check": "/home/kxkm/KXKM_Clown/ops/v2/logs/check-v2-20260317-230624.log",
|
||||
"test": "/home/kxkm/KXKM_Clown/ops/v2/logs/test-v2-20260317-230624.log",
|
||||
"summary": "/home/kxkm/KXKM_Clown/ops/v2/outputs/deep-cycles/summary-20260317-230624.md"
|
||||
},
|
||||
"checks": {
|
||||
"check:v2": "ok",
|
||||
"test:v2": "ok"
|
||||
},
|
||||
"retained_logs": [
|
||||
"/home/kxkm/KXKM_Clown/ops/v2/logs/deep-audit-20260317-230624.json",
|
||||
"/home/kxkm/KXKM_Clown/ops/v2/logs/check-v2-20260317-230624.log",
|
||||
"/home/kxkm/KXKM_Clown/ops/v2/logs/test-v2-20260317-230624.log"
|
||||
],
|
||||
"counts": {
|
||||
"security": 0,
|
||||
"performance": 0,
|
||||
"large_files": 9
|
||||
},
|
||||
"debt": {
|
||||
"score": 40,
|
||||
"level": "medium",
|
||||
"components": {
|
||||
"security": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 0
|
||||
},
|
||||
"performance": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 0
|
||||
},
|
||||
"tsErrors": 0,
|
||||
"largeFiles": 9,
|
||||
"mediumFiles": 10,
|
||||
"deps": 3
|
||||
}
|
||||
},
|
||||
"hotspots": [
|
||||
"packages/persona-domain/src/index.ts",
|
||||
"packages/node-engine/src/index.ts",
|
||||
"packages/storage/src/index.test.ts",
|
||||
"packages/node-engine/src/index.test.ts",
|
||||
"apps/web/src/components/Chat.tsx"
|
||||
],
|
||||
"open_findings": [],
|
||||
"backlog_links": [
|
||||
"lot-12-deep-audit/pipeline-canonique",
|
||||
"lot-12-deep-audit/deep-cycle-operator",
|
||||
"lot-12-deep-audit/ws-chat-seams",
|
||||
"lot-12-deep-audit/ui-contract",
|
||||
"lot-12-deep-audit/web-shell-convergence"
|
||||
],
|
||||
"retention_days": 7,
|
||||
"hashes": {
|
||||
"audit_sha256": "f407098d6b9eefa41e87481fce8160d6c61a56c07b52ffc4ec2cd676aad1dda5",
|
||||
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
|
||||
"test_sha256": "7b4a779c787c08a64f3e9c9de7e65b4e50583d8dfde4da4cb33313f9165ec7eb"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"timestamp": "20260317-231254",
|
||||
"generated_at": "2026-03-17T22:13:03Z",
|
||||
"files": {
|
||||
"audit": "/home/kxkm/KXKM_Clown/ops/v2/logs/deep-audit-20260317-231254.json",
|
||||
"check": "/home/kxkm/KXKM_Clown/ops/v2/logs/check-v2-20260317-231254.log",
|
||||
"test": "/home/kxkm/KXKM_Clown/ops/v2/logs/test-v2-20260317-231254.log",
|
||||
"summary": "/home/kxkm/KXKM_Clown/ops/v2/outputs/deep-cycles/summary-20260317-231254.md"
|
||||
},
|
||||
"checks": {
|
||||
"check:v2": "ok",
|
||||
"test:v2": "ok"
|
||||
},
|
||||
"retained_logs": [
|
||||
"/home/kxkm/KXKM_Clown/ops/v2/logs/deep-audit-20260317-231254.json",
|
||||
"/home/kxkm/KXKM_Clown/ops/v2/logs/check-v2-20260317-231254.log",
|
||||
"/home/kxkm/KXKM_Clown/ops/v2/logs/test-v2-20260317-231254.log"
|
||||
],
|
||||
"counts": {
|
||||
"security": 0,
|
||||
"performance": 0,
|
||||
"large_files": 8
|
||||
},
|
||||
"debt": {
|
||||
"score": 38,
|
||||
"level": "medium",
|
||||
"components": {
|
||||
"security": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 0
|
||||
},
|
||||
"performance": {
|
||||
"P0": 0,
|
||||
"P1": 0,
|
||||
"P2": 0
|
||||
},
|
||||
"tsErrors": 0,
|
||||
"largeFiles": 8,
|
||||
"mediumFiles": 11,
|
||||
"deps": 3
|
||||
}
|
||||
},
|
||||
"hotspots": [
|
||||
"packages/node-engine/src/index.ts",
|
||||
"packages/storage/src/index.test.ts",
|
||||
"packages/node-engine/src/index.test.ts",
|
||||
"apps/web/src/components/Chat.tsx",
|
||||
"apps/web/src/components/ChatHistory.tsx"
|
||||
],
|
||||
"open_findings": [],
|
||||
"backlog_links": [
|
||||
"lot-12-deep-audit/pipeline-canonique",
|
||||
"lot-12-deep-audit/deep-cycle-operator",
|
||||
"lot-12-deep-audit/ws-chat-seams",
|
||||
"lot-12-deep-audit/ui-contract",
|
||||
"lot-12-deep-audit/web-shell-convergence"
|
||||
],
|
||||
"retention_days": 7,
|
||||
"hashes": {
|
||||
"audit_sha256": "e6ccf31c539079d2d9b75f8486dbc3d6e8515f1de0024266acded0e414cb2c46",
|
||||
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
|
||||
"test_sha256": "7d564c8c3c34d7757f09f8c4eed722095ace379bfb80d158e28df56959866a83"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# Deep Cycle Summary 20260317-212918
|
||||
|
||||
- Generated: 2026-03-17T20:29:31Z
|
||||
- Retention policy: 7 days
|
||||
- check:v2: ok
|
||||
- test:v2: ok
|
||||
- security findings: 0
|
||||
- performance findings: 2
|
||||
- debt score: 44/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
|
||||
- P2 apps/api/src/ws-commands.ts:221 — console.log in production
|
||||
- P2 apps/worker/src/index.ts:57 — console.log in production
|
||||
@@ -0,0 +1,19 @@
|
||||
# Deep Cycle Summary 20260317-213137
|
||||
|
||||
- Generated: 2026-03-17T20:31:50Z
|
||||
- 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-214953
|
||||
|
||||
- Generated: 2026-03-17T20:50:06Z
|
||||
- Retention policy: 7 days
|
||||
- check:v2: ok
|
||||
- test:v2: ok
|
||||
- security findings: 0
|
||||
- performance findings: 1
|
||||
- debt score: 42/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
|
||||
- P2 apps/api/src/ws-chat-smoke.test.ts:82 — JSON.parse without try/catch
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user