import "server-only"; import { sql } from "../db"; import { adapterFor, estimateCost, route, type RoutingMode, type AIModel, type Part, type UnifiedMessage, type Usage, type ChatSettings } from "../ai"; import { listModels, requireChatModel } from "../ai/registry"; import { readAttachment } from "../storage"; import { recordUsage, assertWithinBudget } from "../usage"; import { TOOLS, toolDefs, toolByName, type ToolContext } from "../tools"; import { buildSystemPrompt } from "./system"; import { getConversation, getActiveMessages, insertMessage, updateMessage, type MessageRow } from "./store"; import { newId } from "../ids"; import { maybeTitle, extractMemories, detectArtifacts, embedMessages } from "./post"; export type Emit = (event: string, data: unknown) => void; export interface RunOptions { conversationId: string; modelId?: string; // requested (manual) model routingMode?: RoutingMode; settings?: ChatSettings; toolsEnabled?: boolean; toolNames?: string[]; /** when regenerating, the assistant message to replace (its parent stays) */ parentMessageId: string; // the user message this reply answers signal?: AbortSignal; emit: Emit; approvedToolCallIds?: string[]; } const MAX_TOOL_ROUNDS = 8; export async function runAssistant(o: RunOptions): Promise<{ messageId: string; status: string }> { const conv = await getConversation(o.conversationId); if (!conv) throw Object.assign(new Error("Conversation introuvable"), { status: 404 }); const budget = await assertWithinBudget(); if (budget.warning) o.emit("warning", { message: budget.warning }); const history = await getActiveMessages(o.conversationId); const upTo = history.findIndex(m => m.id === o.parentMessageId); const thread = upTo >= 0 ? history.slice(0, upTo + 1) : history; const lastUser = [...thread].reverse().find(m => m.role === "user"); const queryText = lastUser?.text ?? ""; const hasImages = thread.some(m => m.parts.some(p => p.type === "image")); // ---- routing const mode: RoutingMode = o.routingMode ?? (o.modelId ? "manual" : "auto"); let model: AIModel; let routing: Record; if (mode === "manual") { model = await requireChatModel(o.modelId ?? conv.model_id ?? ""); routing = { mode, selected: model.id, reason: "Modèle choisi explicitement." }; } else { const all = await listModels({ onlyChat: true }); const approxTokens = Math.round(thread.reduce((s, m) => s + m.text.length, 0) / 3.5); const d = route(mode, all, o.modelId, queryText, { hasImages, approxTokens, wantsTools: o.toolsEnabled }); model = await requireChatModel(d.selected); routing = { ...d }; } const toolsOn = (o.toolsEnabled ?? true) && model.capabilities.tools === true; const projPerm = (conv.project_id ? ((await sql<{ tool_permissions: Record }[]>`SELECT tool_permissions FROM projects WHERE id = ${conv.project_id}`)[0]?.tool_permissions ?? {}) : {}) as Record; const allowedNames = TOOLS.map(t => t.name).filter(n => projPerm[n] !== false && (!o.toolNames || o.toolNames.includes(n))); const tools = toolsOn ? toolDefs(allowedNames) : []; // ---- attachments summary const atts = await sql<{ id: string; filename: string; mime: string; page_count: number | null; extracted_text: string | null }[]>`SELECT id, filename, mime, page_count, extracted_text FROM attachments WHERE conversation_id = ${o.conversationId} AND kind = 'upload' AND mime NOT LIKE 'image/%' ORDER BY created_at`; const attachmentsSummary = atts.length ? atts.map(a => `- ${a.filename} (${a.mime}${a.page_count ? `, ${a.page_count} p.` : ""}${a.extracted_text ? `, ${a.extracted_text.length.toLocaleString("fr-CA")} car.` : ""})`).join("\n") : undefined; const { system, memoriesUsed, project } = await buildSystemPrompt({ modelId: model.id, projectId: conv.project_id, query: queryText, attachmentsSummary, conversationSettings: conv.settings, toolsEnabled: toolsOn }); const settings: ChatSettings = { ...(conv.settings?.chat as ChatSettings | undefined), ...o.settings }; // ---- assistant message row (streaming) const assistant = await insertMessage({ conversationId: o.conversationId, parentId: o.parentMessageId, role: "assistant", parts: [], modelId: model.id, provider: model.provider, settings: settings as Record, routing, status: "streaming" }); o.emit("start", { messageId: assistant.id, model: model.id, provider: model.provider, routing, memoriesUsed: memoriesUsed.map(m => m.id) }); const parts: Part[] = []; const unified: UnifiedMessage[] = thread.map(m => ({ role: m.role, parts: inlineShortDocs(m.parts, atts) })); const loadAttachment = async (id: string) => readAttachment(id); const t0 = Date.now(); let ttft: number | undefined; let totalUsage: Usage = { input: 0, output: 0, cached: 0, reasoning: 0 }; let status = "complete"; let error: string | null = null; let lastFlush = 0; const flush = async (force = false) => { if (force || Date.now() - lastFlush > 700) { lastFlush = Date.now(); await updateMessage(assistant.id, { parts }); } }; const ctx: ToolContext = { conversationId: o.conversationId, projectId: conv.project_id, messageId: assistant.id, signal: o.signal, allow: new Set(o.approvedToolCallIds ?? []) }; try { for (let round = 0; round <= MAX_TOOL_ROUNDS; round++) { const adapter = adapterFor(model.provider); let text = ""; let reasoning = ""; const calls: { id: string; name: string; input: unknown }[] = []; let stop = "end_turn"; let stopDetails: unknown; let textPart: Extract | null = null; let reasoningPart: Extract | null = null; for await (const ev of adapter.stream({ model, system, messages: [...unified, ...(parts.length ? [{ role: "assistant" as const, parts }] : [])], tools, settings, signal: o.signal, loadAttachment })) { if (ev.type === "text") { if (ttft === undefined) ttft = Date.now() - t0; text += ev.delta; if (!textPart) { textPart = { type: "text", text: "" }; parts.push(textPart); } textPart.text = text; o.emit("text", { delta: ev.delta }); await flush(); } else if (ev.type === "reasoning") { reasoning += ev.delta; if (!reasoningPart) { reasoningPart = { type: "reasoning", text: "", summarized: true }; parts.splice(textPart ? parts.indexOf(textPart) : parts.length, 0, reasoningPart); } reasoningPart.text = reasoning; o.emit("reasoning", { delta: ev.delta }); } else if (ev.type === "tool_call") { calls.push(ev); } else if (ev.type === "usage") { totalUsage = { input: totalUsage.input + ev.usage.input, output: totalUsage.output + ev.usage.output, cached: (totalUsage.cached ?? 0) + (ev.usage.cached ?? 0), reasoning: (totalUsage.reasoning ?? 0) + (ev.usage.reasoning ?? 0) }; } else if (ev.type === "stop") { stop = ev.reason; stopDetails = ev.details; } else if (ev.type === "meta") { o.emit("meta", ev.data); if (ev.data.servedBy) routing.servedBy = ev.data.servedBy; } else if (ev.type === "error") { throw Object.assign(new Error(ev.message), { status: ev.status }); } } if (stop === "cancelled") { status = "cancelled"; break; } if (stop === "refusal") { o.emit("warning", { message: `Le modèle a refusé de répondre (${JSON.stringify(stopDetails ?? {})}).` }); } if (!calls.length) break; // ---- execute tools (parallel), append tool_call parts with results const pending: Part[] = []; for (const c of calls) { const tool = toolByName(c.name); const part: Extract = { type: "tool_call", id: c.id, name: c.name, input: c.input, status: "running" }; parts.push(part); pending.push(part); o.emit("tool_call", { id: c.id, name: c.name, input: c.input, status: tool ? (tool.permission === "approval" && !ctx.allow.has(c.id) ? "pending_approval" : "running") : "error" }); if (!tool) { part.status = "error"; part.error = `Unknown tool ${c.name}`; continue; } if (tool.permission === "approval" && !ctx.allow.has(c.id)) { part.status = "pending_approval"; continue; } } await flush(true); if (pending.some(p => p.type === "tool_call" && p.status === "pending_approval")) { status = "awaiting_approval"; break; } await Promise.all(pending.map(async p => { if (p.type !== "tool_call" || p.status !== "running") return; const tool = toolByName(p.name)!; const ts = Date.now(); const execId = newId("tx"); try { const out = await tool.run((p.input ?? {}) as Record, ctx); p.output = out; p.status = "ok"; p.durationMs = Date.now() - ts; if (p.name === "generate_image" && out && typeof out === "object" && "attachmentId" in out) { const gi: Part = { type: "generated_image", attachmentId: String((out as { attachmentId: string }).attachmentId), prompt: String((p.input as { prompt?: string })?.prompt ?? ""), model: String((out as { model?: string }).model ?? "") }; parts.push(gi); o.emit("part", gi); } } catch (e) { p.status = "error"; p.error = String((e as Error).message ?? e).slice(0, 500); p.durationMs = Date.now() - ts; } await sql`INSERT INTO tool_executions (id, conversation_id, message_id, tool, input, output, status, duration_ms) VALUES (${execId}, ${o.conversationId}, ${assistant.id}, ${p.name}, ${sql.json(p.input as never)}, ${sql.json((p.status === "ok" ? p.output : { error: p.error }) as never)}, ${p.status}, ${p.durationMs ?? null})`; o.emit("tool_result", { id: p.id, status: p.status, output: p.output, error: p.error, durationMs: p.durationMs }); })); await flush(true); if (round === MAX_TOOL_ROUNDS) { o.emit("warning", { message: "Nombre maximal d'appels d'outils atteint." }); } } } catch (e) { const err = e as Error & { status?: number }; if (o.signal?.aborted) status = "cancelled"; else { status = "error"; error = err.message; o.emit("error", { message: err.message, status: err.status }); } } const latency = Date.now() - t0; const cost = estimateCost(model, totalUsage); await updateMessage(assistant.id, { parts, status, error, usage: totalUsage as unknown as Record, costUsd: cost, latencyMs: latency, ttftMs: ttft ?? null, routing }); await recordUsage({ provider: model.provider, modelId: model.id, kind: conv.mode === "research" ? "research" : "chat", conversationId: o.conversationId, projectId: conv.project_id, messageId: assistant.id, usage: totalUsage, costEstimated: cost, latencyMs: latency, ttftMs: ttft, status: status === "error" ? "error" : status === "cancelled" ? "cancelled" : "ok", error }); o.emit("usage", { usage: totalUsage, costUsd: cost, latencyMs: latency, ttftMs: ttft ?? null, model: model.id }); // ---- post-processing (non-blocking for the client, but awaited so the SSE stays open briefly) const artifacts = await detectArtifacts(assistant.id, o.conversationId, conv.project_id, parts); if (artifacts.length) o.emit("artifacts", artifacts); void Promise.allSettled([ maybeTitle(conv, thread, parts).then(t => { if (t) o.emit("title", { title: t }); }), lastUser && status === "complete" ? extractMemories(conv, lastUser as MessageRow, parts, project?.id ?? null) : Promise.resolve(), embedMessages([lastUser?.id, assistant.id].filter((x): x is string => !!x)), ]).then(() => o.emit("done", { messageId: assistant.id, status })).catch(() => o.emit("done", { messageId: assistant.id, status })); return { messageId: assistant.id, status }; } /** Inline the extracted text of short documents directly in the user parts (long ones go through search_documents). */ function inlineShortDocs(parts: Part[], atts: { id: string; filename: string; extracted_text: string | null }[]): Part[] { return parts.flatMap((p): Part[] => { if (p.type !== "file") return [p]; const a = atts.find(x => x.id === p.attachmentId); if (a?.extracted_text && a.extracted_text.length <= 60_000 && p.mime !== "application/pdf") return [{ type: "text" as const, text: `\n${a.extracted_text}\n` }]; return [p]; }); }