import "server-only"; import { sql } from "../db"; import { newId } from "../ids"; import type { Part, Role } from "../ai/types"; export interface ConversationRow { id: string; project_id: string | null; title: string; model_id: string | null; settings: Record; pinned: boolean; archived_at: Date | null; parent_id: string | null; branch_message_id: string | null; mode: string; message_count: number; total_cost_usd: number; last_message_at: Date | null; created_at: Date; updated_at: Date; } export interface MessageRow { id: string; conversation_id: string; parent_id: string | null; role: Role; parts: Part[]; text: string; model_id: string | null; provider: string | null; settings: Record | null; routing: Record | null; usage: Record | null; cost_usd: number | null; latency_ms: number | null; ttft_ms: number | null; status: string; error: string | null; active: boolean; created_at: Date; } export const partsToText = (parts: Part[]) => parts.filter(p => p.type === "text").map(p => (p as { text: string }).text).join("\n"); export async function createConversation(opts: { projectId?: string | null; modelId?: string | null; title?: string; settings?: Record; mode?: string; parentId?: string | null; branchMessageId?: string | null }): Promise { const id = newId("conv"); const rows = await sql`INSERT INTO conversations (id, project_id, title, model_id, settings, mode, parent_id, branch_message_id) VALUES (${id}, ${opts.projectId ?? null}, ${opts.title ?? "Nouvelle conversation"}, ${opts.modelId ?? null}, ${sql.json((opts.settings ?? {}) as never)}, ${opts.mode ?? "chat"}, ${opts.parentId ?? null}, ${opts.branchMessageId ?? null}) RETURNING *`; return rows[0]; } export async function getConversation(id: string): Promise { const rows = await sql`SELECT * FROM conversations WHERE id = ${id}`; return rows[0] ?? null; } export async function listConversations(opts: { projectId?: string | null; archived?: boolean; limit?: number; q?: string } = {}) { const rows = await sql<(ConversationRow & { project_name: string | null })[]>` SELECT c.*, p.name AS project_name FROM conversations c LEFT JOIN projects p ON p.id = c.project_id WHERE (${opts.archived ?? false} AND c.archived_at IS NOT NULL OR NOT ${opts.archived ?? false} AND c.archived_at IS NULL) AND (${opts.projectId === undefined} OR c.project_id IS NOT DISTINCT FROM ${opts.projectId ?? null}) AND (${!opts.q} OR c.tsv @@ plainto_tsquery('simple', ${opts.q ?? ""}) OR c.title ILIKE ${"%" + (opts.q ?? "") + "%"}) ORDER BY c.pinned DESC, coalesce(c.last_message_at, c.created_at) DESC LIMIT ${opts.limit ?? 200}`; return rows; } export async function updateConversation(id: string, patch: Partial> & { archived?: boolean }) { await sql`UPDATE conversations SET title = COALESCE(${patch.title ?? null}, title), project_id = CASE WHEN ${patch.project_id !== undefined} THEN ${patch.project_id ?? null} ELSE project_id END, model_id = COALESCE(${patch.model_id ?? null}, model_id), pinned = COALESCE(${patch.pinned ?? null}, pinned), settings = COALESCE(${patch.settings ? sql.json(patch.settings as never) : null}, settings), archived_at = CASE WHEN ${patch.archived === undefined} THEN archived_at WHEN ${patch.archived ?? false} THEN now() ELSE NULL END WHERE id = ${id}`; } export async function deleteConversation(id: string) { await sql`DELETE FROM conversations WHERE id = ${id}`; } /** Active thread (the path currently displayed). */ export async function getActiveMessages(conversationId: string): Promise { return sql`SELECT * FROM messages WHERE conversation_id = ${conversationId} AND active = true ORDER BY created_at ASC`; } export async function getAllMessages(conversationId: string): Promise { return sql`SELECT * FROM messages WHERE conversation_id = ${conversationId} ORDER BY created_at ASC`; } export async function getMessage(id: string): Promise { const rows = await sql`SELECT * FROM messages WHERE id = ${id}`; return rows[0] ?? null; } export async function insertMessage(m: { conversationId: string; parentId?: string | null; role: Role; parts: Part[]; modelId?: string | null; provider?: string | null; settings?: Record | null; routing?: Record | null; status?: string; id?: string }): Promise { const id = m.id ?? newId("msg"); const rows = await sql`INSERT INTO messages (id, conversation_id, parent_id, role, parts, text, model_id, provider, settings, routing, status) VALUES (${id}, ${m.conversationId}, ${m.parentId ?? null}, ${m.role}, ${sql.json(m.parts as never)}, ${partsToText(m.parts)}, ${m.modelId ?? null}, ${m.provider ?? null}, ${m.settings ? sql.json(m.settings as never) : null}, ${m.routing ? sql.json(m.routing as never) : null}, ${m.status ?? "complete"}) RETURNING *`; await sql`UPDATE conversations SET message_count = message_count + 1, last_message_at = now() WHERE id = ${m.conversationId}`; return rows[0]; } export async function updateMessage(id: string, patch: { parts?: Part[]; status?: string; error?: string | null; usage?: Record | null; costUsd?: number | null; latencyMs?: number | null; ttftMs?: number | null; modelId?: string | null; routing?: Record | null }) { await sql`UPDATE messages SET parts = COALESCE(${patch.parts ? sql.json(patch.parts as never) : null}, parts), text = COALESCE(${patch.parts ? partsToText(patch.parts) : null}, text), status = COALESCE(${patch.status ?? null}, status), error = CASE WHEN ${patch.error !== undefined} THEN ${patch.error ?? null} ELSE error END, usage = COALESCE(${patch.usage ? sql.json(patch.usage as never) : null}, usage), cost_usd = COALESCE(${patch.costUsd ?? null}, cost_usd), latency_ms = COALESCE(${patch.latencyMs ?? null}, latency_ms), ttft_ms = COALESCE(${patch.ttftMs ?? null}, ttft_ms), model_id = COALESCE(${patch.modelId ?? null}, model_id), routing = COALESCE(${patch.routing ? sql.json(patch.routing as never) : null}, routing) WHERE id = ${id}`; if (patch.costUsd) await sql`UPDATE conversations c SET total_cost_usd = (SELECT coalesce(sum(cost_usd),0) FROM messages WHERE conversation_id = c.id) WHERE c.id = (SELECT conversation_id FROM messages WHERE id = ${id})`; } /** Deactivate a message and every active message after it (used by edit / regenerate). */ export async function deactivateFrom(conversationId: string, messageId: string, inclusive: boolean) { await sql`UPDATE messages SET active = false WHERE conversation_id = ${conversationId} AND active = true AND created_at ${inclusive ? sql`>=` : sql`>`} (SELECT created_at FROM messages WHERE id = ${messageId})`; } /** Switch the active path to the sibling `targetId` (same parent) and its descendants chain. */ export async function activateBranch(conversationId: string, targetId: string) { const target = await getMessage(targetId); if (!target) return; await deactivateFrom(conversationId, targetId, true); // re-activate target then follow the latest child chain let cur: MessageRow | null = target; while (cur) { await sql`UPDATE messages SET active = true WHERE id = ${cur.id}`; const kids: MessageRow[] = await sql`SELECT * FROM messages WHERE parent_id = ${cur.id} ORDER BY created_at DESC LIMIT 1`; cur = kids[0] ?? null; } } export async function siblings(messageId: string): Promise<{ id: string; active: boolean; created_at: Date }[]> { const m = await getMessage(messageId); if (!m) return []; return sql`SELECT id, active, created_at FROM messages WHERE conversation_id = ${m.conversation_id} AND parent_id IS NOT DISTINCT FROM ${m.parent_id} AND role = ${m.role} ORDER BY created_at ASC`; } /** Fork a conversation at `messageId` (inclusive) into a new conversation. */ export async function branchConversation(conversationId: string, messageId: string): Promise { const src = await getConversation(conversationId); if (!src) throw new Error("conversation not found"); const msgs = await getActiveMessages(conversationId); const idx = msgs.findIndex(m => m.id === messageId); const keep = idx >= 0 ? msgs.slice(0, idx + 1) : msgs; const conv = await createConversation({ projectId: src.project_id, modelId: src.model_id, title: `${src.title} (branche)`, settings: src.settings, parentId: src.id, branchMessageId: messageId }); let parent: string | null = null; for (const m of keep) { const row = await insertMessage({ conversationId: conv.id, parentId: parent, role: m.role, parts: m.parts, modelId: m.model_id, provider: m.provider, settings: m.settings, routing: m.routing }); await sql`UPDATE messages SET usage = ${m.usage ? sql.json(m.usage as never) : null}, cost_usd = ${m.cost_usd}, latency_ms = ${m.latency_ms}, ttft_ms = ${m.ttft_ms} WHERE id = ${row.id}`; parent = row.id; } return conv; }