demeter

Autonomous Hydroponic Intelligence

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}