rinogeek commited on
Commit
03ab431
·
1 Parent(s): 18a6e87
server/aiCore.js CHANGED
@@ -62,35 +62,24 @@ function countSentences(text) {
62
 
63
  function validateObservation({ text, temperatureC, hasHazard }) {
64
  const t = (text || "").trim();
65
- if (t.length < 80) return { ok: false, reason: "trop court" };
66
- if (t.includes("\n")) return { ok: false, reason: "contient des sauts de ligne" };
67
- if (/(\s|^)[-*]\s/.test(t)) return { ok: false, reason: "ressemble a une liste" };
68
- if (/[#`]/.test(t)) return { ok: false, reason: "contient du markdown" };
69
-
70
- const sentenceCount = countSentences(t);
71
- if (sentenceCount < 6 || sentenceCount > 11) return { ok: false, reason: `nombre de phrases invalide (${sentenceCount})` };
72
-
73
- const tempOk =
74
- /temperature\s*:/i.test(t) ||
75
- /temp/i.test(t) ||
76
- new RegExp(String(Math.round(temperatureC))).test(t) ||
77
- /degres|°c|celsius/i.test(t);
78
- if (!tempOk) return { ok: false, reason: "temperature non mentionnee" };
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 pour l'enseignement. " +
117
- "Tu decris ce qu'un eleve observerait lors du melange de substances, en restant prudent et pedagogique. " +
118
- "Tu ne dois pas inventer des resultats impossibles: si tu n'es pas certain, dis-le explicitement et reste general. " +
119
- "Tu ecris en francais clair, niveau lycee/licence, sans jargon inutile. " +
120
- "Important: ne fais aucun appel d'outil, aucune fonction, aucun format structure. Reponds uniquement en texte.";
 
121
 
122
  const user = [
123
  "Contexte:",
@@ -125,11 +115,9 @@ function buildPrompt({ temperatureC, substances }) {
125
  `- Substances presentes (a melanger):`,
126
  ...lines,
127
  "",
128
- "Rends exactement ce format, en UN SEUL PARAGRAPHE (sans sauts de ligne, sans listes):",
129
- "Observation: <6 a 8 phrases sur ce qu'on voit + securite si dangereux> Temperature: <1 phrase sur l'effet de la temperature> Equation: <equation avec -> ou →; si incertain, le dire brièvement> Cas d'usage: <2 a 3 phrases avec exemples concrets, comme/par exemple savon, encre, nettoyage, analyse, traitement de l'eau> Suggestions: <1 a 2 melanges a tester ensuite en citant uniquement des substances de la liste>",
130
- "Contraintes:",
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 pour l'enseignement. " +
66
- "Tu decris ce qu'un eleve observerait lors du melange de substances, en restant prudent et pedagogique. " +
67
- "Tu ne dois pas inventer des resultats impossibles: si tu n'es pas certain, dis-le explicitement et reste general. " +
68
- "Tu ecris en francais clair, niveau lycee/licence, sans jargon inutile. " +
69
- "Important: ne fais aucun appel d'outil, aucune fonction, aucun format structure. Reponds uniquement en texte.";
 
70
 
71
  const user =
72
  [
@@ -75,11 +76,9 @@ function buildPrompt(args: {
75
  `- Substances presentes (a melanger):`,
76
  ...lines,
77
  "",
78
- "Rends exactement ce format, en UN SEUL PARAGRAPHE (sans sauts de ligne, sans listes):",
79
- "Observation: <6 a 8 phrases sur ce qu'on voit + securite si dangereux> Temperature: <1 phrase sur l'effet de la temperature> Equation: <equation avec -> ou →; si incertain, le dire brièvement> Cas d'usage: <2 a 3 phrases avec exemples concrets, comme/par exemple savon, encre, nettoyage, analyse, traitement de l'eau> Suggestions: <1 a 2 melanges a tester ensuite en citant uniquement des substances de la liste>",
80
- "Contraintes:",
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
- // No lists / markdown: reject obvious patterns.
110
- if (t.includes("\n")) return { ok: false, reason: "contient des sauts de ligne" as const };
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 sentenceCount = countSentences(t);
115
- if (sentenceCount < 6 || sentenceCount > 11) {
116
- return { ok: false, reason: `nombre de phrases invalide (${sentenceCount})` as const };
117
- }
 
 
 
 
118
 
119
- // Temperature must be mentioned.
120
- const tempOk =
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 as const, reason: null as const };
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) && /cas d'usage\s*:/i.test(candidate) && /(->|→)/.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
- .split(",")
381
- .map((s) => s.trim())
382
- .filter(Boolean)
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 avec les labels Observation:, Temperature:, Equation:, Cas d'usage:, Suggestions:, en un seul paragraphe, sans sauts de ligne.`;
513
 
514
- const text = await callGroqChatCompletion({ apiKey, model, system, user: attemptUser });
 
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
- 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,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 || typeof out !== "string") {
37
  throw new Error("Reponse IA invalide");
38
  }
39
- return out.trim();
 
 
 
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
  }