Spaces:
Running
Running
| import type { Plugin, PreviewServer, ViteDevServer } from "vite"; | |
| import { substances } from "../src/data/substances"; | |
| import https from "node:https"; | |
| import { URL } from "node:url"; | |
| type Env = Record<string, string | undefined>; | |
| class UpstreamError extends Error { | |
| status: number; | |
| upstreamMessage?: string; | |
| constructor(status: number, message: string, upstreamMessage?: string) { | |
| super(message); | |
| this.status = status; | |
| this.upstreamMessage = upstreamMessage; | |
| } | |
| } | |
| function readBody(req: any): Promise<string> { | |
| return new Promise((resolve, reject) => { | |
| let data = ""; | |
| req.on("data", (chunk: any) => { | |
| data += chunk; | |
| }); | |
| req.on("end", () => resolve(data)); | |
| req.on("error", reject); | |
| }); | |
| } | |
| function sendJson(res: any, status: number, payload: unknown) { | |
| res.statusCode = status; | |
| res.setHeader("Content-Type", "application/json; charset=utf-8"); | |
| res.end(JSON.stringify(payload)); | |
| } | |
| function pickSubstances(substanceIds: string[]) { | |
| const uniqueIds = Array.from(new Set(substanceIds)).filter(Boolean); | |
| return uniqueIds | |
| .map((id) => substances.find((s) => s.id === id)) | |
| .filter(Boolean); | |
| } | |
| function buildPrompt(args: { | |
| temperatureC: number; | |
| substances: Array<{ | |
| id: string; | |
| name: string; | |
| formula: string; | |
| state: string; | |
| hazard: string; | |
| description: string; | |
| }>; | |
| }) { | |
| const lines = args.substances.map((s) => | |
| [ | |
| `- id: ${s.id}`, | |
| ` nom: ${s.name}`, | |
| ` formule: ${s.formula}`, | |
| ` etat: ${s.state}`, | |
| ` danger: ${s.hazard}`, | |
| ` description: ${s.description}`, | |
| ].join("\n"), | |
| ); | |
| const system = | |
| "Tu es un assistant de laboratoire de chimie. " + | |
| "Decris l'observation du melange en etant TRES CONCIS (MAXIMUM 2 a 3 phrases), en allant droit au but et en enlevant tous les details inutiles. Inclus les rappels de securite si necessaire. " + | |
| "Donne ensuite l'equation correspondante. " + | |
| "Termine avec une application pratique de ce melange dans la vie courante ou l'industrie (ex: 'ce melange sert a la fabrication de detergent'). " + | |
| "Important: Tu dois repondre UNIQUEMENT sous ce format textuel exact (sans markdown, sans blabla):\n" + | |
| "Observation: <tes 2 a 3 phrases ici>\n" + | |
| "Equation: <equation chimique avec -> ou -> ou →>\n" + | |
| "Application: <1 a 2 phrases sur l'utilite pratique>"; | |
| const user = | |
| [ | |
| "Contexte:", | |
| `- Temperature du milieu: ${args.temperatureC} degres Celsius`, | |
| `- Substances presentes (a melanger):`, | |
| ...lines, | |
| "", | |
| "Format attendu strictement: ", | |
| "Observation: <...>", | |
| "Equation: <...>", | |
| "Application: <...>" | |
| ].join("\n"); | |
| return { system, user }; | |
| } | |
| function countSentences(text: string) { | |
| // Heuristic: count sentence terminators. | |
| const matches = text.match(/[.!?](\s|$)/g); | |
| return matches ? matches.length : 0; | |
| } | |
| function normalizeOneParagraph(text: string) { | |
| return (text || "") | |
| .replace(/\s+/g, " ") | |
| .replace(/\u00a0/g, " ") | |
| .trim(); | |
| } | |
| function validateObservation(args: { | |
| text: string; | |
| temperatureC: number; | |
| hasHazard: boolean; | |
| }) { | |
| const t = args.text.trim(); | |
| if (!/^Observation\s*:/i.test(t)) return { ok: false, reason: "Ne commence pas par Observation:" }; | |
| if (!/Equation\s*:/i.test(t)) return { ok: false, reason: "equation manquante (Equation:)" }; | |
| if (!/Application\s*:/i.test(t)) return { ok: false, reason: "application manquante (Application:)" }; | |
| const parts = t.split(/Equation\s*:/i); | |
| const observation = parts[0].replace(/^Observation\s*:/i, '').trim(); | |
| const lowerParts = parts[1].split(/Application\s*:/i); | |
| const equation = lowerParts[0].trim(); | |
| const application = lowerParts[1] ? lowerParts[1].trim() : ""; | |
| if (observation.length < 10) return { ok: false, reason: "observation trop courte" }; | |
| const sentenceCount = countSentences(observation); | |
| if (sentenceCount > 5) return { ok: false, reason: `observation trop longue (${sentenceCount} phrases)` }; | |
| if (equation.length < 2) return { ok: false, reason: "equation invalide" }; | |
| if (application.length < 10) return { ok: false, reason: "application invalide ou trop courte" }; | |
| if (args.hasHazard && !/secur|prud|attention|protection|danger|gants|lunettes|corros/i.test(observation)) { | |
| // on l'accepte tout de meme | |
| } | |
| return { ok: true, reason: null, parsed: { observation, equation, application } }; | |
| } | |
| async function httpsPostJson(args: { | |
| url: string; | |
| headers: Record<string, string>; | |
| body: unknown; | |
| }): Promise<{ status: number; statusText: string; text: string; json: any }> { | |
| const u = new URL(args.url); | |
| const bodyStr = JSON.stringify(args.body); | |
| return new Promise((resolve, reject) => { | |
| const req = https.request( | |
| { | |
| protocol: u.protocol, | |
| hostname: u.hostname, | |
| port: u.port ? Number(u.port) : undefined, | |
| path: `${u.pathname}${u.search}`, | |
| method: "POST", | |
| headers: { | |
| ...args.headers, | |
| "Content-Length": Buffer.byteLength(bodyStr).toString(), | |
| }, | |
| }, | |
| (res) => { | |
| let data = ""; | |
| res.setEncoding("utf8"); | |
| res.on("data", (chunk) => (data += chunk)); | |
| res.on("end", () => { | |
| let parsed: any = null; | |
| try { | |
| parsed = data ? JSON.parse(data) : null; | |
| } catch { | |
| parsed = null; | |
| } | |
| resolve({ | |
| status: res.statusCode || 0, | |
| statusText: res.statusMessage || "", | |
| text: data, | |
| json: parsed, | |
| }); | |
| }); | |
| }, | |
| ); | |
| req.on("error", reject); | |
| req.write(bodyStr); | |
| req.end(); | |
| }); | |
| } | |
| function extractResponsesText(data: any): string | null { | |
| if (typeof data?.output_text === "string" && data.output_text.trim()) { | |
| return data.output_text.trim(); | |
| } | |
| const chunks: string[] = []; | |
| const outputs = Array.isArray(data?.output) ? data.output : []; | |
| for (const out of outputs) { | |
| // OpenAI Responses often returns items with type "message" and a content array. | |
| const content = Array.isArray(out?.content) ? out.content : []; | |
| for (const part of content) { | |
| if (part?.type === "output_text" && typeof part?.text === "string") chunks.push(part.text); | |
| if (part?.type === "text" && typeof part?.text === "string") chunks.push(part.text); | |
| } | |
| // Some providers nest content under message objects. | |
| const msgContent = Array.isArray(out?.message?.content) ? out.message.content : []; | |
| for (const part of msgContent) { | |
| if (part?.type === "output_text" && typeof part?.text === "string") chunks.push(part.text); | |
| if (part?.type === "text" && typeof part?.text === "string") chunks.push(part.text); | |
| } | |
| } | |
| const text = chunks.join("\n").trim(); | |
| return text || null; | |
| } | |
| async function callGroqChatCompletionsText(args: { | |
| apiKey: string; | |
| model: string; | |
| system: string; | |
| user: string; | |
| }) { | |
| let resp: { status: number; statusText: string; text: string; json: any }; | |
| try { | |
| resp = await httpsPostJson({ | |
| url: "https://api.groq.com/openai/v1/chat/completions", | |
| headers: { | |
| "Content-Type": "application/json", | |
| Authorization: `Bearer ${args.apiKey}`, | |
| }, | |
| body: { | |
| model: args.model, | |
| messages: [ | |
| { role: "system", content: args.system }, | |
| { role: "user", content: args.user }, | |
| ], | |
| temperature: 0.2, | |
| max_tokens: 350, | |
| }, | |
| }); | |
| } catch (e: any) { | |
| const code = e?.code ? String(e.code) : "NETWORK_ERROR"; | |
| const msg = e?.message ? String(e.message) : "Network error"; | |
| throw new UpstreamError(0, `Network error calling Groq: ${code} ${msg}`); | |
| } | |
| if (resp.status < 200 || resp.status >= 300) { | |
| const upstreamMessage = resp?.json?.error?.message || resp?.json?.message; | |
| const msg = upstreamMessage | |
| ? `Groq API error ${resp.status}: ${upstreamMessage}` | |
| : `Groq API error ${resp.status}: ${resp.statusText || "Unknown"}`; | |
| throw new UpstreamError(resp.status, msg, upstreamMessage); | |
| } | |
| const data = resp.json; | |
| if (!data || typeof data !== "object") { | |
| throw new UpstreamError(502, "Groq Chat Completions returned non-JSON response"); | |
| } | |
| const choice = Array.isArray((data as any).choices) ? (data as any).choices[0] : undefined; | |
| const msg = choice?.message; | |
| // Standard OpenAI-compatible content. | |
| const content = msg?.content; | |
| if (typeof content === "string" && content.trim()) return content.trim(); | |
| // Legacy completion-style field (some providers/models). | |
| const choiceText = choice?.text; | |
| if (typeof choiceText === "string" && choiceText.trim()) return choiceText.trim(); | |
| // Sometimes providers include a delta even in non-stream mode. | |
| const deltaContent = choice?.delta?.content; | |
| if (typeof deltaContent === "string" && deltaContent.trim()) return deltaContent.trim(); | |
| // Some providers can return content as an array of parts. | |
| if (Array.isArray(content)) { | |
| const parts = content | |
| .map((p: any) => (typeof p?.text === "string" ? p.text : "")) | |
| .filter(Boolean); | |
| const joined = parts.join("\n").trim(); | |
| if (joined) return joined; | |
| } | |
| // Some providers return content as an object part (not array). | |
| if (content && typeof content === "object") { | |
| const objText = (content as any).text; | |
| if (typeof objText === "string" && objText.trim()) return objText.trim(); | |
| } | |
| // Refusal format (treat as recoverable so the next model can be tried). | |
| const refusal = msg?.refusal; | |
| if (typeof refusal === "string" && refusal.trim()) { | |
| throw new UpstreamError(422, "Model refusal", refusal.trim()); | |
| } | |
| // Some models put content into a "reasoning" field and leave content empty. | |
| // Treat as recoverable; the caller will try another model. | |
| const reasoning = msg?.reasoning; | |
| if (typeof reasoning === "string" && reasoning.trim()) { | |
| const candidate = normalizeOneParagraph(reasoning); | |
| if (/equation\s*:/i.test(candidate) && /application\s*:/i.test(candidate) && /(->|→)/.test(candidate)) { | |
| return candidate; | |
| } | |
| throw new UpstreamError(422, "Model returned reasoning without final content"); | |
| } | |
| // Tool calls without text (recoverable, try next model). | |
| const toolCalls = msg?.tool_calls; | |
| if (Array.isArray(toolCalls) && toolCalls.length > 0) { | |
| const sample = JSON.stringify(toolCalls[0]).slice(0, 500); | |
| throw new UpstreamError(422, "Model returned tool_calls with no text", sample); | |
| } | |
| const keys = Object.keys(data).slice(0, 12).join(","); | |
| // Log a small sample for debugging without returning it to the client. | |
| try { | |
| console.error("[groq chat] empty content choice sample:", JSON.stringify(choice).slice(0, 900)); | |
| } catch { | |
| // ignore | |
| } | |
| throw new UpstreamError(502, `Groq Chat Completions returned no message content (keys: ${keys})`); | |
| } | |
| async function callGroqChatCompletion(args: { | |
| apiKey: string; | |
| model: string; | |
| system: string; | |
| user: string; | |
| }) { | |
| let resp: { status: number; statusText: string; text: string; json: any }; | |
| try { | |
| resp = await httpsPostJson({ | |
| url: "https://api.groq.com/openai/v1/responses", | |
| headers: { | |
| "Content-Type": "application/json", | |
| Authorization: `Bearer ${args.apiKey}`, | |
| }, | |
| body: { | |
| model: args.model, | |
| instructions: args.system, | |
| input: args.user, | |
| temperature: 0.2, | |
| max_output_tokens: 350, | |
| }, | |
| }); | |
| } catch (e: any) { | |
| const code = e?.code ? String(e.code) : "NETWORK_ERROR"; | |
| const msg = e?.message ? String(e.message) : "Network error"; | |
| throw new UpstreamError(0, `Network error calling Groq: ${code} ${msg}`); | |
| } | |
| if (resp.status < 200 || resp.status >= 300) { | |
| const upstreamMessage = resp?.json?.error?.message || resp?.json?.message; | |
| const msg = upstreamMessage | |
| ? `Groq API error ${resp.status}: ${upstreamMessage}` | |
| : `Groq API error ${resp.status}: ${resp.statusText || "Unknown"}`; | |
| throw new UpstreamError(resp.status, msg, upstreamMessage); | |
| } | |
| const data: any = resp.json; | |
| const extracted = extractResponsesText(data); | |
| if (extracted) return extracted; | |
| // Some models/providers may not support Responses output_text cleanly; fallback to Chat Completions. | |
| try { | |
| return await callGroqChatCompletionsText(args); | |
| } catch (e) { | |
| // Preserve upstream errors for model fallback logic. | |
| throw e; | |
| } | |
| } | |
| function parseModelList(env: Env) { | |
| const rawList = (env.GROQ_MODELS || process.env.GROQ_MODELS || "").trim(); | |
| const single = (env.GROQ_MODEL || process.env.GROQ_MODEL || "").trim(); | |
| const fromList = rawList | |
| ? rawList | |
| .split(",") | |
| .map((s) => s.trim()) | |
| .filter(Boolean) | |
| : []; | |
| const seed = fromList.length > 0 ? fromList : single ? [single] : []; | |
| const deduped: string[] = []; | |
| const seen = new Set<string>(); | |
| for (const m of seed) { | |
| if (seen.has(m)) continue; | |
| seen.add(m); | |
| deduped.push(m); | |
| } | |
| return deduped; | |
| } | |
| function defaultModelList() { | |
| // Ordered fallback list (deduped). | |
| return [ | |
| "openai/gpt-oss-120b", | |
| "llama-3.1-8b-instant", | |
| "llama-3.3-70b-versatile", | |
| "openai/gpt-oss-20b", | |
| ]; | |
| } | |
| function filterGenerationModels(models: string[]) { | |
| // "safeguard" models often return refusals or reasoning-only payloads; avoid them for generation. | |
| const filtered = models.filter((m) => !/safeguard/i.test(m)); | |
| return filtered.length > 0 ? filtered : models; | |
| } | |
| function shouldFallbackOnUpstreamError(err: UpstreamError) { | |
| // Do not fallback on auth errors: all models will fail the same way. | |
| if (err.status === 401 || err.status === 403) return false; | |
| // Rate limit / quota or temporary outages: try another model. | |
| if (err.status === 429) return true; | |
| if (err.status === 0) return true; // network error (might be transient) | |
| if (err.status >= 500 && err.status <= 599) return true; | |
| // Model unavailable / bad request: often model-specific. | |
| if (err.status === 400 || err.status === 404) return true; | |
| // Validation/refusal-type errors can be model-specific. | |
| if (err.status === 422) return true; | |
| return false; | |
| } | |
| function addRoutes(server: ViteDevServer | PreviewServer, env: Env) { | |
| server.middlewares.use("/api/ai/ping", async (req: any, res: any, next: any) => { | |
| if (req.method !== "GET") { | |
| res.statusCode = 405; | |
| res.setHeader("Allow", "GET"); | |
| return res.end(); | |
| } | |
| const apiKey = (env.GROQ_API_KEY || process.env.GROQ_API_KEY || "").trim(); | |
| const configured = parseModelList(env); | |
| const models = configured.length > 0 ? configured : defaultModelList(); | |
| const generationModels = filterGenerationModels(models); | |
| return sendJson(res, 200, { | |
| ok: true, | |
| hasGroqApiKey: !!apiKey && !apiKey.includes("your_groq_api_key_here"), | |
| groqApiKeyPrefix: apiKey ? apiKey.slice(0, 4) : null, | |
| models, | |
| generationModels, | |
| node: process.version, | |
| }); | |
| }); | |
| server.middlewares.use("/api/ai/mix", async (req: any, res: any, next: any) => { | |
| if (req.method !== "POST") { | |
| res.statusCode = 405; | |
| res.setHeader("Allow", "POST"); | |
| return res.end(); | |
| } | |
| const apiKey = (env.GROQ_API_KEY || process.env.GROQ_API_KEY || "").trim(); | |
| if (!apiKey || apiKey.includes("your_groq_api_key_here")) { | |
| return sendJson(res, 500, { | |
| error: "GROQ_API_KEY manquant. Ajoute-le dans .env (ne le commit pas).", | |
| }); | |
| } | |
| const triedModels: string[] = []; | |
| let lastModel: string | null = null; | |
| try { | |
| const body = await readBody(req); | |
| const parsed = body ? JSON.parse(body) : {}; | |
| const substanceIds: string[] = Array.isArray(parsed?.substanceIds) ? parsed.substanceIds : []; | |
| const temperatureC = Number(parsed?.temperatureC); | |
| const providedSubstances: any[] = Array.isArray(parsed?.substances) ? parsed.substances : []; | |
| const picked = (providedSubstances.length >= 2 ? providedSubstances : pickSubstances(substanceIds)) as any[]; | |
| if (picked.length < 2) { | |
| return sendJson(res, 400, { error: "substances/substanceIds doit contenir au moins 2 substances valides" }); | |
| } | |
| const safeTemp = Number.isFinite(temperatureC) ? temperatureC : 20; | |
| const { system, user } = buildPrompt({ | |
| temperatureC: safeTemp, | |
| substances: picked.map((s: any) => ({ | |
| id: s.id, | |
| name: s.name, | |
| formula: s.formula, | |
| state: s.state, | |
| hazard: s.hazard, | |
| description: s.description, | |
| })), | |
| }); | |
| const configured = parseModelList(env); | |
| const models = configured.length > 0 ? configured : defaultModelList(); | |
| const generationModels = filterGenerationModels(models); | |
| let lastErr: unknown = null; | |
| const hasHazard = picked.some((s: any) => s.hazard && s.hazard !== "none"); | |
| for (const model of generationModels) { | |
| triedModels.push(model); | |
| lastModel = model; | |
| let lastValidation: { ok: boolean; reason: string | null } | null = null; | |
| for (let attempt = 1; attempt <= 2; attempt++) { | |
| try { | |
| const attemptUser = | |
| attempt === 1 | |
| ? user | |
| : `${user}\n\nLe texte precedent ne respecte pas les contraintes (${lastValidation?.reason || "incomplet"}). Regenerate en respectant STRICTEMENT le format attendu sans sauts de lignes et sans details.`; | |
| let text = await callGroqChatCompletion({ apiKey, model, system, user: attemptUser }); | |
| text = normalizeOneParagraph(text); | |
| const v = validateObservation({ text, temperatureC: safeTemp, hasHazard }); | |
| lastValidation = v as { ok: boolean; reason: string | null }; | |
| if (v.ok) return sendJson(res, 200, { text: (v as any).parsed, modelUsed: model, triedModels }); | |
| } catch (e: any) { | |
| lastErr = e; | |
| if (e instanceof UpstreamError) { | |
| if (!shouldFallbackOnUpstreamError(e)) throw e; | |
| // Upstream error: do not keep retrying the same model; try next. | |
| break; | |
| } | |
| throw e; | |
| } | |
| } | |
| // Model responded but didn't meet quality constraints twice: try next model. | |
| lastErr = lastErr || new UpstreamError(400, `Validation failed for model ${model}`, lastValidation?.reason || "validation failed"); | |
| } | |
| // All models failed. | |
| if (lastErr instanceof UpstreamError) throw lastErr; | |
| throw new Error("All models failed"); | |
| } catch (err: any) { | |
| const message = err?.message || "Erreur lors de la generation IA"; | |
| // Avoid leaking secrets; log the error for debugging. | |
| console.error("[/api/ai/mix] error:", message); | |
| if (err instanceof UpstreamError) { | |
| if (err.status === 401 || err.status === 403) { | |
| return sendJson(res, 401, { error: "API key Groq invalide ou non autorisee" }); | |
| } | |
| if (err.status === 429) { | |
| return sendJson(res, 429, { error: "Quota/limite Groq atteinte (429). Reessaye plus tard." }); | |
| } | |
| // Pass through common client errors to make debugging actionable. | |
| if (err.status >= 400 && err.status < 500) { | |
| return sendJson(res, err.status, { | |
| error: message, | |
| triedModels, | |
| lastModel, | |
| }); | |
| } | |
| return sendJson(res, 502, { | |
| error: "Erreur upstream Groq lors de la generation IA", | |
| details: message, | |
| triedModels, | |
| lastModel, | |
| }); | |
| } | |
| // Local server-side error (ex: Node too old -> fetch undefined, JSON parse, etc.) | |
| return sendJson(res, 500, { | |
| error: | |
| "Erreur serveur /api/ai/mix (pas une reponse Groq). Verifie la console serveur et Node >= 18.", | |
| details: message, | |
| triedModels, | |
| lastModel, | |
| }); | |
| } | |
| }); | |
| } | |
| export function aiMixMiddleware(env: Env): Plugin { | |
| return { | |
| name: "ai-mix-middleware", | |
| configureServer(server) { | |
| addRoutes(server, env); | |
| }, | |
| configurePreviewServer(server) { | |
| addRoutes(server, env); | |
| }, | |
| }; | |
| } | |