import "server-only"; import { sql, getSetting } from "../db"; import { newId } from "../ids"; import { adapterFor, estimateCost, rankFor, blendedPrice, type Part } from "../ai"; import { listModels } from "../ai/registry"; import { recordUsage } from "../usage"; import { saveMemoryTool } from "../tools/memory"; import { openaiEmbed } from "../ai/openai"; import { vec } from "../db"; import { recordUsage as recordUsageEv } from "../usage"; /** Embed message texts for semantic chat search (text-embedding-3-small, verified). */ export async function embedMessages(ids: string[]) { if (!ids.length) return; const rows = await sql<{ id: string; text: string }[]>`SELECT id, text FROM messages WHERE id = ANY(${ids}) AND length(text) > 20 AND id NOT IN (SELECT message_id FROM message_embeddings)`; if (!rows.length) return; try { const r = await openaiEmbed(rows.map(x => x.text.slice(0, 8000))); for (let i = 0; i < rows.length; i++) await sql`INSERT INTO message_embeddings (message_id, embedding) VALUES (${rows[i].id}, ${vec(r.vectors[i])}::vector) ON CONFLICT DO NOTHING`; await recordUsageEv({ provider: "openai", modelId: "text-embedding-3-small", kind: "embedding", usage: { input: r.tokens, output: 0 }, costEstimated: r.tokens * 0.02 / 1e6 }); } catch { /* best effort */ } } import type { ConversationRow, MessageRow } from "./store"; /** Cheapest verified model that passed the json test — used for titles / memory extraction. */ export async function utilityModel() { const all = await listModels({ onlyChat: true, includeHidden: true }); // cheap (blended ≤ $1/M), passed the json test, then fastest — reasoning models burn their small output budget on hidden reasoning const ranked = rankFor("economy", all).filter(m => m.benchmark?.perTest?.json === "pass" && m.available && (blendedPrice(m) ?? 99) <= 1) .sort((a, b) => (a.benchmark?.p50LatencyMs ?? 1e9) - (b.benchmark?.p50LatencyMs ?? 1e9)); return ranked[0] ?? rankFor("economy", all)[0] ?? null; } async function quickCompletion(prompt: string, kind: string, maxTokens = 400): Promise { const model = await utilityModel(); if (!model) return null; const adapter = adapterFor(model.provider); let text = ""; let usage = { input: 0, output: 0 }; const t0 = Date.now(); for await (const ev of adapter.stream({ model, messages: [{ role: "user", parts: [{ type: "text", text: prompt }] }], settings: { maxOutputTokens: maxTokens, effort: "low", reasoningSummary: false }, loadAttachment: async () => null })) { if (ev.type === "text") text += ev.delta; else if (ev.type === "usage") usage = ev.usage; else if (ev.type === "error") return null; } await recordUsage({ provider: model.provider, modelId: model.id, kind, usage, costEstimated: estimateCost(model, usage), latencyMs: Date.now() - t0 }); return text; } export async function maybeTitle(conv: ConversationRow, thread: MessageRow[], answer: Part[]): Promise { if (conv.title !== "Nouvelle conversation" || thread.filter(m => m.role === "user").length > 2) return null; const user = thread.find(m => m.role === "user")?.text ?? ""; const ans = answer.filter(p => p.type === "text").map(p => (p as { text: string }).text).join("").slice(0, 600); const t = await quickCompletion(`Give a short title (max 6 words, same language as the exchange, no quotes, no trailing period) for this conversation.\n\nUser: ${user.slice(0, 800)}\nAssistant: ${ans}\n\nTitle:`, "title", 300); const title = t?.trim().split("\n")[0]?.replace(/^["«»\s]+|["«»\s.]+$/g, "").slice(0, 80); if (!title) return null; await sql`UPDATE conversations SET title = ${title} WHERE id = ${conv.id} AND title = 'Nouvelle conversation'`; return title; } export async function extractMemories(conv: ConversationRow, userMsg: MessageRow, answer: Part[], projectId: string | null) { const s = await getSetting<{ enabled: boolean; autoExtract: boolean }>("memory", { enabled: true, autoExtract: true }); if (!s.enabled || !s.autoExtract || userMsg.text.length < 40) return; const out = await quickCompletion(`You extract durable facts about the OWNER of a personal AI assistant from one exchange. Return ONLY a JSON array (possibly empty) of objects {"content": string, "category": "preference"|"writing"|"research"|"project"|"infrastructure"|"objective"|"concept"|"context"}. Rules: only stable, reusable facts about the owner (preferences, ongoing projects, their infrastructure, long-term goals, how they like answers). Never passwords, API keys, addresses, financial identifiers, health data, or facts about third parties. Never facts already obvious from a single question. Max 2 items. Write each fact in the owner's language, third person ("Le propriétaire …"). Exchange: USER: ${userMsg.text.slice(0, 3000)} ASSISTANT: ${answer.filter(p => p.type === "text").map(p => (p as { text: string }).text).join("").slice(0, 1500)} JSON:`, "memory", 300); if (!out) return; try { const arr = JSON.parse(out.replace(/```(?:json)?/g, "").trim()) as { content: string; category: string }[]; for (const it of (Array.isArray(arr) ? arr : []).slice(0, 2)) if (it?.content?.length > 10) await saveMemoryTool(it.content, it.category ?? "context", { conversationId: conv.id, messageId: userMsg.id, projectId }); } catch { /* ignore malformed */ } } const ARTIFACT_LANGS: Record = { html: "html", svg: "svg", mermaid: "mermaid", jsx: "react", tsx: "react", react: "react", python: "python", py: "python", typescript: "typescript", ts: "typescript", markdown: "markdown", md: "markdown" }; /** Create artifact rows for substantial fenced blocks (so they have history and can be saved to projects). */ export async function detectArtifacts(messageId: string, conversationId: string, projectId: string | null, parts: Part[]) { const text = parts.filter(p => p.type === "text").map(p => (p as { text: string }).text).join("\n"); const re = /```([\w+-]+)(?:[ \t]+([^\n]+))?\n([\s\S]*?)```/g; const created: { id: string; title: string; type: string; language: string }[] = []; let m: RegExpExecArray | null; let n = 0; while ((m = re.exec(text)) && n < 6) { const lang = m[1].toLowerCase(); const type = ARTIFACT_LANGS[lang]; const body = m[3]; if (!type || body.split("\n").length < 8) continue; const title = (m[2]?.trim() || firstTitle(body, type) || `${type} ${++n}`).slice(0, 120); const id = newId("art"); // version chaining: same title in the same conversation → new version const prev = await sql<{ root_id: string; version: number }[]>`SELECT root_id, version FROM artifacts WHERE conversation_id = ${conversationId} AND title = ${title} ORDER BY version DESC LIMIT 1`; const root = prev[0]?.root_id ?? id; const version = (prev[0]?.version ?? 0) + 1; await sql`INSERT INTO artifacts (id, conversation_id, message_id, project_id, root_id, version, type, language, title, content) VALUES (${id}, ${conversationId}, ${messageId}, ${projectId}, ${root}, ${version}, ${type}, ${lang}, ${title}, ${body})`; created.push({ id, title, type, language: lang }); } if (created.length) { parts.push(...created.map(c => ({ type: "artifact" as const, artifactId: c.id, title: c.title, artifactType: c.type, language: c.language }))); await sql`UPDATE messages SET parts = ${sql.json(parts as never)} WHERE id = ${messageId}`; } return created; } function firstTitle(body: string, type: string): string | null { if (type === "html") return body.match(/([^<]+)<\/title>/i)?.[1] ?? null; if (type === "markdown") return body.match(/^#\s+(.+)$/m)?.[1] ?? null; if (type === "react") return body.match(/(?:function|const)\s+([A-Z]\w+)/)?.[1] ?? null; if (type === "python") return body.match(/^#\s*(.+)$/m)?.[1] ?? body.match(/def\s+(\w+)/)?.[1] ?? null; return null; }