commit d9c904259d1d8126b882c821916f24da8901fbf2
parent 39bd6d6d45ca9cb6ff6637e21117e0f558600ff5
Author: maydayv7 <maydayv7@gmail.com>
Date: Wed, 25 Mar 2026 15:15:31 +0530
AddCrop page
Diffstat:
9 files changed, 2513 insertions(+), 913 deletions(-)
diff --git a/backend/server/functions.py b/backend/server/functions.py
@@ -3,6 +3,7 @@ import shutil
import json
import traceback
import base64
+import asyncio
from fastapi import UploadFile, HTTPException
from groq import Groq
from qdrant_client.http import models
@@ -124,7 +125,6 @@ async def process_ingest(
async def process_search(file: UploadFile, sensors_str: str, builder):
"""
SIMPLIFIED AGENT LOOP: Atmos + Water + Supervisor ONLY.
- Updated for Web: Base64 Images + Metadata Consistency.
"""
temp_filename = f"temp_search_{file.filename}"
@@ -277,6 +277,124 @@ async def process_search(file: UploadFile, sensors_str: str, builder):
pass
+async def process_cycle_stream(file: UploadFile, sensors_str: str, builder):
+ """
+ REAL-TIME AGENT LOOP: Streams step-by-step reasoning via SSE.
+ """
+ temp_filename = f"temp_stream_{file.filename}"
+
+ try:
+ yield f"data: {json.dumps({'agent': 'SYSTEM', 'text': '๐ Initializing Demeter Orchestrator...'})}\n\n"
+ await asyncio.sleep(0.5)
+
+ # --- 1. SETUP ---
+ file_content = await file.read()
+ with open(temp_filename, "wb") as buffer:
+ buffer.write(file_content)
+
+ image_b64 = base64.b64encode(file_content).decode("utf-8")
+ abs_image_path = os.path.abspath(temp_filename)
+
+ yield f"data: {json.dumps({'agent': 'FETCHER', 'text': '[Fetcher] ๐ก Requesting data from simulator...'})}\n\n"
+ await asyncio.sleep(0.5)
+
+ # --- 2. DATA ---
+ raw_sensor_data = json.loads(sensors_str)
+ clean_sensors = filter_numeric_sensors(raw_sensor_data)
+
+ target_crop = raw_sensor_data.get("crop", "Unknown")
+ target_crop_id = raw_sensor_data.get("crop_id")
+ if not target_crop_id:
+ target_crop_id = f"Batch_{target_crop}_{datetime.now().strftime('%Y%m')}"
+
+ seq_num = get_next_sequence_number(target_crop_id)
+ yield f"data: {json.dumps({'agent': 'FETCHER', 'text': f'[Fetcher] ๐ข Sequence for {target_crop_id}: {seq_num}'})}\n\n"
+ await asyncio.sleep(0.3)
+
+ metadata = {
+ "crop": target_crop,
+ "stage": raw_sensor_data.get("stage", "Unknown"),
+ "crop_id": target_crop_id,
+ "sequence_number": seq_num,
+ "sensors": clean_sensors,
+ "action_taken": "PENDING_DECISION",
+ "outcome": "PENDING",
+ }
+
+ query_fmu = builder.create_fmu(abs_image_path, clean_sensors, metadata=metadata)
+ store_fmu(query_fmu)
+ yield f"data: {json.dumps({'agent': 'FETCHER', 'text': f'[Fetcher] ๐ง FMU Created (ID: {query_fmu.id}) โ Handing off to specialists.'})}\n\n"
+ await asyncio.sleep(0.5)
+
+ # --- 3. RESEARCH ---
+ yield f"data: {json.dumps({'agent': 'RESEARCHER', 'text': f'๐ Searching knowledge base for {target_crop} {metadata['stage']} stage...'})}\n\n"
+ research_query = f"optimal hydroponic conditions for {target_crop} in {metadata['stage']} stage"
+ research_context = researcher.search(research_query)
+ await asyncio.sleep(0.5)
+ yield f"data: {json.dumps({'agent': 'RESEARCHER', 'text': ' ๐ Found relevant scientific data.'})}\n\n"
+
+ # --- 4. AGENTS ---
+ strat_instr = "Maintain optimal crop-specific parameters."
+ strat_name = "STANDARD_MAINTENANCE"
+ action_idx = 0
+
+ yield f"data: {json.dumps({'agent': 'BANDIT', 'text': f'๐ฐ BANDIT STRATEGY: {strat_name}'})}\n\n"
+ await asyncio.sleep(0.3)
+
+ yield f"data: {json.dumps({'agent': 'ATMOSPHERIC', 'text': '๐ฌ๏ธ Atmospheric Agent โ deciding...'})}\n\n"
+ atmos_plan = atmos_agent.reason(
+ sensors=clean_sensors,
+ research=research_context,
+ strategy=strat_instr,
+ history="No history provided.",
+ image_b64=image_b64,
+ )
+ yield f"data: {json.dumps({'agent': 'ATMOSPHERIC', 'text': f' โ
Plan Approved: {json.dumps(atmos_plan)}'})}\n\n"
+ await asyncio.sleep(0.5)
+
+ yield f"data: {json.dumps({'agent': 'WATER', 'text': '๐ง Water Agent โ deciding...'})}\n\n"
+ water_plan = water_agent.reason(
+ sensors=clean_sensors,
+ research=research_context,
+ strategy=strat_instr,
+ history="No history provided.",
+ image_b64=image_b64,
+ )
+ yield f"data: {json.dumps({'agent': 'WATER', 'text': f' โ
Plan Approved: {json.dumps(water_plan)}'})}\n\n"
+ await asyncio.sleep(0.5)
+
+ # --- 5. SUPERVISOR ---
+ yield f"data: {json.dumps({'agent': 'SUPERVISOR', 'text': ' ๐ Supervisor Merging Plans...'})}\n\n"
+ await asyncio.sleep(0.3)
+ yield f"data: {json.dumps({'agent': 'SUPERVISOR', 'text': ' โ๏ธ Supervisor Judging...'})}\n\n"
+
+ final_decision_json = supervisor.synthesize_plan(
+ atmos_plan,
+ water_plan,
+ query_fmu,
+ "No history context.",
+ strategy_info=(strat_name, strat_instr, action_idx),
+ )
+ await asyncio.sleep(0.5)
+ yield f"data: {json.dumps({'agent': 'SUPERVISOR', 'text': ' โ
Plan looks solid.'})}\n\n"
+
+ # --- 6. FINAL ---
+ yield f"data: {json.dumps({'agent': 'SUPERVISOR', 'text': f'๐ Activating Hardware: {json.dumps(final_decision_json)}'})}\n\n"
+ await asyncio.sleep(0.5)
+
+ yield f"data: {json.dumps({'agent': 'SYSTEM', 'text': 'โ
Sent to Simulator. Cycle complete.', 'final_action': final_decision_json, 'phase': 'done'})}\n\n"
+
+ except Exception as e:
+ yield f"data: {json.dumps({'agent': 'SYSTEM', 'text': f'โ Error: {str(e)}', 'level': 'error'})}\n\n"
+
+ finally:
+ if os.path.exists(temp_filename):
+ try:
+ os.remove(temp_filename)
+ except Exception:
+ pass
+
+
def extract_json(text):
"""
Robustly extracts the first valid JSON object from a text string.
diff --git a/backend/server/main.py b/backend/server/main.py
@@ -4,29 +4,30 @@ from dotenv import load_dotenv
# --- PATH FIX ---
current_dir = os.path.dirname(os.path.abspath(__file__))
-# Adjust this depending on where main.py sits relative to the root 'Demeter' folder
-# If main.py is in Demeter/backend/server, root is ../../
+# Adjust this depending on where main.py sits relative to root folder
project_root = os.path.abspath(os.path.join(current_dir, "../../"))
sys.path.append(project_root)
agent_root = os.path.abspath(os.path.join(project_root, "agent"))
sys.path.append(agent_root)
# Load env from root
-env_path = os.path.join(project_root, '.env')
+env_path = os.path.join(project_root, ".env")
if os.path.exists(env_path):
load_dotenv(env_path)
# ----------------
from fastapi import FastAPI, UploadFile, File, Form
from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import StreamingResponse
from Sentinel.agent import FMUBuilder
-# Import the UPDATED logic functions
+# Import the logic functions
from backend.server.functions import (
process_ingest,
process_search,
process_text_query,
process_audio_search,
+ process_cycle_stream,
)
app = FastAPI()
@@ -57,6 +58,16 @@ async def search_endpoint(file: UploadFile = File(...), sensors: str = Form(...)
return await process_search(file, sensors, builder)
+@app.post("/run-cycle-stream")
+async def run_cycle_stream_endpoint(
+ file: UploadFile = File(...), sensors: str = Form(...)
+):
+ return StreamingResponse(
+ process_cycle_stream(file, sensors, builder),
+ media_type="text/event-stream",
+ )
+
+
@app.post("/query-text")
async def text_query_endpoint(query: str = Form(...)):
return await process_text_query(query)
@@ -70,5 +81,4 @@ async def audio_query_endpoint(file: UploadFile = File(...)):
if __name__ == "__main__":
import uvicorn
- # Using 8002 to avoid conflict with Simulator (8001) and React (3000)
uvicorn.run(app, host="0.0.0.0", port=8000)
diff --git a/frontend/src/App.js b/frontend/src/App.js
@@ -5,7 +5,8 @@ import { SettingsProvider } from "./hooks/useSettings";
import LandingPage from "./pages/LandingPage";
import Dashboard from "./pages/Dashboard";
import CropDetails from "./pages/CropDetails";
-import AgentControl from "./pages/AgentControl";
+import AddCrop from "./pages/AddCrop";
+import FarmIntelligence from "./pages/FarmIntelligence";
import Analytics from "./pages/Analytics";
import Alerts from "./pages/Alerts";
import SettingsPage from "./pages/Settings";
@@ -17,9 +18,10 @@ function App() {
<Router>
<Routes>
<Route path="/" element={<LandingPage />} />
- <Route path="/control" element={<AgentControl />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/crop/:cropId" element={<CropDetails />} />
+ <Route path="/add-crop" element={<AddCrop />} />
+ <Route path="/intelligence" element={<FarmIntelligence />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/alerts" element={<Alerts />} />
<Route path="/settings" element={<SettingsPage />} />
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
@@ -6,7 +6,7 @@ import {
BarChart3,
Bell,
Settings,
- Brain,
+ Sparkles,
ChevronLeft,
ChevronRight,
} from "lucide-react";
@@ -32,7 +32,7 @@ export default function Sidebar() {
{ label: "Crops", icon: LayoutGrid, path: "/dashboard" },
{ label: "Analytics", icon: BarChart3, path: "/analytics" },
{ label: "Alerts", icon: Bell, path: "/alerts", badge: alertCount || null },
- { label: "Agent Control", icon: Brain, path: "/control" },
+ { label: "Intelligence", icon: Sparkles, path: "/intelligence" },
{ label: "Settings", icon: Settings, path: "/settings" },
];
@@ -77,11 +77,11 @@ export default function Sidebar() {
width: 32,
height: 32,
borderRadius: 10,
+ flexShrink: 0,
background: "linear-gradient(135deg, #2d7a44, #4ade80)",
display: "flex",
alignItems: "center",
justifyContent: "center",
- flexShrink: 0,
}}
>
<Leaf size={15} fill="white" color="white" />
@@ -135,7 +135,7 @@ export default function Sidebar() {
{collapsed ? <ChevronRight size={11} /> : <ChevronLeft size={11} />}
</button>
- {/* Status pill */}
+ {/* System status pill */}
{!collapsed && (
<div style={{ padding: "12px 12px 0" }}>
<div
@@ -210,7 +210,8 @@ export default function Sidebar() {
{!collapsed && (
<span style={{ fontSize: 13, fontWeight: 500 }}>{label}</span>
)}
- {/* Badge โ unread count */}
+
+ {/* Badge */}
{badge && !collapsed && (
<span
className="alert-pulse"
@@ -246,7 +247,7 @@ export default function Sidebar() {
})}
</nav>
- {/* Alert status summary */}
+ {/* Alert status */}
{!collapsed && (
<div style={{ padding: "0 12px 12px" }}>
<div
diff --git a/frontend/src/pages/AddCrop.jsx b/frontend/src/pages/AddCrop.jsx
@@ -0,0 +1,1276 @@
+import { useRef, useState, useEffect, useCallback } from "react";
+import { useNavigate } from "react-router-dom";
+import { useFarmData } from "../hooks/useFarmData";
+import {
+ Upload,
+ ArrowLeft,
+ Activity,
+ Droplets,
+ Thermometer,
+ Wind,
+ Sprout,
+ Calendar,
+ Database,
+ Play,
+ CheckCircle2,
+ AlertTriangle,
+ Cpu,
+ Waves,
+ FlaskConical,
+ Fan,
+ Brain,
+ ChevronDown,
+ Leaf,
+ Zap,
+ Circle,
+ ChevronRight,
+} from "lucide-react";
+import Sidebar from "../components/Sidebar";
+
+// Agent Formatting
+const AGENT_META = {
+ FETCHER: { color: "#60a5fa", bg: "rgba(96,165,250,0.12)", label: "Fetcher" },
+ JUDGE: { color: "#f59e0b", bg: "rgba(245,158,11,0.12)", label: "Judge" },
+ RESEARCHER: {
+ color: "#a78bfa",
+ bg: "rgba(167,139,250,0.12)",
+ label: "Researcher",
+ },
+ ATMOSPHERIC: {
+ color: "#4ade80",
+ bg: "rgba(74,222,128,0.12)",
+ label: "Atmospheric",
+ },
+ WATER: { color: "#22d3ee", bg: "rgba(34,211,238,0.12)", label: "Water" },
+ SUPERVISOR: {
+ color: "#f97316",
+ bg: "rgba(249,115,22,0.12)",
+ label: "Supervisor",
+ },
+ BANDIT: { color: "#e879f9", bg: "rgba(232,121,249,0.12)", label: "Bandit" },
+ DOCTOR: { color: "#f87171", bg: "rgba(248,113,113,0.12)", label: "Doctor" },
+ SYSTEM: { color: "#6a8a6d", bg: "rgba(106,138,109,0.12)", label: "System" },
+};
+
+function agentFromLine(line) {
+ const up = line.toUpperCase();
+ if (up.includes("[FETCHER]") || up.includes("FETCHING")) return "FETCHER";
+ if (up.includes("[JUDGE]") || up.includes("JUDGE")) return "JUDGE";
+ if (up.includes("BANDIT") || up.includes("STRATEGY")) return "BANDIT";
+ if (up.includes("RESEARCH")) return "RESEARCHER";
+ if (up.includes("ATMO") || up.includes("ATMOSPHERIC")) return "ATMOSPHERIC";
+ if (up.includes("WATER") || up.includes("NUTRIENT")) return "WATER";
+ if (up.includes("SUPERVISOR")) return "SUPERVISOR";
+ if (up.includes("DOCTOR") || up.includes("VISION") || up.includes("DIAGNOS"))
+ return "DOCTOR";
+ return "SYSTEM";
+}
+
+function levelFromLine(line) {
+ const up = line.toUpperCase();
+ if (up.includes("โ") || up.includes("ERROR") || up.includes("CRITICAL"))
+ return "error";
+ if (up.includes("โ ๏ธ") || up.includes("WARN") || up.includes("FAIL"))
+ return "warn";
+ if (up.includes("โ
") || up.includes("APPROV") || up.includes("SUCCESS"))
+ return "success";
+ return "info";
+}
+
+const LEVEL_COLORS = {
+ error: "var(--red)",
+ warn: "var(--amber)",
+ success: "var(--green)",
+ info: "var(--text-2)",
+};
+
+// Input field definitions
+const INPUT_FIELDS = [
+ {
+ label: "pH Level",
+ name: "pH",
+ icon: Droplets,
+ color: "var(--green)",
+ type: "number",
+ step: "0.1",
+ min: "0",
+ max: "14",
+ },
+ {
+ label: "EC (mS/cm)",
+ name: "EC",
+ icon: Activity,
+ color: "var(--amber)",
+ type: "number",
+ step: "0.1",
+ },
+ {
+ label: "Temp (ยฐC)",
+ name: "temp",
+ icon: Thermometer,
+ color: "var(--blue)",
+ type: "number",
+ step: "0.5",
+ },
+ {
+ label: "Humidity (%)",
+ name: "humidity",
+ icon: Wind,
+ color: "#a78bfa",
+ type: "number",
+ step: "1",
+ },
+ {
+ label: "Crop Type",
+ name: "crop",
+ icon: Sprout,
+ color: "var(--green)",
+ type: "select",
+ opts: [
+ "Lettuce",
+ "Tomato",
+ "Cucumber",
+ "Basil",
+ "Spinach",
+ "Kale",
+ "Strawberry",
+ "Pepper",
+ ],
+ },
+ {
+ label: "Growth Stage",
+ name: "stage",
+ icon: Calendar,
+ color: "var(--text-3)",
+ type: "select",
+ opts: ["Seedling", "Vegetative", "Flowering", "Fruiting"],
+ },
+ {
+ label: "Batch / Crop ID",
+ name: "crop_id",
+ icon: Database,
+ color: "var(--text-3)",
+ type: "text",
+ placeholder: "e.g. Batch_A1 (optional)",
+ },
+];
+
+// Cycle status strip
+const CYCLE_PHASES = [
+ { key: "fetch", label: "Fetch", icon: Database },
+ { key: "judge", label: "Judge", icon: Zap },
+ { key: "strategy", label: "Strategy", icon: Brain },
+ { key: "research", label: "Research", icon: Leaf },
+ { key: "plan", label: "Plan", icon: Cpu },
+ { key: "execute", label: "Execute", icon: Play },
+];
+
+function phaseFromLogs(logs) {
+ const last = logs[logs.length - 1]?.text?.toUpperCase() || "";
+ if (last.includes("SENT TO SIMULATOR") || last.includes("CYCLE COMPLETE"))
+ return "execute";
+ if (last.includes("ACTIVATING") || last.includes("SUPERVISOR"))
+ return "execute";
+ if (
+ last.includes("WATER") ||
+ last.includes("ATMOSPHERIC") ||
+ last.includes("MERGING")
+ )
+ return "plan";
+ if (last.includes("RESEARCH") || last.includes("KNOWLEDGE"))
+ return "research";
+ if (last.includes("BANDIT") || last.includes("STRATEGY")) return "strategy";
+ if (last.includes("JUDGE")) return "judge";
+ if (last.includes("FETCHER") || last.includes("FMU")) return "fetch";
+ return null;
+}
+
+// Single log line
+function LogLine({ entry, idx }) {
+ const agent = AGENT_META[entry.agent] || AGENT_META.SYSTEM;
+ const lvlColor = LEVEL_COLORS[entry.level] || LEVEL_COLORS.info;
+
+ return (
+ <div
+ className="animate-fade-in"
+ style={{
+ display: "flex",
+ alignItems: "flex-start",
+ gap: 10,
+ padding: "5px 0",
+ borderBottom: "1px solid rgba(255,255,255,0.03)",
+ animationDelay: `${idx * 20}ms`,
+ }}
+ >
+ {/* Line number */}
+ <span
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ minWidth: 28,
+ paddingTop: 2,
+ }}
+ >
+ {String(idx + 1).padStart(3, "0")}
+ </span>
+ {/* Timestamp */}
+ <span
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ minWidth: 54,
+ paddingTop: 2,
+ flexShrink: 0,
+ }}
+ >
+ {entry.time}
+ </span>
+ {/* Agent badge */}
+ <span
+ style={{
+ fontSize: 9,
+ fontFamily: "DM Mono, monospace",
+ padding: "2px 7px",
+ borderRadius: 4,
+ background: agent.bg,
+ color: agent.color,
+ border: `1px solid ${agent.color}30`,
+ flexShrink: 0,
+ minWidth: 80,
+ textAlign: "center",
+ marginTop: 1,
+ }}
+ >
+ {agent.label}
+ </span>
+ {/* Message */}
+ <span
+ style={{
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
+ color: lvlColor,
+ flex: 1,
+ wordBreak: "break-all",
+ lineHeight: 1.5,
+ }}
+ >
+ {entry.text}
+ </span>
+ </div>
+ );
+}
+
+// MAIN
+export default function AddCrop() {
+ const navigate = useNavigate();
+ const { refreshData } = useFarmData();
+
+ const [file, setFile] = useState(null);
+ const [preview, setPreview] = useState(null);
+ const [sensors, setSensors] = useState({
+ pH: "6.0",
+ EC: "1.4",
+ temp: "24.0",
+ humidity: "65",
+ crop: "Lettuce",
+ stage: "Vegetative",
+ crop_id: "",
+ });
+
+ const [phase, setPhase] = useState("idle"); // idle | running | done | error
+ const [logs, setLogs] = useState([]);
+ const [cycles, setCycles] = useState(0);
+ const [activePhase, setActivePhase] = useState(null);
+ const [finalAction, setFinalAction] = useState(null);
+ const [toast, setToast] = useState(null);
+
+ const logEndRef = useRef(null);
+ const timersRef = useRef([]);
+
+ const AGENT_API =
+ process.env.REACT_APP_AGENT_API_URL || "http://localhost:8000";
+
+ const showToast = (msg, type = "success") => {
+ setToast({ msg, type });
+ setTimeout(() => setToast(null), 3500);
+ };
+
+ // Auto-scroll log
+ useEffect(() => {
+ logEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ }, [logs]);
+
+ // Cleanup timers on unmount
+ useEffect(() => () => timersRef.current.forEach(clearTimeout), []);
+
+ const pushLog = useCallback((text, agentKey) => {
+ const now = new Date();
+ const time = now.toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+ const agent = agentKey || agentFromLine(text);
+ const level = levelFromLine(text);
+ setLogs((prev) => [...prev, { text, agent, level, time }]);
+ }, []);
+
+ async function startCycle() {
+ if (phase === "running") return;
+
+ setPhase("running");
+ setLogs([]);
+ setFinalAction(null);
+
+ try {
+ const formData = new FormData();
+ if (file) {
+ formData.append("file", file);
+ } else {
+ // Placeholder 1x1 png
+ const r = await fetch(
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
+ );
+ const blob = await r.blob();
+ formData.append(
+ "file",
+ new File([blob], "placeholder.png", { type: "image/png" }),
+ );
+ }
+ formData.append(
+ "sensors",
+ JSON.stringify({
+ pH: parseFloat(sensors.pH),
+ EC: parseFloat(sensors.EC),
+ temp: parseFloat(sensors.temp),
+ humidity: parseFloat(sensors.humidity),
+ crop: sensors.crop,
+ stage: sensors.stage,
+ crop_id: sensors.crop_id || undefined,
+ }),
+ );
+
+ const response = await fetch(`${AGENT_API}/run-cycle-stream`, {
+ method: "POST",
+ body: formData,
+ });
+
+ if (!response.ok) throw new Error("Backend connection failed");
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const parts = buffer.split("\n\n");
+ buffer = parts.pop();
+
+ for (const part of parts) {
+ if (part.startsWith("data: ")) {
+ try {
+ const msg = JSON.parse(part.replace("data: ", ""));
+ pushLog(msg.text, msg.agent);
+ if (msg.final_action) setFinalAction(msg.final_action);
+ if (msg.phase === "done") {
+ setPhase("done");
+ setCycles((c) => c + 1);
+ refreshData(); // Refresh global state
+ showToast("Cycle complete โ crop registered โ");
+ }
+ } catch (e) {
+ console.error("Parse error", e);
+ }
+ }
+ }
+ }
+ } catch (err) {
+ console.error(err);
+ setPhase("error");
+ pushLog(`โ Connection Error: ${err.message}`, "SYSTEM");
+ showToast("Failed to connect to agent pipeline", "error");
+ }
+ }
+
+ // Track active pipeline phase from logs
+ useEffect(() => {
+ if (logs.length) setActivePhase(phaseFromLogs(logs));
+ }, [logs]);
+
+ const handleFile = (e) => {
+ if (e.target.files?.[0]) {
+ setFile(e.target.files[0]);
+ setPreview(URL.createObjectURL(e.target.files[0]));
+ }
+ };
+
+ const handleDrop = (e) => {
+ e.preventDefault();
+ const f = e.dataTransfer.files?.[0];
+ if (f && f.type.startsWith("image/")) {
+ setFile(f);
+ setPreview(URL.createObjectURL(f));
+ }
+ };
+
+ const phaseIndex = CYCLE_PHASES.findIndex((p) => p.key === activePhase);
+
+ return (
+ <div
+ style={{
+ display: "flex",
+ height: "100vh",
+ overflow: "hidden",
+ background: "var(--bg)",
+ }}
+ >
+ <Sidebar />
+
+ {/* Toast */}
+ {toast && (
+ <div
+ className="animate-fade-in"
+ style={{
+ position: "fixed",
+ bottom: 30,
+ left: "50%",
+ transform: "translateX(-50%)",
+ zIndex: 100,
+ padding: "12px 24px",
+ borderRadius: 12,
+ fontSize: 14,
+ fontWeight: 600,
+ fontFamily: "DM Mono, monospace",
+ background:
+ toast.type === "error"
+ ? "rgba(248,113,113,0.95)"
+ : "rgba(34,197,94,0.95)",
+ border: `1px solid ${toast.type === "error" ? "rgba(248,113,113,0.4)" : "rgba(74,222,128,0.4)"}`,
+ color: "white",
+ boxShadow: "0 8px 32px rgba(0,0,0,0.4)",
+ display: "flex",
+ alignItems: "center",
+ gap: 10,
+ }}
+ >
+ {toast.type === "error" ? (
+ <AlertTriangle size={18} />
+ ) : (
+ <CheckCircle2 size={18} />
+ )}
+ {toast.msg}
+ </div>
+ )}
+
+ <main
+ style={{
+ flex: 1,
+ display: "flex",
+ flexDirection: "column",
+ overflow: "hidden",
+ }}
+ >
+ {/* Header */}
+ <header
+ style={{
+ flexShrink: 0,
+ padding: "0 24px",
+ height: 64,
+ borderBottom: "1px solid var(--border)",
+ background: "var(--bg-2)",
+ display: "flex",
+ alignItems: "center",
+ gap: 12,
+ }}
+ >
+ <button
+ onClick={() => navigate("/dashboard")}
+ style={{
+ width: 34,
+ height: 34,
+ borderRadius: 8,
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ color: "var(--text-3)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ cursor: "pointer",
+ }}
+ >
+ <ArrowLeft size={15} />
+ </button>
+
+ <div
+ style={{
+ width: 32,
+ height: 32,
+ borderRadius: 8,
+ flexShrink: 0,
+ background: "rgba(74,222,128,0.1)",
+ border: "1px solid rgba(74,222,128,0.2)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
+ <Sprout size={15} style={{ color: "var(--green)" }} />
+ </div>
+
+ <div>
+ <h1 className="page-title">Add New Crop</h1>
+ <p className="page-subtitle">
+ Configure parameters ยท Start cycle ยท Watch agents reason live
+ </p>
+ </div>
+
+ {/* Cycle counter */}
+ {cycles > 0 && (
+ <div
+ style={{
+ marginLeft: "auto",
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ padding: "5px 14px",
+ borderRadius: 20,
+ background: "rgba(74,222,128,0.1)",
+ border: "1px solid rgba(74,222,128,0.25)",
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--green)",
+ }}
+ >
+ <span
+ className="status-dot"
+ style={{
+ width: 6,
+ height: 6,
+ borderRadius: "50%",
+ background: "var(--green)",
+ }}
+ />
+ {cycles} CYCLE{cycles !== 1 ? "S" : ""} DONE
+ </div>
+ )}
+ </header>
+
+ {/* Pipeline phase strip */}
+ {phase === "running" && (
+ <div
+ className="animate-fade-in"
+ style={{
+ flexShrink: 0,
+ padding: "10px 24px",
+ borderBottom: "1px solid var(--border)",
+ background: "var(--bg-3)",
+ display: "flex",
+ alignItems: "center",
+ gap: 0,
+ overflowX: "auto",
+ }}
+ >
+ {CYCLE_PHASES.map((p, i) => {
+ const done = phaseIndex > i;
+ const current = phaseIndex === i;
+ const Icon = p.icon;
+ return (
+ <div
+ key={p.key}
+ style={{ display: "flex", alignItems: "center" }}
+ >
+ <div
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 6,
+ padding: "5px 12px",
+ borderRadius: 20,
+ flexShrink: 0,
+ background: current
+ ? "rgba(74,222,128,0.15)"
+ : done
+ ? "rgba(74,222,128,0.07)"
+ : "transparent",
+ border: `1px solid ${current ? "rgba(74,222,128,0.4)" : done ? "rgba(74,222,128,0.2)" : "transparent"}`,
+ transition: "all 0.3s",
+ }}
+ >
+ {done ? (
+ <CheckCircle2
+ size={12}
+ style={{ color: "var(--green)" }}
+ />
+ ) : current ? (
+ <Activity
+ size={12}
+ style={{ color: "var(--green)" }}
+ className="animate-spin"
+ />
+ ) : (
+ <Circle size={12} style={{ color: "var(--text-3)" }} />
+ )}
+ <span
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ color:
+ current || done ? "var(--green)" : "var(--text-3)",
+ fontWeight: current ? 700 : 400,
+ }}
+ >
+ {p.label}
+ </span>
+ </div>
+ {i < CYCLE_PHASES.length - 1 && (
+ <ChevronRight
+ size={12}
+ style={{
+ color: done ? "var(--green)" : "var(--border)",
+ margin: "0 2px",
+ flexShrink: 0,
+ }}
+ />
+ )}
+ </div>
+ );
+ })}
+ </div>
+ )}
+
+ {/* Main content */}
+ <div
+ style={{
+ flex: 1,
+ overflowY: "auto",
+ padding: 24,
+ display: "flex",
+ flexDirection: "column",
+ gap: 20,
+ }}
+ >
+ {/* Top grid: image + sensors */}
+ <div
+ style={{ display: "grid", gridTemplateColumns: "1fr 2fr", gap: 20 }}
+ >
+ {/* Image upload */}
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
+ <div className="section-label">PLANT IMAGE</div>
+ <label
+ onDrop={handleDrop}
+ onDragOver={(e) => e.preventDefault()}
+ style={{
+ position: "relative",
+ display: "block",
+ borderRadius: 16,
+ overflow: "hidden",
+ cursor: "pointer",
+ height: 260,
+ background: "var(--surface)",
+ border: preview
+ ? "2px solid rgba(74,222,128,0.3)"
+ : "2px dashed var(--border)",
+ transition: "border-color 0.2s",
+ }}
+ >
+ <input
+ type="file"
+ accept="image/*"
+ onChange={handleFile}
+ style={{
+ position: "absolute",
+ inset: 0,
+ opacity: 0,
+ cursor: "pointer",
+ zIndex: 10,
+ }}
+ />
+ {preview ? (
+ <>
+ <img
+ src={preview}
+ alt="preview"
+ style={{
+ width: "100%",
+ height: "100%",
+ objectFit: "cover",
+ }}
+ />
+ <div
+ style={{
+ position: "absolute",
+ inset: 0,
+ background:
+ "linear-gradient(to top, rgba(12,26,14,0.5) 0%, transparent 60%)",
+ }}
+ />
+ <div
+ style={{
+ position: "absolute",
+ bottom: 10,
+ left: 12,
+ right: 12,
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-2)",
+ }}
+ >
+ {file?.name?.slice(0, 30)}
+ </div>
+ </>
+ ) : (
+ <div
+ style={{
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ height: "100%",
+ gap: 14,
+ padding: 24,
+ }}
+ >
+ <div
+ style={{
+ width: 52,
+ height: 52,
+ borderRadius: 16,
+ background: "var(--bg-3)",
+ border: "1px solid var(--border)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
+ <Upload size={22} style={{ color: "var(--text-3)" }} />
+ </div>
+ <div style={{ textAlign: "center" }}>
+ <div
+ style={{
+ fontWeight: 600,
+ fontSize: 14,
+ color: "var(--text-2)",
+ }}
+ >
+ Drop crop image
+ </div>
+ <div
+ style={{
+ fontSize: 11,
+ marginTop: 4,
+ color: "var(--text-3)",
+ }}
+ >
+ PNG, JPG ยท optional but recommended
+ </div>
+ </div>
+ </div>
+ )}
+ </label>
+
+ {/* Start button */}
+ <button
+ onClick={startCycle}
+ disabled={phase === "running"}
+ style={{
+ padding: "14px 0",
+ borderRadius: 12,
+ fontSize: 14,
+ fontWeight: 700,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 8,
+ cursor: phase === "running" ? "not-allowed" : "pointer",
+ background:
+ phase === "running"
+ ? "rgba(74,222,128,0.1)"
+ : phase === "done"
+ ? "rgba(74,222,128,0.15)"
+ : "var(--green)",
+ border:
+ phase === "running" || phase === "done"
+ ? "1px solid rgba(74,222,128,0.4)"
+ : "none",
+ color:
+ phase === "running" || phase === "done"
+ ? "var(--green)"
+ : "#0c1a0e",
+ opacity: phase === "running" ? 0.8 : 1,
+ transition: "all 0.2s",
+ boxShadow:
+ phase === "idle" ? "0 0 20px rgba(74,222,128,0.2)" : "none",
+ }}
+ >
+ {phase === "running" ? (
+ <>
+ <Activity size={15} className="animate-spin" /> Running
+ Agents...
+ </>
+ ) : phase === "done" ? (
+ <>
+ <CheckCircle2 size={15} /> Run Another Cycle
+ </>
+ ) : (
+ <>
+ <Play size={15} fill="currentColor" /> Start Agent Cycle
+ </>
+ )}
+ </button>
+ </div>
+
+ {/* Sensor inputs */}
+ <div
+ style={{
+ borderRadius: 16,
+ padding: 22,
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ }}
+ >
+ <div className="section-label">SENSOR PARAMETERS</div>
+ <div
+ style={{
+ display: "grid",
+ gridTemplateColumns: "1fr 1fr",
+ gap: 14,
+ }}
+ >
+ {INPUT_FIELDS.map(
+ ({
+ label,
+ name,
+ icon: Icon,
+ color,
+ type,
+ opts,
+ placeholder,
+ step,
+ min,
+ max,
+ }) => (
+ <div key={name}>
+ <div
+ className="sensor-label"
+ style={{ color, marginBottom: 5 }}
+ >
+ {label.toUpperCase()}
+ </div>
+ <div style={{ position: "relative" }}>
+ <Icon
+ size={12}
+ style={{
+ position: "absolute",
+ left: 10,
+ top: "50%",
+ transform: "translateY(-50%)",
+ color,
+ zIndex: 1,
+ }}
+ />
+ {type === "select" ? (
+ <>
+ <select
+ value={sensors[name]}
+ onChange={(e) =>
+ setSensors({
+ ...sensors,
+ [name]: e.target.value,
+ })
+ }
+ disabled={phase === "running"}
+ style={{
+ width: "100%",
+ appearance: "none",
+ paddingLeft: 30,
+ paddingRight: 28,
+ paddingTop: 9,
+ paddingBottom: 9,
+ borderRadius: 8,
+ fontSize: 13,
+ fontFamily: "DM Mono, monospace",
+ background: "var(--bg-3)",
+ border: "1px solid var(--border)",
+ color: "var(--text)",
+ outline: "none",
+ cursor: "pointer",
+ opacity: phase === "running" ? 0.7 : 1,
+ }}
+ >
+ {opts.map((o) => (
+ <option key={o} value={o}>
+ {o}
+ </option>
+ ))}
+ </select>
+ <ChevronDown
+ size={10}
+ style={{
+ position: "absolute",
+ right: 10,
+ top: "50%",
+ transform: "translateY(-50%)",
+ pointerEvents: "none",
+ color: "var(--text-3)",
+ }}
+ />
+ </>
+ ) : (
+ <input
+ value={sensors[name]}
+ onChange={(e) =>
+ setSensors({ ...sensors, [name]: e.target.value })
+ }
+ type={type}
+ placeholder={placeholder || ""}
+ step={step}
+ min={min}
+ max={max}
+ disabled={phase === "running"}
+ style={{
+ width: "100%",
+ paddingLeft: 30,
+ paddingRight: 10,
+ paddingTop: 9,
+ paddingBottom: 9,
+ borderRadius: 8,
+ fontSize: 13,
+ fontFamily: "DM Mono, monospace",
+ background: "var(--bg-3)",
+ border: "1px solid var(--border)",
+ color: "var(--text)",
+ outline: "none",
+ boxSizing: "border-box",
+ opacity: phase === "running" ? 0.7 : 1,
+ }}
+ />
+ )}
+ </div>
+ </div>
+ ),
+ )}
+ </div>
+ </div>
+ </div>
+
+ {/* Live agent log */}
+ {(phase !== "idle" || logs.length > 0) && (
+ <div
+ className="animate-fade-up"
+ style={{
+ borderRadius: 16,
+ overflow: "hidden",
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ flexShrink: 0,
+ }}
+ >
+ {/* Log header */}
+ <div
+ style={{
+ padding: "12px 18px",
+ borderBottom: "1px solid var(--border)",
+ background: "var(--bg-3)",
+ display: "flex",
+ alignItems: "center",
+ gap: 12,
+ }}
+ >
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
+ {phase === "running" ? (
+ <span
+ className="alert-pulse"
+ style={{
+ width: 8,
+ height: 8,
+ borderRadius: "50%",
+ background: "var(--green)",
+ flexShrink: 0,
+ }}
+ />
+ ) : phase === "done" ? (
+ <CheckCircle2 size={14} style={{ color: "var(--green)" }} />
+ ) : (
+ <AlertTriangle size={14} style={{ color: "var(--red)" }} />
+ )}
+ <span
+ style={{
+ fontWeight: 700,
+ fontSize: 13,
+ color: "var(--text)",
+ fontFamily: "DM Mono, monospace",
+ }}
+ >
+ {phase === "running"
+ ? "AGENT PIPELINE โ LIVE"
+ : phase === "done"
+ ? "CYCLE COMPLETE"
+ : "PIPELINE LOG"}
+ </span>
+ </div>
+ <span
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ }}
+ >
+ {logs.length} lines
+ </span>
+
+ {/* Agent legend */}
+ <div
+ style={{
+ marginLeft: "auto",
+ display: "flex",
+ gap: 8,
+ flexWrap: "wrap",
+ }}
+ >
+ {Object.entries(AGENT_META)
+ .filter(([k]) => logs.some((l) => l.agent === k))
+ .map(([k, v]) => (
+ <span
+ key={k}
+ style={{
+ fontSize: 9,
+ fontFamily: "DM Mono, monospace",
+ padding: "2px 7px",
+ borderRadius: 4,
+ background: v.bg,
+ color: v.color,
+ border: `1px solid ${v.color}30`,
+ }}
+ >
+ {v.label}
+ </span>
+ ))}
+ </div>
+ </div>
+
+ {/* Log body */}
+ <div
+ style={{
+ padding: "12px 18px",
+ maxHeight: 340,
+ overflowY: "auto",
+ background: "#0a1509",
+ fontFamily: "DM Mono, monospace",
+ }}
+ >
+ {logs.map((entry, i) => (
+ <LogLine key={i} entry={entry} idx={i} />
+ ))}
+ {phase === "running" && (
+ <div
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 6,
+ padding: "4px 0",
+ marginTop: 2,
+ }}
+ >
+ <span
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ minWidth: 28,
+ }}
+ />
+ <span
+ style={{
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--green)",
+ }}
+ className="cursor-blink"
+ >
+ {" "}
+ </span>
+ </div>
+ )}
+ <div ref={logEndRef} />
+ </div>
+ </div>
+ )}
+
+ {/* Final action cards */}
+ {finalAction && phase === "done" && (
+ <div
+ className="animate-fade-up"
+ style={{
+ borderRadius: 16,
+ overflow: "hidden",
+ background: "var(--surface)",
+ border: "1px solid rgba(74,222,128,0.3)",
+ flexShrink: 0,
+ }}
+ >
+ <div
+ style={{
+ padding: "12px 18px",
+ borderBottom: "1px solid var(--border)",
+ background: "rgba(74,222,128,0.05)",
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ }}
+ >
+ <CheckCircle2 size={14} style={{ color: "var(--green)" }} />
+ <span
+ style={{
+ fontWeight: 700,
+ fontSize: 13,
+ color: "var(--text)",
+ }}
+ >
+ ACTUATOR COMMANDS DISPATCHED
+ </span>
+ </div>
+ <div
+ style={{
+ padding: "18px 20px",
+ display: "grid",
+ gridTemplateColumns: "repeat(5, 1fr)",
+ gap: 12,
+ }}
+ >
+ {[
+ {
+ key: "acid_dosage_ml",
+ label: "Acid",
+ unit: "ml",
+ icon: FlaskConical,
+ color: "var(--red)",
+ },
+ {
+ key: "base_dosage_ml",
+ label: "Base",
+ unit: "ml",
+ icon: FlaskConical,
+ color: "#a78bfa",
+ },
+ {
+ key: "nutrient_dosage_ml",
+ label: "Nutrients",
+ unit: "ml",
+ icon: Sprout,
+ color: "var(--green)",
+ },
+ {
+ key: "fan_speed_pct",
+ label: "Fan",
+ unit: "%",
+ icon: Fan,
+ color: "var(--blue)",
+ },
+ {
+ key: "water_refill_l",
+ label: "Water",
+ unit: "L",
+ icon: Waves,
+ color: "#22d3ee",
+ },
+ ].map(({ key, label, unit, icon: Icon, color }) => {
+ const val = finalAction[key] ?? 0;
+ const active = parseFloat(val) > 0;
+ return (
+ <div
+ key={key}
+ style={{
+ borderRadius: 12,
+ padding: "14px 10px",
+ textAlign: "center",
+ background: active ? `${color}12` : "var(--bg-3)",
+ border: `1px solid ${active ? color + "40" : "var(--border)"}`,
+ transition: "all 0.3s",
+ }}
+ >
+ <Icon
+ size={16}
+ style={{
+ color: active ? color : "var(--text-3)",
+ margin: "0 auto 6px",
+ }}
+ />
+ <div
+ style={{
+ fontWeight: 700,
+ fontFamily: "DM Mono, monospace",
+ fontSize: 22,
+ color: active ? color : "var(--text-3)",
+ lineHeight: 1,
+ }}
+ >
+ {val}
+ </div>
+ <div
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ marginTop: 2,
+ }}
+ >
+ {unit}
+ </div>
+ <div
+ style={{
+ fontSize: 11,
+ color: active ? "var(--text-2)" : "var(--text-3)",
+ marginTop: 4,
+ fontWeight: active ? 600 : 400,
+ }}
+ >
+ {label}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ <div style={{ padding: "0 20px 16px", display: "flex", gap: 10 }}>
+ <button
+ onClick={() => navigate("/dashboard")}
+ style={{
+ padding: "9px 20px",
+ borderRadius: 10,
+ fontSize: 13,
+ fontWeight: 600,
+ background: "var(--green)",
+ border: "none",
+ color: "#0c1a0e",
+ cursor: "pointer",
+ }}
+ >
+ View in Dashboard โ
+ </button>
+ <button
+ onClick={startCycle}
+ style={{
+ padding: "9px 20px",
+ borderRadius: 10,
+ fontSize: 13,
+ fontWeight: 600,
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ color: "var(--text-2)",
+ cursor: "pointer",
+ }}
+ >
+ Run Next Cycle
+ </button>
+ </div>
+ </div>
+ )}
+ </div>
+ </main>
+ </div>
+ );
+}
diff --git a/frontend/src/pages/AgentControl.jsx b/frontend/src/pages/AgentControl.jsx
@@ -1,857 +0,0 @@
-import { useRef, useState } from "react";
-import {
- Upload,
- Save,
- Activity,
- Droplets,
- Thermometer,
- Wind,
- Sprout,
- Calendar,
- Database,
- Mic,
- Square,
- Brain,
- ChevronDown,
- Eye,
-} from "lucide-react";
-import { agentService } from "../api/agentApi";
-import { extractSensors } from "../utils/dataUtils";
-import {
- AgentActionWidget,
- AgentOutcomeWidget,
-} from "../components/AgentWidgets";
-import Sidebar from "../components/Sidebar";
-
-const INPUT_FIELDS = [
- {
- label: "pH Level",
- name: "pH",
- icon: Droplets,
- color: "var(--green)",
- type: "number",
- },
- {
- label: "EC (mS/cm)",
- name: "EC",
- icon: Activity,
- color: "var(--amber)",
- type: "number",
- },
- {
- label: "Temp (ยฐC)",
- name: "temp",
- icon: Thermometer,
- color: "var(--blue)",
- type: "number",
- },
- {
- label: "Humidity (%)",
- name: "humidity",
- icon: Wind,
- color: "#a78bfa",
- type: "number",
- },
- {
- label: "Crop Type",
- name: "crop",
- icon: Sprout,
- color: "var(--green)",
- type: "select",
- opts: ["Lettuce", "Tomato", "Cucumber", "Basil", "Spinach"],
- },
- {
- label: "Stage",
- name: "stage",
- icon: Calendar,
- color: "var(--text-3)",
- type: "select",
- opts: ["Seedling", "Vegetative", "Flowering", "Fruiting"],
- },
- {
- label: "Crop ID (Opt)",
- name: "crop_id",
- icon: Database,
- color: "var(--text-3)",
- type: "text",
- placeholder: "Eg. Batch_A1",
- },
-];
-
-export default function AgentControl() {
- const [file, setFile] = useState(null);
- const [preview, setPreview] = useState(null);
-
- const [loadingIngest, setLoadingIngest] = useState(false);
- const [loadingSearch, setLoadingSearch] = useState(false);
-
- const [searchResults, setSearchResults] = useState([]);
- const [textQuery, setTextQuery] = useState("");
-
- const [showExplain, setShowExplain] = useState(false);
- const [explanation, setExplanation] = useState("");
-
- const [isRecording, setIsRecording] = useState(false);
- const mediaRecorderRef = useRef(null);
- const chunksRef = useRef([]);
-
- const [decision, setDecision] = useState(null);
- const [toast, setToast] = useState(null);
-
- const [sensors, setSensors] = useState({
- pH: "6.0",
- EC: "1.2",
- temp: "24.0",
- humidity: "60",
- crop: "Lettuce",
- stage: "Vegetative",
- crop_id: "",
- });
-
- const showToast = (msg, type = "success") => {
- setToast({ msg, type });
- setTimeout(() => setToast(null), 3000);
- };
-
- const handleFile = (e) => {
- if (e.target.files?.[0]) {
- setFile(e.target.files[0]);
- setPreview(URL.createObjectURL(e.target.files[0]));
- setDecision(null);
- }
- };
-
- const handleIngest = async () => {
- if (!file) return showToast("Select an image first", "error");
- setLoadingIngest(true);
- try {
- await agentService.uploadFMU(file, sensors);
- showToast("Memory stored successfully");
- } catch {
- showToast("Ingest failed", "error");
- } finally {
- setLoadingIngest(false);
- }
- };
-
- const handleSearch = async () => {
- if (!file) return showToast("Select an image to analyze", "error");
- setLoadingSearch(true);
- setDecision(null);
- try {
- const res = await agentService.searchFMU(file, sensors);
- if (res.explanation) setExplanation(res.explanation);
- if (res.agent_decision) setDecision(res.agent_decision);
- setSearchResults(res.search_results || []);
- } catch {
- showToast("Analysis failed", "error");
- } finally {
- setLoadingSearch(false);
- }
- };
-
- const handleTextQuery = async () => {
- if (!textQuery) return;
- setLoadingSearch(true);
- try {
- const data = await agentService.queryText(textQuery);
- if (data.results)
- setSearchResults(
- data.results.map((r) => ({
- id: r.id,
- score: r.score || 1,
- payload: r.payload,
- })),
- );
- } catch {
- showToast("Query failed", "error");
- } finally {
- setLoadingSearch(false);
- }
- };
-
- const startRecording = async () => {
- try {
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
- mediaRecorderRef.current = new MediaRecorder(stream);
- chunksRef.current = [];
- mediaRecorderRef.current.ondataavailable = (e) => {
- if (e.data.size > 0) chunksRef.current.push(e.data);
- };
- mediaRecorderRef.current.onstop = async () => {
- const blob = new Blob(chunksRef.current, { type: "audio/webm" });
- setLoadingSearch(true);
- try {
- const data = await agentService.queryAudio(blob);
- if (data.transcription) setTextQuery(data.transcription);
- if (data.results)
- setSearchResults(
- data.results.map((r) => ({
- id: r.id,
- score: r.score || 1,
- payload: r.payload,
- })),
- );
- } finally {
- setLoadingSearch(false);
- }
- stream.getTracks().forEach((t) => t.stop());
- };
- mediaRecorderRef.current.start();
- setIsRecording(true);
- } catch {
- showToast("Microphone access denied", "error");
- }
- };
-
- const stopRecording = () => {
- if (mediaRecorderRef.current && isRecording) {
- mediaRecorderRef.current.stop();
- setIsRecording(false);
- }
- };
-
- return (
- <div
- style={{
- display: "flex",
- height: "100vh",
- overflow: "hidden",
- background: "var(--bg)",
- }}
- >
- <Sidebar />
-
- {/* Toast */}
- {toast && (
- <div
- className="animate-fade-in"
- style={{
- position: "fixed",
- top: 16,
- right: 16,
- zIndex: 50,
- padding: "10px 16px",
- borderRadius: 12,
- fontSize: 13,
- fontFamily: "DM Mono, monospace",
- background:
- toast.type === "error"
- ? "rgba(248,113,113,0.15)"
- : "rgba(74,222,128,0.15)",
- border: `1px solid ${toast.type === "error" ? "rgba(248,113,113,0.4)" : "rgba(74,222,128,0.4)"}`,
- color: toast.type === "error" ? "var(--red)" : "var(--green)",
- }}
- >
- {toast.msg}
- </div>
- )}
-
- <main
- style={{
- flex: 1,
- display: "flex",
- flexDirection: "column",
- overflow: "hidden",
- }}
- >
- {/* Header */}
- <header
- style={{
- flexShrink: 0,
- padding: "0 24px",
- height: 64,
- borderBottom: "1px solid var(--border)",
- background: "var(--bg-2)",
- display: "flex",
- alignItems: "center",
- gap: 12,
- }}
- >
- <div
- style={{
- width: 32,
- height: 32,
- borderRadius: 8,
- background: "rgba(74,222,128,0.1)",
- border: "1px solid rgba(74,222,128,0.2)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- }}
- >
- <Brain size={15} style={{ color: "var(--green)" }} />
- </div>
- <div>
- <h1 className="page-title">Agent Control</h1>
- <p className="page-subtitle">
- Ingest memories ยท Query the Supervisor ยท Run analysis
- </p>
- </div>
- </header>
-
- <div
- style={{
- flex: 1,
- overflowY: "auto",
- padding: 24,
- display: "flex",
- flexDirection: "column",
- gap: 20,
- }}
- >
- {/* Search bar */}
- <div
- style={{
- display: "flex",
- gap: 8,
- padding: 8,
- borderRadius: 12,
- background: "var(--surface)",
- border: "1px solid var(--border)",
- }}
- >
- <button
- onClick={isRecording ? stopRecording : startRecording}
- style={{
- width: 34,
- height: 34,
- borderRadius: 8,
- flexShrink: 0,
- cursor: "pointer",
- background: isRecording
- ? "rgba(248,113,113,0.15)"
- : "var(--bg-3)",
- border: `1px solid ${isRecording ? "rgba(248,113,113,0.4)" : "var(--border)"}`,
- color: isRecording ? "var(--red)" : "var(--text-3)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- }}
- >
- {isRecording ? <Square size={13} /> : <Mic size={13} />}
- </button>
- <input
- value={textQuery}
- onChange={(e) => setTextQuery(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && handleTextQuery()}
- placeholder="Ask Demeter: 'Show all failed Lettuce crops'โฆ"
- style={{
- flex: 1,
- background: "transparent",
- border: "none",
- outline: "none",
- fontSize: 14,
- fontFamily: "DM Mono, monospace",
- color: "var(--text)",
- caretColor: "var(--green)",
- }}
- />
- <button
- onClick={handleTextQuery}
- style={{
- padding: "6px 18px",
- borderRadius: 8,
- fontSize: 13,
- fontWeight: 600,
- background: "var(--green)",
- color: "#0c1a0e",
- border: "none",
- cursor: "pointer",
- }}
- >
- Ask
- </button>
- </div>
-
- {/* Main grid */}
- <div
- style={{ display: "grid", gridTemplateColumns: "2fr 3fr", gap: 20 }}
- >
- {/* Image upload */}
- <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
- <label
- style={{
- position: "relative",
- display: "block",
- borderRadius: 16,
- overflow: "hidden",
- cursor: "pointer",
- height: 300,
- background: "var(--surface)",
- border: "2px dashed var(--border)",
- }}
- >
- <input
- type="file"
- onChange={handleFile}
- style={{
- position: "absolute",
- inset: 0,
- opacity: 0,
- cursor: "pointer",
- zIndex: 10,
- }}
- />
- {preview ? (
- <>
- <img
- src={preview}
- alt="preview"
- style={{
- width: "100%",
- height: "100%",
- objectFit: "cover",
- }}
- />
- <div
- style={{
- position: "absolute",
- inset: 0,
- background:
- "linear-gradient(to top, rgba(12,26,14,0.6) 0%, transparent 60%)",
- }}
- />
- </>
- ) : (
- <div
- style={{
- display: "flex",
- flexDirection: "column",
- alignItems: "center",
- justifyContent: "center",
- height: "100%",
- gap: 12,
- padding: 24,
- }}
- >
- <div
- style={{
- width: 48,
- height: 48,
- borderRadius: 16,
- background: "var(--bg-3)",
- border: "1px solid var(--border)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- }}
- >
- <Upload size={20} style={{ color: "var(--text-3)" }} />
- </div>
- <div style={{ textAlign: "center" }}>
- <div
- style={{
- fontWeight: 600,
- fontSize: 14,
- color: "var(--text-2)",
- }}
- >
- Drop crop image
- </div>
- <div
- style={{
- fontSize: 12,
- marginTop: 4,
- color: "var(--text-3)",
- }}
- >
- PNG, JPG up to 10MB
- </div>
- </div>
- </div>
- )}
- </label>
-
- <div
- style={{
- display: "grid",
- gridTemplateColumns: "1fr 1fr",
- gap: 10,
- }}
- >
- <button
- onClick={handleIngest}
- disabled={loadingIngest || loadingSearch}
- style={{
- padding: "10px 0",
- borderRadius: 12,
- fontSize: 13,
- fontWeight: 600,
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- gap: 6,
- cursor: "pointer",
- background: "var(--surface)",
- border: "1px solid var(--border)",
- color: "var(--text-2)",
- opacity: loadingIngest || loadingSearch ? 0.6 : 1,
- }}
- >
- {loadingIngest ? (
- <Activity size={13} className="animate-spin" />
- ) : (
- <>
- <Save size={13} /> Store
- </>
- )}
- </button>
- <button
- onClick={handleSearch}
- disabled={loadingIngest || loadingSearch}
- style={{
- padding: "10px 0",
- borderRadius: 12,
- fontSize: 13,
- fontWeight: 600,
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- gap: 6,
- cursor: "pointer",
- background: "var(--green)",
- border: "none",
- color: "#0c1a0e",
- opacity: loadingIngest || loadingSearch ? 0.6 : 1,
- }}
- >
- {loadingSearch ? (
- <Activity size={13} className="animate-spin" />
- ) : (
- <>
- <Brain size={13} /> Analyze
- </>
- )}
- </button>
- </div>
- </div>
-
- {/* Sensor inputs */}
- <div
- style={{
- borderRadius: 16,
- padding: 20,
- background: "var(--surface)",
- border: "1px solid var(--border)",
- }}
- >
- <div className="section-label">SENSOR PARAMETERS</div>
- <div
- style={{
- display: "grid",
- gridTemplateColumns: "1fr 1fr",
- gap: 14,
- }}
- >
- {INPUT_FIELDS.map(
- ({
- label,
- name,
- icon: Icon,
- color,
- type,
- opts,
- placeholder,
- }) => (
- <div key={name}>
- <div
- className="sensor-label"
- style={{ color, marginBottom: 5 }}
- >
- {label.toUpperCase()}
- </div>
- <div style={{ position: "relative" }}>
- <Icon
- size={12}
- style={{
- position: "absolute",
- left: 10,
- top: "50%",
- transform: "translateY(-50%)",
- color,
- zIndex: 1,
- }}
- />
- {type === "select" ? (
- <>
- <select
- value={sensors[name]}
- onChange={(e) =>
- setSensors({
- ...sensors,
- [name]: e.target.value,
- })
- }
- style={{
- width: "100%",
- appearance: "none",
- paddingLeft: 30,
- paddingRight: 28,
- paddingTop: 9,
- paddingBottom: 9,
- borderRadius: 8,
- fontSize: 13,
- fontFamily: "DM Mono, monospace",
- background: "var(--bg-3)",
- border: "1px solid var(--border)",
- color: "var(--text)",
- outline: "none",
- cursor: "pointer",
- }}
- >
- {opts.map((o) => (
- <option key={o} value={o}>
- {o}
- </option>
- ))}
- </select>
- <ChevronDown
- size={10}
- style={{
- position: "absolute",
- right: 10,
- top: "50%",
- transform: "translateY(-50%)",
- pointerEvents: "none",
- color: "var(--text-3)",
- }}
- />
- </>
- ) : (
- <input
- value={sensors[name]}
- onChange={(e) =>
- setSensors({ ...sensors, [name]: e.target.value })
- }
- type={type}
- placeholder={placeholder || ""}
- step={type === "number" ? "0.1" : undefined}
- style={{
- width: "100%",
- paddingLeft: 30,
- paddingRight: 10,
- paddingTop: 9,
- paddingBottom: 9,
- borderRadius: 8,
- fontSize: 13,
- fontFamily: "DM Mono, monospace",
- background: "var(--bg-3)",
- border: "1px solid var(--border)",
- color: "var(--text)",
- outline: "none",
- boxSizing: "border-box",
- }}
- />
- )}
- </div>
- </div>
- ),
- )}
- </div>
- </div>
- </div>
-
- {/* Decision output */}
- {decision && (
- <div
- className="animate-fade-up"
- style={{
- borderRadius: 16,
- overflow: "hidden",
- background: "var(--surface)",
- border: "1px solid rgba(74,222,128,0.3)",
- flexShrink: 0,
- }}
- >
- <div
- style={{
- padding: 16,
- borderBottom: "1px solid var(--border)",
- background: "rgba(74,222,128,0.05)",
- display: "flex",
- alignItems: "center",
- justifyContent: "space-between",
- }}
- >
- <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
- <Brain size={15} style={{ color: "var(--green)" }} />
- <span
- style={{
- fontWeight: 700,
- fontSize: 15,
- color: "var(--text)",
- }}
- >
- Supervisor Command
- </span>
- </div>
- <button
- onClick={() => setShowExplain(!showExplain)}
- style={{
- display: "flex",
- alignItems: "center",
- gap: 6,
- fontSize: 12,
- fontFamily: "DM Mono, monospace",
- padding: "5px 12px",
- borderRadius: 8,
- cursor: "pointer",
- background: "var(--surface-2)",
- border: "1px solid var(--border)",
- color: "var(--text-3)",
- }}
- >
- <Eye size={11} /> {showExplain ? "Hide" : "View"} logic
- </button>
- </div>
-
- <div style={{ padding: 20 }}>
- <AgentActionWidget actionTaken={decision} compact={false} />
- </div>
-
- {showExplain && explanation && (
- <div
- style={{
- borderTop: "1px solid var(--border)",
- padding: 20,
- background: "var(--bg-3)",
- }}
- >
- <div className="section-label">SUPERVISOR REASONING</div>
- <pre
- style={{
- fontSize: 12,
- fontFamily: "DM Mono, monospace",
- lineHeight: 1.8,
- whiteSpace: "pre-wrap",
- color: "var(--text-2)",
- margin: 0,
- }}
- >
- {explanation}
- </pre>
- </div>
- )}
- </div>
- )}
-
- {/* Search results */}
- {searchResults.length > 0 && (
- <div>
- <div className="section-label">
- MEMORY MATCHES ยท {searchResults.length} FOUND
- </div>
- <div
- style={{
- display: "grid",
- gridTemplateColumns: "repeat(3,1fr)",
- gap: 14,
- }}
- >
- {searchResults.map((res) => {
- const s = extractSensors(res.payload);
- return (
- <div
- key={res.id}
- className="card-hover"
- style={{
- borderRadius: 14,
- padding: 16,
- background: "var(--surface)",
- border: "1px solid var(--border)",
- }}
- >
- <div
- style={{
- display: "flex",
- alignItems: "center",
- justifyContent: "space-between",
- marginBottom: 12,
- }}
- >
- <span
- style={{
- fontWeight: 600,
- fontSize: 14,
- color: "var(--text)",
- }}
- >
- {res.payload.crop || "Unknown"}
- </span>
- <span
- style={{
- fontSize: 11,
- fontFamily: "DM Mono, monospace",
- padding: "2px 7px",
- borderRadius: 4,
- background: "rgba(74,222,128,0.1)",
- color: "var(--green)",
- }}
- >
- {((res.score || 1) * 100).toFixed(0)}%
- </span>
- </div>
-
- <div
- style={{
- display: "grid",
- gridTemplateColumns: "1fr 1fr",
- gap: 8,
- marginBottom: 12,
- }}
- >
- {[
- { label: "pH", value: s.ph, color: "var(--green)" },
- { label: "EC", value: s.ec, color: "var(--amber)" },
- {
- label: "Temp",
- value: s.temp + "ยฐ",
- color: "var(--blue)",
- },
- {
- label: "RH",
- value: s.humidity + "%",
- color: "#a78bfa",
- },
- ].map(({ label, value, color }) => (
- <div
- key={label}
- style={{
- borderRadius: 8,
- padding: "6px 10px",
- textAlign: "center",
- background: "var(--bg-3)",
- }}
- >
- <div
- className="sensor-label"
- style={{ marginBottom: 2 }}
- >
- {label}
- </div>
- <div className="sensor-value-sm" style={{ color }}>
- {value}
- </div>
- </div>
- ))}
- </div>
-
- {/* Outcome badge */}
- {res.payload.outcome && (
- <AgentOutcomeWidget
- outcome={res.payload.outcome}
- rewardScore={res.payload.reward_score}
- />
- )}
- </div>
- );
- })}
- </div>
- </div>
- )}
- </div>
- </main>
- </div>
- );
-}
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
@@ -21,6 +21,7 @@ import {
Activity,
ChevronLeft,
ChevronRight,
+ PlusCircle,
} from "lucide-react";
import Sidebar from "../components/Sidebar";
@@ -269,6 +270,68 @@ function CropCard({ data, onClick }) {
);
}
+function AddCropCard({ onClick }) {
+ return (
+ <div
+ onClick={onClick}
+ className="card-hover"
+ style={{
+ borderRadius: 16,
+ overflow: "hidden",
+ cursor: "pointer",
+ background: "rgba(74,222,128,0.04)",
+ border: "2px dashed rgba(74,222,128,0.25)",
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ minHeight: 280,
+ gap: 14,
+ padding: 24,
+ transition: "all 0.2s",
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.borderColor = "rgba(74,222,128,0.5)";
+ e.currentTarget.style.background = "rgba(74,222,128,0.07)";
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.borderColor = "rgba(74,222,128,0.25)";
+ e.currentTarget.style.background = "rgba(74,222,128,0.04)";
+ }}
+ >
+ <div
+ style={{
+ width: 52,
+ height: 52,
+ borderRadius: 16,
+ background: "rgba(74,222,128,0.12)",
+ border: "1px solid rgba(74,222,128,0.3)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
+ <PlusCircle size={24} style={{ color: "var(--green)" }} />
+ </div>
+ <div style={{ textAlign: "center" }}>
+ <div style={{ fontWeight: 700, fontSize: 14, color: "var(--green)" }}>
+ Add New Crop
+ </div>
+ <div
+ style={{
+ fontSize: 11,
+ color: "var(--text-3)",
+ marginTop: 4,
+ fontFamily: "DM Mono, monospace",
+ }}
+ >
+ Start a new cycle ยท configure sensors
+ </div>
+ </div>
+ </div>
+ );
+}
+
export default function Dashboard() {
const navigate = useNavigate();
const { dashboard, loading, refreshData } = useFarmData();
@@ -442,10 +505,31 @@ export default function Dashboard() {
))}
</div>
+ {/* Add Crop CTA */}
<button
- onClick={refreshData}
+ onClick={() => navigate("/add-crop")}
style={{
marginLeft: "auto",
+ display: "flex",
+ alignItems: "center",
+ gap: 7,
+ padding: "8px 18px",
+ borderRadius: 10,
+ fontSize: 13,
+ fontWeight: 600,
+ background: "var(--green)",
+ border: "none",
+ color: "#0c1a0e",
+ cursor: "pointer",
+ boxShadow: "0 0 16px rgba(74,222,128,0.2)",
+ }}
+ >
+ <PlusCircle size={15} /> Add Crop
+ </button>
+
+ <button
+ onClick={refreshData}
+ style={{
width: 34,
height: 34,
borderRadius: 8,
@@ -547,8 +631,7 @@ export default function Dashboard() {
color: showFilters ? "var(--green)" : "var(--text-2)",
}}
>
- <SlidersHorizontal size={13} />
- Filters
+ <SlidersHorizontal size={13} /> Filters
{activeFilters > 0 && (
<span
style={{
@@ -750,6 +833,10 @@ export default function Dashboard() {
onClick={() => navigate(`/crop/${crop.id}`)}
/>
))}
+ {/* Always visible at end of first page */}
+ {page === 1 && (
+ <AddCropCard onClick={() => navigate("/add-crop")} />
+ )}
</div>
{/* Pagination */}
@@ -837,43 +924,102 @@ export default function Dashboard() {
gap: 16,
}}
>
- <div
- style={{
- width: 56,
- height: 56,
- borderRadius: 16,
- background: "var(--surface)",
- border: "1px solid var(--border)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- }}
- >
- <Activity size={24} style={{ color: "var(--text-3)" }} />
- </div>
- <div style={{ color: "var(--text-2)", fontSize: 14 }}>
- No crops match your filters
- </div>
- <button
- onClick={() => {
- setSearch("");
- setFilterStage("All");
- setFilterCrop("All");
- setFilterStatus("All");
- }}
- style={{
- fontSize: 12,
- fontFamily: "DM Mono, monospace",
- padding: "6px 16px",
- borderRadius: 8,
- background: "var(--surface)",
- border: "1px solid var(--border)",
- color: "var(--text-3)",
- cursor: "pointer",
- }}
- >
- Clear filters
- </button>
+ {crops.length === 0 ? (
+ <>
+ <div
+ style={{
+ width: 64,
+ height: 64,
+ borderRadius: 20,
+ background: "rgba(74,222,128,0.1)",
+ border: "1px solid rgba(74,222,128,0.2)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
+ <PlusCircle size={28} style={{ color: "var(--green)" }} />
+ </div>
+ <div style={{ textAlign: "center" }}>
+ <div
+ style={{
+ fontWeight: 700,
+ fontSize: 15,
+ color: "var(--text-2)",
+ }}
+ >
+ No crops yet
+ </div>
+ <div
+ style={{
+ fontSize: 12,
+ color: "var(--text-3)",
+ marginTop: 6,
+ }}
+ >
+ Start by adding your first crop batch
+ </div>
+ </div>
+ <button
+ onClick={() => navigate("/add-crop")}
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 7,
+ padding: "10px 22px",
+ borderRadius: 10,
+ fontSize: 13,
+ fontWeight: 600,
+ background: "var(--green)",
+ border: "none",
+ color: "#0c1a0e",
+ cursor: "pointer",
+ }}
+ >
+ <PlusCircle size={15} /> Add Your First Crop
+ </button>
+ </>
+ ) : (
+ <>
+ <div
+ style={{
+ width: 56,
+ height: 56,
+ borderRadius: 16,
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
+ <Activity size={24} style={{ color: "var(--text-3)" }} />
+ </div>
+ <div style={{ color: "var(--text-2)", fontSize: 14 }}>
+ No crops match your filters
+ </div>
+ <button
+ onClick={() => {
+ setSearch("");
+ setFilterStage("All");
+ setFilterCrop("All");
+ setFilterStatus("All");
+ }}
+ style={{
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
+ padding: "6px 16px",
+ borderRadius: 8,
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ color: "var(--text-3)",
+ cursor: "pointer",
+ }}
+ >
+ Clear filters
+ </button>
+ </>
+ )}
</div>
)}
</div>
diff --git a/frontend/src/pages/FarmIntelligence.jsx b/frontend/src/pages/FarmIntelligence.jsx
@@ -0,0 +1,903 @@
+import { useRef, useState } from "react";
+import {
+ Activity,
+ Mic,
+ Square,
+ Brain,
+ Search,
+ Sparkles,
+ ChevronRight,
+ Database,
+ TrendingUp,
+ TrendingDown,
+ Minus,
+ Droplets,
+ Thermometer,
+ Wind,
+ BookOpen,
+} from "lucide-react";
+import { agentService } from "../api/agentApi";
+import { extractSensors } from "../utils/dataUtils";
+import {
+ AgentActionWidget,
+ AgentOutcomeWidget,
+} from "../components/AgentWidgets";
+import Sidebar from "../components/Sidebar";
+import { useFarmData } from "../hooks/useFarmData";
+import { deriveCropStatus } from "../utils/dataUtils";
+
+// Suggestion chips
+const SUGGESTIONS = [
+ "Show all Lettuce crops",
+ "Which crops are in flowering stage?",
+ "Find crops with negative outcomes",
+ "List recent critical failures",
+ "Show Tomato batches",
+ "Find crops with high EC readings",
+];
+
+// INSIGHTS
+function InsightCard({ result, idx }) {
+ const p = result.payload || {};
+ const s = extractSensors(p);
+ const status = deriveCropStatus(p);
+
+ const statusColors = {
+ Healthy: {
+ color: "var(--green)",
+ bg: "rgba(74,222,128,0.1)",
+ border: "rgba(74,222,128,0.25)",
+ },
+ Attention: {
+ color: "var(--amber)",
+ bg: "rgba(245,158,11,0.1)",
+ border: "rgba(245,158,11,0.25)",
+ },
+ Critical: {
+ color: "var(--red)",
+ bg: "rgba(248,113,113,0.1)",
+ border: "rgba(248,113,113,0.25)",
+ },
+ };
+ const sc = statusColors[status] || statusColors.Healthy;
+
+ return (
+ <div
+ className="card-hover animate-fade-up"
+ style={{
+ borderRadius: 14,
+ padding: 18,
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ animationDelay: `${idx * 60}ms`,
+ }}
+ >
+ {/* Header */}
+ <div
+ style={{
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ marginBottom: 14,
+ }}
+ >
+ <div>
+ <div style={{ fontWeight: 700, fontSize: 15, color: "var(--text)" }}>
+ {p.crop || "Unknown"}
+ </div>
+ <div
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ marginTop: 2,
+ }}
+ >
+ {p.crop_id || "โ"} ยท Seq #{p.sequence_number || 1}
+ </div>
+ </div>
+ <div
+ style={{
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "flex-end",
+ gap: 4,
+ }}
+ >
+ <span
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ padding: "3px 8px",
+ borderRadius: 20,
+ background: sc.bg,
+ color: sc.color,
+ border: `1px solid ${sc.border}`,
+ }}
+ >
+ {status.toUpperCase()}
+ </span>
+ {p.stage && (
+ <span
+ style={{
+ fontSize: 9,
+ fontFamily: "DM Mono, monospace",
+ padding: "2px 7px",
+ borderRadius: 4,
+ background: "var(--bg-3)",
+ color: "var(--text-3)",
+ border: "1px solid var(--border)",
+ }}
+ >
+ {p.stage}
+ </span>
+ )}
+ </div>
+ </div>
+
+ {/* Sensors */}
+ <div
+ style={{
+ display: "grid",
+ gridTemplateColumns: "repeat(4,1fr)",
+ gap: 8,
+ marginBottom: 14,
+ }}
+ >
+ {[
+ { icon: Droplets, label: "pH", value: s.ph, color: "var(--green)" },
+ { icon: Activity, label: "EC", value: s.ec, color: "var(--amber)" },
+ {
+ icon: Thermometer,
+ label: "Temp",
+ value: s.temp + "ยฐ",
+ color: "var(--blue)",
+ },
+ {
+ icon: Wind,
+ label: "Humidity",
+ value: s.humidity + "%",
+ color: "#a78bfa",
+ },
+ ].map(({ icon: Icon, label, value, color }) => (
+ <div
+ key={label}
+ style={{
+ borderRadius: 8,
+ padding: "7px 8px",
+ textAlign: "center",
+ background: "var(--bg-3)",
+ }}
+ >
+ <Icon
+ size={10}
+ style={{ color, margin: "0 auto 3px", display: "block" }}
+ />
+ <div className="sensor-label" style={{ marginBottom: 2 }}>
+ {label}
+ </div>
+ <div
+ style={{
+ fontSize: 13,
+ fontWeight: 700,
+ fontFamily: "DM Mono, monospace",
+ color,
+ }}
+ >
+ {value}
+ </div>
+ </div>
+ ))}
+ </div>
+
+ {/* Outcome */}
+ {p.outcome && p.outcome !== "PENDING_OBSERVATION" && (
+ <AgentOutcomeWidget
+ outcome={p.outcome}
+ rewardScore={p.reward_score}
+ strategicIntent={p.strategic_intent}
+ />
+ )}
+
+ {/* Last action */}
+ {p.action_taken && p.action_taken !== "PENDING_ACTION" && (
+ <div style={{ marginTop: 12 }}>
+ <div
+ className="section-label"
+ style={{ fontSize: 9, marginBottom: 8 }}
+ >
+ LAST COMMAND
+ </div>
+ <AgentActionWidget actionTaken={p.action_taken} compact />
+ </div>
+ )}
+ </div>
+ );
+}
+
+// FLEET SUMMARY
+function FleetStat({ label, value, color, icon: Icon }) {
+ return (
+ <div
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 10,
+ padding: "12px 18px",
+ borderRadius: 12,
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ flex: 1,
+ minWidth: 120,
+ }}
+ >
+ <div
+ style={{
+ width: 32,
+ height: 32,
+ borderRadius: 8,
+ background: `${color}18`,
+ border: `1px solid ${color}30`,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ flexShrink: 0,
+ }}
+ >
+ <Icon size={14} style={{ color }} />
+ </div>
+ <div>
+ <div
+ style={{
+ fontSize: 18,
+ fontWeight: 700,
+ fontFamily: "DM Mono, monospace",
+ color,
+ lineHeight: 1,
+ }}
+ >
+ {value}
+ </div>
+ <div style={{ fontSize: 10, color: "var(--text-3)", marginTop: 3 }}>
+ {label}
+ </div>
+ </div>
+ </div>
+ );
+}
+
+// MAIN
+export default function FarmIntelligence() {
+ const [textQuery, setTextQuery] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [results, setResults] = useState([]);
+ const [transcription, setTranscription] = useState("");
+ const [hasQueried, setHasQueried] = useState(false);
+ const [explanation, setExplanation] = useState("");
+ const [showExplain, setShowExplain] = useState(false);
+ const [isRecording, setIsRecording] = useState(false);
+ const [toast, setToast] = useState(null);
+
+ const mediaRecorderRef = useRef(null);
+ const chunksRef = useRef([]);
+
+ const { dashboard } = useFarmData();
+
+ const showToast = (msg, type = "success") => {
+ setToast({ msg, type });
+ setTimeout(() => setToast(null), 3000);
+ };
+
+ const fleetStats = {
+ total: dashboard?.length || 0,
+ healthy: (dashboard || []).filter(
+ (d) => deriveCropStatus(d.payload) === "Healthy",
+ ).length,
+ attention: (dashboard || []).filter(
+ (d) => deriveCropStatus(d.payload) === "Attention",
+ ).length,
+ critical: (dashboard || []).filter(
+ (d) => deriveCropStatus(d.payload) === "Critical",
+ ).length,
+ };
+
+ const handleQuery = async (q) => {
+ const query = q || textQuery;
+ if (!query.trim()) return;
+ setLoading(true);
+ setHasQueried(true);
+ setTextQuery(query);
+ setResults([]);
+ setExplanation("");
+ try {
+ const data = await agentService.queryText(query);
+ if (data.results) {
+ setResults(
+ data.results.map((r) => ({
+ id: r.id,
+ score: r.score || 1,
+ payload: r.payload,
+ })),
+ );
+ }
+ } catch {
+ showToast("Query failed", "error");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const startRecording = async () => {
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ mediaRecorderRef.current = new MediaRecorder(stream);
+ chunksRef.current = [];
+ mediaRecorderRef.current.ondataavailable = (e) => {
+ if (e.data.size > 0) chunksRef.current.push(e.data);
+ };
+ mediaRecorderRef.current.onstop = async () => {
+ const blob = new Blob(chunksRef.current, { type: "audio/webm" });
+ setLoading(true);
+ try {
+ const data = await agentService.queryAudio(blob);
+ if (data.transcription) {
+ setTranscription(data.transcription);
+ setTextQuery(data.transcription);
+ }
+ if (data.results) {
+ setResults(
+ data.results.map((r) => ({
+ id: r.id,
+ score: r.score || 1,
+ payload: r.payload,
+ })),
+ );
+ setHasQueried(true);
+ }
+ } finally {
+ setLoading(false);
+ }
+ stream.getTracks().forEach((t) => t.stop());
+ };
+ mediaRecorderRef.current.start();
+ setIsRecording(true);
+ } catch {
+ showToast("Microphone access denied", "error");
+ }
+ };
+
+ const stopRecording = () => {
+ if (mediaRecorderRef.current && isRecording) {
+ mediaRecorderRef.current.stop();
+ setIsRecording(false);
+ }
+ };
+
+ return (
+ <div
+ style={{
+ display: "flex",
+ height: "100vh",
+ overflow: "hidden",
+ background: "var(--bg)",
+ }}
+ >
+ <Sidebar />
+
+ {toast && (
+ <div
+ className="animate-fade-in"
+ style={{
+ position: "fixed",
+ top: 16,
+ right: 16,
+ zIndex: 50,
+ padding: "10px 16px",
+ borderRadius: 12,
+ fontSize: 13,
+ fontFamily: "DM Mono, monospace",
+ background:
+ toast.type === "error"
+ ? "rgba(248,113,113,0.15)"
+ : "rgba(74,222,128,0.15)",
+ border: `1px solid ${toast.type === "error" ? "rgba(248,113,113,0.4)" : "rgba(74,222,128,0.4)"}`,
+ color: toast.type === "error" ? "var(--red)" : "var(--green)",
+ }}
+ >
+ {toast.msg}
+ </div>
+ )}
+
+ <main
+ style={{
+ flex: 1,
+ display: "flex",
+ flexDirection: "column",
+ overflow: "hidden",
+ }}
+ >
+ {/* Header */}
+ <header
+ style={{
+ flexShrink: 0,
+ padding: "0 24px",
+ height: 64,
+ borderBottom: "1px solid var(--border)",
+ background: "var(--bg-2)",
+ display: "flex",
+ alignItems: "center",
+ gap: 12,
+ }}
+ >
+ <div
+ style={{
+ width: 32,
+ height: 32,
+ borderRadius: 8,
+ background: "rgba(167,139,250,0.1)",
+ border: "1px solid rgba(167,139,250,0.2)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
+ <Sparkles size={15} style={{ color: "#a78bfa" }} />
+ </div>
+ <div>
+ <h1 className="page-title">Farm Intelligence</h1>
+ <p className="page-subtitle">
+ Query your crops ยท Explore patterns ยท Ask anything
+ </p>
+ </div>
+ </header>
+
+ <div
+ style={{
+ flex: 1,
+ overflowY: "auto",
+ padding: 24,
+ display: "flex",
+ flexDirection: "column",
+ gap: 20,
+ }}
+ >
+ {/* Fleet summary */}
+ <div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
+ <FleetStat
+ label="Total Crops"
+ value={fleetStats.total}
+ color="var(--text-2)"
+ icon={Database}
+ />
+ <FleetStat
+ label="Healthy"
+ value={fleetStats.healthy}
+ color="var(--green)"
+ icon={TrendingUp}
+ />
+ <FleetStat
+ label="Needs Attention"
+ value={fleetStats.attention}
+ color="var(--amber)"
+ icon={Minus}
+ />
+ <FleetStat
+ label="Critical"
+ value={fleetStats.critical}
+ color="var(--red)"
+ icon={TrendingDown}
+ />
+ </div>
+
+ {/* Search bar */}
+ <div
+ style={{
+ display: "flex",
+ gap: 8,
+ padding: 8,
+ borderRadius: 14,
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ }}
+ >
+ <button
+ onClick={isRecording ? stopRecording : startRecording}
+ style={{
+ width: 38,
+ height: 38,
+ borderRadius: 8,
+ flexShrink: 0,
+ cursor: "pointer",
+ background: isRecording
+ ? "rgba(248,113,113,0.15)"
+ : "var(--bg-3)",
+ border: `1px solid ${isRecording ? "rgba(248,113,113,0.4)" : "var(--border)"}`,
+ color: isRecording ? "var(--red)" : "var(--text-3)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
+ {isRecording ? <Square size={14} /> : <Mic size={14} />}
+ </button>
+ <input
+ value={textQuery}
+ onChange={(e) => setTextQuery(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleQuery()}
+ placeholder="Ask anything โ 'Show all Tomato crops', 'Which batches are critical?', 'Find failed cycles'โฆ"
+ style={{
+ flex: 1,
+ background: "transparent",
+ border: "none",
+ outline: "none",
+ fontSize: 14,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text)",
+ caretColor: "#a78bfa",
+ }}
+ />
+ <button
+ onClick={() => handleQuery()}
+ disabled={loading}
+ style={{
+ padding: "8px 22px",
+ borderRadius: 10,
+ fontSize: 13,
+ fontWeight: 600,
+ background: loading ? "var(--bg-3)" : "#a78bfa",
+ color: loading ? "var(--text-3)" : "#1a0a2e",
+ border: "none",
+ cursor: loading ? "not-allowed" : "pointer",
+ flexShrink: 0,
+ }}
+ >
+ {loading ? (
+ <Activity size={13} className="animate-spin" />
+ ) : (
+ "Search"
+ )}
+ </button>
+ </div>
+
+ {/* Transcription badge */}
+ {transcription && (
+ <div
+ className="animate-fade-in"
+ style={{
+ padding: "8px 14px",
+ borderRadius: 8,
+ background: "rgba(167,139,250,0.08)",
+ border: "1px solid rgba(167,139,250,0.2)",
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
+ color: "#a78bfa",
+ }}
+ >
+ ๐ Heard: "{transcription}"
+ </div>
+ )}
+
+ {/* Suggestion chips */}
+ {!hasQueried && (
+ <div>
+ <div className="section-label">QUICK QUERIES</div>
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
+ {SUGGESTIONS.map((s) => (
+ <button
+ key={s}
+ onClick={() => handleQuery(s)}
+ style={{
+ padding: "7px 14px",
+ borderRadius: 20,
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
+ cursor: "pointer",
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ color: "var(--text-2)",
+ display: "flex",
+ alignItems: "center",
+ gap: 5,
+ transition: "all 0.15s",
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.borderColor =
+ "rgba(167,139,250,0.4)";
+ e.currentTarget.style.color = "#a78bfa";
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.borderColor = "var(--border)";
+ e.currentTarget.style.color = "var(--text-2)";
+ }}
+ >
+ <Search size={10} /> {s}
+ </button>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* Loading skeleton */}
+ {loading && (
+ <div
+ style={{
+ display: "grid",
+ gridTemplateColumns: "repeat(auto-fill, minmax(320px,1fr))",
+ gap: 14,
+ }}
+ >
+ {[1, 2, 3].map((i) => (
+ <div
+ key={i}
+ className="shimmer"
+ style={{
+ height: 200,
+ borderRadius: 14,
+ border: "1px solid var(--border)",
+ }}
+ />
+ ))}
+ </div>
+ )}
+
+ {/* Results */}
+ {!loading && hasQueried && (
+ <>
+ <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
+ <div className="section-label" style={{ margin: 0 }}>
+ {results.length > 0
+ ? `${results.length} RESULT${results.length !== 1 ? "S" : ""} FOUND`
+ : "NO RESULTS"}
+ </div>
+ {results.length > 0 && (
+ <span
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ }}
+ >
+ for "{textQuery}"
+ </span>
+ )}
+ {results.length > 0 && (
+ <button
+ onClick={() => setShowExplain(!showExplain)}
+ style={{
+ marginLeft: "auto",
+ display: "flex",
+ alignItems: "center",
+ gap: 5,
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ padding: "4px 10px",
+ borderRadius: 7,
+ cursor: "pointer",
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ color: "var(--text-3)",
+ }}
+ >
+ <BookOpen size={11} /> {showExplain ? "Hide" : "View"} query
+ logic
+ </button>
+ )}
+ </div>
+
+ {/* Show query interpretation */}
+ {showExplain && explanation && (
+ <div
+ className="animate-fade-in"
+ style={{
+ padding: 16,
+ borderRadius: 12,
+ background: "var(--bg-3)",
+ border: "1px solid var(--border)",
+ }}
+ >
+ <div className="section-label">
+ SUPERVISOR QUERY INTERPRETATION
+ </div>
+ <pre
+ style={{
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
+ lineHeight: 1.7,
+ whiteSpace: "pre-wrap",
+ color: "var(--text-2)",
+ margin: 0,
+ }}
+ >
+ {explanation}
+ </pre>
+ </div>
+ )}
+
+ {results.length === 0 ? (
+ <div
+ style={{
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ padding: 48,
+ gap: 16,
+ borderRadius: 14,
+ background: "var(--surface)",
+ border: "1px dashed var(--border)",
+ }}
+ >
+ <div
+ style={{
+ width: 52,
+ height: 52,
+ borderRadius: 16,
+ background: "var(--bg-3)",
+ border: "1px solid var(--border)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
+ <Brain size={22} style={{ color: "var(--text-3)" }} />
+ </div>
+ <div style={{ textAlign: "center" }}>
+ <div
+ style={{
+ fontSize: 14,
+ fontWeight: 600,
+ color: "var(--text-2)",
+ }}
+ >
+ No crops matched
+ </div>
+ <div
+ style={{
+ fontSize: 12,
+ color: "var(--text-3)",
+ marginTop: 6,
+ }}
+ >
+ Try a different query or add crops from the Dashboard.
+ </div>
+ </div>
+ <div
+ style={{
+ display: "flex",
+ gap: 8,
+ flexWrap: "wrap",
+ justifyContent: "center",
+ }}
+ >
+ {SUGGESTIONS.slice(0, 3).map((s) => (
+ <button
+ key={s}
+ onClick={() => handleQuery(s)}
+ style={{
+ padding: "6px 12px",
+ borderRadius: 20,
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ cursor: "pointer",
+ background: "var(--bg-3)",
+ border: "1px solid var(--border)",
+ color: "var(--text-3)",
+ }}
+ >
+ {s}
+ </button>
+ ))}
+ </div>
+ </div>
+ ) : (
+ <div
+ style={{
+ display: "grid",
+ gridTemplateColumns: "repeat(auto-fill, minmax(320px,1fr))",
+ gap: 14,
+ }}
+ >
+ {results.map((r, i) => (
+ <InsightCard key={r.id} result={r} idx={i} />
+ ))}
+ </div>
+ )}
+ </>
+ )}
+
+ {/* Empty state before first query */}
+ {!hasQueried && !loading && (
+ <div
+ style={{
+ flex: 1,
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ padding: 48,
+ gap: 20,
+ minHeight: 200,
+ borderRadius: 16,
+ background: "var(--surface)",
+ border: "1px dashed var(--border)",
+ }}
+ >
+ <div
+ style={{
+ width: 64,
+ height: 64,
+ borderRadius: 20,
+ background: "rgba(167,139,250,0.1)",
+ border: "1px solid rgba(167,139,250,0.2)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
+ <Sparkles size={28} style={{ color: "#a78bfa" }} />
+ </div>
+ <div style={{ textAlign: "center", maxWidth: 380 }}>
+ <div
+ style={{
+ fontWeight: 700,
+ fontSize: 16,
+ color: "var(--text)",
+ }}
+ >
+ Ask Demeter anything about your farm
+ </div>
+ <div
+ style={{
+ fontSize: 13,
+ color: "var(--text-3)",
+ marginTop: 8,
+ lineHeight: 1.6,
+ }}
+ >
+ Use natural language โ English, Hindi, or Hinglish โ to search
+ your crop database. The AI Supervisor translates your query
+ into precise filters.
+ </div>
+ </div>
+ <div
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ flexWrap: "wrap",
+ justifyContent: "center",
+ }}
+ >
+ {[
+ "Show all crops",
+ "Find critical plants",
+ "Tomato flowering stage",
+ ].map((s) => (
+ <button
+ key={s}
+ onClick={() => handleQuery(s)}
+ style={{
+ padding: "7px 16px",
+ borderRadius: 20,
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
+ cursor: "pointer",
+ background: "rgba(167,139,250,0.08)",
+ border: "1px solid rgba(167,139,250,0.2)",
+ color: "#a78bfa",
+ display: "flex",
+ alignItems: "center",
+ gap: 5,
+ }}
+ >
+ <ChevronRight size={10} /> {s}
+ </button>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ </main>
+ </div>
+ );
+}
diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx
@@ -8,6 +8,7 @@ import {
Eye,
Zap,
Activity,
+ Sparkles,
} from "lucide-react";
import { useFarmData } from "../hooks/useFarmData";
import { extractSensors, deriveCropStatus } from "../utils/dataUtils";
@@ -294,7 +295,7 @@ export default function LandingPage() {
/>
</button>
<button
- onClick={() => navigate("/control")}
+ onClick={() => navigate("/intelligence")}
className="flex items-center gap-3 px-7 py-3.5 rounded-xl font-semibold text-sm transition-all"
style={{
border: "1px solid var(--border)",
@@ -302,7 +303,7 @@ export default function LandingPage() {
background: "var(--surface)",
}}
>
- <Cpu size={16} /> Agent Control
+ <Sparkles size={16} /> Intelligence
</button>
</div>