feat: wire vLLM + TEI embedding backend

- LLM_API_KEY support across all LLM calls
- TEI embedding server (bge-m3, :9500)
- Hierarchical AGENTS.md documentation
- CLAUDE.md updated for new architecture
This commit is contained in:
Codex Local
2026-04-08 09:41:51 +02:00
parent 2beaaf6179
commit c5f921243e
14 changed files with 1477 additions and 571 deletions
+16 -8
View File
@@ -5,16 +5,24 @@
# cp .env.example .env
# ---------------------------------------------------------------------------
# --- Ollama (LLM inference) ------------------------------------------------
# If Ollama runs natively on the host, use host.docker.internal (default).
# If using the Ollama Docker container (--profile ollama), use http://ollama:11434
OLLAMA_URL=http://host.docker.internal:11434
# --- Local LLM runtime (vLLM / TurboQuant) ---------------------------------
# Primary chat/completion runtime. Must expose an OpenAI-compatible API.
LLM_URL=http://host.docker.internal:11434
LLM_MODEL=qwen-14b-awq
LLM_API_KEY= # Bearer token for vLLM --api-key (leave empty for Ollama)
# --- Embeddings backend (auxiliary) ----------------------------------------
# TEI (recommended): dedicated embedding server on port 9500
# Ollama: fallback, uses /api/embed on port 11434
OLLAMA_URL=http://host.docker.internal:9500
EMBEDDING_BACKEND=tei # "tei" (OpenAI /v1/embeddings) or "ollama" (/api/embed)
RAG_EMBEDDING_MODEL=BAAI/bge-m3 # Model name (must match TEI --model-id or Ollama model)
# --- Ports ------------------------------------------------------------------
APP_PORT=3333 # V1 Express server
API_PORT=4180 # V2 API server
PG_PORT=5432 # PostgreSQL (exposed to host)
# OLLAMA_PORT=11434 # Only needed with --profile ollama
# LLM_PORT=11434 # Only needed if you expose the local runtime from compose
# --- Admin ------------------------------------------------------------------
ADMIN_BOOTSTRAP_TOKEN= # Initial admin token (set a strong secret)
@@ -25,7 +33,7 @@ OWNER_NICK= # Owner nickname in chat
MAX_GENERAL_RESPONDERS=4 # Max personas responding in #general
# --- Vision (image analysis in chat) ----------------------------------------
# VISION_MODEL=qwen3-vl:8b # Ollama model for image analysis (qwen3-vl recommandé)
# VISION_MODEL=qwen3-vl:8b # Vision model used by the configured runtime
# --- Training (Node Engine worker) ------------------------------------------
# PYTHON_BIN=/home/kxkm/venv/bin/python3 # Python with ML libs (PyTorch, Unsloth, TRL)
@@ -37,8 +45,8 @@ MAX_GENERAL_RESPONDERS=4 # Max personas responding in #general
# --- ComfyUI (image generation) --------------------------------------------
# COMFYUI_URL=http://localhost:8188 # ComfyUI API endpoint for /imagine command
# --- Mascarade (LLM orchestrator) ------------------------------------------
# MASCARADE_URL=http://127.0.0.1:8100 # Mascarade API (OpenAI-compatible /v1/chat/completions)
# --- Mascarade (optional cloud/provider routing) ----------------------------
# MASCARADE_URL=http://127.0.0.1:8100 # Optional cloud/provider router
# MASCARADE_API_KEY= # API key for authenticated endpoints (/agents, /orchestrate)
# --- External services (optional) ------------------------------------------
+104 -226
View File
@@ -1,252 +1,130 @@
# Agents, Sous-agents, Competences
# AGENTS.md — KXKM_Clown Monorepo
> "L'infrastructure est une decision politique deployee." -- electron rare
## Orchestration
Multimodal AI chat system. Turborepo monorepo (npm workspaces): 3 apps + 8 packages + 42 scripts. 15+ service mesh. Single RTX 4090 GPU: `MAX_GPU_CONCURRENT=1`.
- Agent racine: **Coordinateur** — planifie, arbitre, synchronise PLAN/TODO/docs
- Sous-agents specialises: analyse code, veille OSS, audit securite, optimisation
- Cadence: synchroniser PLAN.md + TODO.md + docs apres chaque lot
## Key Files
## Matrice des agents (lot 17+)
| File | Purpose |
|------|---------|
| `docker-compose.yml` | 12 services (postgres, searxng, docling, qdrant, ollama, tts, lightrag) with health checks |
| `turbo.json` | Build tasks, caching, workspace graph |
| `package.json` | Root: 15 npm scripts (dev, build, check, test, smoke, verify) |
| `.env.example` | LLM_URL, DATABASE_URL, ports, TTS, RAG model, etc. |
| Agent | Competences | Perimetre | Etat |
## Subdirectories
| Dir | Purpose | Ref |
|-----|---------|-----|
| `apps/` | 3 apps: api (77 TS), web (64 TS/TSX), worker (4 TS) | `apps/AGENTS.md` |
| `packages/` | 8 packages (core, auth, chat-domain, persona-domain, node-engine, storage, tui, ui) | `packages/AGENTS.md` |
| `scripts/` | 42 files: 20 Python, 22 Shell (ops, training, ingestion, voice, image, deploy) | `scripts/AGENTS.md` |
| `docs/` | 50+ specs, spikes, research, audits (SPEC_*.md, AUDIT_*.md, OSS_VEILLE_*.md) | — |
| `ops/` | Monitoring + ops/v2/ (systemd, TUI, health-check.sh, deep-audit.js) | — |
| `models/` | Fine-tuned + LoRA weights (base_models/, finetuned/, lora/, registry.json) | — |
| `data/` | Ephemeral: persona memory, chat logs, context, corpus (v2-local/, chat-logs/) | — |
## Agent Matrix
| Agent | Competences | Scope | Status |
|---|---|---|---|
| Coordinateur | planification, arbitrage, docs de pilotage | PLAN.md, TODO.md, AGENTS.md, README.md | actif |
| Securite | validation input, hardening, rate-limit, RBAC | apps/api, ws-chat, packages/auth | veille |
| Backend API | Express, WS, Ollama, RAG, multimodal pipeline | apps/api/src/ | actif |
| Coordinateur | Planning, arbitration, docs sync | PLAN.md, TODO.md, AGENTS.md, README.md | actif |
| Securite | Input validation, hardening, rate-limit, RBAC | apps/api, ws-chat, packages/auth | veille |
| Backend API | Express, WS, Ollama, RAG, multimodal pipeline | apps/api/src/ (77 TS + 27 tests) | actif |
| Node Engine | DAG, queue, runs, sandbox, training adapters | packages/node-engine, apps/worker | actif |
| Personas | source/feedback/proposals/pharmacius, memoire | packages/persona-domain, ws-chat | actif |
| Frontend | React/Vite, UX Minitel, React Flow, chat, voice | apps/web/src/ | actif |
| Ops/TUI | scripts, logs, rotate/purge, health, audit | ops/v2/, scripts/ | actif |
| Training | DPO, SFT, Unsloth, eval, autoresearch, Ollama import | scripts/, packages/node-engine | actif |
| Multimodal | STT, TTS, vision, PDF, RAG, recherche web | apps/api/src/ws-chat.ts | actif |
| Veille OSS | recherche projets, libs, modeles, benchmarks | docs/OSS_WATCH, docs/HF_MODEL_RESEARCH | periodique |
| Personas | Memory, DPO, pharmacius, coherence | packages/persona-domain, ws-chat (33 personas) | actif |
| Frontend | React/Vite, Minitel theme, React Flow, chat, voice | apps/web/src/ (64 TS/TSX + 10 tests) | actif |
| Ops/TUI | Monitoring, deploy, logs, health, audit | ops/v2/, scripts/, deep-audit.js | actif |
| Training | DPO, SFT, Unsloth, eval, autoresearch | scripts/, packages/node-engine | actif |
| Multimodal | STT, TTS, vision, PDF, RAG, web search | apps/api/src/ws-multimodal.ts | actif |
| Veille OSS | Benchmarks, new libs, licensing, interop | docs/OSS_WATCH, docs/HF_MODEL_RESEARCH | periodique |
## Sous-agents et skill routing
## Message Flow
```mermaid
flowchart TD
Coord[Coordinateur]
Coord --> SecAgent[Securite]
Coord --> BackAgent[Backend API]
Coord --> EngAgent[Node Engine]
Coord --> PersAgent[Personas]
Coord --> FrontAgent[Frontend]
Coord --> OpsAgent[Ops/TUI]
Coord --> TrainAgent[Training]
Coord --> MultiAgent[Multimodal]
Coord --> OSSAgent[Veille OSS]
SecAgent --> |audit| SecScan[Pattern scan P0/P1/P2]
SecAgent --> |fix| SecFix[Correctifs chirurgicaux]
BackAgent --> |analyse| APIAudit[Deep analyse app.ts, ws-chat.ts]
BackAgent --> |refactor| APISplit[Extraction modules]
EngAgent --> |test| EngTest[Tests unitaires node-engine]
EngAgent --> |extend| EngNew[Nouveaux node types]
PersAgent --> |pipeline| PersPipe[Editorial pipeline]
PersAgent --> |finetune| PersDPO[DPO + PCL methodology]
FrontAgent --> |ui| FrontUI[Minitel theme CSS]
FrontAgent --> |perf| FrontPerf[Memoization, lazy load]
OpsAgent --> |monitor| OpsHealth[health-check, deep-audit]
OpsAgent --> |deploy| OpsDeploy[Docker, kxkm-ai]
TrainAgent --> |train| TrainRun[Unsloth/TRL runs]
TrainAgent --> |eval| TrainEval[Scoring, registry]
MultiAgent --> |voice| MultiVoice[XTTS-v2, WebRTC]
MultiAgent --> |search| MultiSearch[SearXNG]
OSSAgent --> |web| OSSWeb[Recherche web]
OSSAgent --> |hf| OSSHF[HuggingFace models]
```
User WS → ws-chat.ts (rate-limit, multimodal dispatch)
→ ws-conversation-router.ts (persona routing, context assembly)
→ ws-persona-router.ts (memory extract/load, responder select)
→ inference-scheduler.ts (single-GPU queue, MAX_GPU_CONCURRENT=1)
→ ws-ollama.ts (token stream, tool-calling)
→ ws-multimodal.ts (TTS, vision, STT, file upload)
→ persona-memory-store.ts (nick-isolated file persist)
→ rag.ts (embedding + LightRAG dual-write)
→ context-store.ts (channel history + compaction)
```
## Todo agents (lot 17+ — mis a jour 2026-03-24)
## Services
### Coordinateur
| Service | Port | Notes |
|---------|------|-------|
| API (HTTP+WS) | 4180 | Node.js Express + ws |
| Frontend (Vite) | 5173 | React + 5 CSS themes |
| Ollama/vLLM | 11434 | LLM runtime + embeddings |
| PostgreSQL | 5432 | Chat, sessions, node-engine runs |
| SearXNG | 8080 | Self-hosted search (DuckDuckGo fallback) |
| Docling | 9400 | PDF extraction |
| LightRAG | 9621 | Graph-RAG, `LLM_MODEL=mistral:7b` to avoid `<think>` corruption |
| TTS (Piper/Chatterbox) | 9100 | Voice synthesis |
| Kokoro TTS | 9201 | Fast TTS, 12 voices |
| ComfyUI | 8188 | Image generation (32 checkpoints + 24 LoRAs) |
| Camoufox | 8091 | Stealth browser for bot-protected sites |
- [x] Consolider PLAN.md avec etat reel (lots 0-94 complets)
- [x] Synchroniser FEATURE_MAP.md matrice
- [x] Mettre a jour TODO.md avec backlog Phase session 2026-03-19/20
- [x] Documenter actions dans ops/v2/logs/
- [x] lot-95: Coordonner E2E Playwright test plan
- [x] lot-100: Design public demo mode access control
## GPU Constraint
### Backend API
All LLM calls → `inference-scheduler.ts`. No direct fetch() outside approved helpers. RTX 4090: `MAX_GPU_CONCURRENT=1`. Context compaction + persona extraction both via `scheduler.submit(priority: "low")`.
- [x] Extraire app-bootstrap.ts et app-middleware.ts de app.ts
- [x] Extraire ws-conversation-router.ts de ws-chat.ts
- [x] ws-chat.ts modularized (425 to 335 LOC, 3 modules extracted)
- [x] app.ts extraction (540 to 131 LOC, create-repos.ts extracted)
- [x] Zod validation on all 19 API route schemas
- [x] Error telemetry (16 labels)
- [x] Perf instrumentation (6 labels, p50/p95/p99), TTFC 284ms
- [x] Smart routing (5 topic domains)
- [x] Dynamic context window (4k-32k)
- [x] NLP auto-detect generation intent (compose vs imagine)
- [x] /speed command for latency diagnostics
- [ ] lot-178: ACE-Step API direct integration (duration fix)
- [ ] lot-180: Timeline data model
- [x] lot-97: Multi-channel support (create/join channels)
- [x] lot-100: Public demo mode read-only routes
## Persona Memory (nick-isolated, 2026-04)
### Node Engine
- **Path**: `data/v2-local/persona-memory/{personaId}/{nick}.json`
- **Modes**: `auto` (Pharmacius, Sherlock, Turing, Ikeda), `explicit` (artistic personas, `/remember` only)
- **Injection cap**: 8 facts max into system prompt
- **Anonymous relay**: `_anonymous` sentinel for unknown-nick chains
- [x] Extraire registry.ts du hotspot node-engine
- [ ] Ajouter node type `music_generation` (ACE-Step 1.5)
- [ ] Ajouter node type `voice_clone` (Chatterbox)
- [ ] Ajouter node type `audio_mix` (multi-track composition)
- [ ] Ajouter node type `audio_effects` (FX chain)
- [ ] lot-96: Automated DPO pipeline (feedback → pairs → training trigger)
## Build & Dev
### Multimodal (composition pipeline)
- [x] 35 music styles ACE-Step
- [x] ComfyUI smart checkpoint selection (32 checkpoints + 24 LoRAs)
- [ ] lot-178: ACE-Step API direct (duration fix)
- [ ] lot-181: TTS voiceover mix into timeline
- [ ] lot-182: Audio effects pipeline (reverb, delay, EQ, compression)
- [ ] lot-183: DAW export (stems, markers, project file)
- [x] lot-184: Multi-track composition (/layer, composition-store)
- [x] lot-185: Composition UI (track lanes, play/pause/seek)
- [x] lot-186: Arrangement tools (/comp structure, section markers)
- [x] lot-187: Auto-mastering (/mix master, loudness normalization, limiter)
- [x] lot-188: /voice TTS voiceover injected into composition
- [x] lot-189: /noise 5 types (white, pink, brown, rain, wind)
- [x] lot-190: /fx 9 audio effects (reverb, delay, chorus, flanger, distortion, bitcrusher, EQ, compressor, tremolo)
- [x] lot-191: /ambient scene generator (forest, ocean, city, space, cave)
- [ ] lot-194: Waveform visualization (wavesurfer.js)
- [ ] lot-195: /remix re-generate specific track
- [ ] lot-199: Stem separation (Demucs v4 htdemucs, 6-stem, MIT)
- [x] lot-200: Full DAW export (WAV stems + JSON project)
### Personas
- [ ] Evaluer PCL (Persona-Aware Contrastive Learning) pour coherence
- [ ] Evaluer OpenCharacter pour generation profils synthetiques
- [x] Ajouter `/compose` command (generation musicale)
### Frontend
- [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
- [x] React.memo + useCallback on ChatSidebar, ChatInput, ChatHistory
- [x] 17 lazy-loaded routes (-53% initial JS)
- [x] CRT CSS-only effect (scanlines, vignette, phosphor glow, boot 0.8s)
- [x] Chat virtualization (react-window, variable row heights)
- [x] Markdown rendering (marked + DOMPurify)
- [x] CRT boot animation (modem dial, scanline reveal)
- [x] 5 CSS themes (minitel, crt, hacker, synthwave, default)
- [x] Mobile responsive pass (touch, bottom nav, viewport units)
- [x] Guest mode read-only UI
- [x] lot-185: Composition timeline UI (waveform view, track lanes)
- [ ] lot-194: Waveform visualization (wavesurfer.js)
- [x] lot-95: E2E Playwright tests (login, chat, upload, admin)
- [x] lot-98: File sharing UI (upload → gallery)
### Ops/TUI
- [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 migrated tmux → systemd
- [x] Systemd services (kxkm-tts + kxkm-lightrag, auto-restart)
- [x] health-check.sh TUI (19 checks)
- [x] Docker compose 12 services with health checks
- [ ] Fix Docker transformers (rebuild propre avec torch)
### Training
- [x] Spike BGE-M3 (resultat negatif sur Apple/Metal, baseline maintenue)
- [x] TTS dual backend Chatterbox/Piper valide
- [x] Tool-calling benchmark (llama3.1 vs qwen3 vs mistral)
- [x] Sherlock migrated to llama3.1:8b-instruct-q4_0
- [ ] lot-96: Persona DPO automation pipeline
- [ ] Tester ACE-Step 1.5 sur RTX 4090
### 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
```mermaid
stateDiagram-v2
[*] --> Analyse: agent lance
Analyse --> Findings: scan code/docs/web
Findings --> Triage: P0/P1/P2 classification
Triage --> Fix_P0: P0 critique
Triage --> Plan_P1: P1 important
Triage --> Backlog_P2: P2 mineur
Fix_P0 --> Test: correction chirurgicale
Plan_P1 --> Test: correction planifiee
Test --> Deploy: tests OK
Deploy --> Log: log + purge
Log --> [*]: cycle termine
Backlog_P2 --> [*]: ajoute au TODO
```bash
npm install # Install all workspaces
npm run dev # Turbo parallel (api, web, worker)
npm run dev:v2:api # API :4180 (tsx watch)
npm run dev:v2:web # Web :5173 (Vite)
npm run check:v2 # tsc --noEmit
npm run -w @kxkm/api test # 278 unit tests
npm run -w @kxkm/web test # 54 unit tests
npm run smoke:v2 # Integration smoke
npm run verify # check + smoke (full gate)
docker compose --profile v2 up -d
```
## Affectations en cours (2026-03-20)
## Environment
### Mission globale
- Deep analyse continue du code, optimisation chirurgicale, et synchronisation documentaire apres chaque lot.
- Priorite execution: P1 fiabilite, puis dette perf/complexite, puis features lot 18-19.
```bash
LLM_URL=http://localhost:11434
LLM_MODEL=qwen-14b-awq
DATABASE_URL=postgres://kxkm:kxkm@localhost:5432/kxkm_clown
V2_API_PORT=4180
TTS_ENABLED=1
VISION_MODEL=qwen3-vl:8b
RAG_EMBEDDING_MODEL=nomic-embed-text
SEARXNG_URL=http://localhost:8080
KXKM_PERSONA_MEMORY_INJECTION_LIMIT=8
```
### Assignations agents -> sous-agents -> competences
## Cycle State (2026-03-20)
| Agent | Sous-agent | Competences principales | Taches assignees immediates |
|---|---|---|---|
| Coordinateur | Planner/Docs | triage, synchronisation, runbook | Maintenir PLAN/TODO, chainer les lots, tracer actions |
| Backend API | WS/HTTP surgeon | websocket, express, validation input | Extraire `ws-chat.ts` en modules, reduire logs, limiter hot paths |
| Node Engine | DAG runtime | graph validation, queue, state machine | Ajouter nodes `music_generation`, `voice_clone`, `document_extraction` |
| Ops/TUI | Audit operator | TUI scripts, logs, rotation, cron | Rendre `deep-audit` zero faux positif critique, pipeline logs + purge |
| Veille OSS | Scout | benchmark OSS, licences, interop | Evaluer Open WebUI, LibreChat, LangGraph, SearXNG, Docling |
- 130+ lots complete (lot-24 to lot-177)
- 425 tests, 0 failures
- 43 chat commands, 33 personas
- 5 CSS themes, 35 music styles (ACE-Step)
- 32 ComfyUI checkpoints + 24 LoRAs
- TTFC 284ms, guest mode, mobile responsive
- Structured logging (pino JSON)
- Systemd services (TTS, LightRAG)
### Workflow d'enchainement
1. Executer audit + tests
2. Corriger de maniere chirurgicale
3. Re-executer audit + tests
4. Mettre a jour docs de pilotage
5. Alimenter TODO suivant avec ordre d'execution
## See Also
### Regles d'operation
- Interroger le user uniquement en cas de blocage reel (acces, choix irreversibles, secrets).
- 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-20 18:00)
- 130+ lots termines (lot-24 a lot-177).
- 425 tests, 0 failures.
- 13 services en production.
- 43 chat commands, 33 personas.
- 35 music styles (ACE-Step), 5 CSS themes.
- 32 ComfyUI checkpoints + 24 LoRAs, smart selection NLP.
- TTFC 284ms.
- Guest mode, mobile responsive.
- Structured logging complet (pino JSON, 0 console.log).
- Systemd services (TTS + LightRAG).
- Frontend: lazy routes (-53%), React.memo, CRT boot, chat virtualization.
### Prochains lots (178-200) — Composition Pipeline
1. lot-178: Compose duration fix (ACE-Step API direct)
2. lot-179: SPEC_COMPOSE_ADVANCED plan
3. lot-180: Timeline data model (tracks, clips, markers)
4. lot-181: TTS voiceover mix into timeline
5. lot-182: Audio effects pipeline (reverb, delay, EQ)
6. lot-183: DAW export (stems, markers, project)
7. lot-184-200: Multi-track, arrangement, mastering, stem separation, MIDI, templates, collab, lyrics, FX rack, automation, samples, spectral view, history, render queue, sharing
- `apps/AGENTS.md` — api, web, worker details
- `packages/AGENTS.md` — shared package breakdown
- `scripts/AGENTS.md` — deployment, training, ops scripts
- `CLAUDE.md` — Architecture, request paths, data directories
- `PLAN.md` / `TODO.md` / `FEATURE_MAP.md` — Roadmap (lots 178-200)
+146
View File
@@ -0,0 +1,146 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Dev (V2)
npm run dev:v2:api # API on :4180 (tsx watch)
npm run dev:v2:web # Frontend on :5173 (Vite)
npm run dev:v2:worker # Background job processor
# Build
npm run build:v2 # TypeScript compile (all workspaces)
npm run turbo:build # Full monorepo build
# Type-check
npm run check:v2 # tsc --noEmit (fast check)
npm run -w @kxkm/api check
# Tests
npm run -w @kxkm/api test # 278 unit tests
npm run -w @kxkm/web test # 54 unit tests
npm run smoke:v2 # Integration smoke
npm run verify # check + smoke (full gate)
# Run a single test file (after build)
cd apps/api && node --experimental-test-module-mocks --test dist/persona-memory-store.test.js
# Docker
docker compose --profile v2 up -d
docker compose --profile v2 --profile ollama up -d # with bundled embeddings backend
```
## Architecture
**Monorepo** (npm workspaces + Turborepo): `apps/` + `packages/`
### API (`apps/api/src/`)
The V2 API is a single Node.js process combining Express (HTTP) and `ws` (WebSocket) on port 4180.
**Request path for a chat message:**
```
ws-chat.ts (WS handler, rate-limit, routing)
→ ws-conversation-router.ts (pick responder personas, build context)
→ ws-ollama.ts (stream tokens from vLLM/TurboQuant runtime, tool-calling)
→ ws-multimodal.ts (TTS streaming, vision, STT, file upload)
→ ws-persona-router.ts (memory load/update, responder selection)
```
**Key files:**
| File | Purpose |
|------|---------|
| `server.ts` | HTTP+WS bootstrap, DAW sample routes, corpus boot |
| `ws-chat.ts` | WS entry — broadcast, rate-limit, multimodal dispatch |
| `ws-conversation-router.ts` | Persona routing, context assembly, TTS chunking, inter-persona depth-3 relay |
| `ws-ollama.ts` | Runtime streaming, tool-calling, `<think>` tag stripping |
| `ws-persona-router.ts` | Memory extract/save, responder selection, InferenceScheduler |
| `personas-default.ts` | 33 persona definitions (`memoryMode`, `corpus[]`, `relations[]`) |
| `persona-memory-store.ts` | Per-nick isolated storage at `data/v2-local/persona-memory/{personaId}/{nick}.json` |
| `persona-memory-policy.ts` | auto/explicit/off modes, `injectionFactsLimit` (default 8) |
| `rag.ts` | Local embedding store, per-persona namespaces, LightRAG dual-write |
| `context-store.ts` | Per-channel conversation memory, LLM compaction via InferenceScheduler |
| `inference-scheduler.ts` | Single-GPU queue (`MAX_GPU_CONCURRENT=1`), all LLM calls must go through it |
| `chat-types.ts` | All shared types: `ChatPersona`, `PersonaMemoryMode`, `ClientInfo`, message union |
| `mcp-tools.ts` | Tool definitions injected per persona (web_search, rag_search, etc.) |
| `web-search.ts` | SearXNG → DuckDuckGo fallback; discovered URLs enqueued to `data/sherlock-discovered-urls.jsonl` |
### Persona Memory (nick-isolated, 2026-04 design)
Memory is keyed by `(personaId, nick)` — one file per user per persona.
- `memoryMode: 'auto'` → Pharmacius, Sherlock, Turing, Ikeda (LLM extraction every 5 responses)
- `memoryMode: 'explicit'` → all artistic personas (Schaeffer, Merzbow, Pina, etc.) — only via `/remember`
- `/remember [@persona|@all] <text>` — direct fact insert, no LLM call
- Injection cap: 8 facts max into system prompt (`injectionFactsLimit`)
- `_anonymous` sentinel for unknown-nick relay chains
### Packages
| Package | Contents |
|---------|---------|
| `core` | Shared types, IDs, permissions |
| `auth` | RBAC, sessions, crypto |
| `chat-domain` | Message types, channels, slash command registry |
| `persona-domain` | Persona model, DPO pairs, feedback pipeline |
| `node-engine` | DAG execution, GPU job queue, 15+ node types |
| `storage` | Postgres repositories, migrations |
### Services & Ports
| Service | Port | Notes |
|---------|------|-------|
| API V2 | 4180 | HTTP + WS |
| Frontend | 5173 | Vite dev |
| vLLM | 8000 | Primary OpenAI-compatible text runtime (qwen-32b-awq) |
| TEI | 9500 | Dedicated embedding server (BAAI/bge-m3, CPU) |
| PostgreSQL | 5432 | V2 persistence (optional for API, required for worker) |
| LightRAG | 9621 | Graph-RAG; `LLM_MODEL=mistral:7b` to avoid `<think>` JSON corruption |
| SearXNG | 8080 | Self-hosted search |
| TTS | 9100 | Piper + Chatterbox |
| Kokoro TTS | 9201 | Fast TTS, 12 voices |
| AI Bridge | 8301 | 19 audio backends |
| ComfyUI | 8188 | Image generation |
| Docling | 9400 | PDF extraction |
| Camoufox | 8091 | Stealth browser fetch (venv, `kxkm-camoufox.service`) |
### InferenceScheduler constraint
**All LLM calls must go through `inference-scheduler.ts`** — no direct `fetch()` to the runtime outside approved helpers. The RTX 4090 has `MAX_GPU_CONCURRENT=1`. `context-store.ts` compaction and `ws-persona-router.ts` extraction both use `scheduler.submit()` with `priority: "low"`.
### Corpus ingestion pipeline
`scripts/ingest_spectacle_corpus.py` ingests domain content into LightRAG:
- Seed URLs + SearXNG discovery + `data/sherlock-discovered-urls.jsonl` (live Sherlock search discoveries)
- Camoufox server (`:8091`) used for bot-protected sites (`artcena.fr`, `culture.gouv.fr`, etc.)
### Data directories
```
data/
v2-local/persona-memory/{personaId}/{nick}.json # Per-user persona memory
context/{channel}.jsonl # Conversation history
sherlock-discovered-urls.jsonl # Corpus queue from web searches
chat-logs/ # Daily JSONL chat logs
manifeste.md # Project philosophy (injected at boot)
```
## Environment Variables
```bash
LLM_URL=http://localhost:8000 # vLLM OpenAI-compatible endpoint
LLM_MODEL=qwen-32b-awq
LLM_API_KEY=vllm-er-2026 # Bearer token for vLLM --api-key
OLLAMA_URL=http://localhost:9500 # TEI embedding server
EMBEDDING_BACKEND=tei # "tei" or "ollama"
RAG_EMBEDDING_MODEL=BAAI/bge-m3
DATABASE_URL=postgres://kxkm:kxkm@localhost:5432/kxkm_clown
V2_API_PORT=4180
TTS_ENABLED=1
VISION_MODEL=qwen3-vl:8b
SEARXNG_URL=http://localhost:8080
KXKM_PERSONA_MEMORY_INJECTION_LIMIT=8 # Max facts injected into system prompt
CHAT_PAUSED=1 # Or create data/chat-paused file
```
+244
View File
@@ -0,0 +1,244 @@
# AGENTS.md — apps/
<!-- Parent: ../AGENTS.md -->
Three applications in Turborepo workspace: api (backend), web (frontend), worker (background jobs).
## api/ — Backend (77 TS files + 27 tests)
Node.js Express + WebSocket on port 4180. Single process: HTTP + WS.
### WebSocket Handlers
| File | Purpose |
|------|---------|
| `ws-chat.ts` | Entry point: rate-limit, broadcast, multimodal dispatch |
| `ws-conversation-router.ts` | Persona routing, context assembly, TTS chunking, inter-persona depth-3 relay |
| `ws-ollama.ts` | Runtime streaming, tool-calling, `<think>` tag stripping |
| `ws-persona-router.ts` | Memory extract/save, responder selection, InferenceScheduler submit |
| `ws-multimodal.ts` | TTS streaming, vision, STT, file upload, ComfyUI integration |
| `ws-upload-handler.ts` | Media ingestion, storage, gallery |
| `ws-chat-helpers.ts` | Utilities: formatting, logging, metrics |
| `ws-chat-logger.ts` | Structured logging (pino JSON) |
| `ws-chat-history.ts` | Per-channel conversation memory, compaction |
| `ws-chat-state.test.ts` | State machine tests |
### Commands (5 handlers)
| File | Purpose |
|------|---------|
| `ws-commands.ts` | Router: dispatch to handlers by `/command` |
| `ws-commands-chat.ts` | `/chat`, `/speed`, `/help` |
| `ws-commands-info.ts` | `/personas`, `/commands`, `/status` |
| `ws-commands-generate.ts` | `/imagine`, `/compose`, `/music` (ACE-Step, ComfyUI) |
| `ws-commands-compose.ts` | DAW composition: `/layer`, `/fx`, `/voice`, `/mix`, `/export` |
### Personas (33 definitions + memory)
| File | Purpose |
|------|---------|
| `personas-default.ts` | 33 personas: Pharmacius, Sherlock, Turing, Ikeda, Schaeffer, Merzbow, Pina, etc. (memoryMode, corpus[], relations[]) |
| `persona-runtime.ts` | Runtime: load, extract, save; DPO feedback pipeline |
| `persona-memory-store.ts` | Per-nick isolated storage: `data/v2-local/persona-memory/{personaId}/{nick}.json` |
| `persona-memory-policy.ts` | auto/explicit/off modes, injectionFactsLimit (default 8) |
| `persona-voices.ts` | Voice config per persona |
| `persona-memory-telemetry.ts` | Metrics: extraction time, injection count, memory size |
### RAG & Inference
| File | Purpose |
|------|---------|
| `rag.ts` | Local embedding store, per-persona namespaces, LightRAG dual-write |
| `inference-scheduler.ts` | Single-GPU queue: `MAX_GPU_CONCURRENT=1`, all LLM calls must go through it |
| `llm-client.ts` | Ollama API wrapper, streaming, tool-calling |
| `deep-research.ts` | Multi-turn research loop via scheduler |
### Storage & Context
| File | Purpose |
|------|---------|
| `context-store.ts` | Per-channel conversation memory, LLM compaction via scheduler |
| `chat-types.ts` | All shared types: ChatPersona, PersonaMemoryMode, ClientInfo, message union |
| `composition-store.ts` | Multi-track composition state (tracks, clips, markers) |
| `media-store.ts` | File storage, gallery, cleanup |
### Services
| File | Purpose |
|------|---------|
| `web-search.ts` | SearXNG → DuckDuckGo fallback; discovered URLs → `data/sherlock-discovered-urls.jsonl` |
| `comfyui.ts` | Image generation: checkpoint + LoRA selection, prompt injection |
| `comfyui-models.ts` | 32 checkpoints + 24 LoRAs registry |
| `voice-samples.ts` | Voice clone sample management |
| `mcp-tools.ts` | Tool definitions injected per persona (web_search, rag_search, compose, imagine, etc.) |
### Routes (6 REST files + bootstrap)
| File | Purpose |
|------|---------|
| `routes/chat-history.ts` | GET/POST chat logs, export |
| `routes/media.ts` | GET/POST media, gallery |
| `routes/personas.ts` | GET personas, memory, feedback |
| `routes/session.ts` | Auth, session mgmt, guest mode |
| `routes/node-engine.ts` | DAG runs, node types, training |
| `media-shared-routes.ts` | Shared media endpoints |
| `app.ts` | Express setup, middleware, WS upgrade |
| `app-bootstrap.ts` | Corpus load, DAW samples, systemd startup |
| `app-middleware.ts` | Auth, CORS, error handlers |
### Server & Infrastructure
| File | Purpose |
|------|---------|
| `server.ts` | HTTP + WS bootstrap, DAW sample routes, corpus boot |
| `create-repos.ts` | DB repo initialization |
| `schemas.ts` | Zod validation: 19 route schemas + command input schemas |
| `error-tracker.ts` | Error telemetry (16 labels) |
| `perf.ts` | Perf instrumentation (6 labels, p50/p95/p99) |
| `logger.ts` | Pino JSON logger config |
### Tests (27 files)
| File | Purpose |
|------|---------|
| `app.test.ts` | HTTP routes, middleware, health checks |
| `ws-chat.test.ts` | WS broadcast, rate-limit, multimodal dispatch |
| `ws-conversation-router.test.ts` | Persona routing, context assembly |
| `ws-ollama.test.ts` | Token streaming, tool-calling, think tag |
| `ws-multimodal.test.ts` | TTS, vision, STT, file upload |
| `ws-upload-handler.test.ts` | Media ingestion |
| `ws-commands.test.ts` | Command routing |
| `persona-memory-store.test.ts` | Nick isolation, file persistence |
| `persona-memory-policy.test.ts` | auto/explicit modes |
| `persona-memory-telemetry.test.ts` | Metrics tracking |
| `persona-runtime.test.ts` | Runtime load/save |
| `rag.test.ts` | Embedding, LightRAG sync |
| `context-store.test.ts` | Channel history, compaction |
| `composition-store.test.ts` | Multi-track state |
| `media-store.test.ts` | File storage |
| `web-search.test.ts` | SearXNG, DuckDuckGo fallback |
| `voice-samples.test.ts` | Voice sample mgmt |
| `mcp-tools.test.ts` | Tool definitions |
| `mcp-server.test.ts` | MCP server integration |
| `chat-history-routes.test.ts` | Chat log routes |
| `media-shared-routes.test.ts` | Media endpoints |
| `ws-chat-smoke.test.ts` | End-to-end smoke |
| `ws-integration.test.ts` | Full integration flow |
| `ws-chat-state.test.ts` | State machine |
| `create-repos.test.ts` | DB setup |
| `app.test.ts` | Bootstrap |
| `integration.test.ts` | Full system |
### Run
```bash
npm run dev:v2:api # tsx watch (localhost:4180)
npm run -w @kxkm/api test # 278 unit tests
cd apps/api && npm test # From api dir
```
## web/ — Frontend (64 TS/TSX files + 10 tests)
React + Vite on port 5173. 5 CSS themes (minitel, crt, hacker, synthwave, default). React.memo + useCallback optimized. 17 lazy-loaded routes (-53% initial JS). Chat virtualization (react-window). CRT boot animation.
### Pages (10)
| Component | Purpose |
|-----------|---------|
| `Chat.tsx` | Main chat interface (virtualized history, input, sidebar) |
| `ImaginePage.tsx` | Image generation (ComfyUI) |
| `ComposePage.tsx` | DAW composition (timeline, tracks, effects) |
| `DawAIPanel.tsx` | AI composition sidebar |
| `LiveFXPage.tsx` | Real-time audio effects |
| `UllaPage.tsx` | Ulla (experimental) |
| `NodeEngineOverview.tsx` | DAG editor, run status |
| `AdminPage.tsx` | Admin dashboard, user management |
| `TrainingDashboard.tsx` | Training runs, metrics |
| `Collectif.tsx` | Multi-user collaboration |
### Components (15+)
| Component | Purpose |
|-----------|---------|
| `ChatMessage.tsx` | Message render, markdown, media player |
| `ChatInput.tsx` | Text input, voice record, file upload, rate-limit indicator |
| `ChatHistory.tsx` | Virtualized scroll (react-window) |
| `ChatSidebar.tsx` | Channel list, persona selector, theme toggle |
| `Header.tsx` | Title, menu, auth |
| `Nav.tsx` | Route navigation |
| `ErrorBoundary.tsx` | Error UI |
| `VoiceChat.tsx` | Push-to-talk, level meter, silence auto-detect |
| `MediaGallery.tsx` | Image/audio gallery, fullscreen player |
| `MediaExplorer.tsx` | File browser, upload |
| `TimelineView.tsx` | DAW track lanes, waveform, play/pause/seek |
| `EngineNode.tsx` | DAG node visual |
| `NodeEditor.tsx` | DAG editor (React Flow) |
| `PersonaList.tsx` | Persona selector with memory stats |
| `PersonaDetail.tsx` | Persona info, memory injection preview |
### Hooks (7)
| Hook | Purpose |
|------|---------|
| `useWebSocket.ts` | WS connection, auto-reconnect, message dispatch |
| `useAppSession.ts` | Session state, auth, guest mode |
| `useChatState.ts` | Chat history, selected persona, channel |
| `useGenerationCommand.ts` | /imagine, /compose submission, progress tracking |
| `useNodeEditor.ts` | DAG state (nodes, edges, zoom) |
| `useHashRoute.ts` | Client-side routing via hash |
| `useKeyboardShortcuts.ts` | Ctrl+K palette, theme toggle, etc. |
| `useMinitelSounds.ts` | 8-bit Minitel UI sounds |
### Library
| File | Purpose |
|------|---------|
| `lib/websocket-url.ts` | WS URL construction (dev vs prod) |
| `api.ts` | HTTP client for REST routes |
| `chat-types.ts` | Frontend message/persona types |
### Tests (10 files)
| File | Purpose |
|------|---------|
| `components/Chat*.test.tsx` | Component render, interaction |
| `components/Header.test.tsx` | Header UI |
| `components/Login.test.tsx` | Auth flow |
| `components/ChannelList.test.tsx` | Channel selector |
| `components/PersonaList.test.tsx` | Persona list |
| `components/RunStatus.test.tsx` | Run status display |
| `components/Nav.test.tsx` | Route nav |
| `hooks/*.test.ts` | Hook logic |
| `App.test.tsx` | App bootstrap |
### Styles
| File | Purpose |
|------|---------|
| `styles.css` | Base theme variables (colors, fonts, spacing) |
| `styles 2.css` | Alternative theme (legacy) |
### Run
```bash
npm run dev:v2:web # Vite (localhost:5173)
npm run -w @kxkm/web test # 54 unit tests
```
## worker/ — Background Jobs (4 TS files)
Node.js background processor. Handles async tasks (training, data ingestion, composition rendering, etc.).
| File | Purpose |
|------|---------|
| `index.ts` | Entry point, job queue setup |
| `worker-runtime.ts` | Job executor: training jobs, DPO pipeline, node runs |
| `logger.ts` | Structured logging |
| `worker-runtime.test.ts` | Runtime tests |
### Run
```bash
npm run dev:v2:worker # Background processor
npm run -w @kxkm/worker test
```
+121 -17
View File
@@ -12,6 +12,7 @@
const DEBUG = process.env.NODE_ENV !== "production" || process.env.DEBUG === "1";
import { trackError } from "./error-tracker.js";
import { scheduler, VRAM_BUDGETS } from "./inference-scheduler.js";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -96,6 +97,50 @@ function buildDefaultOptions(): ContextStoreOptions {
const DEFAULT_OPTIONS: ContextStoreOptions = buildDefaultOptions();
function extractFirstJsonObject(raw: string): string | null {
const start = raw.indexOf("{");
if (start < 0) return null;
let depth = 0;
let inString = false;
let escaped = false;
for (let index = start; index < raw.length; index += 1) {
const char = raw[index];
if (inString) {
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === "\"") {
inString = false;
}
continue;
}
if (char === "\"") {
inString = true;
continue;
}
if (char === "{") {
depth += 1;
continue;
}
if (char === "}") {
depth -= 1;
if (depth === 0) {
return raw.slice(start, index + 1);
}
}
}
return null;
}
// ---------------------------------------------------------------------------
// Context Store
// ---------------------------------------------------------------------------
@@ -133,10 +178,53 @@ export class ContextStore {
}
}
private async writeSummaryFile(filePath: string, summary: ContextSummary): Promise<void> {
const tmp = `${filePath}.${process.pid}.${Date.now().toString(36)}.tmp`;
await fs.writeFile(tmp, `${JSON.stringify(summary, null, 2)}\n`, "utf-8");
await fs.rename(tmp, filePath);
}
private normalizeSummary(raw: unknown, channel: string): ContextSummary | null {
if (!raw || typeof raw !== "object") return null;
const parsed = raw as Record<string, unknown>;
if (typeof parsed.summaryText !== "string") return null;
return {
channel: typeof parsed.channel === "string" && parsed.channel.length > 0 ? parsed.channel : channel,
summaryText: parsed.summaryText,
entriesCompacted: typeof parsed.entriesCompacted === "number" && Number.isFinite(parsed.entriesCompacted)
? parsed.entriesCompacted
: 0,
lastCompactedAt: typeof parsed.lastCompactedAt === "string" ? parsed.lastCompactedAt : new Date(0).toISOString(),
totalCompactions: typeof parsed.totalCompactions === "number" && Number.isFinite(parsed.totalCompactions)
? parsed.totalCompactions
: 0,
};
}
private async readSummary(channel: string): Promise<ContextSummary | null> {
const summaryPath = this.summaryFile(channel);
try {
const raw = await fs.readFile(this.summaryFile(channel), "utf-8");
return this.parseJson<ContextSummary>(raw);
const raw = await fs.readFile(summaryPath, "utf-8");
const parsed = this.parseJson<unknown>(raw);
const normalized = this.normalizeSummary(parsed, channel);
if (normalized) return normalized;
const recoveredRaw = extractFirstJsonObject(raw);
if (recoveredRaw) {
const recoveredParsed = this.parseJson<unknown>(recoveredRaw);
const recovered = this.normalizeSummary(recoveredParsed, channel);
if (recovered) {
await this.writeSummaryFile(summaryPath, recovered);
return recovered;
}
}
const quarantinePath = path.join(
path.dirname(summaryPath),
`${path.basename(summaryPath, ".json")}.corrupt.${Date.now().toString(36)}.json`,
);
await fs.rename(summaryPath, quarantinePath).catch(() => {});
return null;
} catch {
return null;
}
@@ -315,21 +403,37 @@ export class ContextStore {
let summaryText = existingSummary; // fallback
try {
const response = await fetch(`${this.options.ollamaUrl}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: this.options.compactionModel,
messages: [{ role: "user", content: prompt }],
stream: false,
}),
signal: AbortSignal.timeout(120_000),
});
summaryText = await scheduler.submit<string>({
id: `compact-${channel}-${Date.now()}`,
device: "gpu",
priority: "low",
label: `context-compact:${channel}`,
vramMB: VRAM_BUDGETS.ollama,
execute: async () => {
const llmUrl = process.env.LLM_URL || "http://localhost:11434";
const llmModel = process.env.LLM_MODEL || "qwen-14b-awq";
const llmApiKey = process.env.LLM_API_KEY || "";
const llmH: Record<string, string> = { "Content-Type": "application/json" };
if (llmApiKey) llmH["Authorization"] = `Bearer ${llmApiKey}`;
const response = await fetch(`${llmUrl}/v1/chat/completions`, {
method: "POST",
headers: llmH,
body: JSON.stringify({
model: llmModel,
messages: [{ role: "user", content: prompt }],
stream: false,
max_tokens: 2000,
}),
signal: AbortSignal.timeout(120_000),
});
if (response.ok) {
const data = (await response.json()) as { message?: { content?: string } };
summaryText = data.message?.content || existingSummary;
}
if (response.ok) {
const data = (await response.json()) as { choices?: [{ message?: { content?: string } }] };
return data.choices?.[0]?.message?.content || existingSummary;
}
return existingSummary;
},
});
} catch (err) {
trackError("context_summarization", err, { channel });
// Keep existing summary, still compact the raw file
@@ -344,7 +448,7 @@ export class ContextStore {
totalCompactions: (previousSummary?.totalCompactions || 0) + 1,
};
await fs.writeFile(this.summaryFile(channel), JSON.stringify(summaryData, null, 2), "utf-8");
await this.writeSummaryFile(this.summaryFile(channel), summaryData);
// Replace raw file with only recent entries
const newContent = toKeep.join("\n") + "\n";
+116 -84
View File
@@ -1,11 +1,11 @@
/**
* LLM Client — routes through mascarade /v1/chat/completions with Ollama fallback
* Canonical LLM client.
*
* mascarade /v1/chat/completions endpoint (OpenAI-compatible):
* POST { model, messages, temperature, max_tokens }
* Returns: { model, choices: [{ message: { content } }], usage }
* Local runtime:
* vLLM / TurboQuant exposed via OpenAI-compatible `/v1/chat/completions`
*
* Fallback: direct Ollama /api/chat if mascarade is unavailable
* Optional cloud routing:
* Mascarade `/v1/chat/completions` for explicit cloud provider models
*/
import logger from "./logger.js";
@@ -17,9 +17,10 @@ import { incrementCounter } from "./perf.js";
const MASCARADE_URL = process.env.MASCARADE_URL || "http://127.0.0.1:8100";
const MASCARADE_API_KEY = process.env.MASCARADE_API_KEY || "";
const OLLAMA_URL = process.env.OLLAMA_URL || "http://127.0.0.1:11434";
const LLM_URL = process.env.LLM_URL || "http://127.0.0.1:11434";
const LLM_MODEL = process.env.LLM_MODEL || "qwen-14b-awq";
const LLM_TIMEOUT_MS = parseInt(process.env.LLM_TIMEOUT_MS || "45000", 10);
const DEFAULT_MODEL = process.env.LLM_DEFAULT_MODEL || "qwen3.5:9b";
const DEFAULT_MODEL = process.env.LLM_DEFAULT_MODEL || LLM_MODEL;
// RouteLLM-style complexity routing
const ROUTELLM_ENABLED = process.env.ROUTELLM_ENABLED === "true";
@@ -42,7 +43,17 @@ function mascaradeRecheckMs(): number {
export interface ChatMessage {
role: "system" | "user" | "assistant" | "tool";
content: string;
tool_calls?: Array<{ function: { name: string; arguments: Record<string, unknown> } }>;
tool_call_id?: string;
tool_calls?: ChatToolCall[];
}
export interface ChatToolCall {
id?: string;
type?: "function";
function: {
name: string;
arguments: Record<string, unknown> | string;
};
}
export interface ChatOptions {
@@ -61,7 +72,7 @@ export interface ChatResponse {
content: string;
model: string;
provider: string;
toolCalls?: Array<{ function: { name: string; arguments: Record<string, unknown> } }>;
toolCalls?: ChatToolCall[];
thinking?: string;
usage?: { promptTokens: number; completionTokens: number; totalTokens: number };
}
@@ -92,12 +103,12 @@ function shouldTryMascarade(): boolean {
}
// ---------------------------------------------------------------------------
// Parse model: detect "provider:model" vs plain Ollama model
// Parse model: detect "provider:model" vs local runtime model
// ---------------------------------------------------------------------------
function parseModel(model: string | undefined): { provider: string | null; model: string } {
const m = model || DEFAULT_MODEL;
const knownProviders = ["claude", "openai", "mistral-api", "google", "bedrock", "huggingface", "ollama", "llama_cpp"];
const knownProviders = ["claude", "openai", "mistral-api", "google", "bedrock", "huggingface"];
const colonIdx = m.indexOf(":");
if (colonIdx > 0) {
const prefix = m.slice(0, colonIdx);
@@ -105,18 +116,26 @@ function parseModel(model: string | undefined): { provider: string | null; model
return { provider: prefix, model: m.slice(colonIdx + 1) };
}
}
// Ollama model (qwen3.5:9b, mistral:7b, etc.) — let mascarade route via default provider
// Local runtime model (qwen3.5:9b, qwen-14b-awq, mistral:7b, etc.)
return { provider: null, model: m };
}
function resolveRuntimeModel(model: string | undefined): string {
const parsed = parseModel(model);
if (parsed.provider) {
return DEFAULT_MODEL;
}
return parsed.model || DEFAULT_MODEL;
}
// ---------------------------------------------------------------------------
// RouteLLM — complexity scoring for smart routing
// ---------------------------------------------------------------------------
/**
* Score message complexity (0-1). Higher = needs stronger model.
* 0.0-0.3: trivial (salut, oui, merci) → local Ollama
* 0.3-0.6: moderate (short questions) → local Ollama
* 0.0-0.3: trivial (salut, oui, merci) → runtime local
* 0.3-0.6: moderate (short questions) → runtime local
* 0.6-1.0: complex (analysis, code, multilingual) → strong provider if available
*/
function scoreComplexity(messages: ChatMessage[]): number {
@@ -158,74 +177,67 @@ function scoreComplexity(messages: ChatMessage[]): number {
/**
* Decide routing based on complexity score.
* Returns "ollama" to force local, "mascarade" to prefer strong provider, or null for default behavior.
* Returns "runtime" to force local, "mascarade" to prefer strong provider, or null for default behavior.
*/
function routeByComplexity(messages: ChatMessage[]): { route: "ollama" | "mascarade" | null; complexity: number } {
function routeByComplexity(messages: ChatMessage[]): { route: "runtime" | "mascarade" | null; complexity: number } {
if (!ROUTELLM_ENABLED) return { route: null, complexity: -1 };
const complexity = scoreComplexity(messages);
if (complexity < ROUTELLM_THRESHOLD) {
return { route: "ollama", complexity };
return { route: "runtime", complexity };
}
return { route: "mascarade", complexity };
}
// ---------------------------------------------------------------------------
// Non-streaming: mascarade /send → Ollama fallback
// Non-streaming: local runtime primary, mascarade only for explicit cloud/provider routing
// ---------------------------------------------------------------------------
export async function chat(messages: ChatMessage[], opts: ChatOptions = {}): Promise<ChatResponse> {
const { provider } = parseModel(opts.model);
const { route, complexity } = routeByComplexity(messages);
// RouteLLM: skip mascarade entirely for simple messages
if (route === "ollama") {
logger.debug({ complexity, threshold: ROUTELLM_THRESHOLD }, "[llm] routeLLM → ollama (simple)");
return chatViaOllama(messages, opts);
if (!provider) {
if (route === "runtime") {
logger.debug({ complexity, threshold: ROUTELLM_THRESHOLD }, "[llm] routeLLM → runtime (simple)");
}
return chatViaRuntime(messages, opts);
}
if (route === "mascarade") {
logger.debug({ complexity, threshold: ROUTELLM_THRESHOLD }, "[llm] routeLLM → mascarade (complex)");
}
if (shouldTryMascarade()) {
try {
const result = await chatViaMascarade(messages, opts);
// If mascarade returns empty content, fall through to Ollama
if (result.content) return result;
logger.warn("[llm] mascarade returned empty content, falling back to Ollama");
} catch (err) {
logger.warn({ err: (err as Error).message }, "[llm] mascarade failed, falling back to Ollama");
mascaradeAvailable = false;
mascaradeLastCheck = Date.now();
mascaradeFailCount++;
}
if (!shouldTryMascarade()) {
throw new Error("Mascarade unavailable for explicit cloud provider routing");
}
return chatViaOllama(messages, opts);
return chatViaMascarade(messages, opts);
}
// ---------------------------------------------------------------------------
// Streaming: mascarade SSE for cloud providers, direct Ollama for local
// Streaming: mascarade SSE for cloud providers, direct runtime for local
// ---------------------------------------------------------------------------
export async function* streamChat(
messages: ChatMessage[],
opts: ChatOptions = {},
): AsyncGenerator<string, ChatResponse> {
const isCloudProvider = opts.model && /^(claude|openai|mistral-api|google|bedrock):/.test(opts.model);
const isCloudProvider = Boolean(opts.model && /^(claude|openai|mistral-api|google|bedrock):/.test(opts.model));
// Cloud providers → stream via mascarade SSE (real streaming, not dump-all)
if (isCloudProvider && shouldTryMascarade()) {
try {
return yield* streamViaMascarade(messages, opts);
} catch (err) {
logger.warn({ err: (err as Error).message }, "[llm] mascarade stream failed, falling back to Ollama");
logger.warn({ err: (err as Error).message }, "[llm] mascarade stream failed");
mascaradeAvailable = false;
mascaradeLastCheck = Date.now();
mascaradeFailCount++;
throw err;
}
}
// Local models → stream via Ollama directly (fastest path)
return yield* streamViaOllama(messages, opts);
// Local models → stream via runtime directly (fastest path)
return yield* streamViaRuntime(messages, opts);
}
// ---------------------------------------------------------------------------
@@ -242,7 +254,7 @@ async function chatViaMascarade(messages: ChatMessage[], opts: ChatOptions): Pro
const resp = await fetch(`${MASCARADE_URL}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...(process.env.LLM_API_KEY ? { Authorization: `Bearer ${process.env.LLM_API_KEY}` } : {}) },
body: JSON.stringify({
model: modelStr,
messages: messages.map(m => ({ role: m.role, content: m.content })),
@@ -408,81 +420,94 @@ async function* streamViaMascarade(
}
// ---------------------------------------------------------------------------
// Direct Ollama (fallback + streaming)
// Direct runtime (vLLM / TurboQuant)
// ---------------------------------------------------------------------------
async function chatViaOllama(messages: ChatMessage[], opts: ChatOptions): Promise<ChatResponse> {
function toOpenAIMessage(message: ChatMessage): Record<string, unknown> {
const base: Record<string, unknown> = {
role: message.role,
content: message.content,
};
if (message.tool_calls && message.tool_calls.length > 0) {
base.tool_calls = message.tool_calls.map((toolCall) => ({
id: toolCall.id,
type: toolCall.type || "function",
function: {
name: toolCall.function.name,
arguments: typeof toolCall.function.arguments === "string"
? toolCall.function.arguments
: JSON.stringify(toolCall.function.arguments),
},
}));
}
if (message.role === "tool" && message.tool_call_id) {
base.tool_call_id = message.tool_call_id;
}
return base;
}
async function chatViaRuntime(messages: ChatMessage[], opts: ChatOptions): Promise<ChatResponse> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), LLM_TIMEOUT_MS);
const { model } = parseModel(opts.model);
const model = resolveRuntimeModel(opts.model);
try {
const resp = await fetch(`${OLLAMA_URL}/api/chat`, {
const resp = await fetch(`${LLM_URL}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...(process.env.LLM_API_KEY ? { Authorization: `Bearer ${process.env.LLM_API_KEY}` } : {}) },
body: JSON.stringify({
model,
messages: messages.map(m => ({ role: m.role, content: m.content })),
messages: messages.map(toOpenAIMessage),
stream: false,
options: {
num_predict: opts.maxTokens || 800,
...(opts.numCtx ? { num_ctx: opts.numCtx } : {}),
num_batch: opts.numBatch || 512,
},
keep_alive: opts.keepAlive || "30m",
...(opts.think !== undefined ? { think: opts.think } : {}),
temperature: opts.temperature,
max_tokens: opts.maxTokens || 800,
...(opts.tools && opts.tools.length > 0 ? { tools: opts.tools } : {}),
}),
signal: controller.signal,
});
if (!resp.ok) throw new Error(`Ollama ${resp.status}: ${resp.statusText}`);
incrementCounter("llm_ollama_calls");
if (!resp.ok) throw new Error(`vLLM ${resp.status}: ${resp.statusText}`);
incrementCounter("llm_runtime_calls");
const data = await resp.json() as {
message?: { content?: string; thinking?: string; tool_calls?: ChatResponse["toolCalls"] };
choices?: [{ message?: { content?: string; tool_calls?: ChatResponse["toolCalls"] } }];
};
const msg = data.choices?.[0]?.message;
return {
content: data.message?.content || "",
content: msg?.content || "",
model,
provider: "ollama",
toolCalls: data.message?.tool_calls,
thinking: data.message?.thinking,
provider: "vllm",
toolCalls: msg?.tool_calls,
};
} finally {
clearTimeout(timeout);
}
}
async function* streamViaOllama(
async function* streamViaRuntime(
messages: ChatMessage[],
opts: ChatOptions,
): AsyncGenerator<string, ChatResponse> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), LLM_TIMEOUT_MS);
const { model } = parseModel(opts.model);
const model = resolveRuntimeModel(opts.model);
try {
const resp = await fetch(`${OLLAMA_URL}/api/chat`, {
const resp = await fetch(`${LLM_URL}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...(process.env.LLM_API_KEY ? { Authorization: `Bearer ${process.env.LLM_API_KEY}` } : {}) },
body: JSON.stringify({
model,
messages: messages.map(m => ({ role: m.role, content: m.content })),
messages: messages.map(toOpenAIMessage),
stream: true,
options: {
num_predict: opts.maxTokens || 800,
...(opts.numCtx ? { num_ctx: opts.numCtx } : {}),
num_batch: opts.numBatch || 512,
},
keep_alive: opts.keepAlive || "30m",
think: false,
temperature: opts.temperature,
max_tokens: opts.maxTokens || 800,
}),
signal: controller.signal,
});
if (!resp.ok) throw new Error(`Ollama ${resp.status}: ${resp.statusText}`);
if (!resp.ok) throw new Error(`vLLM ${resp.status}: ${resp.statusText}`);
const reader = resp.body?.getReader();
if (!reader) throw new Error("No response body");
@@ -490,18 +515,24 @@ async function* streamViaOllama(
const decoder = new TextDecoder();
let fullText = "";
let inThinking = false;
let sseBuffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
for (const line of chunk.split("\n").filter(Boolean)) {
if (line.length > 102_400) continue; // Skip oversized chunks (100KB max)
sseBuffer += decoder.decode(value, { stream: true });
const lines = sseBuffer.split("\n");
sseBuffer = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const raw = line.slice(6).trim();
if (raw === "[DONE]") break;
try {
const parsed = JSON.parse(line) as { message?: { content?: string }; done?: boolean };
if (parsed.message?.content) {
const c = parsed.message.content;
const parsed = JSON.parse(raw) as { choices?: [{ delta?: { content?: string } }] };
const c = parsed.choices?.[0]?.delta?.content;
if (c) {
fullText += c;
if (c.includes("<think>")) inThinking = true;
if (!inThinking) yield c;
@@ -512,7 +543,7 @@ async function* streamViaOllama(
}
const cleaned = fullText.replace(/<think>[\s\S]*?<\/think>\s*/g, "").trim();
return { content: cleaned, model, provider: "ollama" };
return { content: cleaned, model, provider: "vllm" };
} finally {
clearTimeout(timeout);
}
@@ -523,12 +554,13 @@ async function* streamViaOllama(
// ---------------------------------------------------------------------------
export async function getProviders(): Promise<string[]> {
const providers = ["vllm-turboquant"];
try {
const resp = await fetch(`${MASCARADE_URL}/health`, { signal: AbortSignal.timeout(3000) });
if (!resp.ok) return ["ollama"];
if (!resp.ok) return providers;
const data = await resp.json() as { providers?: string[] };
return data.providers || ["ollama"];
return [...providers, ...(data.providers || [])];
} catch {
return ["ollama"];
return providers;
}
}
+94 -29
View File
@@ -1,5 +1,6 @@
/**
* Minimal local RAG using Ollama embeddings.
* Minimal local RAG with pluggable embedding backend.
* Supports TEI/OpenAI-compatible (/v1/embeddings) and Ollama (/api/embed).
* Stores document chunks with their embeddings in memory.
* Uses cosine similarity for retrieval.
*/
@@ -14,6 +15,7 @@ const RAG_CHUNK_SIZE = Number(process.env.RAG_CHUNK_SIZE) || 500;
const RAG_MIN_SIMILARITY = Number(process.env.RAG_MIN_SIMILARITY) || 0.3;
const RAG_MAX_RESULTS = Number(process.env.RAG_MAX_RESULTS) || 3;
const RAG_EMBEDDING_MODEL = process.env.RAG_EMBEDDING_MODEL || "bge-m3";
const EMBEDDING_BACKEND = process.env.EMBEDDING_BACKEND || "tei"; // "tei" or "ollama"
interface DocumentChunk {
id: string;
@@ -36,46 +38,75 @@ export class LocalRAG {
private options: RAGOptions;
private _rerankerFailCount = 0;
private _rerankerLastFail = 0;
private namespaceChunks: Map<string, DocumentChunk[]> = new Map();
constructor(options: RAGOptions) {
this.options = options;
}
/** Verify embedding model is available on Ollama, pull if missing. */
/** Verify embedding backend is reachable. */
async init(): Promise<void> {
const ollamaUrl = this.options.ollamaUrl;
const url = this.options.ollamaUrl;
const model = this.options.embeddingModel || RAG_EMBEDDING_MODEL;
const backend = EMBEDDING_BACKEND;
try {
const resp = await fetch(`${ollamaUrl}/api/tags`, { signal: AbortSignal.timeout(5000) });
const data = (await resp.json()) as { models?: Array<{ name: string }> };
const models = data.models?.map((m) => m.name) || [];
const available = models.some((m) => m.startsWith(model));
if (!available) {
logger.warn({ model, available: models.slice(0, 5) }, "[rag] Embedding model not found, pulling...");
await fetch(`${ollamaUrl}/api/pull`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: model }),
signal: AbortSignal.timeout(300_000),
});
logger.info({ model }, "[rag] Embedding model pulled successfully");
if (backend === "tei") {
// TEI health check
const resp = await fetch(`${url}/info`, { signal: AbortSignal.timeout(5000) });
if (resp.ok) {
const info = (await resp.json()) as { model_id?: string };
logger.info({ model: info.model_id, backend }, "[rag] TEI embedding server ready");
} else {
logger.warn({ status: resp.status }, "[rag] TEI health check failed");
}
} else {
logger.debug({ model }, "[rag] Embedding model available");
// Ollama: check model availability, pull if missing
const resp = await fetch(`${url}/api/tags`, { signal: AbortSignal.timeout(5000) });
const data = (await resp.json()) as { models?: Array<{ name: string }> };
const models = data.models?.map((m) => m.name) || [];
const available = models.some((m) => m.startsWith(model));
if (!available) {
logger.warn({ model, available: models.slice(0, 5) }, "[rag] Embedding model not found, pulling...");
await fetch(`${url}/api/pull`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: model }),
signal: AbortSignal.timeout(300_000),
});
logger.info({ model }, "[rag] Embedding model pulled successfully");
} else {
logger.debug({ model, backend }, "[rag] Embedding model available");
}
}
} catch (err) {
logger.warn({ err }, "[rag] Could not verify embedding model");
logger.warn({ err, backend }, "[rag] Could not verify embedding backend");
}
}
/** Embed text via Ollama /api/embed */
/** Embed text via TEI (/v1/embeddings) or Ollama (/api/embed). */
async embed(text: string): Promise<number[]> {
const response = await fetch(`${this.options.ollamaUrl}/api/embed`, {
const url = this.options.ollamaUrl;
const model = this.options.embeddingModel || RAG_EMBEDDING_MODEL;
if (EMBEDDING_BACKEND === "tei") {
// OpenAI-compatible format (TEI, vLLM, etc.)
const response = await fetch(`${url}/v1/embeddings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model, input: text }),
});
if (!response.ok) {
throw new Error(`TEI embed returned ${response.status}: ${response.statusText}`);
}
const data = (await response.json()) as { data?: Array<{ embedding: number[] }> };
return data.data?.[0]?.embedding || [];
}
// Ollama format
const response = await fetch(`${url}/api/embed`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: this.options.embeddingModel || RAG_EMBEDDING_MODEL,
input: text,
}),
body: JSON.stringify({ model, input: text }),
});
if (!response.ok) {
throw new Error(`Ollama embed returned ${response.status}: ${response.statusText}`);
@@ -86,14 +117,15 @@ export class LocalRAG {
/** 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> {
async addDocument(text: string, source: string, namespace?: 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 }),
body: JSON.stringify({ text, description: namespace }),
signal: AbortSignal.timeout(5000),
});
if (res.ok) {
logger.debug({ source }, "[rag:lightrag] addDocument to LightRAG OK");
@@ -107,16 +139,25 @@ export class LocalRAG {
// Always index locally
const textChunks = splitIntoChunks(text, RAG_CHUNK_SIZE);
const newChunks: DocumentChunk[] = [];
for (const chunk of textChunks) {
const embedding = await this.embed(chunk);
this.chunks.push({
const docChunk: DocumentChunk = {
id: `${source}_${this.chunks.length}`,
text: chunk,
source,
embedding,
});
};
this.chunks.push(docChunk);
newChunks.push(docChunk);
}
return textChunks.length;
if (namespace) {
if (!this.namespaceChunks.has(namespace)) {
this.namespaceChunks.set(namespace, []);
}
this.namespaceChunks.get(namespace)!.push(...newChunks);
}
return newChunks.length;
}
/** Search for relevant chunks.
@@ -183,6 +224,30 @@ export class LocalRAG {
return this.rerank(query, results, limit);
}
/** Search within a specific persona namespace. Falls back to global search if namespace has no chunks. */
async searchNamespace(
query: string,
namespace: string,
maxResults?: number,
): Promise<Array<{ text: string; source: string; score: number }>> {
const nsChunks = this.namespaceChunks.get(namespace);
if (!nsChunks || nsChunks.length === 0) {
return this.search(query, maxResults);
}
const limit = maxResults ?? RAG_MAX_RESULTS;
const queryEmbedding = await this.embed(query);
const scored = nsChunks.map((chunk) => ({
text: chunk.text,
source: chunk.source,
score: cosineSimilarity(queryEmbedding, chunk.embedding),
}));
scored.sort((a, b) => b.score - a.score);
const results = scored
.filter((s) => s.score >= (this.options.minSimilarity ?? RAG_MIN_SIMILARITY))
.slice(0, limit);
return this.rerank(query, results, limit);
}
/** Rerank results using BGE cross-encoder for improved precision.
* Falls back to original ordering if the reranker is unavailable. */
private async rerank(
+42 -28
View File
@@ -12,6 +12,13 @@ import { validateLoginInput } from "@kxkm/auth";
import { buildChatChannels } from "@kxkm/chat-domain";
import { getRecentErrors, getErrorCounts } from "../error-tracker.js";
import { scheduler, getGPUUtilization } from "../inference-scheduler.js";
const LLM_API_KEY = process.env.LLM_API_KEY || "";
function llmHeaders(): Record<string, string> {
const h: Record<string, string> = { "Content-Type": "application/json" };
if (LLM_API_KEY) h["Authorization"] = `Bearer ${LLM_API_KEY}`;
return h;
}
import type { PersonaRecord } from "@kxkm/persona-domain";
import type { ModelRegistryRecord, NodeGraphRecord, NodeRunRecord } from "@kxkm/node-engine";
import type { StorageMode } from "../app-bootstrap.js";
@@ -87,22 +94,22 @@ export function createSessionRoutes(deps: SessionRouteDeps): Router {
router.get("/api/v2/health", async (_req, res) => {
const startMs = Date.now();
const ollamaUrl = process.env.OLLAMA_URL || "http://localhost:11434";
const llmUrl = process.env.LLM_URL || "http://localhost:11434";
const timeout = <T>(p: Promise<T>, ms = 2000): Promise<T> =>
Promise.race([p, new Promise<never>((_, rej) => setTimeout(() => rej(new Error("timeout")), ms))]);
const [ollamaResult, dbResult] = await Promise.allSettled([
timeout(fetch(`${ollamaUrl}/api/tags`).then(async (r) => {
const body = await r.json() as { models?: unknown[] };
return { ok: r.ok, models: Array.isArray(body.models) ? body.models.length : 0 };
const [runtimeResult, dbResult] = await Promise.allSettled([
timeout(fetch(`${llmUrl}/v1/models`, { headers: llmHeaders() }).then(async (r) => {
const body = await r.json() as { data?: unknown[] };
return { ok: r.ok, models: Array.isArray(body.data) ? body.data.length : 0 };
})),
timeout(personaRepo.list().then((list) => ({ ok: true, count: list.length }))),
]);
const ollama = ollamaResult.status === "fulfilled"
? { status: "ok" as const, models_loaded: ollamaResult.value.models }
: { status: "error" as const, error: (ollamaResult.reason as Error).message };
const runtime = runtimeResult.status === "fulfilled"
? { status: "ok" as const, models_loaded: runtimeResult.value.models }
: { status: "error" as const, error: (runtimeResult.reason as Error).message };
const db = dbResult.status === "fulfilled"
? { status: "ok" as const, personas: dbResult.value.count }
@@ -117,7 +124,7 @@ export function createSessionRoutes(deps: SessionRouteDeps): Router {
roles: ["admin", "editor", "operator", "viewer"] satisfies UserRole[],
uptime_sec: uptimeSec,
uptime_human: uptimeHuman,
ollama,
runtime,
database: db,
health_check_ms: Date.now() - startMs,
}));
@@ -194,12 +201,20 @@ export function createSessionRoutes(deps: SessionRouteDeps): Router {
res.json(asApiData(listWorkflows()));
});
// LLM providers (mascarade status)
// LLM providers and runtime status
router.get("/api/v2/llm-providers", async (_req, res) => {
const { getProviders, checkMascaradeHealth } = await import("../llm-client.js");
const healthy = await checkMascaradeHealth();
const providers = await getProviders();
res.json(asApiData({ mascarade: healthy, providers, fallback: "ollama" }));
res.json(asApiData({
runtime: {
kind: "vllm-turboquant",
url: process.env.LLM_URL || "http://localhost:11434",
model: process.env.LLM_MODEL || "qwen-14b-awq",
},
mascarade: healthy,
providers,
}));
});
// RAG search endpoint — for mascarade MCP tool integration
@@ -433,28 +448,27 @@ export function createSessionRoutes(deps: SessionRouteDeps): Router {
router.post("/api/v2/ai/suggest-prompt", async (req, res) => {
const { type, style: compStyle, existing, context } = req.body || {};
const ollamaUrl = process.env.OLLAMA_URL || "http://localhost:11434";
// Image prompt generation mode — triggered by style:"random" or type not in DAW types
const isImageMode = compStyle === "random" || type === "image";
if (isImageMode) {
const llmUrl = process.env.LLM_URL || "http://localhost:11434";
const llmModel = process.env.LLM_MODEL || "qwen-14b-awq";
try {
const resp = await fetch(`${ollamaUrl}/api/chat`, {
const resp = await fetch(`${llmUrl}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: llmHeaders(),
body: JSON.stringify({
model: "qwen3:8b",
model: llmModel,
messages: [{ role: "user", content: "Generate a creative, detailed image prompt for AI image generation. Be specific about style, lighting, mood, subject. Return ONLY the prompt in English, nothing else. Maximum 100 words." }],
stream: false,
options: { num_predict: 200 },
keep_alive: "30m",
think: false,
max_tokens: 200,
}),
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) throw new Error("Ollama error");
const data = await resp.json() as { message?: { content?: string } };
const prompt = data.message?.content?.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
if (!resp.ok) throw new Error("vLLM error");
const data = await resp.json() as { choices?: [{ message?: { content?: string } }] };
const prompt = data.choices?.[0]?.message?.content?.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
res.json({ ok: true, data: { prompt } });
} catch {
res.json({ ok: true, data: { prompt: "a mystical forest at twilight, bioluminescent mushrooms, fog, cinematic lighting, 8k" } });
@@ -469,22 +483,22 @@ export function createSessionRoutes(deps: SessionRouteDeps): Router {
fx: "un effet sonore ou une texture de fond",
};
const llmUrl = process.env.LLM_URL || "http://localhost:11434";
const llmModel = process.env.LLM_MODEL || "qwen-14b-awq";
try {
const resp = await fetch(`${ollamaUrl}/api/chat`, {
const resp = await fetch(`${llmUrl}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "qwen3:8b",
model: llmModel,
messages: [{ role: "user", content: `Tu es un compositeur sonore. Génère ${typeHints[type as string] || "un prompt audio"} pour une composition de style "${compStyle || "experimental"}". ${existing ? `Le prompt actuel est: "${existing}". Améliore-le.` : ""} ${context ? `Contexte des autres pistes: ${context}` : ""} Réponds UNIQUEMENT le prompt (1-2 phrases max, pas d'explication).` }],
stream: false,
options: { num_predict: 80 },
keep_alive: "30m",
think: false,
max_tokens: 80,
}),
signal: AbortSignal.timeout(10000),
});
const data = await resp.json() as { message?: { content?: string } };
res.json({ ok: true, prompt: data.message?.content?.trim() || "" });
const data = await resp.json() as { choices?: [{ message?: { content?: string } }] };
res.json({ ok: true, prompt: data.choices?.[0]?.message?.content?.trim() || "" });
} catch {
res.json({ ok: false, prompt: "" });
}
+52 -15
View File
@@ -166,27 +166,29 @@ async function main() {
// -----------------------------------------------------------------------
const server = http.createServer(app);
const ollamaUrl = process.env.OLLAMA_URL || "http://localhost:11434";
const embeddingUrl = process.env.OLLAMA_URL || "http://localhost:11435";
// -----------------------------------------------------------------------
// Pre-warm Ollama: load primary model into VRAM (non-blocking)
// First inference is ~1-2s slower without this.
// Pre-warm vLLM: verify model is loaded (non-blocking)
// -----------------------------------------------------------------------
const primaryModel = process.env.OLLAMA_MODEL || "qwen3.5:9b";
fetch(`${ollamaUrl}/api/chat`, {
const llmUrl = process.env.LLM_URL || "http://localhost:11434";
const primaryModel = process.env.LLM_MODEL || "qwen-14b-awq";
const llmApiKey = process.env.LLM_API_KEY || "";
const llmHeaders: Record<string, string> = { "Content-Type": "application/json" };
if (llmApiKey) llmHeaders["Authorization"] = `Bearer ${llmApiKey}`;
fetch(`${llmUrl}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: llmHeaders,
body: JSON.stringify({
model: primaryModel,
messages: [{ role: "user", content: "ping" }],
stream: false,
options: { num_predict: 1, num_ctx: 512 },
keep_alive: "30m",
max_tokens: 1,
}),
}).then(() => {
if (DEBUG) console.log(`[ollama] Pre-warmed ${primaryModel}`);
if (DEBUG) console.log(`[vllm] Pre-warmed ${primaryModel}`);
}).catch(() => {
// Ollama not ready yet — model will load on first real request
// vLLM not ready yet — model will serve on first real request
});
// Pre-warm ComfyUI: load default checkpoint into VRAM (non-blocking)
@@ -195,9 +197,9 @@ async function main() {
});
// -----------------------------------------------------------------------
// Initialize local RAG (embeddings via Ollama)
// Initialize local RAG (embeddings still use the embedding backend)
// -----------------------------------------------------------------------
const rag = new LocalRAG({ ollamaUrl, lightragUrl: process.env.LIGHTRAG_URL, rerankerUrl: process.env.RERANKER_URL });
const rag = new LocalRAG({ ollamaUrl: embeddingUrl, lightragUrl: process.env.LIGHTRAG_URL, rerankerUrl: process.env.RERANKER_URL });
// Expose RAG instance on app for API routes
(app as any)._rag = rag;
@@ -233,11 +235,45 @@ async function main() {
}
})();
// Per-persona corpus boot-loader
(async () => {
for (const persona of DEFAULT_PERSONAS) {
if (!persona.corpus || persona.corpus.length === 0) continue;
const ns = `persona:${persona.id}`;
for (const entry of persona.corpus) {
try {
let text = '';
if (entry.type === 'text' && entry.content) {
text = entry.content;
} else if (entry.type === 'url') {
const resp = await fetch(entry.source, { signal: AbortSignal.timeout(10_000) });
const html = await resp.text();
// Strip scripts/styles first, then all tags
text = html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 8000);
}
if (text) {
await rag.addDocument(text, entry.source, ns);
logger.info({ persona: persona.id, source: entry.source, ns }, '[rag:corpus] loaded');
}
} catch (err) {
logger.warn({ err, persona: persona.id, source: entry.source }, '[rag:corpus] load failed (non-critical)');
}
}
}
logger.info('[rag:corpus] persona corpus boot-loading complete');
})();
// -----------------------------------------------------------------------
// Initialize persistent context store (auto-compaction, 750 MB max)
// -----------------------------------------------------------------------
const contextStore = new ContextStore({
ollamaUrl,
ollamaUrl: embeddingUrl,
maxTotalSizeMB: 750,
maxEntriesBeforeCompact: 200,
compactionModel: "qwen3:8b",
@@ -249,7 +285,7 @@ async function main() {
});
const wss = attachWebSocketChat(server, {
ollamaUrl,
ollamaUrl: embeddingUrl,
rag,
contextStore,
loadPersonas: async () => {
@@ -264,6 +300,7 @@ async function main() {
color: defaultDef?.color || "",
enabled: !(p as unknown as { disabled?: boolean }).disabled,
maxTokens: defaultDef?.maxTokens,
corpus: defaultDef?.corpus,
};
});
},
@@ -289,7 +326,7 @@ async function main() {
app: "@kxkm/api",
port,
ws: "/ws",
ollama: ollamaUrl,
embeddings: embeddingUrl,
}));
});
}
+189 -139
View File
@@ -6,10 +6,43 @@ import { trackError } from "./error-tracker.js";
import logger from "./logger.js";
import type { ToolDefinition } from "./mcp-tools.js";
import type { ChatPersona } from "./chat-types.js";
import type { ChatMessage } from "./llm-client.js";
const FALLBACK_MODEL = process.env.OLLAMA_FALLBACK_MODEL || "qwen3:4b";
const LLM_URL = process.env.LLM_URL || "http://localhost:11434";
const LLM_MODEL = process.env.LLM_MODEL || "qwen-14b-awq";
const LLM_API_KEY = process.env.LLM_API_KEY || "";
// HTTP keep-alive agent: reuses TCP connections to Ollama (saves ~5-20ms per request)
function resolveRuntimeModel(model: string | undefined): string {
if (!model) return LLM_MODEL;
const cloudPrefixed = /^(claude|openai|mistral-api|google|bedrock|huggingface):/i.test(model);
if (cloudPrefixed) return LLM_MODEL;
const compatPrefixed = /^(ollama|vllm|runtime):/i.test(model);
return compatPrefixed ? model.slice(model.indexOf(":") + 1) : model;
}
/** Complete a chat via vLLM OpenAI-compatible API (non-streaming). Strips <think> blocks. */
export async function vllmComplete(
messages: Array<{ role: string; content: string }>,
opts?: { maxTokens?: number; model?: string },
): Promise<string> {
const resp = await fetch(`${LLM_URL}/v1/chat/completions`, {
method: "POST",
headers: llmHeaders(),
body: JSON.stringify({
model: resolveRuntimeModel(opts?.model),
messages,
max_tokens: opts?.maxTokens ?? 800,
stream: false,
}),
signal: AbortSignal.timeout(45_000),
});
if (!resp.ok) throw new Error(`vLLM ${resp.status}: ${resp.statusText}`);
const data = await resp.json() as { choices?: [{ message?: { content?: string } }] };
return (data.choices?.[0]?.message?.content || "")
.replace(/<think>[\s\S]*?<\/think>\s*/g, "").trim();
}
// HTTP keep-alive agent: reuses TCP connections to the local runtime
const ollamaAgent = new http.Agent({
keepAlive: true,
maxSockets: 10,
@@ -30,7 +63,14 @@ try {
// undici not available — fall back to default fetch (still OK, just no keep-alive)
}
/** Fetch with keep-alive connection pooling to Ollama */
/** Common headers for LLM runtime requests */
function llmHeaders(): Record<string, string> {
const h: Record<string, string> = { "Content-Type": "application/json" };
if (LLM_API_KEY) h["Authorization"] = `Bearer ${LLM_API_KEY}`;
return h;
}
/** Fetch with keep-alive connection pooling to the local runtime */
function ollamaFetch(url: string, init: RequestInit): Promise<Response> {
return fetch(url, { ...init, ...ollamaFetchOpts } as RequestInit);
}
@@ -101,11 +141,11 @@ const DEBUG = process.env.NODE_ENV !== "production" || process.env.DEBUG === "1"
// Ollama concurrency limiter (replaces manual semaphore)
// ---------------------------------------------------------------------------
// Match OLLAMA_NUM_PARALLEL (set via sudo-optimize.sh)
// Match local runtime concurrency limits
const ollamaLimit = pLimit(Number(process.env.MAX_OLLAMA_CONCURRENT) || 2);
// ---------------------------------------------------------------------------
// Ollama streaming chat
// Local runtime streaming chat
// ---------------------------------------------------------------------------
export async function streamOllamaChat(
@@ -116,61 +156,64 @@ export async function streamOllamaChat(
onDone: (fullText: string) => void,
onError: (err: Error) => void,
): Promise<void> {
// Always disable thinking for streaming — thinking output goes to separate field, not content stream
const useThinking = false;
await ollamaLimit(async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 45_000);
const runtimeModel = resolveRuntimeModel(persona.model);
const runtimeUrl = ollamaUrl || LLM_URL;
try {
const response = await ollamaFetch(`${ollamaUrl}/api/chat`, {
const response = await ollamaFetch(`${runtimeUrl}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: llmHeaders(),
body: JSON.stringify({
model: persona.model,
model: runtimeModel,
messages: [
{ role: "system", content: persona.systemPrompt },
{ role: "user", content: userMessage },
],
stream: true,
options: { num_predict: estimateMaxTokens(userMessage, persona.maxTokens), num_ctx: estimateNumCtx(persona.systemPrompt, userMessage), num_batch: 512 }, keep_alive: "30m", think: false,
max_tokens: estimateMaxTokens(userMessage, persona.maxTokens),
}),
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Ollama returned ${response.status}: ${response.statusText}`);
throw new Error(`vLLM returned ${response.status}: ${response.statusText}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("No response body from Ollama");
throw new Error("No response body from vLLM");
}
const decoder = new TextDecoder();
let fullText = "";
let inThinking = false;
let sseBuffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n").filter(Boolean);
sseBuffer += decoder.decode(value, { stream: true });
const lines = sseBuffer.split("\n");
sseBuffer = lines.pop() || "";
for (const line of lines) {
try {
const parsed = JSON.parse(line) as { message?: { content?: string }; done?: boolean };
if (parsed.message?.content) {
const c = parsed.message.content;
fullText += c;
// Suppress <think>...</think> from streaming to client
if (c.includes("<think>")) inThinking = true;
if (!inThinking) onChunk(c);
if (c.includes("</think>")) inThinking = false;
const raw = line.startsWith("data: ") ? line.slice(6).trim() : line.trim();
if (!raw) continue;
if (raw === "[DONE]") break;
const c = parseStreamingPayload(raw);
if (c) {
fullText += c;
const visible = stripThinkingFromChunk(c);
if (c.includes("<think>")) inThinking = true;
if (visible && !inThinking) onChunk(visible);
if (c.includes("</think>")) {
inThinking = false;
if (visible) onChunk(visible);
}
} catch {
// Partial JSON -- skip
}
}
}
@@ -179,60 +222,6 @@ export async function streamOllamaChat(
const cleaned = fullText.replace(/<think>[\s\S]*?<\/think>\s*/g, "").trim();
onDone(cleaned);
} catch (err) {
// Try fallback model if primary fails
if (persona.model !== FALLBACK_MODEL) {
logger.warn({ nick: persona.nick, primaryModel: persona.model, fallback: FALLBACK_MODEL }, "Trying fallback model");
const fallbackPersona = { ...persona, model: FALLBACK_MODEL };
try {
const fallbackController = new AbortController();
const fallbackTimeout = setTimeout(() => fallbackController.abort(), 45_000);
try {
const fallbackResp = await ollamaFetch(`${ollamaUrl}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: FALLBACK_MODEL,
messages: [
{ role: "system", content: persona.systemPrompt },
{ role: "user", content: userMessage },
],
stream: true,
options: { num_predict: estimateMaxTokens(userMessage, persona.maxTokens), num_ctx: estimateNumCtx(persona.systemPrompt, userMessage), num_batch: 512 },
keep_alive: "30m",
}),
signal: fallbackController.signal,
});
if (!fallbackResp.ok) throw new Error(`Fallback returned ${fallbackResp.status}`);
const reader = fallbackResp.body?.getReader();
if (!reader) throw new Error("No fallback response body");
const decoder = new TextDecoder();
let fullText = "";
let inThinking = false;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
for (const line of chunk.split("\n").filter(Boolean)) {
try {
const parsed = JSON.parse(line) as { message?: { content?: string } };
if (parsed.message?.content) {
const c = parsed.message.content;
fullText += c;
if (c.includes("<think>")) inThinking = true;
if (!inThinking) onChunk(c);
if (c.includes("</think>")) inThinking = false;
}
} catch { /* partial JSON */ }
}
}
const cleaned = fullText.replace(/<think>[\s\S]*?<\/think>\s*/g, "").trim();
onDone(cleaned);
return;
} finally {
clearTimeout(fallbackTimeout);
}
} catch { /* fallback also failed */ }
}
trackError("ollama", err, { persona: persona.nick, model: persona.model });
onError(err instanceof Error ? err : new Error(String(err)));
} finally {
@@ -246,6 +235,12 @@ function stripThinking(text: string): string {
return text.replace(/<think>[\s\S]*?<\/think>\s*/g, "").trim();
}
function stripThinkingFromChunk(text: string): string {
return text
.replace(/<think>[\s\S]*?<\/think>/g, "")
.replace(/<\/?think>/g, "");
}
/** Clean persona response: strip thinking tokens, self-reference prefix, whitespace */
export function cleanPersonaResponse(text: string, personaNick: string): string {
let cleaned = stripThinking(text);
@@ -260,7 +255,71 @@ export function cleanPersonaResponse(text: string, personaNick: string): string
// ---------------------------------------------------------------------------
interface OllamaToolCall {
function: { name: string; arguments: Record<string, unknown> };
id?: string;
type?: "function";
function: { name: string; arguments: Record<string, unknown> | string };
}
function toRuntimeMessage(message: ChatMessage): Record<string, unknown> {
const payload: Record<string, unknown> = {
role: message.role,
content: message.content,
};
if (message.tool_calls?.length) {
payload.tool_calls = message.tool_calls.map((toolCall) => ({
id: toolCall.id,
type: toolCall.type || "function",
function: {
name: toolCall.function.name,
arguments: typeof toolCall.function.arguments === "string"
? toolCall.function.arguments
: JSON.stringify(toolCall.function.arguments),
},
}));
}
if (message.role === "tool" && message.tool_call_id) {
payload.tool_call_id = message.tool_call_id;
}
return payload;
}
function parseToolArguments(value: Record<string, unknown> | string): Record<string, unknown> {
if (typeof value !== "string") return value;
try {
const parsed = JSON.parse(value) as Record<string, unknown>;
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function parseStreamingPayload(raw: string): string | null {
try {
const parsed = JSON.parse(raw) as {
choices?: [{ delta?: { content?: string } }];
message?: { content?: string };
};
return parsed.choices?.[0]?.delta?.content ?? parsed.message?.content ?? null;
} catch {
return null;
}
}
function extractAssistantMessage(data: {
choices?: [{
message?: {
role?: string;
content?: string;
tool_calls?: OllamaToolCall[];
};
}];
message?: {
role?: string;
content?: string;
tool_calls?: OllamaToolCall[];
};
}): { role?: string; content?: string; tool_calls?: OllamaToolCall[] } | undefined {
return data.choices?.[0]?.message || data.message;
}
/**
@@ -302,7 +361,7 @@ export async function executeToolCall(
: { type: prompt || "pink", duration };
try {
const resp = await fetch(`${AI_BRIDGE}${endpoint}`, {
method: "POST", headers: { "Content-Type": "application/json" },
method: "POST", headers: llmHeaders(),
body: JSON.stringify(body), signal: AbortSignal.timeout(60_000),
});
return resp.ok ? `[Audio généré: ${type} ${duration}s]` : `[Erreur génération: HTTP ${resp.status}]`;
@@ -314,7 +373,7 @@ export async function executeToolCall(
const voice = String(args.voice || "af_heart");
try {
const resp = await fetch(`${AI_BRIDGE}/generate/voice-fast`, {
method: "POST", headers: { "Content-Type": "application/json" },
method: "POST", headers: llmHeaders(),
body: JSON.stringify({ text, voice, speed: 1.0 }), signal: AbortSignal.timeout(30_000),
});
return resp.ok ? `[Voix synthétisée: ${voice}, "${text.slice(0, 50)}"]` : `[Erreur TTS: HTTP ${resp.status}]`;
@@ -330,7 +389,7 @@ export async function executeToolCall(
}
/**
* Stream Ollama chat with optional tool-calling support.
* Stream local runtime chat with optional tool-calling support.
* When tools are provided:
* 1. First do a non-streaming call to see if Ollama wants to use tools
* 2. If tool_calls present, execute them and re-call with tool results
@@ -368,66 +427,54 @@ export async function streamOllamaChatWithTools(
await ollamaLimit(async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 45_000);
const runtimeUrl = ollamaUrl || LLM_URL;
try {
const messages: Array<{ role: string; content: string; tool_calls?: OllamaToolCall[] }> = [
const runtimeModel = resolveRuntimeModel(persona.model);
const messages: ChatMessage[] = [
{ role: "system", content: persona.systemPrompt },
{ role: "user", content: userMessage },
];
// Step 1: Non-streaming probe with tools (only for tool-like messages)
const probeResp = await ollamaFetch(`${ollamaUrl}/api/chat`, {
// Step 1: Non-streaming probe with tools
const probeResp = await ollamaFetch(`${runtimeUrl}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: llmHeaders(),
body: JSON.stringify({
model: persona.model,
messages,
model: runtimeModel,
messages: messages.map(toRuntimeMessage),
tools: tools.map(t => t),
stream: false,
options: { num_predict: estimateMaxTokens(userMessage, persona.maxTokens), num_ctx: estimateNumCtx(persona.systemPrompt, userMessage), num_batch: 512 }, keep_alive: "30m", think: shouldThink(userMessage, persona.model) ? undefined : false,
max_tokens: estimateMaxTokens(userMessage, persona.maxTokens),
}),
signal: controller.signal,
});
if (!probeResp.ok) {
throw new Error(`Ollama returned ${probeResp.status}: ${probeResp.statusText}`);
throw new Error(`vLLM returned ${probeResp.status}: ${probeResp.statusText}`);
}
const probeData = await probeResp.json() as {
choices?: [{
message?: {
role?: string;
content?: string;
tool_calls?: OllamaToolCall[];
};
}];
message?: {
role?: string;
content?: string; thinking?: string;
content?: string;
tool_calls?: OllamaToolCall[];
};
};
const toolCalls = probeData.message?.tool_calls;
const probeMsg = extractAssistantMessage(probeData);
const toolCalls = probeMsg?.tool_calls;
// If no tool calls, use the response directly
if (!toolCalls || toolCalls.length === 0) {
let content = stripThinking(probeData.message?.content || "");
// qwen3.5 thinking mode: content may be empty with reasoning in thinking field
if (!content && probeData.message?.thinking) {
const thinking = probeData.message.thinking;
// Strip <think>...</think> tags first
const stripped = thinking.replace(/<think>[\s\S]*?<\/think>\s*/g, "").trim();
if (stripped) {
content = stripped;
} else {
// Try to extract answer after markers (FR/EN)
const answerMatch = thinking.match(/(?:Answer|Response|Réponse|Output|Conclusion)\s*:\s*([\s\S]+)$/i);
if (answerMatch) {
content = answerMatch[1].trim();
} else {
// Last resort: take last substantial paragraph
const paragraphs = thinking.split("\n\n").filter(p => p.trim().length > 20);
content = paragraphs[paragraphs.length - 1]?.trim() || thinking.trim();
}
}
if (content) {
logger.debug("[ollama] extracted content from thinking field (tool probe)");
}
}
const content = stripThinking(probeMsg?.content || "");
if (content) {
onChunk(content);
}
@@ -438,13 +485,13 @@ export async function streamOllamaChatWithTools(
// Step 2: Execute tool calls (max 1 round)
messages.push({
role: "assistant",
content: probeData.message?.content || "",
content: probeMsg?.content || "",
tool_calls: toolCalls,
});
for (const tc of toolCalls) {
const name = tc.function.name;
const args = tc.function.arguments;
const args = parseToolArguments(tc.function.arguments);
if (DEBUG) console.log(`[mcp-tools] ${persona.nick} calling ${name}(${JSON.stringify(args)})`);
let result: string;
@@ -457,50 +504,53 @@ export async function streamOllamaChatWithTools(
messages.push({
role: "tool",
content: result,
tool_call_id: tc.id,
});
}
// Step 3: Stream the final response with tool context
const streamResp = await ollamaFetch(`${ollamaUrl}/api/chat`, {
const streamResp = await ollamaFetch(`${runtimeUrl}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: llmHeaders(),
body: JSON.stringify({
model: persona.model,
messages,
model: runtimeModel,
messages: messages.map(toRuntimeMessage),
stream: true,
options: { num_predict: estimateMaxTokens(userMessage, persona.maxTokens), num_ctx: estimateNumCtx(persona.systemPrompt, userMessage), num_batch: 512 }, keep_alive: "30m", think: false,
max_tokens: estimateMaxTokens(userMessage, persona.maxTokens),
}),
signal: controller.signal,
});
if (!streamResp.ok) {
throw new Error(`Ollama returned ${streamResp.status}: ${streamResp.statusText}`);
throw new Error(`vLLM returned ${streamResp.status}: ${streamResp.statusText}`);
}
const reader = streamResp.body?.getReader();
if (!reader) {
throw new Error("No response body from Ollama");
throw new Error("No response body from vLLM");
}
const decoder = new TextDecoder();
let fullText = "";
let toolStreamBuf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n").filter(Boolean);
toolStreamBuf += decoder.decode(value, { stream: true });
const tsLines = toolStreamBuf.split("\n");
toolStreamBuf = tsLines.pop() || "";
for (const line of lines) {
try {
const parsed = JSON.parse(line) as { message?: { content?: string }; done?: boolean };
if (parsed.message?.content) {
fullText += parsed.message.content;
onChunk(parsed.message.content);
}
} catch {
// Partial JSON -- skip
for (const line of tsLines) {
const raw = line.startsWith("data: ") ? line.slice(6).trim() : line.trim();
if (!raw) continue;
if (raw === "[DONE]") break;
const c = parseStreamingPayload(raw);
if (c) {
fullText += c;
const visible = stripThinkingFromChunk(c);
if (visible) onChunk(visible);
}
}
}
@@ -517,11 +567,11 @@ export async function streamOllamaChatWithTools(
// ---------------------------------------------------------------------------
// LLM Client — mascarade-backed streaming (OpenAI-compatible)
// Falls back to direct Ollama if mascarade is unavailable.
// Uses mascarade only for explicit cloud-provider streaming.
// Drop-in replacement for streamOllamaChat with same signature.
// ---------------------------------------------------------------------------
import { streamChat as llmStreamChat, type ChatMessage } from "./llm-client.js";
import { streamChat as llmStreamChat } from "./llm-client.js";
const USE_MASCARADE = process.env.USE_MASCARADE !== "0"; // enabled by default
+36 -19
View File
@@ -17,6 +17,7 @@ import {
resetPersonaMemory,
savePersonaMemory,
} from "./persona-memory-store.js";
import { scheduler, VRAM_BUDGETS } from "./inference-scheduler.js";
// ---------------------------------------------------------------------------
// Persona memory (persistent, file-based)
@@ -27,32 +28,48 @@ export async function updatePersonaMemory(
persona: ChatPersona,
recentMessages: string[],
ollamaUrl: string,
userNick: string = "_anonymous",
): Promise<void> {
const startedAt = performance.now();
const memory = await loadPersonaMemory(persona);
const memory = await loadPersonaMemory(persona.id || persona.nick, userNick, persona.nick);
const policy = resolvePersonaMemoryPolicy();
const prompt = buildPersonaMemoryExtractionPrompt(persona, recentMessages, policy);
recordPersonaMemoryAttempt(persona);
try {
const response = await fetch(`${ollamaUrl}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: persona.model,
messages: [{ role: "user", content: prompt }],
stream: false,
format: "json",
}),
signal: AbortSignal.timeout(30_000),
const llmUrl = process.env.LLM_URL || "http://localhost:11434";
const llmModel = process.env.LLM_MODEL || "qwen-14b-awq";
const llmApiKey = process.env.LLM_API_KEY || "";
const llmH: Record<string, string> = { "Content-Type": "application/json" };
if (llmApiKey) llmH["Authorization"] = `Bearer ${llmApiKey}`;
const data = await scheduler.submit<{ choices?: [{ message?: { content?: string } }] }>({
id: `memory-extract-${persona.id}-${Date.now()}`,
device: "gpu",
priority: "low",
label: `memory-extract:${persona.id}`,
vramMB: VRAM_BUDGETS.ollama,
execute: async () => {
const response = await fetch(`${llmUrl}/v1/chat/completions`, {
method: "POST",
headers: llmH,
body: JSON.stringify({
model: llmModel,
messages: [{ role: "user", content: prompt }],
stream: false,
max_tokens: 800,
response_format: { type: "json_object" },
}),
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) {
throw new Error(`Memory update HTTP ${response.status}`);
}
return response.json() as Promise<{ choices?: [{ message?: { content?: string } }] }>;
},
});
if (!response.ok) {
throw new Error(`Memory update HTTP ${response.status}`);
}
const data = (await response.json()) as { message?: { content?: string } };
const rawContent = String(data.message?.content || "").trim();
const rawContent = String(data.choices?.[0]?.message?.content || "").trim();
if (!rawContent) {
logger.error({ nick: persona.nick }, "[persona-router] Empty LLM JSON");
recordPersonaMemorySkip(persona, "empty_response");
@@ -74,7 +91,7 @@ export async function updatePersonaMemory(
recentMessages,
});
await savePersonaMemory(updated, policy);
await savePersonaMemory(updated, userNick, policy);
const durationMs = performance.now() - startedAt;
recordLatency("persona_memory_update", durationMs);
recordPersonaMemoryWrite(persona, memory, updated, durationMs);
+28 -6
View File
@@ -6,13 +6,15 @@
# docker compose --profile v1 up -d # V1 app + postgres
# docker compose --profile v2 up -d # V2 api + worker + postgres
# docker compose --profile v1 --profile v2 up -d # V1 + V2 + postgres
# docker compose --profile ollama up -d # include Ollama container
# docker compose --profile ollama up -d # include Ollama-compatible embeddings backend
#
# By default, Ollama is expected to run natively on the host (port 11434).
# Set OLLAMA_URL in .env to override. Use --profile ollama to run it in Docker.
# By default, the primary LLM runtime is expected on the host (LLM_URL, port 11434).
# OLLAMA_URL is retained for embeddings/RAG compatibility.
# ---------------------------------------------------------------------------
x-common-env: &common-env
LLM_URL: "${LLM_URL:-http://host.docker.internal:11434}"
LLM_MODEL: "${LLM_MODEL:-qwen-14b-awq}"
OLLAMA_URL: "${OLLAMA_URL:-http://host.docker.internal:11434}"
DATABASE_URL: postgres://kxkm:kxkm@postgres:5432/kxkm_clown
NODE_ENV: production
@@ -68,6 +70,8 @@ services:
- ./scripts:/app/scripts:ro
- /home/kxkm/openDAW/packages/app/studio/dist:/app/daw:ro
environment:
LLM_URL: "${LLM_URL:-http://localhost:11434}"
LLM_MODEL: "${LLM_MODEL:-qwen-14b-awq}"
OLLAMA_URL: "http://localhost:11434"
DATABASE_URL: "postgres://kxkm:kxkm@localhost:5432/kxkm_clown"
NODE_ENV: production
@@ -112,6 +116,8 @@ services:
network_mode: host
command: ["node", "apps/worker/dist/index.js"]
environment:
LLM_URL: "${LLM_URL:-http://localhost:11434}"
LLM_MODEL: "${LLM_MODEL:-qwen-14b-awq}"
OLLAMA_URL: "http://localhost:11434"
DATABASE_URL: "postgres://kxkm:kxkm@localhost:5432/kxkm_clown"
NODE_ENV: production
@@ -153,7 +159,7 @@ services:
start_period: 5s
# -------------------------------------------------------------------------
# Ollama — LLM inference server (optional, use --profile ollama)
# Ollama-compatible embeddings backend (optional, use --profile ollama)
# -------------------------------------------------------------------------
ollama:
image: ollama/ollama:latest
@@ -304,7 +310,7 @@ services:
start_period: 120s
# -------------------------------------------------------------------------
# LightRAG — Graph RAG server (Ollama backend)
# LightRAG — Graph RAG server
# API: POST /query, POST /documents/text, GET /health
# Web UI: http://localhost:9621
# -------------------------------------------------------------------------
@@ -323,7 +329,7 @@ services:
LLM_MODEL: "qwen3:8b"
EMBEDDING_MODEL: "nomic-embed-text"
EMBEDDING_DIM: "768"
OLLAMA_HOST: "http://localhost:11434"
OLLAMA_HOST: "${OLLAMA_URL:-http://localhost:11434}"
LLM_BINDING: ollama
EMBEDDING_BINDING: ollama
RAG_DIR: "/data/lightrag"
@@ -383,9 +389,25 @@ services:
depends_on:
- prometheus
# --- Text Embeddings Inference (dedicated embedding server) -----------------
# Usage: docker compose --profile embeddings up -d
# Exposes OpenAI-compatible /v1/embeddings on port 9500
# -------------------------------------------------------------------------
tei:
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.6
container_name: kxkm-tei
restart: unless-stopped
profiles: [v2, embeddings]
ports:
- "9500:80"
volumes:
- tei-models:/data
command: --model-id BAAI/bge-m3 --port 80
volumes:
app-data:
pg-data:
ollama-data:
tei-models:
prometheus-data:
grafana-data:
+141
View File
@@ -0,0 +1,141 @@
# AGENTS.md — packages/
<!-- Parent: ../AGENTS.md -->
8 shared packages in npm workspace. Exported as `@kxkm/*` scope.
## core — Shared Types & Constants (2 TS files)
| File | Purpose |
|------|---------|
| `index.ts` | Persona IDs, channel constants, permission levels, errors |
| `index.test.ts` | Type checks, constant validation |
Core IDs: 33 personas (Pharmacius, Sherlock, Turing, Ikeda, Schaeffer, Merzbow, Pina, etc.). Permissions: read, write, admin.
**Used by**: all packages and apps.
## auth — Authentication & RBAC (2 TS files)
| File | Purpose |
|------|---------|
| `index.ts` | RBAC middleware, session validation, JWT verify, guest mode |
| `index.test.ts` | Auth flow, token expiry, guest access |
Session storage: PostgreSQL (sessionRepo). Guest mode: read-only routes. RBAC: 3 levels (guest, user, admin).
**Used by**: apps/api (middleware), apps/web (session hooks).
## chat-domain — Message & Command Types (2 TS files)
| File | Purpose |
|------|---------|
| `index.ts` | ChatMessage union, Channel, Command registry, slash command definitions |
| `index.test.ts` | Message validation, command parsing |
43 slash commands: /chat, /imagine, /compose, /help, /speed, etc. Message types: text, image, audio, error, system.
**Used by**: apps/api (ws-chat), apps/web (UI).
## persona-domain — Persona Definitions & Memory (4 TS files)
| File | Purpose |
|------|---------|
| `index.ts` | Persona model, memoryMode (auto/explicit/off), corpus[], relations[], DPO pair definitions |
| `editorial.ts` | Editorial pipeline: persona proposal, feedback collection, DPO training triggers |
| `pharmacius.ts` | Pharmacius persona specialization (meta-reflection, composability) |
| `index.test.ts` | Persona validation, memory mode tests |
33 personas with per-persona: memory mode, corpus URLs, related personas (depth-3 relay), voice sample. DPO pair collection: user feedback → training pipeline → Unsloth fine-tuning.
**Used by**: apps/api (ws-chat, persona-runtime), packages/node-engine (training).
## node-engine — DAG Execution & Job Queue (6 TS files)
| File | Purpose |
|------|---------|
| `index.ts` | Node registry, DAG validator, run executor |
| `registry.ts` | 15+ node types: text_generation, image_generation, music_generation, audio_effects, voice_clone, document_extraction, sql_query, etc. |
| `sandbox.ts` | Isolated node execution, timeout, resource limits |
| `training.ts` | Training job: DPO pair collection, Unsloth adapter, registry update |
| `registry.test.ts` | Node type validation |
| `index.test.ts` | DAG execution, queue, run state machine |
GPU-aware queue: submits to `inference-scheduler.ts` for LLM nodes. Training nodes: trigger via worker.
**Used by**: apps/api (routes/node-engine), apps/worker (job executor), packages/storage (run persistence).
## storage — PostgreSQL Persistence (8 TS files)
| File | Purpose |
|------|---------|
| `index.ts` | Repo factory, migration runner |
| `config.test.ts` | DB connection, pool, transaction tests |
| `migration.test.ts` | Schema versioning |
| `session-repo.test.ts` | Session CRUD, expiry cleanup |
| `persona-repo.test.ts` | Persona memory, feedback store |
| `node-engine-repo.test.ts` | Run state, output, logs |
| `test-helpers.ts` | Test DB setup, fixtures |
Repos: SessionRepo (auth), PersonaRepo (memory, DPO feedback), NodeEngineRepo (DAG runs, training jobs). Migrations: auto-run on startup.
**Used by**: apps/api (all routes), apps/worker (job persistence).
## tui — Terminal UI Utilities (3 TS files)
| File | Purpose |
|------|---------|
| `index.ts` | CLI argument parsing, color codes, progress bars, table format |
| `index.test.ts` | Formatter tests |
Used by scripts: health-check.sh, deep-audit.js, ops-tui.sh. Outputs JSON + ANSI colors for logs.
**Used by**: scripts/, ops/ (monitoring).
## ui — Experimental React Components (2 TS files)
| File | Purpose |
|------|---------|
| `index.ts` | Button, Input, Select, Modal, Spinner components (unstyled, Tailwind-ready) |
| `index.test.ts` | Component render tests |
Minimal, reusable. Not currently used in apps/web (which has inline components).
## Package Dependencies
```
core (no deps, baseline types)
auth → core
chat-domain → core
persona-domain → core, chat-domain
node-engine → core, persona-domain
storage → core, auth, chat-domain, persona-domain, node-engine
tui → core
ui → core
```
## Build & Test
```bash
npm run -w @kxkm/core build
npm run -w @kxkm/auth test
npm run -w @kxkm/chat-domain test
npm run -w @kxkm/persona-domain test
npm run -w @kxkm/node-engine test
npm run -w @kxkm/storage test
npm run -w @kxkm/tui test
npm run -w @kxkm/ui test
```
## Export Pattern
Each package exports:
- TypeScript types (.ts)
- CommonJS build (dist/index.js)
- Type declarations (dist/index.d.ts)
```typescript
import { Persona, PersonaMemoryMode } from '@kxkm/persona-domain';
import { Node, DAGRun } from '@kxkm/node-engine';
import { Session } from '@kxkm/auth';
```
+148
View File
@@ -0,0 +1,148 @@
# AGENTS.md — scripts/
<!-- Parent: ../AGENTS.md -->
42 files (20 Python + 22 Shell) organized by domain: ops, training, ingestion, voice, image, deployment.
## Ops & Monitoring (8 files)
| File | Type | Purpose |
|------|------|---------|
| `health-check.sh` | Shell | 19 checks (API, DB, Ollama, TTS, LightRAG, docker) with color output |
| `deep-audit.js` | Node | Comprehensive audit: hot spots, error rates, perf p50/p95/p99, GPU VRAM, memory leaks |
| `ops-tui.sh` | Shell | Interactive ops menu (deploy, restart services, logs, cleanup) |
| `service-status.sh` | Shell | systemd service status (kxkm-tts, kxkm-lightrag, kxkm-api) |
| `health-doc-search.sh` | Shell | LightRAG embedding health check |
| `health-embeddings.sh` | Shell | Ollama embedding model health |
| `health-voice-clone.sh` | Shell | TTS voice clone model check |
| `journald-monitor.sh` | Shell | Tail systemd logs with filtering (error, warning) |
## Deployment (5 files)
| File | Type | Purpose |
|------|------|---------|
| `deploy.sh` | Shell | Systemd service deployment (kxkm-api, kxkm-tts, kxkm-lightrag), auto-restart on fail |
| `rollback-v2.js` | Node | Rollback to previous version (git revert + service restart) |
| `setup-voice-clone.sh` | Shell | Download XTTS-v2 model, configure TTS |
| `qwen3-tts-ondemand.sh` | Shell | Start on-demand TTS server (not systemd) |
| `ollama-import-adapter.sh` | Shell | Import fine-tuned models into Ollama, register in registry.json |
## Training (7 files)
| File | Type | Purpose |
|------|------|---------|
| `train_unsloth.py` | Python | Unsloth fine-tuning pipeline: SFT or DPO, load persona-specific dataset, save to models/finetuned/ |
| `eval_model.py` | Python | Evaluate model: perplexity, BLEU, custom benchmarks, compare against baseline |
| `dpo-pipeline.js` | Node | DPO pair collection automation: fetch feedback from DB, format pairs, trigger training |
| `dpo-export.sh` | Shell | Export DPO pairs to JSONL for analysis |
| `orchestrate_batches.py` | Python | Batch orchestration: schedule training jobs across GPU, queue management |
| `migrate-persona-store-v2.js` | Node | Data migration: v1 persona memory → v2 nick-isolated structure |
| `parity-check.js` | Node | V1 ↔ V2 behavior parity validation |
## Ingestion & RAG (5 files)
| File | Type | Purpose |
|------|------|---------|
| `ingest_spectacle_corpus.py` | Python | LightRAG corpus ingestion: seed URLs + SearXNG discovery + sherlock-discovered-urls.jsonl, Camoufox for bot-protected sites |
| `extract_pdf_docling.py` | Python | Extract text/tables from PDFs via Docling, chunk for embedding |
| `reranker-server.py` | Python | BGE-M3 reranker service (listen :8090) for LightRAG results |
| `generate_image.py` | Python | ComfyUI image generation wrapper (checkpoint + LoRA selection, queue submission) |
| `generate-persona-dialogues.js` | Node | Generate synthetic persona dialogues for DPO training |
## Voice & Audio (5 files)
| File | Type | Purpose |
|------|------|---------|
| `tts-server.py` | Python | Dual TTS backend (Piper + Chatterbox) on :9100, HTTP API |
| `qwen3-tts-server.py` | Python | Qwen3 TTS server alternative (on-demand) |
| `tts_synthesize.py` | Python | Batch TTS synthesis: text → WAV, save to media-store |
| `tts_clone_voice.py` | Python | Voice cloning: sample ingestion, XTTS-v2 fine-tune, register voice |
| `generate-voice-samples.js` | Node | Generate persona voice samples (read persona bio via TTS) |
## Testing & Validation (5 files)
| File | Type | Purpose |
|------|------|---------|
| `run-playwright-e2e.sh` | Shell | Run Playwright E2E tests (login, chat, upload, admin) |
| `test-e2e.js` | Node | E2E test runner (WebDriver + assertions) |
| `test-v2.js` | Node | V2 integration tests (API + WS) |
| `smoke-test.sh` | Shell | Quick smoke: API health, DB connect, Ollama respond |
| `smoke-v2.js` | Node | V2 smoke: WS chat, inference, persona memory |
## Utilities (5 files)
| File | Type | Purpose |
|------|------|---------|
| `chat-pause.sh` | Shell | Pause chat (set CHAT_PAUSED env var or create data/chat-paused file) |
| `cleanup-logs.sh` | Shell | Rotate + compress logs older than 7 days, purge after 30 days |
| `cleanup-test-compositions.js` | Node | Delete test composition artifacts |
| `ollama-warmup.sh` | Shell | Pre-load LLM models into Ollama memory (avoid cold start) |
| `sudo-optimize.sh` | Shell | System optimization (disable swap, tune kernel params) |
## Specialized (2 files)
| File | Type | Purpose |
|------|------|---------|
| `transcribe_audio.py` | Python | Batch audio → text via OpenAI Whisper or ElevenLabs Scribe |
| `xtts_clone.py` | Python | XTTS-v2 voice cloning (wrapper) |
## Integration & Agent Scripts (4 files)
| File | Type | Purpose |
|------|------|---------|
| `v2-agent-task.js` | Node | Execute agent task from PLAN.md (sync, run, log) |
| `v2-autoresearch-loop.js` | Node | Continuous autoresearch: fetch OSS projects, benchmark, report |
| `v2-dpo-pipeline.js` | Node | Automated DPO: feedback → pairs → training trigger → registry update |
| `discord-pharmacius.js` | Node | Discord bot integration (Pharmacius agent) |
## Data Scripts (1 file)
| File | Type | Purpose |
|------|------|---------|
| `patch-plan.py` | Python | Update PLAN.md lot state (experimental, use git instead) |
## Pattern: npm run scripts
All scripts in `package.json`:
```json
{
"scripts": {
"health:v2": "bash scripts/health-check.sh",
"audit:deep": "node scripts/deep-audit.js",
"deploy": "bash scripts/deploy.sh",
"train": "python scripts/train_unsloth.py",
"test:e2e": "bash scripts/run-playwright-e2e.sh"
}
}
```
## Run Examples
```bash
npm run health:v2 # Run health checks
node scripts/deep-audit.js # Deep system audit
bash scripts/deploy.sh # Deploy systemd services
python scripts/train_unsloth.py --dpo --model qwen-14b # Train
bash scripts/run-playwright-e2e.sh # E2E tests
bash scripts/health-check.sh # 19-point health check
```
## Environment Variables (scripts expect)
```bash
LLM_URL=http://localhost:11434
DATABASE_URL=postgres://kxkm:kxkm@localhost:5432/kxkm_clown
SEARXNG_URL=http://localhost:8080
LIGHTRAG_URL=http://localhost:9621
TTS_URL=http://localhost:9100
CAMOUFOX_URL=http://localhost:8091
DISCORD_TOKEN=xxx # For discord-pharmacius.js
```
## Conventions
- **Exit codes**: 0 = success, 1 = error, 2 = skipped
- **Output**: JSON for machine parsing (health-check.sh, deep-audit.js), human-readable tables for TUI
- **Logging**: scripts/logs/*.log (rotated daily by cleanup-logs.sh)
- **Data**: ephemeral files in data/ (sherlock-discovered-urls.jsonl, chat-logs/, etc.)