commit bb76d3df20c3a68e58b28b769ab4f7f87f9a45da
parent 497c3c65607f9930f2e156cc48df30dbed14b3b7
Author: Abhinav Rai <69450646+AbhinavRai01@users.noreply.github.com>
Date: Tue, 3 Mar 2026 10:20:08 +0000
Merge PR
Diffstat:
6 files changed, 665 insertions(+), 75 deletions(-)
diff --git a/agent/sub_agents/Supervisor.py b/agent/sub_agents/Supervisor.py
@@ -1,63 +1,98 @@
+import json
import os
-import ast
+from dotenv import load_dotenv
from openai import OpenAI
-from Qdrant.Store import store_fmu # Import storage function
-MODEL_ID = "openai/gpt-oss-120b"
-API_KEY = os.environ.get("GROQ_API_KEY")
+# Load .env file relative to this script
+current_dir = os.path.dirname(os.path.abspath(__file__))
+env_path = os.path.join(current_dir, '../../.env')
+load_dotenv(env_path)
class SupervisorAgent:
- def __init__(self):
- self.name = "Supervisor"
- if not API_KEY:
- self.client = None
- else:
- self.client = OpenAI(base_url="https://api.groq.com/openai/v1", api_key=API_KEY)
-
- def synthesize_plan(self, atmos_plan: dict, water_plan: dict, fmu, history: list) -> dict:
+ def __init__(self, researcher_agent):
+ self.researcher = researcher_agent
+
+ # ⚡ CONNECT TO GROQ CLOUD
+ # CHECK: Ensure your .env file has 'GROK_API_KEY' or 'GROQ_API_KEY'
+ # We use 'GROQ_API_KEY' here based on your previous messages
+ api_key = os.getenv("GROQ_API_KEY")
+
+ if not api_key:
+ print("⚠️ WARNING: API Key not found. Supervisor may fail.")
+
+ self.llm = OpenAI(
+ base_url="https://api.groq.com/openai/v1",
+ api_key=api_key
+ )
+
+ def reason(self, current_fmu, similar_fmus, sub_agent_outputs):
"""
- 1. Validates and merges plans.
- 2. UPDATES the FMU with the final action.
- 3. UPLOADS the FMU to Qdrant.
+ The Core Reasoning Loop:
+ 1. Contextualize -> 2. Research -> 3. Synthesize -> 4. Decide
"""
- print(f"[{self.name}] 👮 Validating, Merging & Storing...")
-
- # 1. Merge & Reason
- combined_action = {**atmos_plan, **water_plan}
- current_meta = fmu.metadata
-
- prompt = (
- f"You are the Farm Supervisor.\n"
- f"Proposed Atmospheric Actions: {atmos_plan}\n"
- f"Proposed Water Actions: {water_plan}\n"
- f"Current Crop: {current_meta.get('crop')} ({current_meta.get('stage')})\n"
- f"History Context: {history}\n\n"
- f"TASK: Output a single valid JSON object for the simulator controls.\n"
- f"Strict JSON only."
- )
+
+ # --- STEP 1: Formulate the Research Question ---
+ crop = current_fmu['metadata'].get('crop', 'Unknown Crop')
+ stage = current_fmu['metadata'].get('stage', 'Unknown Stage')
+
+ # E.g., "Lettuce Vegetative Low pH issues"
+ research_query = f"{crop} {stage} {sub_agent_outputs.get('nutrient_analysis', '')} issues"
+
+ print(f"🤔 Supervisor is asking Researcher: '{research_query}'")
- final_action = combined_action # Default
- try:
- if self.client:
- response = self.client.chat.completions.create(
- model=MODEL_ID,
- messages=[{"role": "user", "content": prompt}]
- )
- content = response.choices[0].message.content.replace("```json", "").replace("```", "").strip()
- final_action = ast.literal_eval(content)
- except Exception as e:
- print(f"[{self.name}] ⚠️ Reasoning failed, using defaults: {e}")
+ # --- STEP 2: The Researcher Fetches Evidence (RAG) ---
+ # This now returns a clean STRING, not a list
+ scientific_context = self.researcher.search(research_query)
+
+ # --- STEP 3: Synthesize History (Memory) ---
+ history_context = "\n".join([
+ f"- Previous Case (Score {f['score']:.2f}): {f['payload'].get('outcome', 'No outcome recorded')}"
+ for f in similar_fmus
+ ])
- # 2. Update FMU Metadata
- print(f"[{self.name}] 📝 Recording Action: {final_action}")
- fmu.metadata["action_taken"] = str(final_action) # Store as string or dict depending on your DB preference
+ # --- STEP 4: The Final Prompt ---
+ system_prompt = """
+ You are the Chief Supervisor AI of a Hydroponic Facility.
+ Your goal: Synthesize conflicting data to recommend the OPTIMAL action.
- # 3. Clean & Store FMU
- # Critical: Remove large image blob before uploading to Qdrant
- if "image_b64" in fmu.metadata:
- del fmu.metadata["image_b64"]
-
- store_fmu(fmu)
- print(f"[{self.name}] 💾 State (Seq #{fmu.metadata.get('sequence_number')}) saved to DB.")
-
- return final_action
-\ No newline at end of file
+ PRINCIPLES:
+ 1. Plant Health is Priority #1.
+ 2. Verify Sub-Agent claims against the SCIENTIFIC KNOWLEDGE provided.
+ 3. If History contradicts Science, prefer Science (Manuals), but note the anomaly.
+ """
+
+ user_message = f"""
+ ### SITUATION REPORT
+ Target: {crop} ({stage})
+ Sensors: {current_fmu['payload']['sensors']}
+
+ ### SUB-AGENT ALERTS
+ {json.dumps(sub_agent_outputs, indent=2)}
+
+ ### SCIENTIFIC KNOWLEDGE (Verified Manuals)
+ {scientific_context}
+
+ ### HISTORICAL MEMORY (Similar Past Events)
+ {history_context}
+
+ ### COMMAND
+ Analyze the situation. Resolve conflicts between agents using the Manuals.
+ Output JSON: {{ "reasoning": "...", "action": "...", "confidence": 0.0-1.0 }}
+ """
+
+ # --- STEP 5: Execute Reasoning on Groq ---
+ try:
+ response = self.llm.chat.completions.create(
+ # We use Llama-3.1-8b because it is fast and smart enough for this logic
+ model="llama-3.1-8b-instant",
+ messages=[
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_message}
+ ],
+ temperature=0.1, # Low temp for strict logic
+ response_format={"type": "json_object"} # Force valid JSON
+ )
+ return json.loads(response.choices[0].message.content)
+
+ except Exception as e:
+ return {"error": str(e), "reasoning": "Groq Connection Failed"}
+\ No newline at end of file
diff --git a/frontend/src/App.js b/frontend/src/App.js
@@ -1,18 +1,23 @@
-// App.jsx
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
+
+// Pages
import LandingPage from './pages/LandingPage';
-import Dashboard from './pages/Dashboard'; // This is your existing QdrantManager component
+import Dashboard from './pages/Dashboard';
import CropDetails from './pages/CropDetails';
+import AgentControl from './pages/AgentControl';
function App() {
return (
<Router>
<Routes>
- {/* The Landing Page from your design */}
+ {/* Public "Web" Landing Page */}
<Route path="/" element={<LandingPage />} />
- {/* The Dashboard (The CRUD functionality we built previously) */}
+ {/* The "Brain" Control Panel (Python Agent) */}
+ <Route path="/control" element={<AgentControl />} />
+
+ {/* The "CRUD" Dashboard (Node.js Backend) */}
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/crop/:cropId" element={<CropDetails />} />
</Routes>
diff --git a/frontend/src/api/agentApi.js b/frontend/src/api/agentApi.js
@@ -0,0 +1,90 @@
+// src/api/agentApi.js
+
+const API_URL = "http://localhost:8000";
+
+export const agentService = {
+ /**
+ * Uploads an image + sensors to create a new FMU (Functional Memory Unit)
+ */
+ async uploadFMU(file, sensors) {
+ const formData = new FormData();
+ formData.append("file", file);
+ formData.append("sensors", JSON.stringify({
+ pH: parseFloat(sensors.pH),
+ EC: parseFloat(sensors.EC),
+ temp: parseFloat(sensors.temp),
+ humidity: parseFloat(sensors.humidity)
+ }));
+ formData.append("metadata", JSON.stringify({
+ crop: sensors.crop,
+ stage: sensors.stage
+ }));
+
+ try {
+ const res = await fetch(`${API_URL}/ingest`, { method: "POST", body: formData });
+ if (!res.ok) throw new Error(`Server Error: ${res.statusText}`);
+ return await res.json();
+ } catch (error) {
+ console.error("Ingest Service Error:", error);
+ throw error;
+ }
+ },
+
+ /**
+ * Searches for similar memories and gets an Agent Decision
+ */
+ async searchFMU(file, sensors) {
+ const formData = new FormData();
+ formData.append("file", file);
+
+ 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 || "",
+ }));
+
+ try {
+ const res = await fetch(`${API_URL}/search`, {
+ method: "POST",
+ body: formData,
+ });
+ if (!res.ok) throw new Error(`Server Error: ${res.statusText}`);
+ return await res.json();
+ } catch (error) {
+ console.error("Search Service Error:", error);
+ throw error;
+ }
+ },
+
+ /**
+ * Queries the RAG/Agent via Text
+ */
+ async queryText(text) {
+ const formData = new FormData();
+ formData.append("query", text);
+
+ const res = await fetch(`${API_URL}/query-text`, {
+ method: "POST",
+ body: formData
+ });
+ return await res.json();
+ },
+
+ /**
+ * Queries the RAG/Agent via Audio
+ */
+ async queryAudio(audioBlob) {
+ const formData = new FormData();
+ formData.append("file", audioBlob, "recording.webm");
+
+ const res = await fetch(`${API_URL}/query-audio`, {
+ method: "POST",
+ body: formData
+ });
+ return await res.json();
+ }
+};
+\ No newline at end of file
diff --git a/frontend/src/index.css b/frontend/src/index.css
@@ -2,6 +2,11 @@
@tailwind components;
@tailwind utilities;
+:root {
+ --background: #ffffff;
+ --foreground: #171717;
+}
+
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
@@ -11,7 +16,8 @@ body {
-moz-osx-font-smoothing: grayscale;
}
-code {
- font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
- monospace;
-}
+@layer utilities {
+ .animate-in {
+ animation: animate-in 0.5s ease-out;
+ }
+}
+\ No newline at end of file
diff --git a/frontend/src/pages/AgentControl.jsx b/frontend/src/pages/AgentControl.jsx
@@ -0,0 +1,443 @@
+import React, { useRef, useState } from "react";
+import { Link } from "react-router-dom";
+import {
+ Upload, Save, Activity, Droplets, Thermometer, Wind, Search,
+ Sprout, Calendar, BarChart3, ArrowRight, Brain, Mic, Square,
+ ArrowLeft, Leaf, Database, CheckCircle2
+} from "lucide-react";
+import { agentService } from "../api/agentApi";
+
+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 [showExplanation, setShowExplanation] = useState(false);
+ const [explanationText, setExplanationText] = useState("");
+
+ const [isRecording, setIsRecording] = useState(false);
+ const mediaRecorderRef = useRef(null);
+ const chunksRef = useRef([]);
+
+ const [decision, setDecision] = useState(null);
+
+ const [sensors, setSensors] = useState({
+ pH: "6.0",
+ EC: "1.2",
+ temp: "24.0",
+ humidity: "60",
+ crop: "Lettuce",
+ stage: "Vegetative"
+ });
+
+ // --- Handlers ---
+ const handleFileChange = (e) => {
+ if (e.target.files && e.target.files[0]) {
+ const selected = e.target.files[0];
+ setFile(selected);
+ setPreview(URL.createObjectURL(selected));
+ setSearchResults([]);
+ setDecision(null);
+ }
+ };
+
+ const handleInputChange = (e) => {
+ setSensors({ ...sensors, [e.target.name]: e.target.value });
+ };
+
+ const handleIngest = async () => {
+ if (!file) return alert("Please select an image first.");
+ setLoadingIngest(true);
+ try {
+ await agentService.uploadFMU(file, sensors);
+ alert("✅ FMU Created & Stored Successfully!");
+ } catch (error) {
+ console.error(error);
+ alert("❌ Ingest Failed. Check console.");
+ } finally {
+ setLoadingIngest(false);
+ }
+ };
+
+ const handleSearch = async () => {
+ if (!file) return alert("Please select an image to search with.");
+ setLoadingSearch(true);
+ setDecision(null);
+
+ try {
+ const response = await agentService.searchFMU(file, sensors);
+ if (response.explanation) setExplanationText(response.explanation);
+ setSearchResults(response.search_results || []);
+ if (response.agent_decision) setDecision(response.agent_decision);
+ } catch (error) {
+ console.error(error);
+ alert("❌ Search Failed.");
+ } finally {
+ setLoadingSearch(false);
+ }
+ };
+
+ const handleTextQuery = async () => {
+ if (!textQuery) return;
+ setLoadingSearch(true);
+ setSearchResults([]);
+
+ try {
+ const data = await agentService.queryText(textQuery);
+ if (data.results) {
+ const mappedResults = data.results.map(r => ({
+ id: r.id,
+ score: 1.0,
+ payload: r.payload
+ }));
+ setSearchResults(mappedResults);
+ if (mappedResults.length === 0) alert("No records found.");
+ }
+ } catch (e) {
+ console.error(e);
+ alert("Text Query Failed");
+ } 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 audioBlob = new Blob(chunksRef.current, { type: "audio/webm" });
+ await handleAudioUpload(audioBlob);
+ stream.getTracks().forEach(track => track.stop());
+ };
+ mediaRecorderRef.current.start();
+ setIsRecording(true);
+ } catch (err) {
+ console.error("Mic Error:", err);
+ alert("Microphone access denied.");
+ }
+ };
+
+ const stopRecording = () => {
+ if (mediaRecorderRef.current && isRecording) {
+ mediaRecorderRef.current.stop();
+ setIsRecording(false);
+ }
+ };
+
+ const handleAudioUpload = async (audioBlob) => {
+ setLoadingSearch(true);
+ setSearchResults([]);
+ try {
+ const data = await agentService.queryAudio(audioBlob);
+ if (data.transcription) setTextQuery(data.transcription);
+ if (data.results) {
+ const mappedResults = data.results.map(r => ({
+ id: r.id,
+ score: 1.0,
+ payload: r.payload
+ }));
+ setSearchResults(mappedResults);
+ }
+ } catch (e) {
+ console.error(e);
+ alert("Audio Query Failed");
+ } finally {
+ setLoadingSearch(false);
+ }
+ };
+
+ return (
+ <div className="min-h-screen bg-[#F4F9F6] font-sans text-gray-800 pb-20">
+
+ {/* --- 1. NAVBAR (Light Mode) --- */}
+ <nav className="border-b border-gray-200 bg-white sticky top-0 z-20 h-16 shadow-sm">
+ <div className="max-w-7xl mx-auto px-6 h-full flex items-center justify-between">
+ <Link to="/" className="flex items-center space-x-2 hover:opacity-80 transition">
+ <div className="bg-emerald-500 p-1.5 rounded-lg text-white">
+ <Leaf size={20} fill="currentColor" />
+ </div>
+ <span className="text-xl font-bold tracking-tight text-gray-900">
+ Demeter
+ </span>
+ </Link>
+ <div className="flex items-center space-x-6 text-[10px] font-bold text-gray-500 uppercase tracking-widest">
+ <div className="flex items-center space-x-2 bg-emerald-50 text-emerald-700 px-3 py-1.5 rounded-full">
+ <span className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse"></span>
+ <span>System Online</span>
+ </div>
+ <Link to="/dashboard" className="hover:text-emerald-600 transition-colors">
+ Dashboard
+ </Link>
+ </div>
+ </div>
+ </nav>
+
+ <div className="max-w-7xl mx-auto px-6 mt-8">
+
+ {/* Header Section */}
+ <div className="mb-8 flex items-center justify-between">
+ <div className="flex items-center gap-4">
+ <div className="bg-white p-3 rounded-xl border border-gray-200 shadow-sm">
+ <Brain className="text-emerald-500 w-8 h-8"/>
+ </div>
+ <div>
+ <h1 className="text-3xl font-bold text-gray-900">Agent Control Center</h1>
+ <p className="text-gray-500 mt-1">Ingest new crop memories or query the Supervisor Agent</p>
+ </div>
+ </div>
+ <Link to="/" className="flex items-center gap-2 text-sm font-semibold text-gray-600 hover:text-emerald-600 transition-colors bg-white px-4 py-2.5 rounded-lg border border-gray-200 shadow-sm hover:shadow-md">
+ <ArrowLeft size={18} /> Back Home
+ </Link>
+ </div>
+
+ {/* --- MAIN GRID --- */}
+ <div className="grid grid-cols-1 lg:grid-cols-12 gap-8 mb-12">
+
+ {/* LEFT: Image Upload (Span 5) */}
+ <div className="lg:col-span-5 space-y-6">
+ <div className="relative border-2 border-dashed border-gray-300 bg-white rounded-3xl h-[420px] flex flex-col items-center justify-center hover:border-emerald-500/50 hover:bg-emerald-50/30 transition-all group overflow-hidden shadow-sm hover:shadow-md">
+ <input
+ type="file"
+ onChange={handleFileChange}
+ className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
+ />
+ {preview ? (
+ <img src={preview} alt="Preview" className="h-full w-full object-cover" />
+ ) : (
+ <div className="text-center p-6 space-y-4">
+ <div className="w-20 h-20 bg-emerald-50 rounded-full flex items-center justify-center mx-auto group-hover:scale-110 transition-transform duration-300">
+ <Upload className="w-8 h-8 text-emerald-500" />
+ </div>
+ <div>
+ <p className="text-gray-900 font-bold text-lg">Upload Crop Scan</p>
+ <p className="text-gray-400 text-sm">Drag & drop or click to browse</p>
+ </div>
+ <div className="inline-flex items-center gap-2 px-3 py-1 bg-gray-50 rounded-full text-[10px] text-gray-500 font-bold border border-gray-200">
+ JPG, PNG SUPPORTED
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+
+ {/* RIGHT: Controls (Span 7) */}
+ <div className="lg:col-span-7 space-y-6">
+
+ {/* Search Bar */}
+ <div className="bg-white p-2 rounded-2xl border border-gray-200 flex gap-2 shadow-sm focus-within:shadow-md transition-shadow">
+ <button
+ onClick={isRecording ? stopRecording : startRecording}
+ className={`p-3 rounded-xl transition-all flex items-center justify-center ${
+ isRecording
+ ? "bg-red-50 text-red-500 animate-pulse border border-red-100"
+ : "bg-gray-50 text-gray-400 hover:text-gray-600 hover:bg-gray-100"
+ }`}
+ title="Voice Search"
+ >
+ {isRecording ? <Square className="w-5 h-5" /> : <Mic className="w-5 h-5" />}
+ </button>
+ <input
+ type="text"
+ value={textQuery}
+ onChange={(e) => setTextQuery(e.target.value)}
+ placeholder="Ask Demeter: 'Show me all failed Lettuce crops'..."
+ className="flex-1 bg-transparent border-none outline-none text-gray-700 placeholder-gray-400 px-2 font-medium"
+ />
+ <button
+ onClick={handleTextQuery}
+ className="bg-emerald-500 hover:bg-emerald-600 text-white px-6 py-2 rounded-xl font-bold transition-all shadow-lg shadow-emerald-500/20"
+ >
+ Ask Agent
+ </button>
+ </div>
+
+ {/* Sensor Inputs Panel */}
+ <div className="bg-white border border-gray-200 rounded-3xl p-8 space-y-6 shadow-sm">
+ <div className="flex items-center justify-between">
+ <h3 className="text-gray-900 font-bold flex items-center gap-2 text-lg">
+ <Activity className="w-5 h-5 text-emerald-500"/> Manual Parameters
+ </h3>
+ </div>
+
+ <div className="grid grid-cols-2 gap-5">
+ {[
+ { label: "pH Level", name: "pH", icon: Droplets, color: "text-emerald-600", bg: "bg-emerald-50", type: "number" },
+ { label: "EC (mS/cm)", name: "EC", icon: Activity, color: "text-yellow-600", bg: "bg-yellow-50", type: "number" },
+ { label: "Temp (°C)", name: "temp", icon: Thermometer, color: "text-red-600", bg: "bg-red-50", type: "number" },
+ { label: "Humidity (%)", name: "humidity", icon: Wind, color: "text-blue-600", bg: "bg-blue-50", type: "number" },
+ { label: "Crop", name: "crop", icon: Sprout, color: "text-green-600", bg: "bg-green-50", type: "select", options: ["Lettuce", "Tomato", "Cucumber", "Basil", "Spinach", "Kale"] },
+ { label: "Stage", name: "stage", icon: Calendar, color: "text-purple-600", bg: "bg-purple-50", type: "select", options: ["Seedling", "Vegetative", "Flowering", "Fruiting", "Mature"] }
+ ].map((field) => (
+ <div key={field.name} className="space-y-2 group">
+ <label className={`text-[11px] font-bold uppercase tracking-wider ${field.color} ml-1`}>{field.label}</label>
+ <div className="relative">
+ <div className={`absolute left-3 top-2.5 w-8 h-8 rounded-lg ${field.bg} flex items-center justify-center z-10`}>
+ <field.icon className={`w-4 h-4 ${field.color}`} />
+ </div>
+ {field.type === "select" ? (
+ <select
+ name={field.name}
+ value={sensors[field.name]}
+ onChange={handleInputChange}
+ className="w-full bg-gray-50 border border-gray-200 rounded-xl py-3 pl-14 pr-4 focus:ring-2 focus:ring-emerald-500 focus:bg-white outline-none transition-all text-gray-800 font-bold appearance-none cursor-pointer"
+ >
+ {field.options.map((opt) => (
+ <option key={opt} value={opt}>{opt}</option>
+ ))}
+ </select>
+ ) : (
+ <input
+ name={field.name}
+ value={sensors[field.name]}
+ onChange={handleInputChange}
+ type="number" step="0.1"
+ className="w-full bg-gray-50 border border-gray-200 rounded-xl py-3 pl-14 pr-4 focus:ring-2 focus:ring-emerald-500 focus:bg-white outline-none transition-all text-gray-800 font-bold"
+ />
+ )}
+ </div>
+ </div>
+ ))}
+ </div>
+
+ {/* Action Buttons */}
+ <div className="grid grid-cols-2 gap-4 pt-4 border-t border-gray-100">
+ <button
+ onClick={handleIngest}
+ disabled={loadingIngest || loadingSearch}
+ className="py-3.5 bg-gray-100 hover:bg-emerald-50 text-gray-600 hover:text-emerald-700 font-bold rounded-xl transition-all disabled:opacity-50 flex items-center justify-center space-x-2 group"
+ >
+ {loadingIngest ? <Activity className="animate-spin w-5 h-5" /> : <><Save className="w-5 h-5 text-gray-400 group-hover:text-emerald-500 transition-colors" /> <span>Store Memory</span></>}
+ </button>
+
+ <button
+ onClick={handleSearch}
+ disabled={loadingIngest || loadingSearch}
+ className="py-3.5 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-xl transition-all shadow-lg shadow-blue-500/20 disabled:opacity-50 flex items-center justify-center space-x-2 group"
+ >
+ {loadingSearch ? <Activity className="animate-spin w-5 h-5" /> : <><Search className="w-5 h-5" /> <span>Reason & Solve</span></>}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* --- OUTPUT SECTION --- */}
+
+ {/* 1. Decision Card (Supervisor) */}
+ {decision && (
+ <div className="mb-12 animate-in fade-in slide-in-from-bottom-4 duration-700">
+ <div className="bg-white border border-emerald-100 rounded-3xl overflow-hidden shadow-xl shadow-emerald-500/10 relative">
+ {/* Background Decoration */}
+ <div className="absolute top-0 right-0 w-64 h-64 bg-emerald-50 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2 opacity-50"></div>
+
+ <div className="p-6 border-b border-gray-100 flex justify-between items-center relative z-10 bg-white/80 backdrop-blur-sm">
+ <h3 className="text-gray-900 font-bold text-lg flex items-center gap-2">
+ <div className="bg-emerald-500 text-white p-1.5 rounded-lg"><Brain size={18}/></div>
+ Demeter Recommendation
+ </h3>
+ <button
+ onClick={() => setShowExplanation(!showExplanation)}
+ className="text-xs font-semibold text-emerald-600 hover:text-emerald-700 bg-emerald-50 px-3 py-1.5 rounded-full transition-colors flex items-center gap-1"
+ >
+ <Search size={12}/> View Logic Trace
+ </button>
+ </div>
+
+ <div className="p-8 relative z-10">
+ <p className="text-xl text-gray-700 leading-relaxed font-medium">
+ {decision.reasoning || "Analyzing..."}
+ </p>
+ </div>
+
+ {showExplanation && (
+ <div className="bg-gray-50 p-6 border-t border-gray-200 animate-in slide-in-from-top-2">
+ <h4 className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-3">
+ Supervisor Thought Process
+ </h4>
+ <div className="text-gray-600 text-sm whitespace-pre-wrap font-mono leading-relaxed bg-white p-4 rounded-xl border border-gray-200 shadow-sm">
+ {explanationText || "Generating logic trace..."}
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+
+ {/* 2. Search Results Grid */}
+ {searchResults.length > 0 && (
+ <div className="animate-in fade-in slide-in-from-bottom-8 duration-700">
+ <div className="flex items-center justify-between mb-8">
+ <h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
+ <Database className="w-6 h-6 text-blue-500" />
+ Retrieved Memory Matches
+ </h2>
+ <span className="text-[11px] font-bold text-blue-700 bg-blue-50 px-3 py-1.5 rounded-full border border-blue-100">
+ {searchResults.length} SIMILAR CASES FOUND
+ </span>
+ </div>
+
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
+ {searchResults.map((res) => (
+ <div key={res.id} className="bg-white border border-gray-200 rounded-2xl hover:border-blue-300 hover:shadow-xl hover:shadow-blue-500/10 transition-all group relative overflow-hidden flex flex-col">
+ {/* Confidence Badge */}
+ <div className="absolute top-0 right-0 bg-blue-600 text-white text-[10px] font-bold px-3 py-1 rounded-bl-xl shadow-lg z-10">
+ {(res.score * 100).toFixed(1)}% MATCH
+ </div>
+
+ {/* Card Header */}
+ <div className="p-5 border-b border-gray-100 bg-gray-50/50">
+ <h3 className="text-lg font-bold text-gray-900 flex items-center gap-2">
+ <Sprout className="w-5 h-5 text-emerald-500" />
+ {res.payload.crop}
+ </h3>
+ <span className="text-xs text-gray-400 font-bold uppercase tracking-wider mt-1 block">
+ {res.payload.stage} Phase
+ </span>
+ </div>
+
+ {/* Card Body */}
+ <div className="p-5 space-y-4 flex-1">
+ <div className="flex items-center justify-between text-sm text-gray-500">
+ <div className="flex items-center gap-2 font-medium"><Calendar className="w-4 h-4 text-gray-400" /> Date</div>
+ <span className="font-mono text-gray-700 bg-gray-100 px-2 py-0.5 rounded text-xs">
+ {res.payload.timestamp ? new Date(res.payload.timestamp).toLocaleDateString() : 'N/A'}
+ </span>
+ </div>
+
+ <div className="grid grid-cols-2 gap-2 mt-2">
+ <div className="text-center p-2 rounded-lg bg-emerald-50 border border-emerald-100">
+ <div className="text-[10px] text-emerald-600 font-bold uppercase">pH Level</div>
+ <div className="text-emerald-800 font-mono font-bold text-lg">{res.payload.sensor_data?.pH || '-'}</div>
+ </div>
+ <div className="text-center p-2 rounded-lg bg-yellow-50 border border-yellow-100">
+ <div className="text-[10px] text-yellow-600 font-bold uppercase">EC Level</div>
+ <div className="text-yellow-800 font-mono font-bold text-lg">{res.payload.sensor_data?.EC || '-'}</div>
+ </div>
+ </div>
+ </div>
+
+ {/* Card Footer */}
+ <div className="p-4 border-t border-gray-100 bg-white">
+ <button className="w-full py-2.5 bg-white border border-gray-200 hover:bg-gray-50 text-gray-600 text-xs font-bold uppercase rounded-lg transition-colors flex items-center justify-center gap-2 group-hover:text-blue-600 group-hover:border-blue-200">
+ <span>Load Full Context</span> <ArrowRight className="w-3 h-3 group-hover:translate-x-1 transition-transform" />
+ </button>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
+\ No newline at end of file
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
@@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
-import { fetchDashboardData } from '../api/farmApi'; // Import the new API
+import { fetchDashboardData } from '../api/farmApi';
import {
- LayoutGrid, BarChart3, Bell, Settings, Droplet, Sun, Leaf, Search, Database, Thermometer, LogOut
+ LayoutGrid, BarChart3, Bell, Settings, Droplet, Sun, Leaf, Search, Database, Thermometer, LogOut, Brain
} from 'lucide-react';
const Dashboard = () => {
@@ -16,21 +16,19 @@ const Dashboard = () => {
if (data && data.length > 0) {
// --- DATA MAPPING ---
- // Converts raw Qdrant payload to UI format
const formattedData = data.map(item => {
const p = item.payload || {};
- const sensors = p.sensors || {}; // Handle missing sensors gracefully
+ const sensors = p.sensors || {};
return {
id: p.crop_id || item.id,
- name: p.crop || "Unknown Crop", // From your screenshot
+ name: p.crop || "Unknown Crop",
location: p.location || "Unit A-1 • Hydroponic",
- // Select image based on crop name (simple fallback logic)
image: getImageForCrop(p.crop),
status: p.outcome === 'CRITICAL' ? 'Critical' : (p.action_taken === 'PENDING_ACTION' ? 'Attention' : 'Healthy'),
statusMsg: p.stage || "Growing",
- maturity: calculateMaturity(p.sequence_number), // Mock logic based on sequence
- daysLeft: 30 - (p.sequence_number || 0), // Mock logic
+ maturity: calculateMaturity(p.sequence_number),
+ daysLeft: 30 - (p.sequence_number || 0),
sensors: {
lux: `${sensors.lux || 0}k`,
temp: `${sensors.temp || 0}°C`,
@@ -40,7 +38,6 @@ const Dashboard = () => {
});
setCrops(formattedData);
} else {
- // Fallback or Empty State
setCrops([]);
}
setLoading(false);
@@ -49,7 +46,6 @@ const Dashboard = () => {
loadData();
}, []);
- // Helper to pick images based on crop name string
const getImageForCrop = (name) => {
if (!name) return "https://images.unsplash.com/photo-1618164436241-4473940d1f5c?q=80&w=2000";
const n = name.toLowerCase();
@@ -61,7 +57,6 @@ const Dashboard = () => {
};
const calculateMaturity = (seq) => {
- // Just a visual approximation: assume sequence 10 is 100%
const val = (seq || 1) * 10;
return val > 100 ? 100 : val;
};
@@ -69,7 +64,7 @@ const Dashboard = () => {
return (
<div className="flex h-screen bg-[#F4F9F6] font-sans text-gray-800">
- {/* SIDEBAR (Unchanged) */}
+ {/* SIDEBAR */}
<aside className="w-64 bg-white border-r border-gray-100 flex flex-col justify-between hidden md:flex">
<div>
<div className="p-6 flex items-center gap-3">
@@ -98,9 +93,22 @@ const Dashboard = () => {
{/* MAIN CONTENT */}
<main className="flex-1 flex flex-col h-full overflow-hidden">
+
+ {/* --- HEADER (Updated with Button) --- */}
<header className="h-20 px-8 flex items-center justify-between bg-white border-b border-gray-50">
<h2 className="text-lg font-bold text-gray-800">Crops Overview</h2>
+
<div className="flex items-center gap-4">
+ {/* NEW BUTTON FOR AGENT CONTROL */}
+ <button
+ onClick={() => navigate('/control')}
+ className="flex items-center gap-2 bg-white border border-emerald-100 text-emerald-600 hover:bg-emerald-50 px-4 py-2 rounded-lg text-sm font-bold transition-colors shadow-sm hover:shadow-md"
+ >
+ <Brain size={18} /> Agent Control
+ </button>
+
+ <div className="w-px h-6 bg-gray-200 mx-2"></div>
+
<div className="flex items-center gap-2 bg-emerald-50 px-3 py-1.5 rounded-full text-xs font-semibold text-emerald-700">
<span className="w-2 h-2 rounded-full bg-emerald-500"></span> System online
</div>
@@ -131,7 +139,7 @@ const Dashboard = () => {
);
};
-// Sub-components (Kept minimal for brevity)
+// Sub-components
const SidebarItem = ({ icon, label, active }) => (
<div className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all ${active ? 'bg-emerald-50 text-emerald-700 font-semibold' : 'text-gray-500 hover:bg-gray-50'}`}>
{icon} <span className="flex-1 text-sm">{label}</span>