From 8dd2394893b125206bf366a8fdef3ccdc70c6e04 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 10 Apr 2026 16:03:00 +0200 Subject: [PATCH] feat: implement admin AI chat route with SQL execution and database schema context --- src/app/api/admin/ai-chat/route.ts | 427 +++++++++++------------------ 1 file changed, 167 insertions(+), 260 deletions(-) diff --git a/src/app/api/admin/ai-chat/route.ts b/src/app/api/admin/ai-chat/route.ts index e8b192d..847015a 100644 --- a/src/app/api/admin/ai-chat/route.ts +++ b/src/app/api/admin/ai-chat/route.ts @@ -1,6 +1,5 @@ import { NextRequest } from "next/server"; import { auth } from "@/lib/auth"; -import { getDb } from "@/db"; import fs from "fs/promises"; import path from "path"; import { GoogleGenAI, Type } from "@google/genai"; @@ -32,199 +31,185 @@ async function getAiConfig(): Promise { } const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"; +const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr"; -// Tools available to the admin AI -const TOOLS = [ +// ─── Tool definitions ──────────────────────────────────────────────────────── + +const TOOLS_OPENROUTER = [ { type: "function", function: { - name: "execute_sql", - description: - "Execute a READ-ONLY SQL SELECT query on the CollectFlow PostgreSQL database. Use this to answer questions about data.", + name: "call_api", + description: "Appelle un endpoint de l'API FF Nancy pour récupérer des données (articles, stock, ventes, fournisseurs, commandes, etc.).", parameters: { type: "object", properties: { - query: { + endpoint: { type: "string", - description: - "A valid PostgreSQL SELECT query. Only SELECT statements are allowed.", + description: "Chemin de l'endpoint, ex: /api/articles?codefou=FOU001&limit=50 ou /api/stock/article/12345", }, }, - required: ["query"], - }, - }, - }, - { - type: "function", - function: { - name: "get_db_schema", - description: - "Returns the full database schema with table names, columns, and descriptions.", - parameters: { - type: "object", - properties: {}, - required: [], + required: ["endpoint"], }, }, }, ]; -const DB_SCHEMA = ` -# Schéma complet de la base de données CollectFlow (PostgreSQL) +const TOOLS_GOOGLE = [{ + functionDeclarations: [ + { + name: "call_api", + description: "Appelle un endpoint de l'API FF Nancy pour récupérer des données (articles, stock, ventes, fournisseurs, commandes, etc.).", + parameters: { + type: Type.OBJECT, + properties: { + endpoint: { + type: Type.STRING, + description: "Chemin de l'endpoint, ex: /api/articles?codefou=FOU001&limit=50 ou /api/stock/article/12345", + }, + }, + required: ["endpoint"], + }, + }, + ], +}]; -## Table : ventes_produits -Table principale. Contient l'historique des ventes par produit, par magasin, par mois. -Chaque ligne = 1 produit × 1 magasin × 1 mois. +// ─── API caller ─────────────────────────────────────────────────────────────── -Colonnes : -- id (serial PK) -- codein (varchar) — identifiant produit interne unique FF Nancy -- code_fournisseur (varchar) — code fournisseur (ex: "FOU001") -- nom_fournisseur (varchar) — nom complet du fournisseur -- libelle1 (varchar) — libellé principal du produit -- gtin (varchar) — code-barres EAN -- reference (varchar) — référence produit fournisseur -- colisage (numeric) — quantité par colis -- code_gamme (varchar) — classification actuelle : A, B, C, D, ou Z -- code_gamme_init (varchar) — classification d'origine à l'import -- code3 (varchar) — code sous-catégorie produit -- libelle3 (varchar) — libellé sous-catégorie -- magasin (varchar) — nom ou code magasin (ex: "Supermarché Est") -- code_magasin (varchar) — identifiant court magasin (ex: "001") -- annee (smallint) — année de la période (ex: 2024) -- mois (smallint) — mois de la période (1-12) -- periode (varchar) — format "YYYY-MM" (ex: "2024-03") -- quantite (numeric) — quantité vendue sur la période -- montant_mvt (numeric) — chiffre d'affaires HT en euros -- marge_mvt (numeric) — marge brute HT en euros -- prix_unitaire (numeric) — prix de vente unitaire HT (si disponible) -- imported_at (timestamp) — date d'import -- updated_at (timestamp) — dernière mise à jour +async function callFfApi(endpoint: string): Promise { + // Sécurité : uniquement GET, uniquement les endpoints documentés + const allowedPrefixes = [ + "/api/health", "/api/sync", "/api/schema", + "/api/articles", "/api/stock", "/api/fournisseurs", + "/api/commandes", "/api/mouvements", "/api/performance", + "/api/publicites", "/api/commandes-auto", + ]; + const isAllowed = allowedPrefixes.some(p => endpoint.startsWith(p)); + if (!isAllowed) { + return `Erreur: endpoint non autorisé — "${endpoint}". Utilise uniquement les endpoints documentés.`; + } -Requêtes utiles sur ventes_produits : -- Lister les fournisseurs distincts : SELECT DISTINCT code_fournisseur, nom_fournisseur FROM ventes_produits ORDER BY nom_fournisseur -- CA total par fournisseur : SELECT nom_fournisseur, SUM(montant_mvt) as ca FROM ventes_produits GROUP BY nom_fournisseur ORDER BY ca DESC -- Produits par gamme : SELECT code_gamme, COUNT(DISTINCT codein) FROM ventes_produits GROUP BY code_gamme -- Évolution mensuelle : SELECT periode, SUM(montant_mvt) as ca FROM ventes_produits GROUP BY periode ORDER BY periode -- Produits d'un fournisseur : SELECT DISTINCT codein, libelle1, code_gamme FROM ventes_produits WHERE code_fournisseur = '...' + try { + const url = `${FF_API_BASE}${endpoint}`; + const res = await fetch(url, { + headers: { "Content-Type": "application/json" }, + cache: "no-store", + }); -## Table : users -Utilisateurs de l'application CollectFlow. + if (!res.ok) { + return `Erreur API ${res.status}: ${await res.text()}`; + } -Colonnes : -- id (serial PK) -- username (varchar unique) — identifiant de connexion -- password_hash (text) — ⛔ NE JAMAIS EXPOSER CE CHAMP -- role (varchar) — 'admin' (accès total) ou 'user' (accès restreint) -- created_at (timestamp) + const data = await res.json(); + const text = JSON.stringify(data); -## Table : session_snapshots -Historique des sessions d'arbitrage de gamme sauvegardées par les utilisateurs. -Un snapshot = une session de travail où un utilisateur a modifié des classifications gamme. + // Tronquer si trop long (max 6000 chars) + if (text.length > 6000) { + return text.substring(0, 6000) + `\n...(tronqué, ${text.length} caractères total)`; + } + return text; + } catch (err) { + const msg = err instanceof Error ? err.message : "Erreur inconnue"; + return `Erreur réseau: ${msg}`; + } +} -Colonnes : -- id (serial PK) -- user_id (int FK → users.id) -- code_fournisseur (varchar) -- nom_fournisseur (varchar) -- magasin (varchar) -- changes (jsonb) — objet JSON : { "codein": { "before": "A", "after": "B" }, ... } -- summary_json (jsonb) — statistiques : { "total": N, "byGamme": {...}, ... } -- label (text) — nom donné au snapshot par l'utilisateur -- type (varchar) — 'snapshot' (sauvegarde manuelle) ou 'export' (export Excel/PDF) -- created_at (timestamp) +// ─── System prompt ──────────────────────────────────────────────────────────── -Requêtes utiles sur session_snapshots : -- Derniers snapshots : SELECT id, label, nom_fournisseur, magasin, type, created_at FROM session_snapshots ORDER BY created_at DESC LIMIT 20 -- Compter les changements d'un snapshot : SELECT id, label, jsonb_object_keys(changes) FROM session_snapshots -- Snapshots par utilisateur : SELECT u.username, COUNT(*) FROM session_snapshots s JOIN users u ON u.id = s.user_id GROUP BY u.username +const SYSTEM_PROMPT = `Tu es un assistant IA administrateur expert pour l'application CollectFlow, un outil de gestion de gammes produits et d'analyse des ventes pour des magasins de distribution alimentaire (Foirfouille Nancy). -## Table : ai_supplier_context -Contexte métier personnalisé par fournisseur, saisi par les managers pour guider l'IA d'analyse de gamme. +## Outil disponible +- \`call_api\` : appelle un endpoint GET de l'API FF Nancy et retourne les données JSON -Colonnes : -- code_fournisseur (varchar PK) -- context (text) — instructions et règles métier spécifiques au fournisseur -- updated_at (timestamp) +## API FF Nancy — Documentation complète +Base URL : ${FF_API_BASE} +Format des dates : YYYY-MM-DD +Identifiants articles : toujours \`no_id\` (entier interne) +Codes site : ex 000, 001, 002… -## Règles métier — Classification gamme (A/B/C/D/Z) -- **A** : Produit pilier — fort volume, fort CA, indispensable au rayon -- **B** : Bon produit — bonne rotation, CA correct -- **C** : Produit de performance — faible rotation mais maintenu pour compléter l'offre -- **D** : Produit saisonnier ou de niche — présence justifiée par événement ou spécificité -- **Z** : Produit en sortie de gamme — à déréférencer +### Santé / Infra +- GET /api/health — vérifie que l'API et PostgreSQL répondent +- GET /api/sync/status — état de la synchronisation en cours +- GET /api/schema/tables?search=nom — liste les tables PG +- GET /api/schema/tables/:nom — colonnes d'une table +- GET /api/schema/top?limit=10 — tables les plus peuplées -## Informations sur l'application CollectFlow -- Application web Next.js de gestion de gammes produits pour des magasins de distribution alimentaire -- Les données viennent d'une API externe (FF Nancy) synchronisée chaque nuit -- Plusieurs magasins peuvent être gérés, chacun a son propre assortiment -- Les utilisateurs arbitrent manuellement les gammes produit par fournisseur -- Les sessions sont sauvegardées comme snapshots pour historique et audit -`; +### Articles +- GET /api/articles?search=&codein=&ean=&codefou=&actif=1&page=1&limit=50 — liste avec PA, GTIN, fournisseur principal (max 500/page) +- GET /api/articles/:id — détail complet d'un article (no_id) +- GET /api/articles/:id/referentiel — fournisseurs, tarifs, codes EAN +- GET /api/articles/:id/mouvements — historique des mouvements +- GET /api/articles/:id/mensuel — CA mensuel de l'article -const SYSTEM_PROMPT = `Tu es un assistant IA administrateur expert pour l'application CollectFlow, un outil de gestion de gammes produits et d'analyse des ventes pour des magasins de distribution alimentaire. +Le champ **prix d'achat (PA)** est retourné directement dans GET /api/articles (champ \`pa\` ou \`prix_achat\`). +Pour un article précis : GET /api/articles/:id/referentiel pour voir tous les tarifs fournisseurs. -## Outils disponibles -- \`execute_sql\` : exécute une requête SELECT sur la base PostgreSQL -- \`get_db_schema\` : retourne le schéma complet de la base de données +### Stock +- GET /api/stock?site=001&page=1&limit=100 — stock global tous articles (max 1000/page) +- GET /api/stock/article/:id — stock par site pour un article (no_id) → contient aussi \`prmp\` (Prix de Revient Moyen Pondéré) +- GET /api/stock/site/:site — tous les articles d'un site donné +- GET /api/stock/article/:id/historique — historique stock de l'article +- GET /api/stock/article/:id/periode?dateDebut=&dateFin= — stock sur période +- GET /api/stock/valorisation — valeur totale du stock par site -Le schéma complet est déjà inclus dans ton contexte ci-dessous — utilise \`get_db_schema\` uniquement si tu as besoin de détails supplémentaires. +Le **PRMP** (prix réel moyen pondéré) est dans GET /api/stock/article/:id, champ \`prmp\` par site. -## Schéma de la base de données -${DB_SCHEMA} +### Fournisseurs +- GET /api/fournisseurs?search=nom — liste des fournisseurs (champs : code, nom) +- GET /api/fournisseurs/:code/articles — articles du fournisseur +- GET /api/fournisseurs/:code/commandes — commandes du fournisseur -## Règles SQL strictes -- N'utilise JAMAIS INSERT, UPDATE, DELETE, DROP, TRUNCATE, ALTER, CREATE -- N'expose JAMAIS le champ \`password_hash\` de la table \`users\` -- Toujours ajouter LIMIT (max 200 lignes par défaut, sauf agrégats) -- Pour les analyses larges, utilise des agrégats (SUM, COUNT, AVG, GROUP BY) -- Préfère des requêtes précises et ciblées plutôt que SELECT * -- Si une question nécessite plusieurs requêtes, enchaîne-les avec plusieurs appels \`execute_sql\` +### Commandes +- GET /api/commandes?dateDebut=&dateFin=&codefou= — liste des commandes fournisseurs +- GET /api/commandes/:noCommande — détail d'une commande + ses lignes +- GET /api/commandes/receptions/liste — liste des réceptions + +### Commandes automatiques +- GET /api/commandes-auto?site=&codefou= — résumé des propositions par fournisseur/site avec comparaison franco + Champs : franco_ht, montant_propo_ht, franco_atteint (OUI/NON/N/A), ecart_franco +- GET /api/commandes-auto/:codefou?site= — détail d'un fournisseur : résumé + liste des articles proposés avec stock et montant + +### Mouvements +- GET /api/mouvements/types — types de mouvements disponibles +- GET /api/mouvements/articles?dateDebut=&dateFin=&site= — mouvements par article +- GET /api/mouvements/entrees?dateDebut=&dateFin= — entrées marchandises +- GET /api/mouvements/regularisations?dateDebut=&dateFin= — régularisations inventaire +- GET /api/mouvements/reglements?dateDebut=&dateFin= — règlements +- GET /api/mouvements/synthese?dateDebut=&dateFin=&site= — synthèse des mouvements + +### Performance / CA +- GET /api/performance/ca?dateDebut=&dateFin=&site=&groupBy=mois — CA global (groupBy: jour ou mois) +- GET /api/performance/hitparade?dateDebut=&dateFin=&site=&limit=20&groupBy=ca — top articles (groupBy: qte, ca ou marge) +- GET /api/performance/ca/nomenclature?dateDebut=&dateFin=&site=&niveau= — CA par nomenclature +- GET /api/performance/ca/fournisseur?dateDebut=&dateFin=&site= — CA par fournisseur +- GET /api/performance/ca/gamme?dateDebut=&dateFin=&site= — CA par gamme (A/B/C/D/Z) +- GET /api/performance/dashboard — indicateurs résumés pour dashboard + +### Publicités +- GET /api/publicites?search=&site=&dateDebut=&dateFin=&page=1&limit=50 — liste des publicités +- GET /api/publicites/:code?site= — détail d'une publicité + +## Contexte métier CollectFlow +- Application de gestion de gammes pour magasins Foirfouille Nancy +- Gammes A/B/C/D/Z : A=pilier, B=bonne rotation, C=performance, D=saisonnier/niche, Z=sortie +- Les ventes sont suivies par magasin (site) et par mois +- Les utilisateurs arbitrent les classifications gamme par fournisseur ## Stratégie d'analyse -1. Commence toujours par explorer les données disponibles si tu ne connais pas les valeurs exactes (codes fournisseurs, codes magasins, périodes disponibles) -2. Adapte tes requêtes aux données réelles trouvées -3. Présente les résultats de façon lisible avec des tableaux, listes et titres +1. Si tu ne connais pas un code fournisseur ou un no_id, commence par chercher avec /api/fournisseurs?search= ou /api/articles?search= +2. Enchaîne plusieurs appels \`call_api\` si nécessaire pour compléter l'analyse +3. Pour le prix d'achat : utilise /api/articles?codefou=CODE (champ pa) ou /api/articles/:id/referentiel +4. Pour le PRMP (prix réel) : utilise /api/stock/article/:id (champ prmp par site) +5. Ajoute toujours \`limit\` pour éviter des réponses trop volumineuses (limit=20 à 100 selon le contexte) ## Format de réponse — IMPORTANT -- Réponds TOUJOURS en **Markdown** : utilise ## titres, **gras**, tableaux \`| col | col |\`, listes \`-\`, blocs de code \`\`\`sql +- Réponds TOUJOURS en **Markdown** : titres ##, **gras**, tableaux \`| col | col |\`, listes \`-\`, blocs de code - N'utilise JAMAIS de HTML (
, , ,
, etc.) - Réponds toujours en français - Sois précis, structuré et complet — ne tronque jamais ta réponse - Si les données sont nombreuses, résume et mets en avant les points clés`; -async function executeSql(query: string): Promise { - // Security: only allow SELECT statements - const normalizedQuery = query.trim().toUpperCase(); - if (!normalizedQuery.startsWith("SELECT") && !normalizedQuery.startsWith("WITH")) { - return "Erreur: Seules les requêtes SELECT sont autorisées."; - } - - // Block dangerous keywords - const blocked = ["INSERT", "UPDATE", "DELETE", "DROP", "TRUNCATE", "ALTER", "CREATE", "GRANT", "REVOKE"]; - for (const kw of blocked) { - if (new RegExp(`\\b${kw}\\b`).test(normalizedQuery)) { - return `Erreur: Le mot-clé ${kw} n'est pas autorisé.`; - } - } - - try { - const db = getDb(); - const result = await db.execute(query as any); - const rows = result.rows ?? []; - - if (rows.length === 0) return "Aucun résultat."; - - // Return as compact JSON (max 200 rows) - const limited = rows.slice(0, 200); - const truncated = rows.length > 200 ? ` (résultats tronqués à 200/${rows.length})` : ""; - - return JSON.stringify(limited, null, 2) + truncated; - } catch (err) { - const msg = err instanceof Error ? err.message : "Erreur inconnue"; - return `Erreur SQL: ${msg}`; - } -} +// ─── OpenRouter handler ─────────────────────────────────────────────────────── async function callOpenRouter( messages: object[], @@ -239,17 +224,14 @@ async function callOpenRouter( temperature: 0.3, max_tokens: 8192, }; - if (useTools) { - body.tools = TOOLS; + body.tools = TOOLS_OPENROUTER; body.tool_choice = "auto"; } - if (stream) { body.stream = true; body.stream_options = { include_usage: true }; } - return fetch(OPENROUTER_URL, { method: "POST", headers: { @@ -262,7 +244,8 @@ async function callOpenRouter( }); } -// Google AI Studio handler +// ─── Google AI handler ──────────────────────────────────────────────────────── + async function handleGoogleAi(messages: Array<{ role: string; content: string }>, config: AiConfig): Promise { if (!config.googleAiKey) { return new Response(JSON.stringify({ error: "No Google AI key configured" }), { status: 500 }); @@ -271,25 +254,6 @@ async function handleGoogleAi(messages: Array<{ role: string; content: string }> const model = config.googleAiModel ?? "gemini-2.0-flash"; const encoder = new TextEncoder(); - const googleTools = [{ - functionDeclarations: [ - { - name: "execute_sql", - description: "Execute a READ-ONLY SQL SELECT query on the CollectFlow PostgreSQL database.", - parameters: { - type: Type.OBJECT, - properties: { query: { type: Type.STRING, description: "A valid PostgreSQL SELECT query." } }, - required: ["query"], - }, - }, - { - name: "get_db_schema", - description: "Returns the full database schema with table names, columns, and descriptions.", - parameters: { type: Type.OBJECT, properties: {} }, - }, - ], - }]; - const stream = new ReadableStream({ async start(controller) { function send(event: object) { @@ -299,27 +263,24 @@ async function handleGoogleAi(messages: Array<{ role: string; content: string }> try { const ai = new GoogleGenAI({ apiKey: config.googleAiKey! }); - // Build full contents array for generateContent - const contents: Array<{ role: string; parts: Array<{ text: string }> }> = [ + const contents: Array<{ role: string; parts: any[] }> = [ ...messages.map((m) => ({ role: m.role === "assistant" ? "model" : "user", parts: [{ text: m.content }], })), ]; - // Agentic loop — streaming throughout - for (let round = 0; round < 8; round++) { + for (let round = 0; round < 10; round++) { const streamResponse = await ai.models.generateContentStream({ model, contents: contents as any, config: { systemInstruction: SYSTEM_PROMPT, - tools: googleTools as any, + tools: TOOLS_GOOGLE as any, maxOutputTokens: 8192, }, }); - // Collect stream, forwarding text chunks immediately let collectedText = ""; const collectedParts: any[] = []; @@ -329,28 +290,16 @@ async function handleGoogleAi(messages: Array<{ role: string; content: string }> collectedText += text; send({ type: "text", content: text }); } - // Collect parts for function calls const parts = chunk.candidates?.[0]?.content?.parts; if (parts) collectedParts.push(...parts); } - // Check for function calls in collected parts const fnCallParts = collectedParts.filter((p: any) => p.functionCall); - if (fnCallParts.length === 0) { - // Pure text response — done - break; - } + if (fnCallParts.length === 0) break; - // Had function calls — add model turn to history - if (collectedText) { - // Mixed text + function calls - contents.push({ role: "model", parts: collectedParts }); - } else { - contents.push({ role: "model", parts: fnCallParts }); - } + contents.push({ role: "model", parts: collectedText ? collectedParts : fnCallParts }); - // Execute tool calls const toolResultParts: Array<{ functionResponse: { name: string; response: { output: string } } }> = []; for (const part of fnCallParts) { const fn = part.functionCall as { name: string; args: Record }; @@ -358,10 +307,8 @@ async function handleGoogleAi(messages: Array<{ role: string; content: string }> send({ type: "tool_start", name: fn.name, args }); let result = ""; - if (fn.name === "execute_sql") { - result = await executeSql(args.query as string); - } else if (fn.name === "get_db_schema") { - result = DB_SCHEMA; + if (fn.name === "call_api") { + result = await callFfApi(args.endpoint as string); } else { result = `Outil inconnu: ${fn.name}`; } @@ -393,15 +340,15 @@ async function handleGoogleAi(messages: Array<{ role: string; content: string }> }); } +// ─── POST handler ───────────────────────────────────────────────────────────── + export async function POST(req: NextRequest) { - // Auth check const session = await auth(); if (!session || (session.user as any)?.role !== "admin") { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 403 }); } const { messages, model } = await req.json(); - const aiConfig = await getAiConfig(); if (aiConfig.provider === "google") { @@ -413,13 +360,11 @@ export async function POST(req: NextRequest) { return new Response(JSON.stringify({ error: "No OpenRouter API key configured" }), { status: 500 }); } - // Build message list with system prompt const fullMessages: object[] = [ { role: "system", content: SYSTEM_PROMPT }, ...messages, ]; - // SSE stream const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { @@ -433,71 +378,51 @@ export async function POST(req: NextRequest) { let totalCompletionTokens = 0; let directAnswer: string | null = null; - // Tool-calling loop (non-streaming for tool phase) - for (let round = 0; round < 8; round++) { + for (let round = 0; round < 10; round++) { const res = await callOpenRouter(currentMessages, model, apiKey, true, false); - if (!res.ok) { - const errText = await res.text(); - send({ type: "error", message: `OpenRouter error: ${errText}` }); + send({ type: "error", message: `OpenRouter error: ${await res.text()}` }); controller.close(); return; } const data = await res.json(); - const usage = data.usage; - if (usage) { - totalPromptTokens += usage.prompt_tokens ?? 0; - totalCompletionTokens += usage.completion_tokens ?? 0; + if (data.usage) { + totalPromptTokens += data.usage.prompt_tokens ?? 0; + totalCompletionTokens += data.usage.completion_tokens ?? 0; } - const choice = data.choices?.[0]; - const assistantMessage = choice?.message; - + const assistantMessage = data.choices?.[0]?.message; if (!assistantMessage) break; - currentMessages.push(assistantMessage); const toolCalls = assistantMessage.tool_calls; if (!toolCalls || toolCalls.length === 0) { - // Model gave a direct answer — capture it and skip the extra final call directAnswer = assistantMessage.content ?? ""; break; } - // Execute tool calls const toolResults: object[] = []; for (const tc of toolCalls) { const fnName = tc.function?.name; let args: Record = {}; - try { - args = JSON.parse(tc.function?.arguments ?? "{}"); - } catch { /* ignore */ } + try { args = JSON.parse(tc.function?.arguments ?? "{}"); } catch { /* ignore */ } send({ type: "tool_start", name: fnName, args }); let result = ""; - if (fnName === "execute_sql") { - result = await executeSql(args.query as string); - } else if (fnName === "get_db_schema") { - result = DB_SCHEMA; + if (fnName === "call_api") { + result = await callFfApi(args.endpoint as string); } else { result = `Outil inconnu: ${fnName}`; } send({ type: "tool_result", name: fnName, result: result.substring(0, 500) + (result.length > 500 ? "..." : "") }); - - toolResults.push({ - role: "tool", - tool_call_id: tc.id, - content: result, - }); + toolResults.push({ role: "tool", tool_call_id: tc.id, content: result }); } - currentMessages.push(...toolResults); } - // If model gave a direct answer in the loop, stream it character by character if (directAnswer !== null) { send({ type: "text", content: directAnswer }); send({ type: "usage", prompt_tokens: totalPromptTokens, completion_tokens: totalCompletionTokens }); @@ -506,22 +431,16 @@ export async function POST(req: NextRequest) { return; } - // Otherwise make a final streaming call after tool use + // Final streaming call after tool use const finalRes = await callOpenRouter(currentMessages, model, apiKey, false, true); - if (!finalRes.ok) { - const errText = await finalRes.text(); - send({ type: "error", message: `OpenRouter error: ${errText}` }); + send({ type: "error", message: `OpenRouter error: ${await finalRes.text()}` }); controller.close(); return; } - // Stream the response chunks const reader = finalRes.body?.getReader(); - if (!reader) { - controller.close(); - return; - } + if (!reader) { controller.close(); return; } const textDecoder = new TextDecoder(); let buffer = ""; @@ -530,26 +449,19 @@ export async function POST(req: NextRequest) { while (true) { const { done, value } = await reader.read(); if (done) break; - buffer += textDecoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; - for (const line of lines) { if (!line.startsWith("data: ")) continue; const raw = line.slice(6).trim(); if (raw === "[DONE]") continue; - try { const chunk = JSON.parse(raw); const delta = chunk.choices?.[0]?.delta?.content; - if (delta) { - send({ type: "text", content: delta }); - } - if (chunk.usage) { - finalUsage = chunk.usage; - } - } catch { /* ignore malformed */ } + if (delta) send({ type: "text", content: delta }); + if (chunk.usage) finalUsage = chunk.usage; + } catch { /* ignore */ } } } @@ -558,12 +470,7 @@ export async function POST(req: NextRequest) { totalCompletionTokens += finalUsage.completion_tokens ?? 0; } - send({ - type: "usage", - prompt_tokens: totalPromptTokens, - completion_tokens: totalCompletionTokens, - }); - + send({ type: "usage", prompt_tokens: totalPromptTokens, completion_tokens: totalCompletionTokens }); send({ type: "done" }); controller.close(); } catch (err) {