mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat: implement admin AI chat route with SQL execution and database schema context
This commit is contained in:
1 parent
6e10364922
commit
8dd2394893
1 file changed
+167
-260
+167
-260
@@ -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<AiConfig> {
|
||||
}
|
||||
|
||||
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<string> {
|
||||
// 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 (<div>, <table>, <b>, <br>, 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<string> {
|
||||
// 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<Response> {
|
||||
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<string, unknown> };
|
||||
@@ -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<string, unknown> = {};
|
||||
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) {
|
||||
|
||||
Reference in new issue
Block a user