Spaces:
Sleeping
Sleeping
Update
Browse files- server/aiCore.js +26 -38
- server/aiMixMiddleware.ts +31 -47
- src/components/lab/ResultsPanel.tsx +16 -8
- src/pages/Lab.tsx +52 -52
- src/services/aiMix.ts +6 -3
server/aiCore.js
CHANGED
|
@@ -62,35 +62,24 @@ function countSentences(text) {
|
|
| 62 |
|
| 63 |
function validateObservation({ text, temperatureC, hasHazard }) {
|
| 64 |
const t = (text || "").trim();
|
| 65 |
-
|
| 66 |
-
if (
|
| 67 |
-
if (/
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
const
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
if (!
|
| 79 |
-
|
| 80 |
-
if (!/equation\s*:/i.test(t)) return { ok: false, reason: "equation manquante" };
|
| 81 |
-
if (!/(->|→)/.test(t)) return { ok: false, reason: "equation sans fleche de reaction" };
|
| 82 |
-
|
| 83 |
-
if (!/cas d'usage\s*:/i.test(t)) return { ok: false, reason: "cas d'usage manquant" };
|
| 84 |
-
// Force concrete examples rather than generic statements.
|
| 85 |
-
if (!/par exemple|comme/i.test(t)) return { ok: false, reason: "cas d'usage pas assez concret (manque 'comme/par exemple')" };
|
| 86 |
-
if (!/suggestions?\s*:/i.test(t)) return { ok: false, reason: "suggestions manquantes" };
|
| 87 |
-
|
| 88 |
-
if (hasHazard) {
|
| 89 |
-
const safetyOk = /secur|prud|attention|protection|danger|gants|lunettes|corros/i.test(t);
|
| 90 |
-
if (!safetyOk) return { ok: false, reason: "pas de rappel securite" };
|
| 91 |
}
|
| 92 |
|
| 93 |
-
return { ok: true, reason: null };
|
| 94 |
}
|
| 95 |
|
| 96 |
function normalizeOneParagraph(text) {
|
|
@@ -113,11 +102,12 @@ function buildPrompt({ temperatureC, substances }) {
|
|
| 113 |
);
|
| 114 |
|
| 115 |
const system =
|
| 116 |
-
"Tu es un assistant de laboratoire de chimie
|
| 117 |
-
"
|
| 118 |
-
"
|
| 119 |
-
"Tu
|
| 120 |
-
"
|
|
|
|
| 121 |
|
| 122 |
const user = [
|
| 123 |
"Contexte:",
|
|
@@ -125,11 +115,9 @@ function buildPrompt({ temperatureC, substances }) {
|
|
| 125 |
`- Substances presentes (a melanger):`,
|
| 126 |
...lines,
|
| 127 |
"",
|
| 128 |
-
"
|
| 129 |
-
"Observation: <
|
| 130 |
-
"
|
| 131 |
-
"- 6 a 11 phrases au total",
|
| 132 |
-
"- pas de markdown, pas d'emojis",
|
| 133 |
].join("\n");
|
| 134 |
|
| 135 |
return { system, user };
|
|
@@ -349,7 +337,7 @@ export function createAiCore(env = {}) {
|
|
| 349 |
text = normalizeOneParagraph(text);
|
| 350 |
const v = validateObservation({ text, temperatureC: safeTemp, hasHazard });
|
| 351 |
lastValidation = v;
|
| 352 |
-
if (v.ok) return { text, modelUsed: model, triedModels };
|
| 353 |
} catch (e) {
|
| 354 |
lastErr = e;
|
| 355 |
if (e instanceof UpstreamError) {
|
|
|
|
| 62 |
|
| 63 |
function validateObservation({ text, temperatureC, hasHazard }) {
|
| 64 |
const t = (text || "").trim();
|
| 65 |
+
|
| 66 |
+
if (!/^Observation\s*:/i.test(t)) return { ok: false, reason: "Ne commence pas par Observation:" };
|
| 67 |
+
if (!/Equation\s*:/i.test(t)) return { ok: false, reason: "equation manquante (Equation:)" };
|
| 68 |
+
|
| 69 |
+
const parts = t.split(/Equation\s*:/i);
|
| 70 |
+
const observation = parts[0].replace(/^Observation\s*:/i, '').trim();
|
| 71 |
+
const equation = parts[1].trim();
|
| 72 |
+
|
| 73 |
+
if (observation.length < 10) return { ok: false, reason: "observation trop courte" };
|
| 74 |
+
const sentenceCount = countSentences(observation);
|
| 75 |
+
if (sentenceCount > 5) return { ok: false, reason: `observation trop longue (${sentenceCount} phrases)` };
|
| 76 |
+
if (equation.length < 2) return { ok: false, reason: "equation invalide" };
|
| 77 |
+
|
| 78 |
+
if (hasHazard && !/secur|prud|attention|protection|danger|gants|lunettes|corros/i.test(observation)) {
|
| 79 |
+
// on peut ne pas bloquer rigidement mais le logguer, ici on accepte
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
}
|
| 81 |
|
| 82 |
+
return { ok: true, reason: null, parsed: { observation, equation } };
|
| 83 |
}
|
| 84 |
|
| 85 |
function normalizeOneParagraph(text) {
|
|
|
|
| 102 |
);
|
| 103 |
|
| 104 |
const system =
|
| 105 |
+
"Tu es un assistant de laboratoire de chimie. " +
|
| 106 |
+
"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. " +
|
| 107 |
+
"Donne ensuite l'equation correspondante. " +
|
| 108 |
+
"Important: Tu dois repondre UNIQUEMENT sous ce format textuel exact (sans markdown, sans blabla):\n" +
|
| 109 |
+
"Observation: <tes 2 a 3 phrases ici>\n" +
|
| 110 |
+
"Equation: <equation chimique avec -> ou -> ou →>";
|
| 111 |
|
| 112 |
const user = [
|
| 113 |
"Contexte:",
|
|
|
|
| 115 |
`- Substances presentes (a melanger):`,
|
| 116 |
...lines,
|
| 117 |
"",
|
| 118 |
+
"Format attendu strictement: ",
|
| 119 |
+
"Observation: <...>",
|
| 120 |
+
"Equation: <...>"
|
|
|
|
|
|
|
| 121 |
].join("\n");
|
| 122 |
|
| 123 |
return { system, user };
|
|
|
|
| 337 |
text = normalizeOneParagraph(text);
|
| 338 |
const v = validateObservation({ text, temperatureC: safeTemp, hasHazard });
|
| 339 |
lastValidation = v;
|
| 340 |
+
if (v.ok) return { text: v.parsed, modelUsed: model, triedModels };
|
| 341 |
} catch (e) {
|
| 342 |
lastErr = e;
|
| 343 |
if (e instanceof UpstreamError) {
|
server/aiMixMiddleware.ts
CHANGED
|
@@ -62,11 +62,12 @@ function buildPrompt(args: {
|
|
| 62 |
);
|
| 63 |
|
| 64 |
const system =
|
| 65 |
-
"Tu es un assistant de laboratoire de chimie
|
| 66 |
-
"
|
| 67 |
-
"
|
| 68 |
-
"Tu
|
| 69 |
-
"
|
|
|
|
| 70 |
|
| 71 |
const user =
|
| 72 |
[
|
|
@@ -75,11 +76,9 @@ function buildPrompt(args: {
|
|
| 75 |
`- Substances presentes (a melanger):`,
|
| 76 |
...lines,
|
| 77 |
"",
|
| 78 |
-
"
|
| 79 |
-
"Observation: <
|
| 80 |
-
"
|
| 81 |
-
"- 6 a 11 phrases au total",
|
| 82 |
-
"- pas de markdown, pas d'emojis",
|
| 83 |
].join("\n");
|
| 84 |
|
| 85 |
return { system, user };
|
|
@@ -104,40 +103,24 @@ function validateObservation(args: {
|
|
| 104 |
hasHazard: boolean;
|
| 105 |
}) {
|
| 106 |
const t = args.text.trim();
|
| 107 |
-
if (t.length < 80) return { ok: false, reason: "trop court" as const };
|
| 108 |
|
| 109 |
-
//
|
| 110 |
-
if (
|
| 111 |
-
if (/(\s|^)[-*]\s/.test(t)) return { ok: false, reason: "ressemble a une liste" as const };
|
| 112 |
-
if (/[#`]/.test(t)) return { ok: false, reason: "contient du markdown" as const };
|
| 113 |
|
| 114 |
-
const
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
/temperature\s*:/i.test(t) ||
|
| 122 |
-
/temp/i.test(t) ||
|
| 123 |
-
new RegExp(String(Math.round(args.temperatureC))).test(t) ||
|
| 124 |
-
/degres|°c|celsius/i.test(t);
|
| 125 |
-
if (!tempOk) return { ok: false, reason: "temperature non mentionnee" as const };
|
| 126 |
-
|
| 127 |
-
if (!/equation\s*:/i.test(t)) return { ok: false, reason: "equation manquante" as const };
|
| 128 |
-
if (!/(->|→)/.test(t)) return { ok: false, reason: "equation sans fleche de reaction" as const };
|
| 129 |
-
|
| 130 |
-
if (!/cas d'usage\s*:/i.test(t)) return { ok: false, reason: "cas d'usage manquant" as const };
|
| 131 |
-
if (!/par exemple|comme/i.test(t)) return { ok: false, reason: "cas d'usage pas assez concret (manque 'comme/par exemple')" as const };
|
| 132 |
-
if (!/suggestions?\s*:/i.test(t)) return { ok: false, reason: "suggestions manquantes" as const };
|
| 133 |
-
|
| 134 |
-
// Safety reminder required if any hazardous substance is involved.
|
| 135 |
-
if (args.hasHazard) {
|
| 136 |
-
const safetyOk = /secur|prud|attention|protection|danger|gants|lunettes|corros/i.test(t);
|
| 137 |
-
if (!safetyOk) return { ok: false, reason: "pas de rappel securite" as const };
|
| 138 |
}
|
| 139 |
|
| 140 |
-
return { ok: true
|
| 141 |
}
|
| 142 |
|
| 143 |
async function httpsPostJson(args: {
|
|
@@ -299,7 +282,7 @@ async function callGroqChatCompletionsText(args: {
|
|
| 299 |
const reasoning = msg?.reasoning;
|
| 300 |
if (typeof reasoning === "string" && reasoning.trim()) {
|
| 301 |
const candidate = normalizeOneParagraph(reasoning);
|
| 302 |
-
if (/equation\s*:/i.test(candidate) && /
|
| 303 |
return candidate;
|
| 304 |
}
|
| 305 |
throw new UpstreamError(422, "Model returned reasoning without final content");
|
|
@@ -377,9 +360,9 @@ function parseModelList(env: Env) {
|
|
| 377 |
|
| 378 |
const fromList = rawList
|
| 379 |
? rawList
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
: [];
|
| 384 |
|
| 385 |
const seed = fromList.length > 0 ? fromList : single ? [single] : [];
|
|
@@ -509,12 +492,13 @@ function addRoutes(server: ViteDevServer | PreviewServer, env: Env) {
|
|
| 509 |
const attemptUser =
|
| 510 |
attempt === 1
|
| 511 |
? user
|
| 512 |
-
: `${user}\n\nLe texte precedent ne respecte pas les contraintes (${lastValidation?.reason || "incomplet"}). Regenerate en respectant STRICTEMENT le format
|
| 513 |
|
| 514 |
-
|
|
|
|
| 515 |
const v = validateObservation({ text, temperatureC: safeTemp, hasHazard });
|
| 516 |
-
lastValidation = v;
|
| 517 |
-
if (v.ok) return sendJson(res, 200, { text, modelUsed: model, triedModels });
|
| 518 |
} catch (e: any) {
|
| 519 |
lastErr = e;
|
| 520 |
if (e instanceof UpstreamError) {
|
|
|
|
| 62 |
);
|
| 63 |
|
| 64 |
const system =
|
| 65 |
+
"Tu es un assistant de laboratoire de chimie. " +
|
| 66 |
+
"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. " +
|
| 67 |
+
"Donne ensuite l'equation correspondante. " +
|
| 68 |
+
"Important: Tu dois repondre UNIQUEMENT sous ce format textuel exact (sans markdown, sans blabla):\n" +
|
| 69 |
+
"Observation: <tes 2 a 3 phrases ici>\n" +
|
| 70 |
+
"Equation: <equation chimique avec -> ou -> ou →>";
|
| 71 |
|
| 72 |
const user =
|
| 73 |
[
|
|
|
|
| 76 |
`- Substances presentes (a melanger):`,
|
| 77 |
...lines,
|
| 78 |
"",
|
| 79 |
+
"Format attendu strictement: ",
|
| 80 |
+
"Observation: <...>",
|
| 81 |
+
"Equation: <...>"
|
|
|
|
|
|
|
| 82 |
].join("\n");
|
| 83 |
|
| 84 |
return { system, user };
|
|
|
|
| 103 |
hasHazard: boolean;
|
| 104 |
}) {
|
| 105 |
const t = args.text.trim();
|
|
|
|
| 106 |
|
| 107 |
+
if (!/^Observation\s*:/i.test(t)) return { ok: false, reason: "Ne commence pas par Observation:" };
|
| 108 |
+
if (!/Equation\s*:/i.test(t)) return { ok: false, reason: "equation manquante (Equation:)" };
|
|
|
|
|
|
|
| 109 |
|
| 110 |
+
const parts = t.split(/Equation\s*:/i);
|
| 111 |
+
const observation = parts[0].replace(/^Observation\s*:/i, '').trim();
|
| 112 |
+
const equation = parts[1].trim();
|
| 113 |
+
|
| 114 |
+
if (observation.length < 10) return { ok: false, reason: "observation trop courte" };
|
| 115 |
+
const sentenceCount = countSentences(observation);
|
| 116 |
+
if (sentenceCount > 5) return { ok: false, reason: `observation trop longue (${sentenceCount} phrases)` };
|
| 117 |
+
if (equation.length < 2) return { ok: false, reason: "equation invalide" };
|
| 118 |
|
| 119 |
+
if (args.hasHazard && !/secur|prud|attention|protection|danger|gants|lunettes|corros/i.test(observation)) {
|
| 120 |
+
// on l'accepte tout de meme
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
}
|
| 122 |
|
| 123 |
+
return { ok: true, reason: null, parsed: { observation, equation } };
|
| 124 |
}
|
| 125 |
|
| 126 |
async function httpsPostJson(args: {
|
|
|
|
| 282 |
const reasoning = msg?.reasoning;
|
| 283 |
if (typeof reasoning === "string" && reasoning.trim()) {
|
| 284 |
const candidate = normalizeOneParagraph(reasoning);
|
| 285 |
+
if (/equation\s*:/i.test(candidate) && /(->|→)/.test(candidate)) {
|
| 286 |
return candidate;
|
| 287 |
}
|
| 288 |
throw new UpstreamError(422, "Model returned reasoning without final content");
|
|
|
|
| 360 |
|
| 361 |
const fromList = rawList
|
| 362 |
? rawList
|
| 363 |
+
.split(",")
|
| 364 |
+
.map((s) => s.trim())
|
| 365 |
+
.filter(Boolean)
|
| 366 |
: [];
|
| 367 |
|
| 368 |
const seed = fromList.length > 0 ? fromList : single ? [single] : [];
|
|
|
|
| 492 |
const attemptUser =
|
| 493 |
attempt === 1
|
| 494 |
? user
|
| 495 |
+
: `${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.`;
|
| 496 |
|
| 497 |
+
let text = await callGroqChatCompletion({ apiKey, model, system, user: attemptUser });
|
| 498 |
+
text = normalizeOneParagraph(text);
|
| 499 |
const v = validateObservation({ text, temperatureC: safeTemp, hasHazard });
|
| 500 |
+
lastValidation = v as { ok: boolean; reason: string | null };
|
| 501 |
+
if (v.ok) return sendJson(res, 200, { text: (v as any).parsed, modelUsed: model, triedModels });
|
| 502 |
} catch (e: any) {
|
| 503 |
lastErr = e;
|
| 504 |
if (e instanceof UpstreamError) {
|
src/components/lab/ResultsPanel.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
|
|
| 4 |
import { Trash2, Download } from "lucide-react";
|
| 5 |
|
| 6 |
interface ResultsPanelProps {
|
| 7 |
-
results: string[];
|
| 8 |
onClear: () => void;
|
| 9 |
}
|
| 10 |
|
|
@@ -18,9 +18,9 @@ const ResultsPanel = ({ results, onClear }: ResultsPanelProps) => {
|
|
| 18 |
const headers = ['#', 'Observation', 'Heure'];
|
| 19 |
const rows = results.map((result, index) => [
|
| 20 |
index + 1,
|
| 21 |
-
`"${result.replace(/"/g, '""')}"`, // Escape quotes in CSV
|
| 22 |
-
new Date().toLocaleTimeString('fr-FR', {
|
| 23 |
-
hour: '2-digit',
|
| 24 |
minute: '2-digit',
|
| 25 |
second: '2-digit'
|
| 26 |
})
|
|
@@ -80,23 +80,31 @@ const ResultsPanel = ({ results, onClear }: ResultsPanelProps) => {
|
|
| 80 |
{results.map((result, index) => (
|
| 81 |
<div
|
| 82 |
key={index}
|
| 83 |
-
className="p-3 bg-gradient-card border border-border rounded-lg animate-slide-in"
|
| 84 |
>
|
| 85 |
<div className="flex items-start gap-2">
|
| 86 |
<div className="w-6 h-6 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-bold flex-shrink-0">
|
| 87 |
{index + 1}
|
| 88 |
</div>
|
| 89 |
<div className="flex-1 min-w-0">
|
| 90 |
-
<p className="text-sm leading-relaxed">{result}</p>
|
| 91 |
<p className="text-xs text-muted-foreground mt-1">
|
| 92 |
-
{new Date().toLocaleTimeString('fr-FR', {
|
| 93 |
-
hour: '2-digit',
|
| 94 |
minute: '2-digit',
|
| 95 |
second: '2-digit'
|
| 96 |
})}
|
| 97 |
</p>
|
| 98 |
</div>
|
| 99 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
</div>
|
| 101 |
))}
|
| 102 |
</div>
|
|
|
|
| 4 |
import { Trash2, Download } from "lucide-react";
|
| 5 |
|
| 6 |
interface ResultsPanelProps {
|
| 7 |
+
results: { observation: string; equation: string }[];
|
| 8 |
onClear: () => void;
|
| 9 |
}
|
| 10 |
|
|
|
|
| 18 |
const headers = ['#', 'Observation', 'Heure'];
|
| 19 |
const rows = results.map((result, index) => [
|
| 20 |
index + 1,
|
| 21 |
+
`"${result.observation.replace(/"/g, '""')} [Eq: ${result.equation.replace(/"/g, '""')}]"`, // Escape quotes in CSV
|
| 22 |
+
new Date().toLocaleTimeString('fr-FR', {
|
| 23 |
+
hour: '2-digit',
|
| 24 |
minute: '2-digit',
|
| 25 |
second: '2-digit'
|
| 26 |
})
|
|
|
|
| 80 |
{results.map((result, index) => (
|
| 81 |
<div
|
| 82 |
key={index}
|
| 83 |
+
className="p-3 bg-gradient-card border border-border rounded-lg animate-slide-in flex flex-col gap-2"
|
| 84 |
>
|
| 85 |
<div className="flex items-start gap-2">
|
| 86 |
<div className="w-6 h-6 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-bold flex-shrink-0">
|
| 87 |
{index + 1}
|
| 88 |
</div>
|
| 89 |
<div className="flex-1 min-w-0">
|
| 90 |
+
<p className="text-sm leading-relaxed">{result.observation}</p>
|
| 91 |
<p className="text-xs text-muted-foreground mt-1">
|
| 92 |
+
{new Date().toLocaleTimeString('fr-FR', {
|
| 93 |
+
hour: '2-digit',
|
| 94 |
minute: '2-digit',
|
| 95 |
second: '2-digit'
|
| 96 |
})}
|
| 97 |
</p>
|
| 98 |
</div>
|
| 99 |
</div>
|
| 100 |
+
{result.equation && (
|
| 101 |
+
<div className="mt-1 p-2 bg-muted/50 rounded-md border border-border/50">
|
| 102 |
+
<p className="text-[10px] font-semibold text-muted-foreground mb-1 uppercase tracking-wider">Équation chimique</p>
|
| 103 |
+
<p className="text-sm font-mono text-primary font-medium overflow-x-auto whitespace-nowrap scrollbar-none">
|
| 104 |
+
{result.equation}
|
| 105 |
+
</p>
|
| 106 |
+
</div>
|
| 107 |
+
)}
|
| 108 |
</div>
|
| 109 |
))}
|
| 110 |
</div>
|
src/pages/Lab.tsx
CHANGED
|
@@ -20,13 +20,13 @@ const STORAGE_KEY = 'lab-workspaces';
|
|
| 20 |
|
| 21 |
const Lab = () => {
|
| 22 |
const [selectedItem, setSelectedItem] = useState<string | null>(null);
|
| 23 |
-
const [results, setResults] = useState<string[]>([]);
|
| 24 |
const [safetyWarning, setSafetyWarning] = useState<string | null>(null);
|
| 25 |
const [temperatureC, setTemperatureC] = useState<number>(20);
|
| 26 |
const canvasRef = useRef<{ getSnapshot: () => string | null; loadSnapshot: (data: string) => void } | null>(null);
|
| 27 |
const pendingMixKeyRef = useRef<string>("");
|
| 28 |
const lastSuccessMixKeyRef = useRef<string>("");
|
| 29 |
-
|
| 30 |
// Load workspaces from localStorage or use default
|
| 31 |
const loadWorkspaces = (): Workspace[] => {
|
| 32 |
try {
|
|
@@ -42,17 +42,17 @@ const Lab = () => {
|
|
| 42 |
console.error("Erreur lors du chargement:", error);
|
| 43 |
}
|
| 44 |
return [
|
| 45 |
-
{
|
| 46 |
-
id: "1",
|
| 47 |
-
name: "Expérience 1",
|
| 48 |
-
canvasData: null,
|
| 49 |
isFavorite: false,
|
| 50 |
history: [],
|
| 51 |
historyIndex: -1
|
| 52 |
},
|
| 53 |
];
|
| 54 |
};
|
| 55 |
-
|
| 56 |
// Workspace management
|
| 57 |
const [workspaces, setWorkspaces] = useState<Workspace[]>(loadWorkspaces);
|
| 58 |
const [activeWorkspaceId, setActiveWorkspaceId] = useState(() => {
|
|
@@ -61,7 +61,7 @@ const Lab = () => {
|
|
| 61 |
});
|
| 62 |
|
| 63 |
const activeWorkspace = workspaces.find((w) => w.id === activeWorkspaceId);
|
| 64 |
-
|
| 65 |
const canUndo = (activeWorkspace?.historyIndex ?? -1) > 0;
|
| 66 |
const canRedo = (activeWorkspace?.historyIndex ?? -1) < (activeWorkspace?.history?.length ?? 0) - 1;
|
| 67 |
|
|
@@ -97,39 +97,39 @@ const Lab = () => {
|
|
| 97 |
}
|
| 98 |
};
|
| 99 |
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
|
| 104 |
// Safety banner (instant feedback), then ask the IA.
|
| 105 |
checkSafety(unique);
|
| 106 |
const key = `${[...unique].sort().join("+")}@${temperatureC}`;
|
| 107 |
if (pendingMixKeyRef.current === key || lastSuccessMixKeyRef.current === key) return;
|
| 108 |
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
console.error("AI mix error:", error);
|
| 134 |
toast.error(error instanceof Error ? error.message : "Erreur lors de la génération IA");
|
| 135 |
} finally {
|
|
@@ -157,9 +157,9 @@ const Lab = () => {
|
|
| 157 |
|
| 158 |
const handleWorkspaceDelete = (id: string) => {
|
| 159 |
if (workspaces.length === 1) return;
|
| 160 |
-
|
| 161 |
setWorkspaces((prev) => prev.filter((w) => w.id !== id));
|
| 162 |
-
|
| 163 |
if (activeWorkspaceId === id) {
|
| 164 |
const remainingWorkspaces = workspaces.filter((w) => w.id !== id);
|
| 165 |
setActiveWorkspaceId(remainingWorkspaces[0].id);
|
|
@@ -184,13 +184,13 @@ const Lab = () => {
|
|
| 184 |
if (w.id === activeWorkspaceId) {
|
| 185 |
const currentHistory = w.history || [];
|
| 186 |
const currentIndex = w.historyIndex ?? -1;
|
| 187 |
-
|
| 188 |
// Remove any "future" history if we're not at the end
|
| 189 |
const newHistory = currentHistory.slice(0, currentIndex + 1);
|
| 190 |
-
|
| 191 |
// Add new state to history (limit to 50 states)
|
| 192 |
const updatedHistory = [...newHistory, data].slice(-50);
|
| 193 |
-
|
| 194 |
return {
|
| 195 |
...w,
|
| 196 |
canvasData: data,
|
|
@@ -205,10 +205,10 @@ const Lab = () => {
|
|
| 205 |
|
| 206 |
const handleUndo = () => {
|
| 207 |
if (!canUndo || !activeWorkspace) return;
|
| 208 |
-
|
| 209 |
const newIndex = (activeWorkspace.historyIndex ?? 0) - 1;
|
| 210 |
const previousState = activeWorkspace.history?.[newIndex];
|
| 211 |
-
|
| 212 |
if (previousState !== undefined) {
|
| 213 |
setWorkspaces((prev) =>
|
| 214 |
prev.map((w) =>
|
|
@@ -222,10 +222,10 @@ const Lab = () => {
|
|
| 222 |
|
| 223 |
const handleRedo = () => {
|
| 224 |
if (!activeWorkspace || !canRedo) return;
|
| 225 |
-
|
| 226 |
const newIndex = activeWorkspace.historyIndex! + 1;
|
| 227 |
const canvasData = activeWorkspace.history![newIndex];
|
| 228 |
-
|
| 229 |
setWorkspaces((prev) =>
|
| 230 |
prev.map((w) =>
|
| 231 |
w.id === activeWorkspaceId
|
|
@@ -243,25 +243,25 @@ const Lab = () => {
|
|
| 243 |
input.onchange = (e) => {
|
| 244 |
const file = (e.target as HTMLInputElement).files?.[0];
|
| 245 |
if (!file) return;
|
| 246 |
-
|
| 247 |
const reader = new FileReader();
|
| 248 |
reader.onload = (event) => {
|
| 249 |
try {
|
| 250 |
const imported = JSON.parse(event.target?.result as string) as Workspace;
|
| 251 |
-
|
| 252 |
// Validate structure
|
| 253 |
if (!imported.id || !imported.name) {
|
| 254 |
toast.error("Fichier JSON invalide");
|
| 255 |
return;
|
| 256 |
}
|
| 257 |
-
|
| 258 |
// Generate new ID to avoid conflicts
|
| 259 |
const newWorkspace: Workspace = {
|
| 260 |
...imported,
|
| 261 |
id: String(Date.now()),
|
| 262 |
name: `${imported.name} (importé)`
|
| 263 |
};
|
| 264 |
-
|
| 265 |
setWorkspaces((prev) => [...prev, newWorkspace]);
|
| 266 |
setActiveWorkspaceId(newWorkspace.id);
|
| 267 |
toast.success(`Plan de travail "${newWorkspace.name}" importé avec succès`);
|
|
@@ -278,7 +278,7 @@ const Lab = () => {
|
|
| 278 |
// Export workspace to JSON
|
| 279 |
const handleExportWorkspace = () => {
|
| 280 |
if (!activeWorkspace) return;
|
| 281 |
-
|
| 282 |
const dataStr = JSON.stringify(activeWorkspace, null, 2);
|
| 283 |
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
| 284 |
const url = URL.createObjectURL(dataBlob);
|
|
@@ -294,7 +294,7 @@ const Lab = () => {
|
|
| 294 |
const handleLoadTemplate = (templateId: string) => {
|
| 295 |
const template = experimentTemplates.find(t => t.id === templateId);
|
| 296 |
if (!template) return;
|
| 297 |
-
|
| 298 |
const newId = String(Date.now());
|
| 299 |
const newWorkspace: Workspace = {
|
| 300 |
id: newId,
|
|
@@ -304,10 +304,10 @@ const Lab = () => {
|
|
| 304 |
history: [],
|
| 305 |
historyIndex: -1,
|
| 306 |
};
|
| 307 |
-
|
| 308 |
setWorkspaces((prev) => [...prev, newWorkspace]);
|
| 309 |
setActiveWorkspaceId(newId);
|
| 310 |
-
|
| 311 |
// Show instructions
|
| 312 |
toast.success(`Modèle "${template.name}" chargé`, {
|
| 313 |
description: template.instructions[0]
|
|
|
|
| 20 |
|
| 21 |
const Lab = () => {
|
| 22 |
const [selectedItem, setSelectedItem] = useState<string | null>(null);
|
| 23 |
+
const [results, setResults] = useState<{ observation: string; equation: string }[]>([]);
|
| 24 |
const [safetyWarning, setSafetyWarning] = useState<string | null>(null);
|
| 25 |
const [temperatureC, setTemperatureC] = useState<number>(20);
|
| 26 |
const canvasRef = useRef<{ getSnapshot: () => string | null; loadSnapshot: (data: string) => void } | null>(null);
|
| 27 |
const pendingMixKeyRef = useRef<string>("");
|
| 28 |
const lastSuccessMixKeyRef = useRef<string>("");
|
| 29 |
+
|
| 30 |
// Load workspaces from localStorage or use default
|
| 31 |
const loadWorkspaces = (): Workspace[] => {
|
| 32 |
try {
|
|
|
|
| 42 |
console.error("Erreur lors du chargement:", error);
|
| 43 |
}
|
| 44 |
return [
|
| 45 |
+
{
|
| 46 |
+
id: "1",
|
| 47 |
+
name: "Expérience 1",
|
| 48 |
+
canvasData: null,
|
| 49 |
isFavorite: false,
|
| 50 |
history: [],
|
| 51 |
historyIndex: -1
|
| 52 |
},
|
| 53 |
];
|
| 54 |
};
|
| 55 |
+
|
| 56 |
// Workspace management
|
| 57 |
const [workspaces, setWorkspaces] = useState<Workspace[]>(loadWorkspaces);
|
| 58 |
const [activeWorkspaceId, setActiveWorkspaceId] = useState(() => {
|
|
|
|
| 61 |
});
|
| 62 |
|
| 63 |
const activeWorkspace = workspaces.find((w) => w.id === activeWorkspaceId);
|
| 64 |
+
|
| 65 |
const canUndo = (activeWorkspace?.historyIndex ?? -1) > 0;
|
| 66 |
const canRedo = (activeWorkspace?.historyIndex ?? -1) < (activeWorkspace?.history?.length ?? 0) - 1;
|
| 67 |
|
|
|
|
| 97 |
}
|
| 98 |
};
|
| 99 |
|
| 100 |
+
const handleMix = async (substanceIds: string[]) => {
|
| 101 |
+
const unique = Array.from(new Set(substanceIds)).filter(Boolean);
|
| 102 |
+
if (unique.length < 2) return;
|
| 103 |
|
| 104 |
// Safety banner (instant feedback), then ask the IA.
|
| 105 |
checkSafety(unique);
|
| 106 |
const key = `${[...unique].sort().join("+")}@${temperatureC}`;
|
| 107 |
if (pendingMixKeyRef.current === key || lastSuccessMixKeyRef.current === key) return;
|
| 108 |
|
| 109 |
+
pendingMixKeyRef.current = key;
|
| 110 |
+
try {
|
| 111 |
+
const picked = unique
|
| 112 |
+
.map((id) => substances.find((s) => s.id === id))
|
| 113 |
+
.filter(Boolean)
|
| 114 |
+
.map((s: any) => ({
|
| 115 |
+
id: s.id,
|
| 116 |
+
name: s.name,
|
| 117 |
+
formula: s.formula,
|
| 118 |
+
state: s.state,
|
| 119 |
+
hazard: s.hazard,
|
| 120 |
+
description: s.description,
|
| 121 |
+
}));
|
| 122 |
+
|
| 123 |
+
if (picked.length < 2) throw new Error("Substances invalides pour l'IA");
|
| 124 |
+
|
| 125 |
+
const text = await generateAiMixObservation({
|
| 126 |
+
substances: picked,
|
| 127 |
+
substanceIds: unique,
|
| 128 |
+
temperatureC,
|
| 129 |
+
});
|
| 130 |
+
setResults((prev) => [...prev, text]);
|
| 131 |
+
lastSuccessMixKeyRef.current = key;
|
| 132 |
+
} catch (error) {
|
| 133 |
console.error("AI mix error:", error);
|
| 134 |
toast.error(error instanceof Error ? error.message : "Erreur lors de la génération IA");
|
| 135 |
} finally {
|
|
|
|
| 157 |
|
| 158 |
const handleWorkspaceDelete = (id: string) => {
|
| 159 |
if (workspaces.length === 1) return;
|
| 160 |
+
|
| 161 |
setWorkspaces((prev) => prev.filter((w) => w.id !== id));
|
| 162 |
+
|
| 163 |
if (activeWorkspaceId === id) {
|
| 164 |
const remainingWorkspaces = workspaces.filter((w) => w.id !== id);
|
| 165 |
setActiveWorkspaceId(remainingWorkspaces[0].id);
|
|
|
|
| 184 |
if (w.id === activeWorkspaceId) {
|
| 185 |
const currentHistory = w.history || [];
|
| 186 |
const currentIndex = w.historyIndex ?? -1;
|
| 187 |
+
|
| 188 |
// Remove any "future" history if we're not at the end
|
| 189 |
const newHistory = currentHistory.slice(0, currentIndex + 1);
|
| 190 |
+
|
| 191 |
// Add new state to history (limit to 50 states)
|
| 192 |
const updatedHistory = [...newHistory, data].slice(-50);
|
| 193 |
+
|
| 194 |
return {
|
| 195 |
...w,
|
| 196 |
canvasData: data,
|
|
|
|
| 205 |
|
| 206 |
const handleUndo = () => {
|
| 207 |
if (!canUndo || !activeWorkspace) return;
|
| 208 |
+
|
| 209 |
const newIndex = (activeWorkspace.historyIndex ?? 0) - 1;
|
| 210 |
const previousState = activeWorkspace.history?.[newIndex];
|
| 211 |
+
|
| 212 |
if (previousState !== undefined) {
|
| 213 |
setWorkspaces((prev) =>
|
| 214 |
prev.map((w) =>
|
|
|
|
| 222 |
|
| 223 |
const handleRedo = () => {
|
| 224 |
if (!activeWorkspace || !canRedo) return;
|
| 225 |
+
|
| 226 |
const newIndex = activeWorkspace.historyIndex! + 1;
|
| 227 |
const canvasData = activeWorkspace.history![newIndex];
|
| 228 |
+
|
| 229 |
setWorkspaces((prev) =>
|
| 230 |
prev.map((w) =>
|
| 231 |
w.id === activeWorkspaceId
|
|
|
|
| 243 |
input.onchange = (e) => {
|
| 244 |
const file = (e.target as HTMLInputElement).files?.[0];
|
| 245 |
if (!file) return;
|
| 246 |
+
|
| 247 |
const reader = new FileReader();
|
| 248 |
reader.onload = (event) => {
|
| 249 |
try {
|
| 250 |
const imported = JSON.parse(event.target?.result as string) as Workspace;
|
| 251 |
+
|
| 252 |
// Validate structure
|
| 253 |
if (!imported.id || !imported.name) {
|
| 254 |
toast.error("Fichier JSON invalide");
|
| 255 |
return;
|
| 256 |
}
|
| 257 |
+
|
| 258 |
// Generate new ID to avoid conflicts
|
| 259 |
const newWorkspace: Workspace = {
|
| 260 |
...imported,
|
| 261 |
id: String(Date.now()),
|
| 262 |
name: `${imported.name} (importé)`
|
| 263 |
};
|
| 264 |
+
|
| 265 |
setWorkspaces((prev) => [...prev, newWorkspace]);
|
| 266 |
setActiveWorkspaceId(newWorkspace.id);
|
| 267 |
toast.success(`Plan de travail "${newWorkspace.name}" importé avec succès`);
|
|
|
|
| 278 |
// Export workspace to JSON
|
| 279 |
const handleExportWorkspace = () => {
|
| 280 |
if (!activeWorkspace) return;
|
| 281 |
+
|
| 282 |
const dataStr = JSON.stringify(activeWorkspace, null, 2);
|
| 283 |
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
| 284 |
const url = URL.createObjectURL(dataBlob);
|
|
|
|
| 294 |
const handleLoadTemplate = (templateId: string) => {
|
| 295 |
const template = experimentTemplates.find(t => t.id === templateId);
|
| 296 |
if (!template) return;
|
| 297 |
+
|
| 298 |
const newId = String(Date.now());
|
| 299 |
const newWorkspace: Workspace = {
|
| 300 |
id: newId,
|
|
|
|
| 304 |
history: [],
|
| 305 |
historyIndex: -1,
|
| 306 |
};
|
| 307 |
+
|
| 308 |
setWorkspaces((prev) => [...prev, newWorkspace]);
|
| 309 |
setActiveWorkspaceId(newId);
|
| 310 |
+
|
| 311 |
// Show instructions
|
| 312 |
toast.success(`Modèle "${template.name}" chargé`, {
|
| 313 |
description: template.instructions[0]
|
src/services/aiMix.ts
CHANGED
|
@@ -10,7 +10,7 @@ export async function generateAiMixObservation(args: {
|
|
| 10 |
temperatureC: number;
|
| 11 |
// Back-compat (dev): optional ids
|
| 12 |
substanceIds?: string[];
|
| 13 |
-
}): Promise<string> {
|
| 14 |
const resp = await fetch("/api/ai/mix", {
|
| 15 |
method: "POST",
|
| 16 |
headers: { "Content-Type": "application/json" },
|
|
@@ -33,8 +33,11 @@ export async function generateAiMixObservation(args: {
|
|
| 33 |
|
| 34 |
const data: any = await resp.json();
|
| 35 |
const out = data?.text;
|
| 36 |
-
if (!out ||
|
| 37 |
throw new Error("Reponse IA invalide");
|
| 38 |
}
|
| 39 |
-
return
|
|
|
|
|
|
|
|
|
|
| 40 |
}
|
|
|
|
| 10 |
temperatureC: number;
|
| 11 |
// Back-compat (dev): optional ids
|
| 12 |
substanceIds?: string[];
|
| 13 |
+
}): Promise<{ observation: string; equation: string }> {
|
| 14 |
const resp = await fetch("/api/ai/mix", {
|
| 15 |
method: "POST",
|
| 16 |
headers: { "Content-Type": "application/json" },
|
|
|
|
| 33 |
|
| 34 |
const data: any = await resp.json();
|
| 35 |
const out = data?.text;
|
| 36 |
+
if (!out || !out.observation || !out.equation) {
|
| 37 |
throw new Error("Reponse IA invalide");
|
| 38 |
}
|
| 39 |
+
return {
|
| 40 |
+
observation: out.observation.trim(),
|
| 41 |
+
equation: out.equation.trim()
|
| 42 |
+
};
|
| 43 |
}
|