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:
@@ -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
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
{
|
||||
"topics": {},
|
||||
"pins": {},
|
||||
"savedAt": "2026-03-21T22:07:08.487Z"
|
||||
"savedAt": "2026-03-21T22:17:08.574Z"
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
"nick": "Pharmacius",
|
||||
"facts": [],
|
||||
"summary": "",
|
||||
"lastUpdated": "2026-03-21T22:09:33.070Z"
|
||||
"lastUpdated": "2026-03-21T22:20:02.284Z"
|
||||
}
|
||||
+9
-4
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.` });
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user