mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-21 05:58:10 +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 { 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) {
|
||||||
|
|||||||
Reference in new issue
Block a user