demeter
Autonomous Hydroponic Intelligencegit clone https://github.com/maydayv7/demeter
frontend/src/components/AgentWidgets.jsx (8185B)
1import { Fan, FlaskConical, Sprout, Waves } from "lucide-react";
2import { useT } from "../hooks/useTranslation";
3
4// Shared action metadata config
5const ACTION_META = {
6 acid_dosage_ml: {
7 labelKey: "widget_acid",
8 icon: FlaskConical,
9 unit: "ml",
10 color: "var(--red)",
11 bg: "rgba(248,113,113,0.1)",
12 descKey: "widget_ph_down",
13 },
14 base_dosage_ml: {
15 labelKey: "widget_base",
16 icon: FlaskConical,
17 unit: "ml",
18 color: "#a78bfa",
19 bg: "rgba(167,139,250,0.1)",
20 descKey: "widget_ph_up",
21 },
22 nutrient_dosage_ml: {
23 labelKey: "widget_nutrients",
24 icon: Sprout,
25 unit: "ml",
26 color: "var(--green)",
27 bg: "rgba(74,222,128,0.1)",
28 descKey: "widget_ec_boost",
29 },
30 fan_speed_pct: {
31 labelKey: "widget_fan",
32 icon: Fan,
33 unit: "%",
34 color: "var(--blue)",
35 bg: "rgba(96,165,250,0.1)",
36 descKey: "widget_airflow",
37 },
38 water_refill_l: {
39 labelKey: "widget_water",
40 icon: Waves,
41 unit: "L",
42 color: "#22d3ee",
43 bg: "rgba(34,211,238,0.1)",
44 descKey: "widget_dilution",
45 },
46};
47
48// Parse action JSON safely
49function parseAction(raw) {
50 if (!raw || raw === "PENDING_ACTION") return null;
51 if (typeof raw === "object") return raw;
52 try {
53 return JSON.parse(raw);
54 } catch {
55 try {
56 return JSON.parse(
57 raw
58 .replace(/'/g, '"')
59 .replace(/\bNone\b/g, "null")
60 .replace(/\bTrue\b/g, "true")
61 .replace(/\bFalse\b/g, "false"),
62 );
63 } catch {
64 return null;
65 }
66 }
67}
68
69// Show actuator commands as cards
70export function AgentActionWidget({ actionTaken, compact = false }) {
71 const { t } = useT();
72 const action = parseAction(actionTaken);
73 if (!action) return null;
74
75 const entries = Object.entries(ACTION_META).map(([key, meta]) => ({
76 key,
77 meta,
78 value: action[key] ?? 0,
79 }));
80
81 const active = entries.filter((e) => parseFloat(e.value) > 0);
82 const display = active.length > 0 ? active : entries;
83
84 if (compact) {
85 return (
86 <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
87 {display.map(({ key, meta, value }) => {
88 const Icon = meta.icon;
89 return (
90 <div
91 key={key}
92 style={{
93 display: "flex",
94 alignItems: "center",
95 gap: 5,
96 padding: "4px 10px",
97 borderRadius: 20,
98 background: meta.bg,
99 border: `1px solid ${meta.color}40`,
100 }}
101 >
102 <Icon size={11} style={{ color: meta.color }} />
103 <span
104 style={{
105 fontSize: 12,
106 fontFamily: "DM Mono, monospace",
107 color: meta.color,
108 fontWeight: 600,
109 }}
110 >
111 {value}
112 {meta.unit}
113 </span>
114 <span
115 style={{
116 fontSize: 10,
117 fontFamily: "DM Mono, monospace",
118 color: "var(--text-3)",
119 }}
120 >
121 {t(meta.descKey)}
122 </span>
123 </div>
124 );
125 })}
126 </div>
127 );
128 }
129
130 return (
131 <div
132 style={{
133 display: "grid",
134 gridTemplateColumns: "repeat(auto-fill, minmax(110px, 1fr))",
135 gap: 10,
136 }}
137 >
138 {entries.map(({ key, meta, value }) => {
139 const Icon = meta.icon;
140 const isActive = parseFloat(value) > 0;
141 return (
142 <div
143 key={key}
144 style={{
145 borderRadius: 12,
146 padding: "14px 12px",
147 textAlign: "center",
148 background: isActive ? meta.bg : "var(--bg-3)",
149 border: `1px solid ${isActive ? meta.color + "40" : "var(--border)"}`,
150 transition: "all 0.2s",
151 }}
152 >
153 <div
154 style={{
155 width: 32,
156 height: 32,
157 borderRadius: 8,
158 background: isActive ? meta.bg : "var(--border)",
159 display: "flex",
160 alignItems: "center",
161 justifyContent: "center",
162 margin: "0 auto 8px",
163 }}
164 >
165 <Icon
166 size={14}
167 style={{ color: isActive ? meta.color : "var(--text-3)" }}
168 />
169 </div>
170 <div
171 style={{
172 fontWeight: 700,
173 fontFamily: "DM Mono, monospace",
174 fontSize: 22,
175 color: isActive ? meta.color : "var(--text-3)",
176 lineHeight: 1,
177 }}
178 >
179 {value}
180 </div>
181 <div
182 style={{
183 fontSize: 10,
184 fontFamily: "DM Mono, monospace",
185 color: "var(--text-3)",
186 marginTop: 2,
187 }}
188 >
189 {meta.unit}
190 </div>
191 <div
192 style={{
193 fontSize: 11,
194 color: isActive ? "var(--text-2)" : "var(--text-3)",
195 marginTop: 4,
196 fontWeight: isActive ? 600 : 400,
197 }}
198 >
199 {t(meta.labelKey)}
200 </div>
201 </div>
202 );
203 })}
204 </div>
205 );
206}
207
208// Agent Outcome
209export function AgentOutcomeWidget({ outcome, rewardScore, strategicIntent }) {
210 const { t, td } = useT();
211 if (!outcome || outcome === "PENDING_OBSERVATION") return null;
212
213 const raw = outcome.split("| Reward:")[0].trim();
214 const reward =
215 rewardScore != null
216 ? parseFloat(rewardScore)
217 : outcome.includes("Reward:")
218 ? parseFloat(outcome.split("Reward:")[1])
219 : null;
220
221 const isPositive = raw === "IMPROVED" || (reward != null && reward > 0.3);
222 const isNegative =
223 raw === "DETERIORATED" || (reward != null && reward < -0.1);
224
225 const color = isNegative
226 ? "var(--red)"
227 : isPositive
228 ? "var(--green)"
229 : "var(--amber)";
230 const bg = isNegative
231 ? "rgba(248,113,113,0.08)"
232 : isPositive
233 ? "rgba(74,222,128,0.08)"
234 : "rgba(245,158,11,0.08)";
235 const border = isNegative
236 ? "rgba(248,113,113,0.25)"
237 : isPositive
238 ? "rgba(74,222,128,0.25)"
239 : "rgba(245,158,11,0.25)";
240
241 const emoji = isNegative ? "▼" : isPositive ? "▲" : "●";
242
243 // Try to use a static translation for known outcomes, else dynamic fallback
244 let translatedRaw = raw;
245 if (raw === "IMPROVED") translatedRaw = t("outcome_improved");
246 else if (raw === "DETERIORATED") translatedRaw = t("outcome_deteriorated");
247 else if (raw === "STABLE") translatedRaw = t("outcome_stable");
248 else translatedRaw = td(raw);
249
250 return (
251 <div
252 style={{
253 borderRadius: 12,
254 padding: "12px 16px",
255 background: bg,
256 border: `1px solid ${border}`,
257 display: "flex",
258 alignItems: "center",
259 gap: 12,
260 flexWrap: "wrap",
261 }}
262 >
263 <span
264 style={{
265 fontSize: 18,
266 fontFamily: "DM Mono, monospace",
267 color,
268 fontWeight: 700,
269 flexShrink: 0,
270 }}
271 >
272 {emoji} {translatedRaw}
273 </span>
274
275 {reward != null && (
276 <span
277 style={{
278 fontSize: 13,
279 fontFamily: "DM Mono, monospace",
280 padding: "2px 10px",
281 borderRadius: 20,
282 background: `${color}20`,
283 color,
284 border: `1px solid ${color}40`,
285 flexShrink: 0,
286 }}
287 >
288 {t("reward_label")}: {reward > 0 ? "+" : ""}
289 {reward.toFixed(2)}
290 </span>
291 )}
292
293 {strategicIntent && (
294 <span
295 style={{
296 fontSize: 11,
297 fontFamily: "DM Mono, monospace",
298 padding: "2px 10px",
299 borderRadius: 20,
300 background: "var(--bg-3)",
301 color: "var(--text-3)",
302 border: "1px solid var(--border)",
303 flexShrink: 0,
304 }}
305 >
306 {td(strategicIntent.replace(/_/g, " "))}
307 </span>
308 )}
309 </div>
310 );
311}