demeter

Autonomous Hydroponic Intelligence
commit d9c904259d1d8126b882c821916f24da8901fbf2
parent 39bd6d6d45ca9cb6ff6637e21117e0f558600ff5
Author: maydayv7 <maydayv7@gmail.com>
Date:   Wed, 25 Mar 2026 15:15:31 +0530

AddCrop page

Diffstat:
Mbackend/server/functions.py | 120++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mbackend/server/main.py | 20+++++++++++++++-----
Mfrontend/src/App.js | 6++++--
Mfrontend/src/components/Sidebar.jsx | 13+++++++------
Afrontend/src/pages/AddCrop.jsx | 1276+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dfrontend/src/pages/AgentControl.jsx | 857-------------------------------------------------------------------------------
Mfrontend/src/pages/Dashboard.jsx | 226+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Afrontend/src/pages/FarmIntelligence.jsx | 903+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mfrontend/src/pages/LandingPage.jsx | 5+++--
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>