feat: implement admin AI chat route with SQL execution and database schema context

This commit is contained in:
Michael committed 2026-04-10 16:03:00 +02:00
1 parent 6e10364922
commit 8dd2394893
1 file changed
+167 -260
+167 -260
View File
@@ -1,6 +1,5 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { getDb } from "@/db";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
import { GoogleGenAI, Type } from "@google/genai"; import { GoogleGenAI, Type } from "@google/genai";
@@ -32,199 +31,185 @@ async function getAiConfig(): Promise<AiConfig> {
} }
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"; 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 // ─── Tool definitions ────────────────────────────────────────────────────────
const TOOLS = [
const TOOLS_OPENROUTER = [
{ {
type: "function", type: "function",
function: { function: {
name: "execute_sql", name: "call_api",
description: description: "Appelle un endpoint de l'API FF Nancy pour récupérer des données (articles, stock, ventes, fournisseurs, commandes, etc.).",
"Execute a READ-ONLY SQL SELECT query on the CollectFlow PostgreSQL database. Use this to answer questions about data.",
parameters: { parameters: {
type: "object", type: "object",
properties: { properties: {
query: { endpoint: {
type: "string", type: "string",
description: description: "Chemin de l'endpoint, ex: /api/articles?codefou=FOU001&limit=50 ou /api/stock/article/12345",
"A valid PostgreSQL SELECT query. Only SELECT statements are allowed.",
}, },
}, },
required: ["query"], required: ["endpoint"],
},
},
},
{
type: "function",
function: {
name: "get_db_schema",
description:
"Returns the full database schema with table names, columns, and descriptions.",
parameters: {
type: "object",
properties: {},
required: [],
}, },
}, },
}, },
]; ];
const DB_SCHEMA = ` const TOOLS_GOOGLE = [{
# Schéma complet de la base de données CollectFlow (PostgreSQL) 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 // ─── API caller ───────────────────────────────────────────────────────────────
Table principale. Contient l'historique des ventes par produit, par magasin, par mois.
Chaque ligne = 1 produit × 1 magasin × 1 mois.
Colonnes : async function callFfApi(endpoint: string): Promise<string> {
- id (serial PK) // Sécurité : uniquement GET, uniquement les endpoints documentés
- codein (varchar) — identifiant produit interne unique FF Nancy const allowedPrefixes = [
- code_fournisseur (varchar) — code fournisseur (ex: "FOU001") "/api/health", "/api/sync", "/api/schema",
- nom_fournisseur (varchar) — nom complet du fournisseur "/api/articles", "/api/stock", "/api/fournisseurs",
- libelle1 (varchar) — libellé principal du produit "/api/commandes", "/api/mouvements", "/api/performance",
- gtin (varchar) — code-barres EAN "/api/publicites", "/api/commandes-auto",
- reference (varchar) — référence produit fournisseur ];
- colisage (numeric) — quantité par colis const isAllowed = allowedPrefixes.some(p => endpoint.startsWith(p));
- code_gamme (varchar) — classification actuelle : A, B, C, D, ou Z if (!isAllowed) {
- code_gamme_init (varchar) — classification d'origine à l'import return `Erreur: endpoint non autorisé — "${endpoint}". Utilise uniquement les endpoints documentés.`;
- 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
Requêtes utiles sur ventes_produits : try {
- Lister les fournisseurs distincts : SELECT DISTINCT code_fournisseur, nom_fournisseur FROM ventes_produits ORDER BY nom_fournisseur const url = `${FF_API_BASE}${endpoint}`;
- CA total par fournisseur : SELECT nom_fournisseur, SUM(montant_mvt) as ca FROM ventes_produits GROUP BY nom_fournisseur ORDER BY ca DESC const res = await fetch(url, {
- Produits par gamme : SELECT code_gamme, COUNT(DISTINCT codein) FROM ventes_produits GROUP BY code_gamme headers: { "Content-Type": "application/json" },
- Évolution mensuelle : SELECT periode, SUM(montant_mvt) as ca FROM ventes_produits GROUP BY periode ORDER BY periode cache: "no-store",
- Produits d'un fournisseur : SELECT DISTINCT codein, libelle1, code_gamme FROM ventes_produits WHERE code_fournisseur = '...' });
## Table : users if (!res.ok) {
Utilisateurs de l'application CollectFlow. return `Erreur API ${res.status}: ${await res.text()}`;
}
Colonnes : const data = await res.json();
- id (serial PK) const text = JSON.stringify(data);
- 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)
## Table : session_snapshots // Tronquer si trop long (max 6000 chars)
Historique des sessions d'arbitrage de gamme sauvegardées par les utilisateurs. if (text.length > 6000) {
Un snapshot = une session de travail où un utilisateur a modifié des classifications gamme. 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 : // ─── System prompt ────────────────────────────────────────────────────────────
- 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)
Requêtes utiles sur session_snapshots : 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).
- 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
## Table : ai_supplier_context ## Outil disponible
Contexte métier personnalisé par fournisseur, saisi par les managers pour guider l'IA d'analyse de gamme. - \`call_api\` : appelle un endpoint GET de l'API FF Nancy et retourne les données JSON
Colonnes : ## API FF Nancy — Documentation complète
- code_fournisseur (varchar PK) Base URL : ${FF_API_BASE}
- context (text) — instructions et règles métier spécifiques au fournisseur Format des dates : YYYY-MM-DD
- updated_at (timestamp) Identifiants articles : toujours \`no_id\` (entier interne)
Codes site : ex 000, 001, 002…
## Règles métier — Classification gamme (A/B/C/D/Z) ### Santé / Infra
- **A** : Produit pilier — fort volume, fort CA, indispensable au rayon - GET /api/health — vérifie que l'API et PostgreSQL répondent
- **B** : Bon produit — bonne rotation, CA correct - GET /api/sync/status — état de la synchronisation en cours
- **C** : Produit de performance — faible rotation mais maintenu pour compléter l'offre - GET /api/schema/tables?search=nom — liste les tables PG
- **D** : Produit saisonnier ou de niche — présence justifiée par événement ou spécificité - GET /api/schema/tables/:nom — colonnes d'une table
- **Z** : Produit en sortie de gamme — à déréférencer - GET /api/schema/top?limit=10 — tables les plus peuplées
## Informations sur l'application CollectFlow ### Articles
- Application web Next.js de gestion de gammes produits pour des magasins de distribution alimentaire - GET /api/articles?search=&codein=&ean=&codefou=&actif=1&page=1&limit=50 — liste avec PA, GTIN, fournisseur principal (max 500/page)
- Les données viennent d'une API externe (FF Nancy) synchronisée chaque nuit - GET /api/articles/:id — détail complet d'un article (no_id)
- Plusieurs magasins peuvent être gérés, chacun a son propre assortiment - GET /api/articles/:id/referentiel — fournisseurs, tarifs, codes EAN
- Les utilisateurs arbitrent manuellement les gammes produit par fournisseur - GET /api/articles/:id/mouvements — historique des mouvements
- Les sessions sont sauvegardées comme snapshots pour historique et audit - 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 ### Stock
- \`execute_sql\` : exécute une requête SELECT sur la base PostgreSQL - GET /api/stock?site=001&page=1&limit=100 — stock global tous articles (max 1000/page)
- \`get_db_schema\` : retourne le schéma complet de la base de données - 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 ### Fournisseurs
${DB_SCHEMA} - 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 ### Commandes
- N'utilise JAMAIS INSERT, UPDATE, DELETE, DROP, TRUNCATE, ALTER, CREATE - GET /api/commandes?dateDebut=&dateFin=&codefou= — liste des commandes fournisseurs
- N'expose JAMAIS le champ \`password_hash\` de la table \`users\` - GET /api/commandes/:noCommande — détail d'une commande + ses lignes
- Toujours ajouter LIMIT (max 200 lignes par défaut, sauf agrégats) - GET /api/commandes/receptions/liste — liste des réceptions
- 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 * ### Commandes automatiques
- Si une question nécessite plusieurs requêtes, enchaîne-les avec plusieurs appels \`execute_sql\` - 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 ## 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) 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. Adapte tes requêtes aux données réelles trouvées 2. Enchaîne plusieurs appels \`call_api\` si nécessaire pour compléter l'analyse
3. Présente les résultats de façon lisible avec des tableaux, listes et titres 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 ## 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 (<div>, <table>, <b>, <br>, etc.) - N'utilise JAMAIS de HTML (<div>, <table>, <b>, <br>, etc.)
- Réponds toujours en français - Réponds toujours en français
- Sois précis, structuré et complet — ne tronque jamais ta réponse - 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`; - Si les données sont nombreuses, résume et mets en avant les points clés`;
async function executeSql(query: string): Promise<string> { // ─── OpenRouter handler ───────────────────────────────────────────────────────
// 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}`;
}
}
async function callOpenRouter( async function callOpenRouter(
messages: object[], messages: object[],
@@ -239,17 +224,14 @@ async function callOpenRouter(
temperature: 0.3, temperature: 0.3,
max_tokens: 8192, max_tokens: 8192,
}; };
if (useTools) { if (useTools) {
body.tools = TOOLS; body.tools = TOOLS_OPENROUTER;
body.tool_choice = "auto"; body.tool_choice = "auto";
} }
if (stream) { if (stream) {
body.stream = true; body.stream = true;
body.stream_options = { include_usage: true }; body.stream_options = { include_usage: true };
} }
return fetch(OPENROUTER_URL, { return fetch(OPENROUTER_URL, {
method: "POST", method: "POST",
headers: { 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<Response> { async function handleGoogleAi(messages: Array<{ role: string; content: string }>, config: AiConfig): Promise<Response> {
if (!config.googleAiKey) { if (!config.googleAiKey) {
return new Response(JSON.stringify({ error: "No Google AI key configured" }), { status: 500 }); 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 model = config.googleAiModel ?? "gemini-2.0-flash";
const encoder = new TextEncoder(); 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({ const stream = new ReadableStream({
async start(controller) { async start(controller) {
function send(event: object) { function send(event: object) {
@@ -299,27 +263,24 @@ async function handleGoogleAi(messages: Array<{ role: string; content: string }>
try { try {
const ai = new GoogleGenAI({ apiKey: config.googleAiKey! }); const ai = new GoogleGenAI({ apiKey: config.googleAiKey! });
// Build full contents array for generateContent const contents: Array<{ role: string; parts: any[] }> = [
const contents: Array<{ role: string; parts: Array<{ text: string }> }> = [
...messages.map((m) => ({ ...messages.map((m) => ({
role: m.role === "assistant" ? "model" : "user", role: m.role === "assistant" ? "model" : "user",
parts: [{ text: m.content }], parts: [{ text: m.content }],
})), })),
]; ];
// Agentic loop — streaming throughout for (let round = 0; round < 10; round++) {
for (let round = 0; round < 8; round++) {
const streamResponse = await ai.models.generateContentStream({ const streamResponse = await ai.models.generateContentStream({
model, model,
contents: contents as any, contents: contents as any,
config: { config: {
systemInstruction: SYSTEM_PROMPT, systemInstruction: SYSTEM_PROMPT,
tools: googleTools as any, tools: TOOLS_GOOGLE as any,
maxOutputTokens: 8192, maxOutputTokens: 8192,
}, },
}); });
// Collect stream, forwarding text chunks immediately
let collectedText = ""; let collectedText = "";
const collectedParts: any[] = []; const collectedParts: any[] = [];
@@ -329,28 +290,16 @@ async function handleGoogleAi(messages: Array<{ role: string; content: string }>
collectedText += text; collectedText += text;
send({ type: "text", content: text }); send({ type: "text", content: text });
} }
// Collect parts for function calls
const parts = chunk.candidates?.[0]?.content?.parts; const parts = chunk.candidates?.[0]?.content?.parts;
if (parts) collectedParts.push(...parts); if (parts) collectedParts.push(...parts);
} }
// Check for function calls in collected parts
const fnCallParts = collectedParts.filter((p: any) => p.functionCall); const fnCallParts = collectedParts.filter((p: any) => p.functionCall);
if (fnCallParts.length === 0) { if (fnCallParts.length === 0) break;
// Pure text response — done
break;
}
// Had function calls — add model turn to history contents.push({ role: "model", parts: collectedText ? collectedParts : fnCallParts });
if (collectedText) {
// Mixed text + function calls
contents.push({ role: "model", parts: collectedParts });
} else {
contents.push({ role: "model", parts: fnCallParts });
}
// Execute tool calls
const toolResultParts: Array<{ functionResponse: { name: string; response: { output: string } } }> = []; const toolResultParts: Array<{ functionResponse: { name: string; response: { output: string } } }> = [];
for (const part of fnCallParts) { for (const part of fnCallParts) {
const fn = part.functionCall as { name: string; args: Record<string, unknown> }; const fn = part.functionCall as { name: string; args: Record<string, unknown> };
@@ -358,10 +307,8 @@ async function handleGoogleAi(messages: Array<{ role: string; content: string }>
send({ type: "tool_start", name: fn.name, args }); send({ type: "tool_start", name: fn.name, args });
let result = ""; let result = "";
if (fn.name === "execute_sql") { if (fn.name === "call_api") {
result = await executeSql(args.query as string); result = await callFfApi(args.endpoint as string);
} else if (fn.name === "get_db_schema") {
result = DB_SCHEMA;
} else { } else {
result = `Outil inconnu: ${fn.name}`; 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) { export async function POST(req: NextRequest) {
// Auth check
const session = await auth(); const session = await auth();
if (!session || (session.user as any)?.role !== "admin") { if (!session || (session.user as any)?.role !== "admin") {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 403 }); return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 403 });
} }
const { messages, model } = await req.json(); const { messages, model } = await req.json();
const aiConfig = await getAiConfig(); const aiConfig = await getAiConfig();
if (aiConfig.provider === "google") { 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 }); return new Response(JSON.stringify({ error: "No OpenRouter API key configured" }), { status: 500 });
} }
// Build message list with system prompt
const fullMessages: object[] = [ const fullMessages: object[] = [
{ role: "system", content: SYSTEM_PROMPT }, { role: "system", content: SYSTEM_PROMPT },
...messages, ...messages,
]; ];
// SSE stream
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const stream = new ReadableStream({ const stream = new ReadableStream({
async start(controller) { async start(controller) {
@@ -433,71 +378,51 @@ export async function POST(req: NextRequest) {
let totalCompletionTokens = 0; let totalCompletionTokens = 0;
let directAnswer: string | null = null; let directAnswer: string | null = null;
// Tool-calling loop (non-streaming for tool phase) for (let round = 0; round < 10; round++) {
for (let round = 0; round < 8; round++) {
const res = await callOpenRouter(currentMessages, model, apiKey, true, false); const res = await callOpenRouter(currentMessages, model, apiKey, true, false);
if (!res.ok) { if (!res.ok) {
const errText = await res.text(); send({ type: "error", message: `OpenRouter error: ${await res.text()}` });
send({ type: "error", message: `OpenRouter error: ${errText}` });
controller.close(); controller.close();
return; return;
} }
const data = await res.json(); const data = await res.json();
const usage = data.usage; if (data.usage) {
if (usage) { totalPromptTokens += data.usage.prompt_tokens ?? 0;
totalPromptTokens += usage.prompt_tokens ?? 0; totalCompletionTokens += data.usage.completion_tokens ?? 0;
totalCompletionTokens += usage.completion_tokens ?? 0;
} }
const choice = data.choices?.[0]; const assistantMessage = data.choices?.[0]?.message;
const assistantMessage = choice?.message;
if (!assistantMessage) break; if (!assistantMessage) break;
currentMessages.push(assistantMessage); currentMessages.push(assistantMessage);
const toolCalls = assistantMessage.tool_calls; const toolCalls = assistantMessage.tool_calls;
if (!toolCalls || toolCalls.length === 0) { if (!toolCalls || toolCalls.length === 0) {
// Model gave a direct answer — capture it and skip the extra final call
directAnswer = assistantMessage.content ?? ""; directAnswer = assistantMessage.content ?? "";
break; break;
} }
// Execute tool calls
const toolResults: object[] = []; const toolResults: object[] = [];
for (const tc of toolCalls) { for (const tc of toolCalls) {
const fnName = tc.function?.name; const fnName = tc.function?.name;
let args: Record<string, unknown> = {}; let args: Record<string, unknown> = {};
try { try { args = JSON.parse(tc.function?.arguments ?? "{}"); } catch { /* ignore */ }
args = JSON.parse(tc.function?.arguments ?? "{}");
} catch { /* ignore */ }
send({ type: "tool_start", name: fnName, args }); send({ type: "tool_start", name: fnName, args });
let result = ""; let result = "";
if (fnName === "execute_sql") { if (fnName === "call_api") {
result = await executeSql(args.query as string); result = await callFfApi(args.endpoint as string);
} else if (fnName === "get_db_schema") {
result = DB_SCHEMA;
} else { } else {
result = `Outil inconnu: ${fnName}`; result = `Outil inconnu: ${fnName}`;
} }
send({ type: "tool_result", name: fnName, result: result.substring(0, 500) + (result.length > 500 ? "..." : "") }); 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); currentMessages.push(...toolResults);
} }
// If model gave a direct answer in the loop, stream it character by character
if (directAnswer !== null) { if (directAnswer !== null) {
send({ type: "text", content: directAnswer }); send({ type: "text", content: directAnswer });
send({ type: "usage", prompt_tokens: totalPromptTokens, completion_tokens: totalCompletionTokens }); send({ type: "usage", prompt_tokens: totalPromptTokens, completion_tokens: totalCompletionTokens });
@@ -506,22 +431,16 @@ export async function POST(req: NextRequest) {
return; 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); const finalRes = await callOpenRouter(currentMessages, model, apiKey, false, true);
if (!finalRes.ok) { if (!finalRes.ok) {
const errText = await finalRes.text(); send({ type: "error", message: `OpenRouter error: ${await finalRes.text()}` });
send({ type: "error", message: `OpenRouter error: ${errText}` });
controller.close(); controller.close();
return; return;
} }
// Stream the response chunks
const reader = finalRes.body?.getReader(); const reader = finalRes.body?.getReader();
if (!reader) { if (!reader) { controller.close(); return; }
controller.close();
return;
}
const textDecoder = new TextDecoder(); const textDecoder = new TextDecoder();
let buffer = ""; let buffer = "";
@@ -530,26 +449,19 @@ export async function POST(req: NextRequest) {
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
buffer += textDecoder.decode(value, { stream: true }); buffer += textDecoder.decode(value, { stream: true });
const lines = buffer.split("\n"); const lines = buffer.split("\n");
buffer = lines.pop() ?? ""; buffer = lines.pop() ?? "";
for (const line of lines) { for (const line of lines) {
if (!line.startsWith("data: ")) continue; if (!line.startsWith("data: ")) continue;
const raw = line.slice(6).trim(); const raw = line.slice(6).trim();
if (raw === "[DONE]") continue; if (raw === "[DONE]") continue;
try { try {
const chunk = JSON.parse(raw); const chunk = JSON.parse(raw);
const delta = chunk.choices?.[0]?.delta?.content; const delta = chunk.choices?.[0]?.delta?.content;
if (delta) { if (delta) send({ type: "text", content: delta });
send({ type: "text", content: delta }); if (chunk.usage) finalUsage = chunk.usage;
} } catch { /* ignore */ }
if (chunk.usage) {
finalUsage = chunk.usage;
}
} catch { /* ignore malformed */ }
} }
} }
@@ -558,12 +470,7 @@ export async function POST(req: NextRequest) {
totalCompletionTokens += finalUsage.completion_tokens ?? 0; totalCompletionTokens += finalUsage.completion_tokens ?? 0;
} }
send({ send({ type: "usage", prompt_tokens: totalPromptTokens, completion_tokens: totalCompletionTokens });
type: "usage",
prompt_tokens: totalPromptTokens,
completion_tokens: totalCompletionTokens,
});
send({ type: "done" }); send({ type: "done" });
controller.close(); controller.close();
} catch (err) { } catch (err) {