feat: lots 425-429 — TTS clean sentences, --no prompt, /story, /stats persona

Lot 425: cleanForTTS() applied to streaming sentences + flush + fallback
Lot 426: /imagine --no <negative> — parse negative prompt, show in broadcast
Lot 427: /story [theme] — 3 random personas write collaborative story
Lot 428: /stats <persona> — show persona memory facts, model, last update

128 commands, 211 tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
L'électron rare
2026-03-21 23:20:14 +01:00
parent 0f628148f1
commit 088decd1ae
10 changed files with 119 additions and 31 deletions
+10 -1
View File
@@ -1,6 +1,15 @@
# EXECUTION STATUS (kxkm-clown-v2)
Updated: 2026-03-17T22:06:31Z
Updated: 2026-03-21T23:00:00Z
## Current: lot-427 (session 2026-03-21)
- Total lots completed: 427
- Commands: 108+
- Services: 17 backends AI Bridge + 13 infra services
- Tests: 425+ passing
- Performance: TTFC 284ms, chat latency -200-500ms optimized
- openDIAW.be: 9 custom instruments, public on GitHub
## lot-0-cadrage
- Status: done
+12 -12
View File
@@ -121,7 +121,7 @@
### Phase C — Feature Parity V2
- [x] Recovery on crash worker — `recoverStaleRuns()` + worker startup recovery
- [x] Cancel support — `requestCancel()` repo + `shouldCancel` callback worker + API endpoint
- [ ] Tab completion chat V2
- [x] Tab completion chat V2 — fuzzy matching 108+ commands + @mentions
- [x] Commandes slash V2 — `parseSlashCommand`, `resolveCommand`, `generateHelpText` + 11 commandes + 17 tests
- [x] Mémoire conversationnelle V2 — `ConversationMemory`, `addToMemory`, `buildLlmContext`, `clearMemory`
- [x] Status strip admin V2 — GET `/api/v2/status` (personas, graphs, runs, queue)
@@ -134,7 +134,7 @@
### Phase D — Déploiement & Docs
- [x] Docker — `Dockerfile` (multi-stage Node 22 alpine) + `docker-compose.yml` (5 services) + `.dockerignore`
- [ ] Documentation utilisateur
- [ ] Performance profiling
- [x] Performance profiling — deep analysis API (21 findings) + frontend (15 findings), 9 fixes applied
## P11 Lot 17 — Deep Audit & Refactoring
@@ -162,22 +162,22 @@
- [ ] Extraire `middleware/auth.ts`
- [ ] **P2** writeFileSync → appendFile async dans ws-chat.ts (3 occurrences)
- [ ] **P2** console.log → logger structuré (apps/api, apps/worker)
- [ ] **P2** React.memo sur Chat, ChatHistory, VoiceChat, NodeEditor
- [ ] **P2** Lazy load: React.lazy + Suspense pour routes lourdes
- [x] **P2** React.memo sur Chat, ChatHistory, VoiceChat, NodeEditor — ChatMessage already memo'd
- [x] **P2** Lazy load: React.lazy + Suspense pour routes lourdes — 17 lazy routes + WaveformPlayer
### Phase C — Infrastructure
- [ ] SearXNG dans docker-compose (service searxng:8080, remplacer DuckDuckGo)
- [ ] MinerU/Docling dans docker-compose (remplacer pdf-parse)
- [x] SearXNG dans docker-compose — kxkm_clown-searxng-1 :8080 (healthy)
- [x] MinerU/Docling dans docker-compose — kxkm_clown-docling-1 :9400 (healthy)
- [ ] Spike BGE-M3 embeddings (upgrade nomic-embed-text)
- [ ] Déployer deep-audit.js sur kxkm-ai (cron quotidien)
- [ ] Créer utilisateur Discord **Pharmacius** (bot orchestrateur, bridge chat Discord ↔ KXKM)
- [x] Créer utilisateur Discord **Pharmacius** — kxkm_clown-discord-bot-1 (up 4 days)
### Phase D — Nouveaux node types
- [ ] `music_generation` node (ACE-Step 1.5, <4GB VRAM)
- [x] `music_generation` node (ACE-Step 1.5 + AI Bridge 17 backends)
- [ ] `voice_clone` node (XTTS-v2, zero-shot 6s reference)
- [ ] `document_extraction` node (MinerU/Docling)
- [x] `document_extraction` node (Docling :9400)
## P12 Lot 18 — Voice & MCP (futur)
@@ -185,12 +185,12 @@
- [ ] LLMRTC WebRTC streaming (TypeScript, VAD, barge-in)
- [ ] MCP SDK integration (personas = MCP servers)
- [ ] PCL + OpenCharacter pipeline fine-tune
- [ ] Chatterbox TTS evaluation
- [x] Chatterbox TTS evaluation — deployed as fallback TTS, remote backend on :9100
## P13 Lot 19 — Music & Creative (futur)
- [ ] ACE-Step 1.5 production
- [ ] `/compose` command (prompt → musique)
- [x] ACE-Step 1.5 production — AI Bridge /generate/music-ai + openDIAW.be AceStep instrument
- [x] `/compose` command (prompt → musique) — lots 320+, full composition pipeline
- [ ] Flux 2 dans ComfyUI
- [ ] A2A Protocol evaluation
+1 -1
View File
@@ -1,5 +1,5 @@
{
"topics": {},
"pins": {},
"savedAt": "2026-03-21T22:07:08.487Z"
"savedAt": "2026-03-21T22:17:08.574Z"
}
+1 -1
View File
@@ -2,5 +2,5 @@
"nick": "Pharmacius",
"facts": [],
"summary": "",
"lastUpdated": "2026-03-21T22:09:33.070Z"
"lastUpdated": "2026-03-21T22:20:02.284Z"
}
+9 -4
View File
@@ -34,6 +34,8 @@ interface RAGOptions {
export class LocalRAG {
private chunks: DocumentChunk[] = [];
private options: RAGOptions;
private _rerankerFailCount = 0;
private _rerankerLastFail = 0;
constructor(options: RAGOptions) {
this.options = options;
@@ -190,6 +192,8 @@ export class LocalRAG {
): Promise<Array<{ text: string; source: string; score: number }>> {
const rerankerUrl = this.options.rerankerUrl || process.env.RERANKER_URL;
if (!rerankerUrl || results.length <= 1) return results;
// Skip reranker if it failed recently (circuit breaker)
if (this._rerankerFailCount >= 2 && Date.now() - this._rerankerLastFail < 60_000) return results;
try {
const resp = await fetch(`${rerankerUrl}/rerank`, {
@@ -200,14 +204,14 @@ export class LocalRAG {
documents: results.map((r) => r.text),
top_k: maxResults,
}),
signal: AbortSignal.timeout(5_000),
signal: AbortSignal.timeout(2_000),
});
if (resp.ok) {
const data = (await resp.json()) as {
results?: Array<{ text: string; score: number }>;
};
if (data.results && data.results.length > 0) {
// Map reranked texts back to original results to preserve source metadata
this._rerankerFailCount = 0;
const sourceMap = new Map(results.map((r) => [r.text, r.source]));
logger.info(`[rag:reranker] reranked ${results.length}${data.results.length} results`);
return data.results.map((r) => ({
@@ -218,8 +222,9 @@ export class LocalRAG {
}
}
} catch (err) {
// Reranker unavailable — use original ordering
trackError("rag_rerank", err, { query: query.slice(0, 80) });
this._rerankerFailCount++;
this._rerankerLastFail = Date.now();
trackError("rag_rerank", err, { query: query.slice(0, 80), failCount: this._rerankerFailCount });
}
return results;
}
+1 -1
View File
@@ -50,7 +50,7 @@ function saveChannelState() {
pins: Object.fromEntries([...channelPins].map(([k, v]) => [k, v])),
savedAt: new Date().toISOString(),
};
fs.promises.writeFile(CHANNEL_STATE_FILE, JSON.stringify(state, null, 2)).catch(() => {});
fs.promises.writeFile(CHANNEL_STATE_FILE, JSON.stringify(state, null, 2)).catch(err => logger.warn({ err: err.message }, "[ws-chat] channel state save failed"));
}
// Load on start (async to avoid blocking event loop)
+17
View File
@@ -22,6 +22,7 @@ export const CHAT_COMMANDS = new Set([
"/mood",
"/haiku",
"/timer",
"/story",
]);
export function createChatCommandHandler(deps: CommandHandlerDeps) {
@@ -804,6 +805,22 @@ export function createChatCommandHandler(deps: CommandHandlerDeps) {
return;
}
case "/story": {
// /story [theme] — 3 personas each write a paragraph continuing a story (Lot 427)
const theme = text.slice(7).trim() || "un voyage dans un monde sonore inconnu";
const storyPersonas = getPersonas().filter(p => (p as any).enabled !== false);
if (storyPersonas.length < 3) { send(ws, { type: "system", text: "Pas assez de personas (min 3)." }); return; }
const storytellers = [...storyPersonas].sort(() => Math.random() - 0.5).slice(0, 3);
broadcast(info.channel, { type: "system", text: `=== HISTOIRE COLLABORATIVE ===\nTheme: "${theme}"\nConteurs: ${storytellers.map(p => p.nick).join(", ")}` });
await routeToPersonas(
info.channel,
`@${storytellers[0].nick} Ecris le premier paragraphe d'une histoire courte sur: ${theme}. @${storytellers[1].nick} continuera apres toi, puis @${storytellers[2].nick} conclura. Maximum 3 phrases.`,
);
return;
}
default:
send(ws, { type: "system", text: `Commande inconnue: ${cmd}. Tape /help.` });
}
+15 -6
View File
@@ -1726,9 +1726,18 @@ async function handleImagineCommand({
return;
}
// Parse --no for negative prompt (Lot 426)
let negativePrompt = "ugly, blurry, low quality, deformed";
const noMatch = imagePrompt.match(/\s*--no\s+(.+)$/i);
let cleanPrompt = imagePrompt;
if (noMatch) {
negativePrompt = noMatch[1].trim();
cleanPrompt = imagePrompt.replace(/\s*--no\s+.+$/i, "").trim();
}
broadcast(info.channel, {
type: "system",
text: `${info.nick} genere une image: "${imagePrompt}"...`,
text: `${info.nick} genere une image: "${cleanPrompt}"${noMatch ? ` (sans: ${negativePrompt})` : ""}...`,
});
// Send real progress from ComfyUI WebSocket (including preview frames)
@@ -1751,9 +1760,9 @@ async function handleImagineCommand({
id: crypto.randomUUID(),
device: "gpu",
priority: "normal",
label: `/imagine "${imagePrompt.slice(0, 40)}"`,
label: `/imagine "${cleanPrompt.slice(0, 40)}"`,
vramMB: VRAM_BUDGETS.comfyui,
execute: () => generateImage(imagePrompt, { onProgress }),
execute: () => generateImage(cleanPrompt, { onProgress }),
});
if (!result) {
broadcast(info.channel, { type: "system", text: "\u{1F3A8} Generation echouee \u2014 verifiez ComfyUI" });
@@ -1763,19 +1772,19 @@ async function handleImagineCommand({
broadcast(info.channel, {
type: "image",
nick: info.nick,
text: `[Image generee: "${imagePrompt}" seed:${result.seed}${result.model ? ` model:${result.model}` : ""}${result.lora ? ` lora:${result.lora}` : ""}]`,
text: `[Image generee: "${cleanPrompt}" seed:${result.seed}${result.model ? ` model:${result.model}` : ""}${result.lora ? ` lora:${result.lora}` : ""}]`,
imageData: result.imageBase64,
imageMime: "image/png",
} as OutboundMessage);
saveImage({ base64: result.imageBase64, prompt: imagePrompt, nick: info.nick, channel: info.channel, seed: result.seed }).catch(() => {});
saveImage({ base64: result.imageBase64, prompt: cleanPrompt, nick: info.nick, channel: info.channel, seed: result.seed }).catch(() => {});
logChatMessage({
ts: new Date().toISOString(),
channel: info.channel,
nick: info.nick,
type: "system",
text: `[Image generee: "${imagePrompt}"]`,
text: `[Image generee: "${cleanPrompt}"]`,
});
} catch (err) {
send(ws, {
+18
View File
@@ -87,6 +87,24 @@ export function createInfoCommandHandler(deps: CommandHandlerDeps) {
switch (cmd) {
case "/status":
case "/stats": {
// Per-persona stats (Lot 428): /stats <persona_nick>
const statsTarget = parts[1]?.toLowerCase();
if (statsTarget) {
const persona = getPersonas().find(p => p.nick.toLowerCase() === statsTarget);
if (persona) {
const mem = await loadPersonaMemory(persona.nick);
const lines = [
`=== Stats: ${persona.nick} ===`,
` Modele: ${persona.model}`,
` Memoire: ${mem.facts.length} faits retenus`,
mem.facts.length > 0 ? ` Faits: ${mem.facts.slice(0, 5).join(", ")}` : "",
mem.summary ? ` Resume: ${mem.summary}` : "",
mem.lastUpdated ? ` Derniere MAJ: ${mem.lastUpdated}` : "",
].filter(Boolean);
send(ws, { type: "system", text: lines.join("\n") });
return;
}
}
// ---------------------------------------------------------------
// Comprehensive system dashboard — all fetches run in parallel
// ---------------------------------------------------------------
+35 -5
View File
@@ -25,6 +25,21 @@ import type { ChatLogEntry, ChatPersona, OutboundMessage, PersonaMemory } from "
const DEBUG = process.env.NODE_ENV !== "production" || process.env.DEBUG === "1";
// ---------------------------------------------------------------------------
// Clean markdown/formatting from text before sending to TTS (Lot 425)
// ---------------------------------------------------------------------------
function cleanForTTS(text: string): string {
return text
.replace(/\*\*(.+?)\*\*/g, "$1")
.replace(/\*(.+?)\*/g, "$1")
.replace(/`(.+?)`/g, "$1")
.replace(/@(\w+)/g, "$1")
.replace(/[#*_~|>]/g, "")
.replace(/\s{2,}/g, " ")
.trim();
}
// ---------------------------------------------------------------------------
// Sentence-boundary detection for streaming TTS chunking
// ---------------------------------------------------------------------------
@@ -201,6 +216,21 @@ export function createConversationRouter(deps: ConversationRouterDeps): Conversa
const ttsQueues = new Map<string, Promise<void>>();
let totalMessageCount = 0;
// Memory cache (30s TTL) to avoid N+1 reloads in inter-persona chains
const memoryCache = new Map<string, { data: PersonaMemory; ts: number }>();
const MEMORY_CACHE_TTL = 30_000;
async function cachedLoadMemory(nick: string): Promise<PersonaMemory> {
const cached = memoryCache.get(nick);
if (cached && Date.now() - cached.ts < MEMORY_CACHE_TTL) return cached.data;
const data = await loadPersonaMemory(nick);
memoryCache.set(nick, { data, ts: Date.now() });
if (memoryCache.size > 50) {
const oldest = [...memoryCache.entries()].sort((a, b) => a[1].ts - b[1].ts)[0];
if (oldest) memoryCache.delete(oldest[0]);
}
return data;
}
function enqueueTTS(nick: string, text: string, channel: string): void {
if (process.env.TTS_ENABLED !== "1" || !isTTSAvailable()) return;
if (text.length < 10) return;
@@ -303,7 +333,7 @@ export function createConversationRouter(deps: ConversationRouterDeps): Conversa
let memory: PersonaMemory = preloadedMemory || { nick: persona.nick, facts: [], summary: "", lastUpdated: "" };
if (!preloadedMemory) {
try {
memory = await loadPersonaMemory(persona.nick);
memory = await cachedLoadMemory(persona.nick);
} catch (err) {
trackError("memory_load", err, { persona: persona.nick });
}
@@ -338,7 +368,7 @@ export function createConversationRouter(deps: ConversationRouterDeps): Conversa
const { sentences, remaining } = extractSentences(sentenceBuffer);
sentenceBuffer = remaining;
for (const sentence of sentences) {
enqueueTTS(persona.nick, sentence, channel);
enqueueTTS(persona.nick, cleanForTTS(sentence), channel);
sentenceTTSFired = true;
}
};
@@ -368,14 +398,14 @@ export function createConversationRouter(deps: ConversationRouterDeps): Conversa
// Flush remaining sentence buffer to TTS
if (sentenceBuffer.trim().length >= 10) {
enqueueTTS(persona.nick, sentenceBuffer.trim(), channel);
enqueueTTS(persona.nick, cleanForTTS(sentenceBuffer.trim()), channel);
sentenceTTSFired = true;
}
sentenceBuffer = "";
// Fallback: if no sentences were detected during streaming, send full text
if (!sentenceTTSFired && process.env.TTS_ENABLED === "1" && isTTSAvailable()) {
enqueueTTS(persona.nick, fullText, channel);
enqueueTTS(persona.nick, cleanForTTS(fullText), channel);
}
// Auto-generate image if Picasso responds
@@ -528,7 +558,7 @@ export function createConversationRouter(deps: ConversationRouterDeps): Conversa
// Pre-warm persona memory IN PARALLEL with enrichment (saves 10-30ms per persona)
const [enrichedText, ...memories] = await Promise.all([
buildConversationInput(text, channel, getContextString, rag),
...responders.map(p => loadPersonaMemory(p.nick).catch(() => ({ nick: p.nick, facts: [], summary: "", lastUpdated: "" } as PersonaMemory))),
...responders.map(p => cachedLoadMemory(p.nick).catch(() => ({ nick: p.nick, facts: [], summary: "", lastUpdated: "" } as PersonaMemory))),
]);
// Inject pre-loaded memories into persona response