Files
kxkm_clown/apps/api/src/app.ts
T
L'électron rare 491e6ad8a4 chantier-1+2: voice cloning XTTS-v2 + génération musicale /compose
## Chantier 1 — Voice Cloning
- scripts/xtts_clone.py: Coqui XTTS-v2 voice cloning (6s sample → voix)
- ws-chat.ts: synthesizeTTS avec détection sample → XTTS ou Piper
- PersonaDetail.tsx: upload/delete sample vocal
- app.ts: endpoints voice-sample (POST/GET/DELETE)

## Chantier 2 — Génération Musicale
- scripts/compose_music.py: ACE-Step fallback MusicGen
- /compose command (5min timeout, broadcast audio base64)
- Chat.tsx: player <audio controls> inline
- Eno persona (musique générative/ambient)
- Pharmacius: routing @Eno

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 09:00:01 +01:00

1292 lines
45 KiB
TypeScript

import net from "node:net";
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import express, { type Request, type Response, type NextFunction } from "express";
import {
asApiData,
createId,
isUserRole,
type AuthSession,
type Permission,
type UserRole,
} from "@kxkm/core";
import { createSessionRecord, hasPermission, extractSessionId, generateSessionToken, validateLoginInput } from "@kxkm/auth";
import { buildChatChannels } from "@kxkm/chat-domain";
import {
PERSONA_SEED_CATALOG,
clonePersona,
createFeedback,
createProposal,
extractDPOPairs,
type PersonaFeedbackRecord,
type PersonaProposalRecord,
type PersonaRecord,
type PersonaSourceRecord,
} from "@kxkm/persona-domain";
import {
createNodeEngineOverview,
createNodeGraph,
createNodeRun,
type ModelRegistryRecord,
type NodeGraphRecord,
type NodeRunRecord,
} from "@kxkm/node-engine";
import {
loadDatabaseConfig,
createPostgresPool,
runMigrations,
createSessionRepo,
createPersonaRepo,
createNodeGraphRepo,
createNodeRunRepo,
createPersonaSourceRepo,
createPersonaFeedbackRepo,
createPersonaProposalRepo,
} from "@kxkm/storage";
const COOKIE_NAME = "kxkm_v2_session";
function localStoreFiles() {
const storeDir = path.resolve(process.cwd(), process.env.KXKM_LOCAL_DATA_DIR || "data/v2-local");
return {
personas: path.join(storeDir, "personas.json"),
personaSources: path.join(storeDir, "persona-sources.json"),
personaFeedback: path.join(storeDir, "persona-feedback.json"),
personaProposals: path.join(storeDir, "persona-proposals.json"),
};
}
interface SessionRequest extends Request {
session?: AuthSession;
}
async function readJson<T>(filePath: string, fallback: T): Promise<T> {
try {
const raw = await readFile(filePath, "utf8");
return JSON.parse(raw) as T;
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code !== "ENOENT") {
console.warn(`[kxkm/api] failed to read ${filePath}: ${e.message}`);
}
return fallback;
}
}
async function writeJson(filePath: string, data: unknown): Promise<void> {
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
}
// ---------------------------------------------------------------------------
// In-memory repo adapters (fallback when DATABASE_URL is not set)
// ---------------------------------------------------------------------------
function createInMemorySessionRepo() {
const sessions = new Map<string, AuthSession>();
return {
async create(input: { username: string; role: UserRole; expiresAt?: string }): Promise<AuthSession> {
const id = generateSessionToken();
const session = createSessionRecord({ username: input.username, role: input.role }, id);
sessions.set(id, session);
return session;
},
async findById(id: string): Promise<AuthSession | null> {
return sessions.get(id) || null;
},
async deleteById(id: string): Promise<void> {
sessions.delete(id);
},
async deleteExpired(): Promise<number> {
const now = Date.now();
let count = 0;
for (const [id, session] of sessions) {
if (new Date(session.expiresAt).getTime() < now) {
sessions.delete(id);
count++;
}
}
return count;
},
};
}
function createInMemoryPersonaRepo() {
const files = localStoreFiles();
const personas = new Map<string, PersonaRecord>();
let loaded = false;
async function ensureLoaded(): Promise<void> {
if (loaded) return;
loaded = true;
const saved = await readJson<PersonaRecord[]>(files.personas, []);
if (saved.length > 0) {
for (const persona of saved) {
personas.set(persona.id, { ...persona });
}
return;
}
for (const seed of PERSONA_SEED_CATALOG) {
personas.set(seed.id, clonePersona(seed));
}
await writeJson(files.personas, [...personas.values()]);
}
async function persist(): Promise<void> {
await writeJson(files.personas, [...personas.values()]);
}
return {
async list(): Promise<PersonaRecord[]> {
await ensureLoaded();
return [...personas.values()];
},
async findById(id: string): Promise<PersonaRecord | null> {
await ensureLoaded();
return personas.get(id) || null;
},
async upsert(persona: PersonaRecord): Promise<PersonaRecord> {
await ensureLoaded();
personas.set(persona.id, { ...persona });
await persist();
return { ...persona };
},
async seedCatalog(catalog: PersonaRecord[]): Promise<void> {
await ensureLoaded();
let changed = false;
for (const p of catalog) {
if (!personas.has(p.id)) {
personas.set(p.id, clonePersona(p));
changed = true;
}
}
if (changed) {
await persist();
}
},
};
}
function createInMemoryNodeGraphRepo() {
const graphs = new Map<string, NodeGraphRecord>([
["starter_local_eval", createNodeGraph("starter_local_eval", "Prototype local evaluation graph")],
]);
// Fix: createNodeGraph generates a random id, so re-set with desired id
const starterGraph: NodeGraphRecord = { id: "starter_local_eval", name: "starter_local_eval", description: "Prototype local evaluation graph" };
graphs.set(starterGraph.id, starterGraph);
return {
async list(): Promise<NodeGraphRecord[]> {
return [...graphs.values()];
},
async findById(id: string): Promise<NodeGraphRecord | null> {
return graphs.get(id) || null;
},
async create(graph: NodeGraphRecord): Promise<NodeGraphRecord> {
graphs.set(graph.id, { ...graph });
return { ...graph };
},
async update(id: string, patch: Partial<NodeGraphRecord>): Promise<NodeGraphRecord | null> {
const graph = graphs.get(id);
if (!graph) return null;
if (patch.name !== undefined) graph.name = patch.name;
if (patch.description !== undefined) graph.description = patch.description;
return { ...graph };
},
};
}
function createInMemoryNodeRunRepo() {
const runs = new Map<string, NodeRunRecord>();
return {
async list(): Promise<NodeRunRecord[]> {
return [...runs.values()];
},
async findById(id: string): Promise<NodeRunRecord | null> {
return runs.get(id) || null;
},
async create(run: NodeRunRecord): Promise<NodeRunRecord> {
runs.set(run.id, { ...run });
return { ...run };
},
async updateStatus(id: string, status: NodeRunRecord["status"]): Promise<void> {
const run = runs.get(id);
if (run) run.status = status;
},
async requestCancel(id: string): Promise<void> {
const run = runs.get(id);
if (run) run.status = "cancelled";
},
async deleteOlderThan(date: string): Promise<number> {
const threshold = new Date(date).getTime();
let count = 0;
for (const [id, run] of runs) {
if (
["completed", "failed", "cancelled"].includes(run.status) &&
new Date(run.createdAt).getTime() < threshold
) {
runs.delete(id);
count++;
}
}
return count;
},
};
}
function createInMemoryPersonaSourceRepo() {
const files = localStoreFiles();
const sources = new Map<string, PersonaSourceRecord>();
let loaded = false;
async function ensureLoaded(): Promise<void> {
if (loaded) return;
loaded = true;
const saved = await readJson<Record<string, PersonaSourceRecord>>(files.personaSources, {});
for (const [personaId, source] of Object.entries(saved)) {
sources.set(personaId, { ...source });
}
}
async function persist(): Promise<void> {
const objectView = Object.fromEntries(sources.entries());
await writeJson(files.personaSources, objectView);
}
return {
async findByPersonaId(personaId: string): Promise<PersonaSourceRecord | null> {
await ensureLoaded();
return sources.get(personaId) || null;
},
async upsert(source: PersonaSourceRecord): Promise<PersonaSourceRecord> {
await ensureLoaded();
sources.set(source.personaId, { ...source });
await persist();
return { ...source };
},
};
}
function createInMemoryPersonaFeedbackRepo() {
const files = localStoreFiles();
const feedback = new Map<string, PersonaFeedbackRecord[]>();
let loaded = false;
async function ensureLoaded(): Promise<void> {
if (loaded) return;
loaded = true;
const saved = await readJson<PersonaFeedbackRecord[]>(files.personaFeedback, []);
for (const record of saved) {
const list = feedback.get(record.personaId) || [];
list.push({ ...record });
feedback.set(record.personaId, list);
}
}
async function persist(): Promise<void> {
const all = [...feedback.values()].flat();
await writeJson(files.personaFeedback, all);
}
return {
async listByPersonaId(personaId: string): Promise<PersonaFeedbackRecord[]> {
await ensureLoaded();
return feedback.get(personaId) || [];
},
async create(record: PersonaFeedbackRecord): Promise<PersonaFeedbackRecord> {
await ensureLoaded();
const list = feedback.get(record.personaId) || [];
list.push({ ...record });
feedback.set(record.personaId, list);
await persist();
return { ...record };
},
};
}
function createInMemoryPersonaProposalRepo() {
const files = localStoreFiles();
const proposals = new Map<string, PersonaProposalRecord[]>();
let loaded = false;
async function ensureLoaded(): Promise<void> {
if (loaded) return;
loaded = true;
const saved = await readJson<PersonaProposalRecord[]>(files.personaProposals, []);
for (const record of saved) {
const list = proposals.get(record.personaId) || [];
list.push({ ...record });
proposals.set(record.personaId, list);
}
}
async function persist(): Promise<void> {
const all = [...proposals.values()].flat();
await writeJson(files.personaProposals, all);
}
return {
async listByPersonaId(personaId: string): Promise<PersonaProposalRecord[]> {
await ensureLoaded();
return proposals.get(personaId) || [];
},
async create(record: PersonaProposalRecord): Promise<PersonaProposalRecord> {
await ensureLoaded();
const list = proposals.get(record.personaId) || [];
list.push({ ...record });
proposals.set(record.personaId, list);
await persist();
return { ...record };
},
async markApplied(id: string): Promise<void> {
await ensureLoaded();
for (const list of proposals.values()) {
const proposal = list.find((p) => p.id === id);
if (proposal) {
proposal.applied = true;
await persist();
return;
}
}
},
};
}
// ---------------------------------------------------------------------------
// Repo interface types (union of Postgres and in-memory)
// ---------------------------------------------------------------------------
type SessionRepo = ReturnType<typeof createInMemorySessionRepo>;
type PersonaRepo = ReturnType<typeof createInMemoryPersonaRepo>;
type GraphRepo = ReturnType<typeof createInMemoryNodeGraphRepo>;
type RunRepo = ReturnType<typeof createInMemoryNodeRunRepo>;
type SourceRepo = ReturnType<typeof createInMemoryPersonaSourceRepo>;
type FeedbackRepo = ReturnType<typeof createInMemoryPersonaFeedbackRepo>;
type ProposalRepo = ReturnType<typeof createInMemoryPersonaProposalRepo>;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const modelRegistry: ModelRegistryRecord[] = [
{ id: "qwen2.5:14b", label: "Qwen 2.5 14B", runtime: "local_gpu" },
{ id: "mistral:7b", label: "Mistral 7B", runtime: "local_cpu" },
{ id: "mythalion:latest", label: "Mythalion", runtime: "local_gpu" },
];
function readRouteParam(value: string | string[] | undefined): string {
return Array.isArray(value) ? value[0] || "" : value || "";
}
function escapeForHtml(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function setSessionCookie(res: Response, sessionId: string): void {
res.setHeader("Set-Cookie", `${COOKIE_NAME}=${sessionId}; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`);
}
function clearSessionCookie(res: Response): void {
res.setHeader("Set-Cookie", `${COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`);
}
// ---------------------------------------------------------------------------
// Persona sub-store default helper
// ---------------------------------------------------------------------------
function defaultPersonaSource(personaId: string, personaName: string): PersonaSourceRecord {
return {
personaId,
subjectName: personaName || personaId,
summary: "Aucune source structuree pour le moment.",
references: [],
};
}
// ---------------------------------------------------------------------------
// Simulated run transition (dev/demo purposes)
// ---------------------------------------------------------------------------
function enqueueRunTransition(runId: string, runRepo: RunRepo): void {
const timer1 = setTimeout(async () => {
const run = await runRepo.findById(runId);
if (!run || run.status !== "queued") {
clearTimeout(timer2);
return;
}
await runRepo.updateStatus(runId, "running");
}, 50);
const timer2 = setTimeout(async () => {
const run = await runRepo.findById(runId);
if (!run || run.status !== "running") return;
await runRepo.updateStatus(runId, "completed");
}, 150);
}
// ---------------------------------------------------------------------------
// Subnet helpers (mirrors V1 network-policy.js, simplified for single subnet)
// ---------------------------------------------------------------------------
interface ParsedSubnet {
version: number;
mask: bigint;
network: bigint;
}
function normalizeIp(value: string): string {
let ip = value.trim();
const zoneIndex = ip.indexOf("%");
if (zoneIndex >= 0) ip = ip.slice(0, zoneIndex);
if (ip.startsWith("::ffff:") && net.isIP(ip.slice(7)) === 4) {
return ip.slice(7);
}
return ip;
}
function ipv4ToBigInt(ip: string): bigint {
return ip.split(".").reduce((r, o) => (r << 8n) + BigInt(Number.parseInt(o, 10)), 0n);
}
function ipv6ToBigInt(ip: string): bigint {
const parts = ip.split("::");
const head = parts[0] ? parts[0].split(":").filter(Boolean) : [];
const tail = parts[1] ? parts[1].split(":").filter(Boolean) : [];
const missing = 8 - (head.length + tail.length);
const groups = [...head, ...Array.from({ length: missing }, () => "0"), ...tail];
return groups.reduce((r, g) => (r << 16n) + BigInt(Number.parseInt(g || "0", 16)), 0n);
}
function parseSubnet(entry: string): ParsedSubnet | null {
const raw = entry.trim();
if (!raw) return null;
const [addressPart, prefixPart] = raw.split("/");
const address = normalizeIp(addressPart);
const version = net.isIP(address);
if (!version) return null;
const totalBits = version === 4 ? 32 : 128;
const prefix = prefixPart === undefined ? totalBits : Number.parseInt(prefixPart, 10);
if (!Number.isInteger(prefix) || prefix < 0 || prefix > totalBits) return null;
const bits = BigInt(totalBits);
const hostBits = BigInt(totalBits - prefix);
const allOnes = (1n << bits) - 1n;
const mask = prefix === 0 ? 0n : (allOnes << hostBits) & allOnes;
const value = version === 4 ? ipv4ToBigInt(address) : ipv6ToBigInt(address);
return { version, mask, network: value & mask };
}
function isIpInSubnet(ip: string, subnet: ParsedSubnet): boolean {
const normalized = normalizeIp(ip);
const version = net.isIP(normalized);
if (!version || version !== subnet.version) return false;
const value = version === 4 ? ipv4ToBigInt(normalized) : ipv6ToBigInt(normalized);
return (value & subnet.mask) === subnet.network;
}
// ---------------------------------------------------------------------------
// App factory
// ---------------------------------------------------------------------------
export async function createApp(): Promise<{ app: express.Express; personaRepo: PersonaRepo }> {
let sessionRepo: SessionRepo;
let personaRepo: PersonaRepo;
let graphRepo: GraphRepo;
let runRepo: RunRepo;
let sourceRepo: SourceRepo;
let feedbackRepo: FeedbackRepo;
let proposalRepo: ProposalRepo;
let storageMode: "postgres" | "memory";
const isProduction = (process.env.NODE_ENV || "").toLowerCase() === "production";
if (!process.env.DATABASE_URL && isProduction) {
throw new Error("DATABASE_URL is required when NODE_ENV=production");
}
if (process.env.DATABASE_URL) {
const dbConfig = loadDatabaseConfig();
const pool = createPostgresPool(dbConfig);
await runMigrations(pool);
sessionRepo = createSessionRepo(pool);
personaRepo = createPersonaRepo(pool);
graphRepo = createNodeGraphRepo(pool);
runRepo = createNodeRunRepo(pool);
sourceRepo = createPersonaSourceRepo(pool);
feedbackRepo = createPersonaFeedbackRepo(pool);
proposalRepo = createPersonaProposalRepo(pool);
// Seed personas from catalog
await personaRepo.seedCatalog(PERSONA_SEED_CATALOG.map(clonePersona));
storageMode = "postgres";
} else {
console.warn("[kxkm/api] DATABASE_URL not set — using local persona storage + in-memory runtime stores");
sessionRepo = createInMemorySessionRepo();
personaRepo = createInMemoryPersonaRepo();
graphRepo = createInMemoryNodeGraphRepo();
runRepo = createInMemoryNodeRunRepo();
sourceRepo = createInMemoryPersonaSourceRepo();
feedbackRepo = createInMemoryPersonaFeedbackRepo();
proposalRepo = createInMemoryPersonaProposalRepo();
storageMode = "memory";
}
const app = express();
app.use(express.json());
// Session middleware
app.use((req: SessionRequest, _res: Response, next: NextFunction) => {
const sessionId = extractSessionId(req as unknown as { cookies?: Record<string, string>; headers?: Record<string, string> });
if (!sessionId) {
next();
return;
}
sessionRepo.findById(sessionId).then((session) => {
if (session) req.session = session;
next();
}).catch(next);
});
function requireSession(req: SessionRequest, res: Response, next: NextFunction): void {
if (!req.session) {
res.status(401).json({ ok: false, error: "session_required" });
return;
}
next();
}
function requirePermission(permission: Permission) {
return (req: SessionRequest, res: Response, next: NextFunction) => {
if (!req.session) {
res.status(401).json({ ok: false, error: "session_required" });
return;
}
if (!hasPermission(req.session.role, permission)) {
res.status(403).json({ ok: false, error: "permission_denied" });
return;
}
next();
};
}
// -----------------------------------------------------------------------
// Subnet gate — restrict /api/v2/admin/* when ADMIN_SUBNET is set
// -----------------------------------------------------------------------
if (process.env.ADMIN_SUBNET) {
const subnet = parseSubnet(process.env.ADMIN_SUBNET);
if (subnet) {
app.use("/api/v2/admin", (req: Request, res: Response, next: NextFunction) => {
const ip = normalizeIp(req.ip || req.socket?.remoteAddress || "");
if (!isIpInSubnet(ip, subnet)) {
res.status(403).json({ ok: false, error: "subnet_denied" });
return;
}
next();
});
}
}
// -----------------------------------------------------------------------
// Routes
// -----------------------------------------------------------------------
app.get("/api/v2/health", (_req, res) => {
res.json(asApiData({
app: "@kxkm/api",
storage: storageMode, // BUG-06 fix: don't leak DATABASE_URL
roles: ["admin", "editor", "operator", "viewer"] satisfies UserRole[],
}));
});
// Public status strip — no auth required
app.get("/api/v2/status", async (_req, res) => {
try {
const [personas, graphs, runs] = await Promise.all([
personaRepo.list(),
graphRepo.list(),
runRepo.list(),
]);
const activePersonas = personas.filter((p) => (p as unknown as { enabled?: boolean }).enabled !== false);
const runningRuns = runs.filter((r) => r.status === "running");
const queuedRuns = runs.filter((r) => r.status === "queued");
res.json(asApiData({
name: "KXKM_Clown",
version: "2.0.0",
storage: storageMode,
personas: { total: personas.length, active: activePersonas.length },
nodeEngine: {
graphs: graphs.length,
runs: runs.length,
running: runningRuns.length,
queued: queuedRuns.length,
},
}));
} catch (err) {
res.status(500).json({ ok: false, error: "status_error" });
}
});
app.post("/api/session/login", async (req, res) => {
try {
const input = validateLoginInput(req.body);
// SEC-04 fix: Never trust client-supplied role — assign viewer by default.
// Admin role requires ADMIN_TOKEN env var match.
let role: UserRole = "viewer";
const adminToken = process.env.ADMIN_TOKEN;
const tokenMatches = Boolean(adminToken && req.body?.token === adminToken);
if (input.role === "admin" && tokenMatches) {
role = "admin";
} else if (input.role === "operator" || input.role === "editor") {
// Allow non-admin elevated roles only if admin token is provided
if (tokenMatches) {
role = input.role as UserRole;
}
}
const session = await sessionRepo.create({ username: input.username, role });
setSessionCookie(res, session.id);
res.json(asApiData(session));
} catch {
res.status(400).json({ ok: false, error: "invalid_login_payload" });
}
});
app.get("/api/session", (req: SessionRequest, res) => {
if (!req.session) {
res.status(401).json({ ok: false, error: "session_required" });
return;
}
res.json(asApiData(req.session));
});
app.post("/api/session/logout", async (req: SessionRequest, res) => {
if (req.session) {
await sessionRepo.deleteById(req.session.id);
}
clearSessionCookie(res);
res.json(asApiData({ loggedOut: true }));
});
app.get("/api/chat/channels", requireSession, (_req, res) => {
res.json(asApiData(buildChatChannels(modelRegistry.map((model) => model.id))));
});
app.get("/api/personas", requireSession, async (_req, res) => {
const list = await personaRepo.list();
res.json(asApiData(list));
});
app.get("/api/personas/:id", requireSession, async (req, res) => {
const personaId = readRouteParam(req.params.id);
const persona = await personaRepo.findById(personaId);
if (!persona) {
res.status(404).json({ ok: false, error: "persona_not_found" });
return;
}
res.json(asApiData(persona));
});
app.post("/api/admin/personas", requirePermission("persona:write"), async (req: SessionRequest, res) => {
const name = String(req.body?.name || "").trim();
if (!name) {
res.status(400).json({ ok: false, error: "name_required" });
return;
}
const persona: PersonaRecord = {
id: createId("persona"),
name,
model: String(req.body?.model || "qwen3:8b"),
summary: String(req.body?.summary || ""),
editable: true,
enabled: req.body?.enabled !== undefined ? Boolean(req.body.enabled) : true,
};
await personaRepo.upsert(persona);
await feedbackRepo.create(
createFeedback(persona.id, "admin_edit", `Persona creee par ${req.session?.username || "unknown"}`),
);
res.status(201).json(asApiData(persona));
});
app.put("/api/admin/personas/:id", requirePermission("persona:write"), async (req: SessionRequest, res) => {
const personaId = readRouteParam(req.params.id);
const persona = await personaRepo.findById(personaId);
if (!persona) {
res.status(404).json({ ok: false, error: "persona_not_found" });
return;
}
persona.name = String(req.body?.name || persona.name);
persona.model = String(req.body?.model || persona.model);
persona.summary = String(req.body?.summary || persona.summary);
if (req.body?.enabled !== undefined) {
(persona as unknown as Record<string, unknown>).enabled = Boolean(req.body.enabled);
}
await personaRepo.upsert(persona);
await feedbackRepo.create(
createFeedback(persona.id, "admin_edit", `Persona editee par ${req.session?.username || "unknown"}`),
);
res.json(asApiData(persona));
});
app.post("/api/admin/personas/:id/toggle", requirePermission("persona:write"), async (req: SessionRequest, res) => {
const personaId = readRouteParam(req.params.id);
const persona = await personaRepo.findById(personaId);
if (!persona) {
res.status(404).json({ ok: false, error: "persona_not_found" });
return;
}
const enabled = req.body?.enabled !== undefined ? Boolean(req.body.enabled) : !(persona as unknown as { enabled?: boolean }).enabled;
(persona as unknown as Record<string, unknown>).enabled = enabled;
await personaRepo.upsert(persona);
await feedbackRepo.create(
createFeedback(persona.id, "admin_edit", `Persona ${enabled ? "activee" : "desactivee"} par ${req.session?.username || "unknown"}`),
);
res.json(asApiData(persona));
});
app.get("/api/admin/personas/:id/source", requirePermission("persona:read"), async (req, res) => {
const personaId = readRouteParam(req.params.id);
const persona = await personaRepo.findById(personaId);
const source = await sourceRepo.findByPersonaId(personaId);
res.json(asApiData(source || defaultPersonaSource(personaId, persona?.name || personaId)));
});
app.put("/api/admin/personas/:id/source", requirePermission("persona:write"), async (req, res) => {
const personaId = readRouteParam(req.params.id);
const source: PersonaSourceRecord = {
personaId,
subjectName: String(req.body?.subjectName || personaId),
summary: String(req.body?.summary || ""),
references: Array.isArray(req.body?.references) ? req.body.references.map(String) : [],
};
const saved = await sourceRepo.upsert(source);
res.json(asApiData(saved));
});
app.get("/api/admin/personas/:id/feedback", requirePermission("persona:read"), async (req, res) => {
const personaId = readRouteParam(req.params.id);
const list = await feedbackRepo.listByPersonaId(personaId);
res.json(asApiData(list));
});
app.get("/api/admin/personas/:id/proposals", requirePermission("persona:read"), async (req, res) => {
const personaId = readRouteParam(req.params.id);
const list = await proposalRepo.listByPersonaId(personaId);
res.json(asApiData(list));
});
app.post("/api/admin/personas/:id/reinforce", requirePermission("persona:write"), async (req: SessionRequest, res) => {
const personaId = readRouteParam(req.params.id);
const persona = await personaRepo.findById(personaId);
if (!persona) {
res.status(404).json({ ok: false, error: "persona_not_found" });
return;
}
const existingFeedback = await feedbackRepo.listByPersonaId(persona.id);
const suffix = existingFeedback.length ? " affinee par feedback" : " calibree par source";
const after = {
name: String(req.body?.name || persona.name),
model: String(req.body?.model || persona.model),
summary: String(req.body?.summary || `${persona.summary}${suffix}`),
};
const apply = Boolean(req.body?.apply);
const proposal = createProposal(persona, after, "reinforce_v2", apply);
if (apply) {
persona.name = after.name;
persona.model = after.model;
persona.summary = after.summary;
await personaRepo.upsert(persona);
}
const saved = await proposalRepo.create(proposal);
res.json(asApiData(saved));
});
app.post("/api/admin/personas/:id/revert", requirePermission("persona:write"), async (req, res) => {
const personaId = readRouteParam(req.params.id);
const persona = await personaRepo.findById(personaId);
const personaProposals = await proposalRepo.listByPersonaId(personaId);
const lastApplied = [...personaProposals].reverse().find((proposal) => proposal.applied);
if (!persona || !lastApplied) {
res.status(404).json({ ok: false, error: "proposal_not_found" });
return;
}
persona.name = lastApplied.before.name;
persona.model = lastApplied.before.model;
persona.summary = lastApplied.before.summary;
await personaRepo.upsert(persona);
res.json(asApiData(persona));
});
// Voice sample upload for XTTS-v2 cloning
app.post("/api/admin/personas/:id/voice-sample", requirePermission("persona:write"), async (req: SessionRequest, res) => {
const personaId = readRouteParam(req.params.id);
const persona = await personaRepo.findById(personaId);
if (!persona) {
res.status(404).json({ ok: false, error: "persona_not_found" });
return;
}
const audioB64 = req.body?.audio as string | undefined;
if (!audioB64 || typeof audioB64 !== "string") {
res.status(400).json({ ok: false, error: "audio_required (base64 field 'audio')" });
return;
}
// Decode and validate size (max 10 MB)
const buffer = Buffer.from(audioB64, "base64");
if (buffer.length > 10 * 1024 * 1024) {
res.status(400).json({ ok: false, error: "file_too_large (max 10 MB)" });
return;
}
const voiceSamplesDir = path.resolve(process.cwd(), "data", "voice-samples");
await mkdir(voiceSamplesDir, { recursive: true });
const sampleName = persona.name.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
const samplePath = path.join(voiceSamplesDir, `${sampleName}.wav`);
await writeFile(samplePath, buffer);
res.json({ ok: true, data: { personaId, samplePath: `data/voice-samples/${sampleName}.wav`, size: buffer.length } });
});
app.delete("/api/admin/personas/:id/voice-sample", requirePermission("persona:write"), async (req: SessionRequest, res) => {
const personaId = readRouteParam(req.params.id);
const persona = await personaRepo.findById(personaId);
if (!persona) {
res.status(404).json({ ok: false, error: "persona_not_found" });
return;
}
const sampleName = persona.name.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
const samplePath = path.resolve(process.cwd(), "data", "voice-samples", `${sampleName}.wav`);
try {
const { unlink } = await import("node:fs/promises");
await unlink(samplePath);
res.json({ ok: true, data: { deleted: true } });
} catch {
res.status(404).json({ ok: false, error: "sample_not_found" });
}
});
app.get("/api/admin/personas/:id/voice-sample", requirePermission("persona:read"), async (req, res) => {
const personaId = readRouteParam(req.params.id);
const persona = await personaRepo.findById(personaId);
if (!persona) {
res.status(404).json({ ok: false, error: "persona_not_found" });
return;
}
const sampleName = persona.name.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
const samplePath = path.resolve(process.cwd(), "data", "voice-samples", `${sampleName}.wav`);
try {
await stat(samplePath);
res.json({ ok: true, data: { hasVoiceSample: true, samplePath: `data/voice-samples/${sampleName}.wav` } });
} catch {
res.json({ ok: true, data: { hasVoiceSample: false } });
}
});
app.get("/api/admin/node-engine/overview", requirePermission("node_engine:read"), async (_req, res) => {
const allRuns = await runRepo.list();
const allGraphs = await graphRepo.list();
const overview = createNodeEngineOverview({
graphs: allGraphs.length,
models: modelRegistry.length,
queuedRuns: allRuns.filter((run) => run.status === "queued").length,
runningRuns: allRuns.filter((run) => run.status === "running").length,
});
res.json(asApiData(overview));
});
app.get("/api/admin/node-engine/graphs", requirePermission("node_engine:read"), async (_req, res) => {
const list = await graphRepo.list();
res.json(asApiData(list));
});
app.post("/api/admin/node-engine/graphs", requirePermission("node_engine:operate"), async (req, res) => {
const graph = createNodeGraph(
String(req.body?.name || "graph"),
String(req.body?.description || ""),
);
const created = await graphRepo.create(graph);
res.status(201).json(asApiData(created));
});
app.put("/api/admin/node-engine/graphs/:id", requirePermission("node_engine:operate"), async (req, res) => {
const graphId = readRouteParam(req.params.id);
const graph = await graphRepo.findById(graphId);
if (!graph) {
res.status(404).json({ ok: false, error: "graph_not_found" });
return;
}
const updated = await graphRepo.update(graphId, {
name: String(req.body?.name || graph.name),
description: String(req.body?.description || graph.description),
});
res.json(asApiData(updated));
});
app.post("/api/admin/node-engine/graphs/:id/run", requirePermission("node_engine:operate"), async (req, res) => {
const graphId = readRouteParam(req.params.id);
const graph = await graphRepo.findById(graphId);
if (!graph) {
res.status(404).json({ ok: false, error: "graph_not_found" });
return;
}
const run = createNodeRun(graphId, "queued");
const created = await runRepo.create(run);
if (!req.body?.hold) {
enqueueRunTransition(created.id, runRepo);
}
res.status(201).json(asApiData(created));
});
app.get("/api/admin/node-engine/runs/:id", requirePermission("node_engine:read"), async (req, res) => {
const runId = readRouteParam(req.params.id);
const run = await runRepo.findById(runId);
if (!run) {
res.status(404).json({ ok: false, error: "run_not_found" });
return;
}
res.json(asApiData(run));
});
app.post("/api/admin/node-engine/runs/:id/cancel", requirePermission("node_engine:operate"), async (req, res) => {
const runId = readRouteParam(req.params.id);
const run = await runRepo.findById(runId);
if (!run) {
res.status(404).json({ ok: false, error: "run_not_found" });
return;
}
await runRepo.updateStatus(runId, "cancelled");
res.json(asApiData({ ...run, status: "cancelled" }));
});
app.post("/api/v2/node-engine/runs/:id/cancel", requirePermission("node_engine:operate"), async (req, res) => {
const runId = readRouteParam(req.params.id);
const run = await runRepo.findById(runId);
if (!run) {
res.status(404).json({ ok: false, error: "run_not_found" });
return;
}
await runRepo.requestCancel(runId);
res.json({ ok: true });
});
app.get("/api/admin/node-engine/artifacts/:runId", requirePermission("node_engine:read"), (req, res) => {
const runId = readRouteParam(req.params.runId);
res.json(asApiData({
runId,
artifacts: [
{ id: createId("artifact"), label: "overview.json", storage: "filesystem" },
],
}));
});
app.get("/api/admin/node-engine/models", requirePermission("node_engine:read"), (_req, res) => {
res.json(asApiData(modelRegistry));
});
// -----------------------------------------------------------------------
// Analytics — aggregate chat log stats
// -----------------------------------------------------------------------
app.get("/api/v2/analytics", async (_req, res) => {
const logDir = path.join(process.cwd(), process.env.KXKM_LOCAL_DATA_DIR || "data", "chat-logs");
const stats = {
totalMessages: 0,
totalDays: 0,
personaMessages: {} as Record<string, number>,
userMessages: 0,
systemMessages: 0,
uploadsCount: 0,
messagesPerDay: [] as Array<{ date: string; count: number }>,
topPersonas: [] as Array<{ nick: string; count: number }>,
};
try {
await mkdir(logDir, { recursive: true });
const files = await readdir(logDir);
stats.totalDays = files.filter((f) => f.endsWith(".jsonl")).length;
for (const file of files.sort().reverse().slice(0, 30)) {
if (!file.endsWith(".jsonl")) continue;
const date = file.replace("v2-", "").replace(".jsonl", "");
const content = await readFile(path.join(logDir, file), "utf-8");
let dayCount = 0;
for (const line of content.split("\n")) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line);
stats.totalMessages++;
dayCount++;
if (entry.type === "message" && entry.nick) {
if (entry.nick.startsWith("user_")) {
stats.userMessages++;
} else {
stats.personaMessages[entry.nick] = (stats.personaMessages[entry.nick] || 0) + 1;
}
}
if (entry.type === "upload") stats.uploadsCount++;
} catch {}
}
stats.messagesPerDay.push({ date, count: dayCount });
}
stats.topPersonas = Object.entries(stats.personaMessages)
.map(([nick, count]) => ({ nick, count }))
.sort((a, b) => b.count - a.count);
} catch {}
res.json({ ok: true, data: stats });
});
// -----------------------------------------------------------------------
// Retention sweep — delete old completed/failed/cancelled runs
// -----------------------------------------------------------------------
app.post("/api/v2/admin/retention-sweep", requirePermission("node_engine:operate"), async (req, res) => {
const maxAgeDays = Number(req.body?.maxAgeDays) || 30;
const cutoff = new Date(Date.now() - maxAgeDays * 24 * 60 * 60 * 1000).toISOString();
const deleted = await runRepo.deleteOlderThan(cutoff);
res.json({ ok: true, deleted });
});
// -----------------------------------------------------------------------
// Export conversation as HTML
// -----------------------------------------------------------------------
app.get("/api/v2/export/html", requireSession, async (req: SessionRequest, res) => {
try {
const channel = readRouteParam(req.query?.channel as string || "general");
const personas = await personaRepo.list();
const personaMap = new Map(personas.map((p) => {
const rec = p as unknown as { id: string; nick?: string; name?: string };
return [rec.id, rec.nick || rec.name || rec.id] as const;
}));
// Build simple HTML export (session data placeholder — real impl would read from storage)
const html = `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>KXKM_Clown — Export #${escapeForHtml(channel)}</title>
<style>
body { font-family: monospace; background: #1a1a2e; color: #e0e0e0; padding: 20px; }
.msg { margin: 4px 0; } .nick { font-weight: bold; } .ts { color: #666; font-size: 0.85em; }
h1 { color: #16213e; background: #0f3460; padding: 8px 16px; border-radius: 4px; }
</style>
</head>
<body>
<h1>#${escapeForHtml(channel)} — exported ${new Date().toISOString()}</h1>
<p>Channel: <strong>#${escapeForHtml(channel)}</strong> | Personas: ${personas.length} | Storage: ${storageMode}</p>
<p><em>Full message history requires session storage integration.</em></p>
</body>
</html>`;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="kxkm-export-${channel}.html"`);
res.send(html);
} catch {
res.status(500).json({ ok: false, error: "export_error" });
}
});
// -----------------------------------------------------------------------
// Export DPO training pairs as JSONL
// -----------------------------------------------------------------------
app.get("/api/v2/export/dpo", requirePermission("persona:read"), async (req: SessionRequest, res) => {
try {
const filterPersonaId = req.query?.persona_id ? readRouteParam(req.query.persona_id as string) : null;
let personas = await personaRepo.list();
if (filterPersonaId) {
personas = personas.filter((p) => p.id === filterPersonaId);
if (personas.length === 0) {
res.status(404).json({ ok: false, error: "persona_not_found" });
return;
}
}
const allPairs: Array<{ prompt: string; chosen: string; rejected: string; persona_id: string }> = [];
for (const persona of personas) {
const feedback = await feedbackRepo.listByPersonaId(persona.id);
const pairs = extractDPOPairs(feedback, persona);
for (const pair of pairs) {
allPairs.push({
prompt: pair.prompt,
chosen: pair.chosen,
rejected: pair.rejected,
persona_id: pair.personaId,
});
}
}
res.setHeader("Content-Type", "application/x-ndjson");
res.setHeader("Content-Disposition", `attachment; filename="dpo-pairs-${new Date().toISOString().slice(0, 10)}.jsonl"`);
const lines = allPairs.map((pair) => JSON.stringify(pair));
res.send(lines.join("\n") + (lines.length ? "\n" : ""));
} catch {
res.status(500).json({ ok: false, error: "dpo_export_error" });
}
});
// -----------------------------------------------------------------------
// Chat history — browse past chat log files
// -----------------------------------------------------------------------
const chatLogDir = path.resolve(process.cwd(), process.env.KXKM_LOCAL_DATA_DIR || "data", "chat-logs");
app.get("/api/v2/chat/history", requireSession, async (_req, res) => {
try {
await mkdir(chatLogDir, { recursive: true });
const entries = await readdir(chatLogDir);
const jsonlFiles = entries.filter((f) => f.startsWith("v2-") && f.endsWith(".jsonl"));
const files: Array<{ date: string; lines: number; size: number }> = [];
for (const filename of jsonlFiles) {
const dateMatch = filename.match(/^v2-(\d{4}-\d{2}-\d{2})\.jsonl$/);
if (!dateMatch) continue;
const filePath = path.join(chatLogDir, filename);
const fileStat = await stat(filePath);
const content = await readFile(filePath, "utf8");
const lineCount = content.trim() ? content.trim().split("\n").length : 0;
files.push({ date: dateMatch[1], lines: lineCount, size: fileStat.size });
}
files.sort((a, b) => b.date.localeCompare(a.date));
res.json(asApiData({ files }));
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code === "ENOENT") {
res.json(asApiData({ files: [] }));
return;
}
res.status(500).json({ ok: false, error: "chat_history_error" });
}
});
app.get("/api/v2/chat/search", requireSession, async (req, res) => {
const query = String(req.query.q || "").toLowerCase();
if (!query || query.length < 2) {
return res.json({ results: [], query: "" });
}
const limit = Math.min(Number(req.query.limit) || 50, 200);
const results: Array<{ date: string; ts: string; nick: string; text: string; type: string }> = [];
try {
await mkdir(chatLogDir, { recursive: true });
const files = await readdir(chatLogDir);
// Sort files descending (newest first)
files.sort().reverse();
for (const file of files) {
if (!file.endsWith(".jsonl")) continue;
if (results.length >= limit) break;
const dateMatch = file.match(/^v2-(\d{4}-\d{2}-\d{2})\.jsonl$/);
if (!dateMatch) continue;
const date = dateMatch[1];
const content = await readFile(path.join(chatLogDir, file), "utf-8");
for (const line of content.split("\n")) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line);
const text = String(entry.text || "").toLowerCase();
const nick = String(entry.nick || "").toLowerCase();
if (text.includes(query) || nick.includes(query)) {
results.push({
date,
ts: entry.ts || "",
nick: entry.nick || "",
text: entry.text || "",
type: entry.type || "message",
});
if (results.length >= limit) break;
}
} catch {}
}
}
} catch {}
res.json({ results, query, total: results.length });
});
app.get("/api/v2/chat/history/:date", requireSession, async (req, res) => {
try {
const date = readRouteParam(req.params.date);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
res.status(400).json({ ok: false, error: "invalid_date_format" });
return;
}
const filePath = path.join(chatLogDir, `v2-${date}.jsonl`);
let content: string;
try {
content = await readFile(filePath, "utf8");
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code === "ENOENT") {
res.status(404).json({ ok: false, error: "log_not_found" });
return;
}
throw err;
}
const allLines = content.trim().split("\n").filter(Boolean);
const limit = Math.min(Math.max(Number(req.query?.limit) || 200, 1), 1000);
const offset = Math.max(Number(req.query?.offset) || 0, 0);
const sliced = allLines.slice(offset, offset + limit);
const messages = sliced.map((line) => {
try {
return JSON.parse(line);
} catch {
return { text: line, type: "raw" };
}
});
res.json(asApiData({ messages, total: allLines.length, limit, offset }));
} catch {
res.status(500).json({ ok: false, error: "chat_history_error" });
}
});
return { app, personaRepo };
}