commit fe73a38ebc1de57ed58f11d8b5cd3849ad039592
parent d9c904259d1d8126b882c821916f24da8901fbf2
Author: maydayv7 <maydayv7@gmail.com>
Date: Wed, 25 Mar 2026 16:21:31 +0530
Show explanation logs
Also begin work on Farm Intelligence refactor
Diffstat:
7 files changed, 1943 insertions(+), 458 deletions(-)
diff --git a/backend/server/functions.py b/backend/server/functions.py
@@ -397,26 +397,39 @@ async def process_cycle_stream(file: UploadFile, sensors_str: str, builder):
def extract_json(text):
"""
- Robustly extracts the first valid JSON object from a text string.
+ Robustly extracts the first valid JSON object from text string.
"""
- try:
- # 1. Try finding content inside ```json ... ```
- match = re.search(r"```json\s*(\{.*?\})\s*```", text, re.DOTALL)
- if match:
- return json.loads(match.group(1))
-
- # 2. Try finding content inside plain ``` ... ```
- match = re.search(r"```\s*(\{.*?\})\s*```", text, re.DOTALL)
- if match:
- return json.loads(match.group(1))
-
- # 3. Fallback: Find the first outermost { ... }
- match = re.search(r"(\{.*\})", text, re.DOTALL)
- if match:
- return json.loads(match.group(1))
-
- except Exception:
- pass
+ # Strip <think>...</think> blocks FIRST — reasoning models emit these before the answer
+ cleaned = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
+
+ for source in (cleaned, text): # fall back to raw text if stripping broke something
+ try:
+ # 1. Try direct parse (model returned only JSON)
+ return json.loads(source)
+ except Exception:
+ pass
+
+ try:
+ # 2. Try finding content inside ```json ... ```
+ match = re.search(r"```json\s*(\{.*?\})\s*```", source, re.DOTALL)
+ if match:
+ return json.loads(match.group(1))
+
+ # 3. Try finding content inside plain ``` ... ```
+ match = re.search(r"```\s*(\{.*?\})\s*```", source, re.DOTALL)
+ if match:
+ return json.loads(match.group(1))
+
+ # 4. Fallback: Find the LAST outermost { ... } (avoids grabbing think-block JSON)
+ matches = list(
+ re.finditer(r"(\{[^{}]*(?:\{[^{}]*\}[^{}]*)?\})", source, re.DOTALL)
+ )
+ if matches:
+ return json.loads(matches[-1].group(1))
+
+ except Exception:
+ pass
+
return {}
diff --git a/frontend/src/api/agentApi.js b/frontend/src/api/agentApi.js
@@ -77,6 +77,7 @@ export const agentService = {
/**
* Queries the RAG/Agent via Text
+ * Returns: { status, results, query_logic }
*/
async queryText(text) {
if (USE_MOCK_DATA) {
@@ -88,12 +89,17 @@ export const agentService = {
d.payload.stage?.toLowerCase().includes(q) ||
d.payload.crop_id?.toLowerCase().includes(q),
);
+ const results = filtered.length ? filtered : MOCK_DASHBOARD;
return {
status: "success",
- results: (filtered.length ? filtered : MOCK_DASHBOARD).map((d) => ({
+ results: results.map((d) => ({
id: d.id,
+ score: Math.random() * 0.3 + 0.7,
payload: d.payload,
})),
+ query_logic: {
+ must: q ? [{ key: "crop", match: q }] : [],
+ },
};
}
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
@@ -30,8 +30,8 @@ export default function Sidebar() {
const NAV = [
{ label: "Crops", icon: LayoutGrid, path: "/dashboard" },
- { label: "Analytics", icon: BarChart3, path: "/analytics" },
{ label: "Alerts", icon: Bell, path: "/alerts", badge: alertCount || null },
+ { label: "Analytics", icon: BarChart3, path: "/analytics" },
{ label: "Intelligence", icon: Sparkles, path: "/intelligence" },
{ label: "Settings", icon: Settings, path: "/settings" },
];
diff --git a/frontend/src/data/mockData.js b/frontend/src/data/mockData.js
@@ -11,6 +11,75 @@ const ts = (daysAgo, hour = 10, min = 0) => {
return d.toISOString();
};
+// Explanation log samples
+const EXPLANATION_LOGS = {
+ lettuce: `1. **Observation**: Sensors show pH 6.1, EC 1.4 dS/m, Temp 23.5°C, Humidity 68%.
+ All parameters are within acceptable range for Vegetative Lettuce.
+
+2. **Precedent**: 3 similar past states found. In 2 of those cases, a slight EC boost
+ improved growth rate by +12%. No disease was detected in the last 5 cycles.
+
+3. **Logic**: EC at 1.4 is slightly below the 1.5–1.8 target for late vegetative.
+ A small nutrient dosage increase will push it into the optimal window.
+ Fan speed is adequate; no VPD concerns at current temp/humidity.
+
+4. **Conclusion**: Dosing 2.5ml nutrients is the safest, most targeted intervention.
+ No pH correction needed. Maintain current atmospheric settings.`,
+
+ tomato: `1. **Observation**: pH 5.8, EC 2.1 dS/m, Temp 26°C, Humidity 58%.
+ EC is elevated for flowering stage — approaching upper safe limit of 2.2.
+
+2. **Precedent**: 2 similar flowering Tomato states found. In one prior case,
+ EC > 2.2 triggered blossom drop. Acid dosage was effective in 1 case.
+
+3. **Logic**: pH 5.8 is at the lower end for Tomato flowering (optimal 6.0–6.5).
+ A controlled acid dose of 1.5ml will bring pH down slightly to 5.9,
+ while nutrient top-up at 4ml maintains bloom support without pushing EC over limit.
+
+4. **Conclusion**: Targeted acid dosage + nutrient boost is the correct intervention.
+ Increased fan speed to 60% to manage VPD in warm conditions.`,
+
+ basil: `1. **Observation**: CRITICAL — pH 7.8 (target 5.5–6.5), EC 0.6 (very low), Temp 29.5°C.
+ Multiple out-of-range parameters detected simultaneously.
+
+2. **Precedent**: 1 similar critical Basil state found. Previous corrective action
+ required 8ml acid dosage to restore pH. Recovery took 2 cycles.
+
+3. **Logic**: pH 7.8 indicates severe alkalinity — likely nutrient lockout.
+ At this pH, iron and manganese become unavailable causing yellowing.
+ EC 0.6 confirms nutrient starvation. Aggressive correction required.
+ High temp (29.5°C) increases risk of bolting.
+
+4. **Conclusion**: Emergency acid dosage (8ml) is necessary. Fan at 80% to cool.
+ Nutrient addition deferred until pH stabilizes to avoid compounding stress.`,
+
+ spinach: `1. **Observation**: pH 6.3, EC 1.6 dS/m, Temp 21°C, Humidity 72%.
+ All parameters within optimal range for Spinach Vegetative stage.
+
+2. **Precedent**: 4 similar states found, all showing positive outcomes.
+ Gentle interventions in this range historically improve harvest weight by 8–15%.
+
+3. **Logic**: Conditions are near-ideal. pH 6.3 is perfect for Spinach (optimal 6.0–7.0).
+ Light nutrient boost (1ml) maintains EC momentum. Water refill supports
+ root zone hydration without diluting nutrients significantly.
+
+4. **Conclusion**: Minimal intervention strategy. 1ml nutrient + 2L water refill.
+ Fan at 35% maintains gentle airflow — excessive airflow stresses cool-season crops.`,
+
+ cucumber: `1. **Observation**: pH 5.5 (slightly acidic), EC 2.8 (high for fruiting), Temp 27.5°C.
+ Fruiting stage with elevated EC — risk of nutrient burn increasing.
+
+2. **Precedent**: 3 fruiting Cucumber states found. EC > 2.5 in 2 cases led to
+ tip burn on fruit edges. Water flush was effective in reducing EC.
+
+3. **Logic**: EC 2.8 exceeds the 2.5 ceiling for fruiting Cucumbers.
+ Base dosage (2ml) will gently raise pH from 5.5 toward optimal 5.8–6.2.
+ 5L water refill will dilute EC concentration to ~2.4, within safe range.
+
+4. **Conclusion**: Base pH correction + aggressive water refill to manage EC.
+ High fan speed (70%) compensates for elevated temperature and humidity needs.`,
+};
+
// Dashboard snapshots (latest per crop)
export const MOCK_DASHBOARD = [
{
@@ -33,6 +102,7 @@ export const MOCK_DASHBOARD = [
strategic_intent: "MAINTAIN_CURRENT",
bandit_action_id: 0,
reward_score: 0.8,
+ explanation_log: EXPLANATION_LOGS.lettuce,
},
},
{
@@ -55,6 +125,7 @@ export const MOCK_DASHBOARD = [
strategic_intent: "INCREASE_EC_BLOOM",
bandit_action_id: 6,
reward_score: 0.4,
+ explanation_log: EXPLANATION_LOGS.tomato,
},
},
{
@@ -77,6 +148,7 @@ export const MOCK_DASHBOARD = [
strategic_intent: "AGGRESSIVE_PH_DOWN",
bandit_action_id: 2,
reward_score: -0.6,
+ explanation_log: EXPLANATION_LOGS.basil,
},
},
{
@@ -99,6 +171,7 @@ export const MOCK_DASHBOARD = [
strategic_intent: "GENTLE_PH_BALANCING",
bandit_action_id: 4,
reward_score: 0.7,
+ explanation_log: EXPLANATION_LOGS.spinach,
},
},
{
@@ -121,12 +194,13 @@ export const MOCK_DASHBOARD = [
strategic_intent: "LOWER_EC_FLUSH",
bandit_action_id: 7,
reward_score: 0.3,
+ explanation_log: EXPLANATION_LOGS.cucumber,
},
},
];
// Detailed history per crop (multiple snapshots)
-function makeHistory(cropId, cropName, stage, n, baseVals) {
+function makeHistory(cropId, cropName, stage, n, baseVals, explanationLog) {
return Array.from({ length: n }, (_, i) => {
const jitter = (range) => (Math.random() - 0.5) * range;
return {
@@ -164,42 +238,54 @@ function makeHistory(cropId, cropName, stage, n, baseVals) {
"MAX_AIR_CIRCULATION",
][i % 5],
reward_score: i % 5 === 0 ? -0.4 : i % 3 === 0 ? 0.3 : 0.75,
+ // Only the most recent few entries have explanation logs
+ explanation_log: i >= n - 3 ? explanationLog : "PENDING_ANALYSIS",
},
};
});
}
export const MOCK_HISTORY = [
- ...makeHistory("Batch_Lettuce_2025A", "Lettuce", "Vegetative", 14, {
- ph: 6.1,
- ec: 1.4,
- temp: 23.5,
- humidity: 68,
- }),
- ...makeHistory("Batch_Tomato_2025B", "Tomato", "Flowering", 22, {
- ph: 5.9,
- ec: 2.0,
- temp: 26.0,
- humidity: 58,
- }),
- ...makeHistory("Batch_Basil_2025C", "Basil", "Seedling", 5, {
- ph: 7.2,
- ec: 0.7,
- temp: 29.0,
- humidity: 80,
- }),
- ...makeHistory("Batch_Spinach_2025D", "Spinach", "Vegetative", 9, {
- ph: 6.3,
- ec: 1.6,
- temp: 21.5,
- humidity: 72,
- }),
- ...makeHistory("Batch_Cucumber_2025E", "Cucumber", "Fruiting", 31, {
- ph: 5.6,
- ec: 2.7,
- temp: 27.0,
- humidity: 56,
- }),
+ ...makeHistory(
+ "Batch_Lettuce_2025A",
+ "Lettuce",
+ "Vegetative",
+ 14,
+ { ph: 6.1, ec: 1.4, temp: 23.5, humidity: 68 },
+ EXPLANATION_LOGS.lettuce,
+ ),
+ ...makeHistory(
+ "Batch_Tomato_2025B",
+ "Tomato",
+ "Flowering",
+ 22,
+ { ph: 5.9, ec: 2.0, temp: 26.0, humidity: 58 },
+ EXPLANATION_LOGS.tomato,
+ ),
+ ...makeHistory(
+ "Batch_Basil_2025C",
+ "Basil",
+ "Seedling",
+ 5,
+ { ph: 7.2, ec: 0.7, temp: 29.0, humidity: 80 },
+ EXPLANATION_LOGS.basil,
+ ),
+ ...makeHistory(
+ "Batch_Spinach_2025D",
+ "Spinach",
+ "Vegetative",
+ 9,
+ { ph: 6.3, ec: 1.6, temp: 21.5, humidity: 72 },
+ EXPLANATION_LOGS.spinach,
+ ),
+ ...makeHistory(
+ "Batch_Cucumber_2025E",
+ "Cucumber",
+ "Fruiting",
+ 31,
+ { ph: 5.6, ec: 2.7, temp: 27.0, humidity: 56 },
+ EXPLANATION_LOGS.cucumber,
+ ),
];
// Mock search / agent response
@@ -213,18 +299,7 @@ export const MOCK_SEARCH_RESULT = {
fan_speed_pct: 55,
water_refill_l: 1.5,
},
- explanation: `1. **Observation**: Sensors show pH 6.2, EC 1.4 dS/m, Temp 23.5°C, Humidity 68%.
- All parameters are within acceptable range for Vegetative Lettuce.
-
-2. **Precedent**: 3 similar past states found. In 2 of those cases, a slight EC boost
- improved growth rate. No disease was detected in the last 5 cycles.
-
-3. **Logic**: EC at 1.4 is slightly below the 1.5-1.8 target for late vegetative.
- A small nutrient dosage increase will push it into the optimal window.
- Fan speed is adequate; no VPD concerns.
-
-4. **Conclusion**: Dosing 3.0ml nutrients is the safest, most targeted intervention.
- No pH correction needed. Maintain current atmospheric settings.`,
+ explanation: EXPLANATION_LOGS.lettuce,
search_results: MOCK_DASHBOARD.slice(0, 3).map((d, i) => ({
id: d.id,
score: 0.95 - i * 0.08,
diff --git a/frontend/src/pages/AddCrop.jsx b/frontend/src/pages/AddCrop.jsx
@@ -818,7 +818,7 @@ export default function AddCrop() {
</>
) : (
<>
- <Play size={15} fill="currentColor" /> Start Agent Cycle
+ <Play size={15} fill="currentColor" /> Start Monitoring
</>
)}
</button>
diff --git a/frontend/src/pages/CropDetails.jsx b/frontend/src/pages/CropDetails.jsx
@@ -1,7 +1,17 @@
import React, { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { fetchCropDetails } from "../api/farmApi";
-import { ArrowLeft, Thermometer, Droplet, Wind, Activity } from "lucide-react";
+import {
+ ArrowLeft,
+ Thermometer,
+ Droplet,
+ Wind,
+ Activity,
+ Brain,
+ ChevronDown,
+ ChevronUp,
+ Sparkles,
+} from "lucide-react";
import {
AreaChart,
Area,
@@ -103,6 +113,303 @@ function logDotColor(payload) {
return "var(--green)";
}
+// Explanation block
+function ExplanationLogBlock({ log }) {
+ const [expanded, setExpanded] = useState(false);
+
+ const isPending =
+ !log || log === "PENDING_ANALYSIS" || log.trim().length === 0;
+
+ if (isPending) {
+ return (
+ <div
+ style={{
+ borderRadius: 14,
+ padding: "18px 20px",
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ }}
+ >
+ <div
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 10,
+ marginBottom: 12,
+ }}
+ >
+ <div
+ style={{
+ width: 32,
+ height: 32,
+ borderRadius: 8,
+ background: "rgba(167,139,250,0.08)",
+ border: "1px solid rgba(167,139,250,0.15)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
+ <Brain size={14} style={{ color: "#a78bfa" }} />
+ </div>
+ <div className="section-label" style={{ marginBottom: 0 }}>
+ AI DECISION REASONING
+ </div>
+ <span
+ style={{
+ fontSize: 9,
+ fontFamily: "DM Mono, monospace",
+ padding: "2px 8px",
+ borderRadius: 20,
+ background: "var(--bg-3)",
+ border: "1px solid var(--border)",
+ color: "var(--text-3)",
+ }}
+ >
+ PENDING
+ </span>
+ </div>
+ <div
+ style={{
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ fontStyle: "italic",
+ }}
+ >
+ Explanation will be generated after the agent completes its first
+ analysis cycle for this crop.
+ </div>
+ </div>
+ );
+ }
+
+ // Parse log to detect numbered steps for highlighting
+ const lines = log.split("\n").filter((l) => l.trim());
+ const isStructured = lines.some((l) => /^\d+\./.test(l.trim()));
+
+ // Preview: first 3 lines
+ const previewLines = lines.slice(0, 3);
+ const hasMore = lines.length > 3;
+
+ return (
+ <div
+ style={{
+ borderRadius: 14,
+ background: "var(--surface)",
+ border: "1px solid rgba(167,139,250,0.2)",
+ overflow: "hidden",
+ flexShrink: 0,
+ }}
+ >
+ {/* Header */}
+ <div
+ style={{
+ padding: "14px 20px",
+ borderBottom: "1px solid var(--border)",
+ background: "rgba(167,139,250,0.05)",
+ display: "flex",
+ alignItems: "center",
+ gap: 10,
+ }}
+ >
+ <div
+ style={{
+ width: 28,
+ height: 28,
+ borderRadius: 7,
+ background: "rgba(167,139,250,0.12)",
+ border: "1px solid rgba(167,139,250,0.25)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ flexShrink: 0,
+ }}
+ >
+ <Sparkles size={12} style={{ color: "#a78bfa" }} />
+ </div>
+ <div style={{ flex: 1 }}>
+ <div className="section-label" style={{ marginBottom: 0 }}>
+ AI DECISION REASONING
+ </div>
+ <div
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ marginTop: 2,
+ }}
+ >
+ Chain-of-thought log generated by the Explainer agent
+ </div>
+ </div>
+ {hasMore && (
+ <button
+ onClick={() => setExpanded((e) => !e)}
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 5,
+ padding: "4px 10px",
+ borderRadius: 7,
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ cursor: "pointer",
+ background: "var(--bg-3)",
+ border: "1px solid var(--border)",
+ color: "var(--text-3)",
+ }}
+ >
+ {expanded ? (
+ <>
+ <ChevronUp size={11} /> Collapse
+ </>
+ ) : (
+ <>
+ <ChevronDown size={11} /> Expand
+ </>
+ )}
+ </button>
+ )}
+ </div>
+
+ {/* Content */}
+ <div style={{ padding: "16px 20px" }}>
+ {isStructured ? (
+ // Render numbered steps as styled blocks
+ <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
+ {(expanded ? lines : previewLines).map((line, i) => {
+ const stepMatch = line
+ .trim()
+ .match(/^(\d+)\.\s+\*\*(.+?)\*\*:?\s*(.*)/);
+
+ if (stepMatch) {
+ const [, num, title, body] = stepMatch;
+ const stepColors = [
+ "var(--blue)",
+ "#a78bfa",
+ "var(--amber)",
+ "var(--green)",
+ ];
+ const col = stepColors[(parseInt(num) - 1) % stepColors.length];
+ return (
+ <div
+ key={i}
+ style={{
+ display: "flex",
+ gap: 12,
+ padding: "10px 14px",
+ borderRadius: 10,
+ background: "var(--bg-3)",
+ border: `1px solid ${col}18`,
+ }}
+ >
+ <div
+ style={{
+ width: 22,
+ height: 22,
+ borderRadius: "50%",
+ background: `${col}18`,
+ border: `1px solid ${col}40`,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ flexShrink: 0,
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: col,
+ fontWeight: 700,
+ }}
+ >
+ {num}
+ </div>
+ <div>
+ <div
+ style={{
+ fontSize: 12,
+ fontWeight: 700,
+ color: col,
+ fontFamily: "DM Mono, monospace",
+ marginBottom: 3,
+ }}
+ >
+ {title}
+ </div>
+ {body && (
+ <div
+ style={{
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-2)",
+ lineHeight: 1.7,
+ }}
+ >
+ {body}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+ }
+
+ // Regular line
+ return (
+ <div
+ key={i}
+ style={{
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-2)",
+ lineHeight: 1.7,
+ padding: "2px 0",
+ }}
+ >
+ {line}
+ </div>
+ );
+ })}
+
+ {!expanded && hasMore && (
+ <button
+ onClick={() => setExpanded(true)}
+ style={{
+ alignSelf: "flex-start",
+ padding: "5px 12px",
+ borderRadius: 7,
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ cursor: "pointer",
+ background: "rgba(167,139,250,0.08)",
+ border: "1px solid rgba(167,139,250,0.2)",
+ color: "#a78bfa",
+ }}
+ >
+ + {lines.length - previewLines.length} more lines
+ </button>
+ )}
+ </div>
+ ) : (
+ // Plain text fallback
+ <pre
+ style={{
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-2)",
+ lineHeight: 1.75,
+ whiteSpace: "pre-wrap",
+ margin: 0,
+ maxHeight: expanded ? "none" : 140,
+ overflow: expanded ? "visible" : "hidden",
+ }}
+ >
+ {log}
+ </pre>
+ )}
+ </div>
+ </div>
+ );
+}
+
const TABS = ["overview", "sensors", "log"];
export default function CropDetails() {
@@ -115,6 +422,7 @@ export default function CropDetails() {
const [latest, setLatest] = useState(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState("overview");
+ const [showExp, setShowExp] = useState(false);
useEffect(() => {
fetchCropDetails(cropId).then((data) => {
@@ -379,6 +687,9 @@ export default function CropDetails() {
/>
)}
+ {/* Explanation Log */}
+ <ExplanationLogBlock log={p.explanation_log} />
+
{/* pH chart */}
<div
style={{
@@ -618,139 +929,221 @@ export default function CropDetails() {
{[...history]
.reverse()
.slice(0, logLimit)
- .map((h, i) => (
- <div
- key={i}
- style={{
- borderBottom: "1px solid var(--border)",
- transition: "background 0.12s",
- cursor: "default",
- }}
- onMouseEnter={(e) =>
- (e.currentTarget.style.background =
- "rgba(74,222,128,0.04)")
- }
- onMouseLeave={(e) =>
- (e.currentTarget.style.background = "transparent")
- }
- >
- {/* Row header */}
+ .map((h, i) => {
+ const hasExplanation =
+ h.payload?.explanation_log &&
+ h.payload.explanation_log !== "PENDING_ANALYSIS";
+
+ return (
<div
+ key={i}
style={{
- display: "flex",
- alignItems: "center",
- gap: 10,
- padding: "10px 20px",
+ borderBottom: "1px solid var(--border)",
+ transition: "background 0.12s",
+ cursor: "default",
}}
+ onMouseEnter={(e) =>
+ (e.currentTarget.style.background =
+ "rgba(74,222,128,0.04)")
+ }
+ onMouseLeave={(e) =>
+ (e.currentTarget.style.background = "transparent")
+ }
>
- <span
- style={{
- width: 7,
- height: 7,
- borderRadius: "50%",
- background: logDotColor(h.payload),
- flexShrink: 0,
- }}
- />
- <span
- style={{
- fontSize: 11,
- fontFamily: "DM Mono, monospace",
- color: "var(--text-3)",
- flexShrink: 0,
- width: 52,
- }}
- >
- {h.payload?.timestamp
- ? new Date(h.payload.timestamp).toLocaleTimeString(
- [],
- { hour: "2-digit", minute: "2-digit" },
- )
- : "--"}
- </span>
- <span
+ {/* Row header */}
+ <div
style={{
- fontSize: 11,
- fontFamily: "DM Mono, monospace",
- color: "var(--text-3)",
- width: 36,
- flexShrink: 0,
+ display: "flex",
+ alignItems: "center",
+ gap: 10,
+ padding: "10px 20px",
}}
>
- #{h.payload?.sequence_number || i}
- </span>
+ <span
+ style={{
+ width: 7,
+ height: 7,
+ borderRadius: "50%",
+ background: logDotColor(h.payload),
+ flexShrink: 0,
+ }}
+ />
+ <span
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ flexShrink: 0,
+ width: 52,
+ }}
+ >
+ {h.payload?.timestamp
+ ? new Date(
+ h.payload.timestamp,
+ ).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ })
+ : "--"}
+ </span>
+ <span
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ width: 36,
+ flexShrink: 0,
+ }}
+ >
+ #{h.payload?.sequence_number || i}
+ </span>
- {/* Sensor snapshot */}
- <div
- style={{ display: "flex", gap: 12, flexWrap: "wrap" }}
- >
- {[
- {
- label: "pH",
- value: h.cleanSensors?.ph,
- color: "var(--green)",
- },
- {
- label: "EC",
- value: h.cleanSensors?.ec,
- color: "var(--amber)",
- },
- {
- label: "T",
- value: h.cleanSensors?.temp + "°",
- color: "var(--blue)",
- },
- {
- label: "H",
- value: h.cleanSensors?.humidity + "%",
- color: "#a78bfa",
- },
- ].map(({ label, value, color }) => (
- <span
- key={label}
- style={{
- fontSize: 13,
- fontFamily: "DM Mono, monospace",
- display: "flex",
- alignItems: "baseline",
- gap: 3,
- }}
- >
+ {/* Sensor snapshot */}
+ <div
+ style={{
+ display: "flex",
+ gap: 12,
+ flexWrap: "wrap",
+ }}
+ >
+ {[
+ {
+ label: "pH",
+ value: h.cleanSensors?.ph,
+ color: "var(--green)",
+ },
+ {
+ label: "EC",
+ value: h.cleanSensors?.ec,
+ color: "var(--amber)",
+ },
+ {
+ label: "T",
+ value: h.cleanSensors?.temp + "°",
+ color: "var(--blue)",
+ },
+ {
+ label: "H",
+ value: h.cleanSensors?.humidity + "%",
+ color: "#a78bfa",
+ },
+ ].map(({ label, value, color }) => (
<span
- style={{ color: "var(--text-3)", fontSize: 11 }}
+ key={label}
+ style={{
+ fontSize: 13,
+ fontFamily: "DM Mono, monospace",
+ display: "flex",
+ alignItems: "baseline",
+ gap: 3,
+ }}
>
- {label}
- </span>
- <span style={{ color, fontWeight: 700 }}>
- {formatNumber(value)}
+ <span
+ style={{
+ color: "var(--text-3)",
+ fontSize: 11,
+ }}
+ >
+ {label}
+ </span>
+ <span style={{ color, fontWeight: 700 }}>
+ {formatNumber(value)}
+ </span>
</span>
+ ))}
+ </div>
+
+ {/* Outcome badge */}
+ {h.payload?.outcome && (
+ <span style={{ marginLeft: "auto", flexShrink: 0 }}>
+ <AgentOutcomeWidget
+ outcome={h.payload.outcome}
+ rewardScore={h.payload.reward_score}
+ />
</span>
- ))}
+ )}
+
+ {/* Explanation toggle */}
+ {hasExplanation && (
+ <button
+ onClick={() => setShowExp((v) => !v)}
+ style={{
+ flexShrink: 0,
+ display: "flex",
+ alignItems: "center",
+ gap: 4,
+ padding: "3px 8px",
+ borderRadius: 6,
+ fontSize: 9,
+ fontFamily: "DM Mono, monospace",
+ cursor: "pointer",
+ background: showExp
+ ? "rgba(167,139,250,0.12)"
+ : "var(--bg-3)",
+ border: `1px solid ${showExp ? "rgba(167,139,250,0.3)" : "var(--border)"}`,
+ color: showExp ? "#a78bfa" : "var(--text-3)",
+ }}
+ >
+ <Brain size={9} />
+ {showExp ? "Hide" : "Why?"}
+ </button>
+ )}
</div>
- {/* Outcome badge */}
- {h.payload?.outcome && (
- <span style={{ marginLeft: "auto", flexShrink: 0 }}>
- <AgentOutcomeWidget
- outcome={h.payload.outcome}
- rewardScore={h.payload.reward_score}
- />
- </span>
- )}
- </div>
+ {/* Action row */}
+ {h.payload?.action_taken &&
+ h.payload.action_taken !== "PENDING_ACTION" && (
+ <div style={{ padding: "0 20px 10px 46px" }}>
+ <AgentActionWidget
+ actionTaken={h.payload.action_taken}
+ compact
+ />
+ </div>
+ )}
- {/* Action row*/}
- {h.payload?.action_taken &&
- h.payload.action_taken !== "PENDING_ACTION" && (
- <div style={{ padding: "0 20px 10px 46px" }}>
- <AgentActionWidget
- actionTaken={h.payload.action_taken}
- compact
- />
+ {/* Explanation log inline */}
+ {hasExplanation && showExp && (
+ <div
+ className="animate-fade-in"
+ style={{
+ margin: "0 20px 12px 46px",
+ padding: "12px 14px",
+ borderRadius: 10,
+ background: "rgba(167,139,250,0.05)",
+ border: "1px solid rgba(167,139,250,0.15)",
+ }}
+ >
+ <div
+ style={{
+ fontSize: 9,
+ fontFamily: "DM Mono, monospace",
+ color: "#a78bfa",
+ marginBottom: 8,
+ }}
+ >
+ <Brain
+ size={9}
+ style={{ display: "inline", marginRight: 5 }}
+ />
+ AI REASONING FOR THIS DECISION
+ </div>
+ <pre
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-2)",
+ lineHeight: 1.7,
+ whiteSpace: "pre-wrap",
+ margin: 0,
+ }}
+ >
+ {h.payload.explanation_log}
+ </pre>
</div>
)}
- </div>
- ))}
+ </div>
+ );
+ })}
</div>
</div>
)}
diff --git a/frontend/src/pages/FarmIntelligence.jsx b/frontend/src/pages/FarmIntelligence.jsx
@@ -1,4 +1,4 @@
-import { useRef, useState } from "react";
+import { useRef, useState, useMemo, useEffect } from "react";
import {
Activity,
Mic,
@@ -6,7 +6,6 @@ import {
Brain,
Search,
Sparkles,
- ChevronRight,
Database,
TrendingUp,
TrendingDown,
@@ -15,6 +14,14 @@ import {
Thermometer,
Wind,
BookOpen,
+ MessageSquare,
+ ChevronDown,
+ Leaf,
+ X,
+ Cpu,
+ GitBranch,
+ Zap,
+ BarChart2,
} from "lucide-react";
import { agentService } from "../api/agentApi";
import { extractSensors } from "../utils/dataUtils";
@@ -26,17 +33,340 @@ 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",
+// Suggestion banks
+const GLOBAL_SUGGESTIONS = [
+ "Show all crops",
+ "Which crops are critical?",
+ "Find crops in flowering stage",
+ "List recent negative outcomes",
"Show Tomato batches",
- "Find crops with high EC readings",
+ "Find crops with high EC",
];
-// INSIGHTS
+const CROP_SUGGESTIONS = (crop, cropId) => [
+ `Why did we take the last decision for ${crop}?`,
+ `Explain the current action for ${cropId}`,
+ `Is ${crop} performing well?`,
+ `What should I watch out for with ${crop}?`,
+ `How has ${crop} been trending lately?`,
+ `Compare ${crop} to similar crops`,
+];
+
+// Thinking block renderer
+function ThinkingBlock({ text }) {
+ const [open, setOpen] = useState(false);
+ if (!text) return null;
+ return (
+ <div
+ style={{
+ borderRadius: 10,
+ border: "1px solid rgba(167,139,250,0.2)",
+ background: "rgba(167,139,250,0.04)",
+ overflow: "hidden",
+ marginBottom: 14,
+ }}
+ >
+ <button
+ onClick={() => setOpen((o) => !o)}
+ style={{
+ width: "100%",
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ padding: "9px 14px",
+ background: "transparent",
+ border: "none",
+ cursor: "pointer",
+ textAlign: "left",
+ }}
+ >
+ <Brain size={11} style={{ color: "#a78bfa", flexShrink: 0 }} />
+ <span
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "#a78bfa",
+ flex: 1,
+ }}
+ >
+ THINKING PROCESS {open ? "▲" : "▼"}
+ </span>
+ <span
+ style={{
+ fontSize: 9,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ }}
+ >
+ {text.split("\n").filter(Boolean).length} steps
+ </span>
+ </button>
+ {open && (
+ <div
+ style={{
+ padding: "0 14px 12px",
+ borderTop: "1px solid rgba(167,139,250,0.15)",
+ }}
+ >
+ <pre
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ lineHeight: 1.75,
+ whiteSpace: "pre-wrap",
+ color: "var(--text-3)",
+ margin: "10px 0 0",
+ }}
+ >
+ {text}
+ </pre>
+ </div>
+ )}
+ </div>
+ );
+}
+
+// LLM Answer block
+function LLMAnswerBlock({ answer, thinking, query, cropContext }) {
+ if (!answer) return null;
+
+ return (
+ <div
+ className="animate-fade-in"
+ style={{
+ borderRadius: 14,
+ background: "var(--surface)",
+ border: "1px solid rgba(167,139,250,0.25)",
+ overflow: "hidden",
+ }}
+ >
+ {/* Header */}
+ <div
+ style={{
+ padding: "12px 18px",
+ borderBottom: "1px solid var(--border)",
+ background: "rgba(167,139,250,0.06)",
+ display: "flex",
+ alignItems: "center",
+ gap: 10,
+ }}
+ >
+ <div
+ style={{
+ width: 26,
+ height: 26,
+ borderRadius: 6,
+ background: "rgba(167,139,250,0.15)",
+ border: "1px solid rgba(167,139,250,0.3)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ flexShrink: 0,
+ }}
+ >
+ <Sparkles size={12} style={{ color: "#a78bfa" }} />
+ </div>
+ <div style={{ flex: 1 }}>
+ <div
+ style={{
+ fontSize: 11,
+ fontFamily: "DM Mono, monospace",
+ color: "#a78bfa",
+ fontWeight: 600,
+ }}
+ >
+ DEMETER INTELLIGENCE
+ </div>
+ {cropContext && (
+ <div
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ marginTop: 1,
+ }}
+ >
+ Context: {cropContext.crop} · {cropContext.cropId}
+ </div>
+ )}
+ </div>
+ <div
+ style={{
+ fontSize: 9,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ padding: "2px 8px",
+ borderRadius: 20,
+ background: "var(--bg-3)",
+ border: "1px solid var(--border)",
+ }}
+ >
+ {cropContext ? "CROP-AWARE" : "FLEET-WIDE"}
+ </div>
+ </div>
+
+ <div style={{ padding: 18 }}>
+ {/* Thinking */}
+ <ThinkingBlock text={thinking} />
+
+ {/* Answer */}
+ <div
+ style={{
+ fontSize: 13,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-2)",
+ lineHeight: 1.8,
+ whiteSpace: "pre-wrap",
+ }}
+ >
+ {answer}
+ </div>
+ </div>
+ </div>
+ );
+}
+
+// Related crops card
+function RelatedCropCard({ item, score }) {
+ const p = item.payload || {};
+ const s = extractSensors(p);
+ const status = deriveCropStatus(p);
+
+ const statusColor =
+ status === "Healthy"
+ ? "var(--green)"
+ : status === "Attention"
+ ? "var(--amber)"
+ : "var(--red)";
+
+ const scoreColor =
+ score > 0.8
+ ? "var(--green)"
+ : score > 0.6
+ ? "var(--amber)"
+ : "var(--text-3)";
+
+ return (
+ <div
+ style={{
+ borderRadius: 12,
+ padding: "14px 16px",
+ background: "var(--bg-3)",
+ border: "1px solid var(--border)",
+ display: "flex",
+ flexDirection: "column",
+ gap: 10,
+ }}
+ >
+ <div
+ style={{
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ }}
+ >
+ <div>
+ <div style={{ fontWeight: 700, fontSize: 13, color: "var(--text)" }}>
+ {p.crop || "Unknown"}
+ </div>
+ <div
+ style={{
+ fontSize: 10,
+ 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: 9,
+ fontFamily: "DM Mono, monospace",
+ padding: "2px 7px",
+ borderRadius: 20,
+ color: statusColor,
+ background: `${statusColor}18`,
+ border: `1px solid ${statusColor}30`,
+ }}
+ >
+ {status.toUpperCase()}
+ </span>
+ {score !== undefined && (
+ <span
+ style={{
+ fontSize: 9,
+ fontFamily: "DM Mono, monospace",
+ color: scoreColor,
+ }}
+ >
+ {(score * 100).toFixed(0)}% match
+ </span>
+ )}
+ </div>
+ </div>
+
+ {/* Mini sensor row */}
+ <div style={{ display: "flex", gap: 8 }}>
+ {[
+ { label: "pH", value: s.ph, color: "var(--green)" },
+ { label: "EC", value: s.ec, color: "var(--amber)" },
+ { label: "T", value: `${s.temp}°`, color: "var(--blue)" },
+ { label: "H", value: `${s.humidity}%`, color: "#a78bfa" },
+ ].map(({ label, value, color }) => (
+ <div
+ key={label}
+ style={{
+ flex: 1,
+ textAlign: "center",
+ padding: "5px 4px",
+ borderRadius: 6,
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ }}
+ >
+ <div
+ style={{
+ fontSize: 9,
+ color: "var(--text-3)",
+ fontFamily: "DM Mono, monospace",
+ }}
+ >
+ {label}
+ </div>
+ <div
+ style={{
+ fontSize: 11,
+ fontWeight: 700,
+ color,
+ fontFamily: "DM Mono, monospace",
+ marginTop: 1,
+ }}
+ >
+ {value}
+ </div>
+ </div>
+ ))}
+ </div>
+
+ {/* Outcome */}
+ {p.outcome && p.outcome !== "PENDING_OBSERVATION" && (
+ <AgentOutcomeWidget outcome={p.outcome} rewardScore={p.reward_score} />
+ )}
+ </div>
+ );
+}
+
+// Main insight card (search results)
function InsightCard({ result, idx }) {
const p = result.payload || {};
const s = extractSensors(p);
@@ -215,7 +545,7 @@ function InsightCard({ result, idx }) {
);
}
-// FLEET SUMMARY
+// Fleet stat pill
function FleetStat({ label, value, color, icon: Icon }) {
return (
<div
@@ -266,23 +596,274 @@ function FleetStat({ label, value, color, icon: Icon }) {
);
}
+// Crop selector dropdown
+function CropSelector({ crops, selectedCrop, onSelect, onClear }) {
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+
+ useEffect(() => {
+ if (!open) return;
+ const handler = (e) => {
+ if (ref.current && !ref.current.contains(e.target)) setOpen(false);
+ };
+ document.addEventListener("mousedown", handler);
+ return () => document.removeEventListener("mousedown", handler);
+ }, [open]);
+
+ return (
+ <div ref={ref} style={{ position: "relative" }}>
+ <button
+ onClick={() => setOpen((o) => !o)}
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 7,
+ padding: "7px 12px",
+ borderRadius: 10,
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
+ cursor: "pointer",
+ background: selectedCrop ? "rgba(74,222,128,0.1)" : "var(--surface)",
+ border: `1px solid ${selectedCrop ? "rgba(74,222,128,0.35)" : "var(--border)"}`,
+ color: selectedCrop ? "var(--green)" : "var(--text-2)",
+ flexShrink: 0,
+ minWidth: 160,
+ justifyContent: "space-between",
+ whiteSpace: "nowrap",
+ }}
+ >
+ <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
+ <Leaf size={12} style={{ flexShrink: 0 }} />
+ <span
+ style={{
+ maxWidth: 110,
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ }}
+ >
+ {selectedCrop
+ ? `${selectedCrop.crop} · ${selectedCrop.cropId}`
+ : "All Crops"}
+ </span>
+ </div>
+ <div style={{ display: "flex", alignItems: "center", gap: 4 }}>
+ {selectedCrop && (
+ <span
+ onClick={(e) => {
+ e.stopPropagation();
+ onClear();
+ }}
+ style={{
+ display: "flex",
+ alignItems: "center",
+ color: "var(--text-3)",
+ cursor: "pointer",
+ }}
+ >
+ <X size={11} />
+ </span>
+ )}
+ <ChevronDown size={11} style={{ opacity: 0.6 }} />
+ </div>
+ </button>
+
+ {open && (
+ <div
+ className="animate-fade-in"
+ style={{
+ position: "absolute",
+ top: "calc(100% + 6px)",
+ left: 0,
+ zIndex: 50,
+ minWidth: 240,
+ borderRadius: 12,
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
+ boxShadow: "0 8px 32px rgba(0,0,0,0.3)",
+ overflow: "hidden",
+ }}
+ >
+ <div
+ style={{
+ padding: "8px 12px 6px",
+ borderBottom: "1px solid var(--border)",
+ }}
+ >
+ <div
+ style={{
+ fontSize: 9,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ }}
+ >
+ SELECT CROP — {crops.length} available
+ </div>
+ </div>
+
+ <div style={{ maxHeight: 260, overflowY: "auto" }}>
+ {/* All option */}
+ <div
+ onClick={() => {
+ onClear();
+ setOpen(false);
+ }}
+ style={{
+ padding: "9px 14px",
+ cursor: "pointer",
+ display: "flex",
+ alignItems: "center",
+ gap: 10,
+ background: !selectedCrop
+ ? "rgba(74,222,128,0.07)"
+ : "transparent",
+ borderBottom: "1px solid var(--border)",
+ }}
+ onMouseEnter={(e) =>
+ (e.currentTarget.style.background = "rgba(255,255,255,0.04)")
+ }
+ onMouseLeave={(e) =>
+ (e.currentTarget.style.background = !selectedCrop
+ ? "rgba(74,222,128,0.07)"
+ : "transparent")
+ }
+ >
+ <Database size={12} style={{ color: "var(--text-3)" }} />
+ <div>
+ <div
+ style={{
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-2)",
+ }}
+ >
+ All Crops
+ </div>
+ <div style={{ fontSize: 10, color: "var(--text-3)" }}>
+ Fleet-wide query
+ </div>
+ </div>
+ </div>
+
+ {crops.map((c) => {
+ const isSelected = selectedCrop?.cropId === c.cropId;
+ const statusColor =
+ c.status === "Healthy"
+ ? "var(--green)"
+ : c.status === "Attention"
+ ? "var(--amber)"
+ : "var(--red)";
+ return (
+ <div
+ key={c.cropId}
+ onClick={() => {
+ onSelect(c);
+ setOpen(false);
+ }}
+ style={{
+ padding: "9px 14px",
+ cursor: "pointer",
+ display: "flex",
+ alignItems: "center",
+ gap: 10,
+ background: isSelected
+ ? "rgba(74,222,128,0.07)"
+ : "transparent",
+ borderBottom: "1px solid rgba(255,255,255,0.04)",
+ }}
+ onMouseEnter={(e) =>
+ (e.currentTarget.style.background =
+ "rgba(255,255,255,0.04)")
+ }
+ onMouseLeave={(e) =>
+ (e.currentTarget.style.background = isSelected
+ ? "rgba(74,222,128,0.07)"
+ : "transparent")
+ }
+ >
+ <div
+ style={{
+ width: 8,
+ height: 8,
+ borderRadius: "50%",
+ background: statusColor,
+ flexShrink: 0,
+ }}
+ />
+ <div style={{ flex: 1 }}>
+ <div
+ style={{
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text)",
+ fontWeight: isSelected ? 700 : 400,
+ }}
+ >
+ {c.crop}
+ </div>
+ <div
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ marginTop: 1,
+ }}
+ >
+ {c.cropId} · {c.stage}
+ </div>
+ </div>
+ <span
+ style={{
+ fontSize: 9,
+ fontFamily: "DM Mono, monospace",
+ color: statusColor,
+ }}
+ >
+ {c.status.toUpperCase()}
+ </span>
+ </div>
+ );
+ })}
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
+
// MAIN
export default function FarmIntelligence() {
const [textQuery, setTextQuery] = useState("");
const [loading, setLoading] = useState(false);
const [results, setResults] = useState([]);
+ const [relatedCrops, setRelatedCrops] = useState([]);
const [transcription, setTranscription] = useState("");
const [hasQueried, setHasQueried] = useState(false);
- const [explanation, setExplanation] = useState("");
- const [showExplain, setShowExplain] = useState(false);
+ const [llmAnswer, setLlmAnswer] = useState("");
+ const [llmThinking, setLlmThinking] = useState("");
+ const [queryLogic, setQueryLogic] = useState("");
+ const [showQueryLogic, setShowQueryLogic] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [toast, setToast] = useState(null);
+ const [selectedCrop, setSelectedCrop] = useState(null);
+ const [mode, setMode] = useState("search"); // "search" | "ask"
const mediaRecorderRef = useRef(null);
const chunksRef = useRef([]);
const { dashboard } = useFarmData();
+ // Build crop list from dashboard
+ const cropList = useMemo(() => {
+ if (!dashboard?.length) return [];
+ return dashboard.map((d) => ({
+ crop: d.payload?.crop || "Unknown",
+ cropId: d.payload?.crop_id || d.id,
+ stage: d.payload?.stage || "",
+ status: deriveCropStatus(d.payload),
+ payload: d.payload,
+ }));
+ }, [dashboard]);
+
const showToast = (msg, type = "success") => {
setToast({ msg, type });
setTimeout(() => setToast(null), 3000);
@@ -301,14 +882,160 @@ export default function FarmIntelligence() {
).length,
};
- const handleQuery = async (q) => {
+ // Build rich context for LLM
+ const buildLLMContext = (cropCtx) => {
+ if (cropCtx) {
+ // Specific crop context
+ const p = cropCtx.payload || {};
+ const sensors = extractSensors(p);
+
+ // Find history from dashboard for same cropId
+ const cropDashboardItems = (dashboard || []).filter(
+ (d) => d.payload?.crop_id === cropCtx.cropId,
+ );
+
+ return `
+CROP CONTEXT:
+- Crop: ${p.crop || cropCtx.crop}
+- Batch ID: ${p.crop_id || cropCtx.cropId}
+- Growth Stage: ${p.stage || "Unknown"}
+- Sequence Number: ${p.sequence_number || "—"}
+- Last Updated: ${p.timestamp ? new Date(p.timestamp).toLocaleString() : "Unknown"}
+
+LATEST SENSOR READINGS:
+- pH: ${sensors.ph}
+- EC: ${sensors.ec} dS/m
+- Temperature: ${sensors.temp}°C
+- Humidity: ${sensors.humidity}%
+
+LATEST AGENT DECISION:
+- Action Taken: ${p.action_taken && p.action_taken !== "PENDING_ACTION" ? p.action_taken : "None recorded"}
+- Outcome: ${p.outcome && p.outcome !== "PENDING_OBSERVATION" ? p.outcome : "Pending"}
+- Reward Score: ${p.reward_score ?? "N/A"}
+- Strategic Intent: ${p.strategic_intent || "N/A"}
+
+EXPLANATION LOG (AI Decision Reasoning):
+${p.explanation_log && p.explanation_log !== "PENDING_ANALYSIS" ? p.explanation_log : "Not yet generated."}
+
+FLEET OVERVIEW (for comparison):
+- Total crops: ${fleetStats.total}
+- Healthy: ${fleetStats.healthy}, Needs Attention: ${fleetStats.attention}, Critical: ${fleetStats.critical}
+`.trim();
+ } else {
+ // Fleet-wide context
+ const cropSummaries = (dashboard || [])
+ .slice(0, 10)
+ .map((d) => {
+ const p = d.payload || {};
+ const s = extractSensors(p);
+ return ` - ${p.crop || "?"} (${p.crop_id || d.id}): Stage=${p.stage}, pH=${s.ph}, EC=${s.ec}, Status=${deriveCropStatus(p)}, Outcome=${p.outcome || "Pending"}`;
+ })
+ .join("\n");
+
+ return `
+FLEET OVERVIEW:
+- Total crops: ${fleetStats.total}
+- Healthy: ${fleetStats.healthy}, Needs Attention: ${fleetStats.attention}, Critical: ${fleetStats.critical}
+
+CURRENT CROPS:
+${cropSummaries || "No crops in database."}
+
+SYSTEM: Hydroponic multi-crop farm management system (Demeter).
+`.trim();
+ }
+ };
+
+ // Ask LLM
+ const handleAsk = async (q) => {
+ const query = q || textQuery;
+ if (!query.trim()) return;
+ setLoading(true);
+ setHasQueried(true);
+ setTextQuery(query);
+ setLlmAnswer("");
+ setLlmThinking("");
+ setResults([]);
+ setRelatedCrops([]);
+
+ try {
+ const context = buildLLMContext(selectedCrop);
+
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ model: "claude-sonnet-4-20250514",
+ max_tokens: 1000,
+ thinking: { type: "enabled", budget_tokens: 5000 },
+ system: `You are Demeter Intelligence, an expert AI agronomist and data analyst for a hydroponic farm management system.
+You have access to live farm data and must answer questions about crop health, agent decisions, and farm performance.
+Be specific, cite the actual numbers from the data, and be practical. Keep answers concise but thorough.
+When explaining agent decisions, reference the explanation_log if available.`,
+ messages: [
+ {
+ role: "user",
+ content: `FARM DATA:\n${context}\n\nQUESTION: ${query}`,
+ },
+ ],
+ }),
+ });
+
+ const data = await response.json();
+
+ let thinking = "";
+ let answer = "";
+
+ for (const block of data.content || []) {
+ if (block.type === "thinking") thinking = block.thinking;
+ if (block.type === "text") answer = block.text;
+ }
+
+ setLlmThinking(thinking);
+ setLlmAnswer(answer || "No response generated.");
+
+ // Also run a search to show related crops
+ if (selectedCrop) {
+ // For specific crop, show similar crops via vector search
+ try {
+ const searchData = await agentService.queryText(selectedCrop.crop);
+ if (searchData.results) {
+ setRelatedCrops(
+ searchData.results
+ .filter((r) => r.payload?.crop_id !== selectedCrop.cropId)
+ .slice(0, 3)
+ .map((r) => ({
+ id: r.id,
+ score: r.score || 0.85,
+ payload: r.payload,
+ })),
+ );
+ }
+ } catch {
+ // Related crops are optional
+ }
+ }
+ } catch (e) {
+ console.error(e);
+ showToast("LLM query failed", "error");
+ setLlmAnswer("Failed to get a response. Please check your connection.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // Search (Qdrant filter)
+ const handleSearch = async (q) => {
const query = q || textQuery;
if (!query.trim()) return;
setLoading(true);
setHasQueried(true);
setTextQuery(query);
setResults([]);
- setExplanation("");
+ setLlmAnswer("");
+ setLlmThinking("");
+ setRelatedCrops([]);
+ setQueryLogic("");
+
try {
const data = await agentService.queryText(query);
if (data.results) {
@@ -320,13 +1047,22 @@ export default function FarmIntelligence() {
})),
);
}
+ // Show query interpretation if available
+ if (data.query_logic)
+ setQueryLogic(JSON.stringify(data.query_logic, null, 2));
} catch {
- showToast("Query failed", "error");
+ showToast("Search failed", "error");
} finally {
setLoading(false);
}
};
+ const handleQuery = (q) => {
+ if (mode === "ask") return handleAsk(q);
+ return handleSearch(q);
+ };
+
+ // Voice
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
@@ -373,6 +1109,10 @@ export default function FarmIntelligence() {
}
};
+ const suggestions = selectedCrop
+ ? CROP_SUGGESTIONS(selectedCrop.crop, selectedCrop.cropId)
+ : GLOBAL_SUGGESTIONS;
+
return (
<div
style={{
@@ -446,9 +1186,54 @@ export default function FarmIntelligence() {
<div>
<h1 className="page-title">Farm Intelligence</h1>
<p className="page-subtitle">
- Query your crops · Explore patterns · Ask anything
+ Query your crops · Ask Demeter anything · Explore patterns
</p>
</div>
+
+ {/* Mode toggle */}
+ <div
+ style={{
+ marginLeft: "auto",
+ display: "flex",
+ gap: 0,
+ borderRadius: 9,
+ background: "var(--bg-3)",
+ border: "1px solid var(--border)",
+ padding: 3,
+ }}
+ >
+ {[
+ { key: "search", label: "Search", icon: Search },
+ { key: "ask", label: "Ask AI", icon: MessageSquare },
+ ].map(({ key, label, icon: Icon }) => (
+ <button
+ key={key}
+ onClick={() => {
+ setMode(key);
+ setHasQueried(false);
+ setResults([]);
+ setLlmAnswer("");
+ setLlmThinking("");
+ }}
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 5,
+ padding: "5px 14px",
+ borderRadius: 7,
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
+ cursor: "pointer",
+ background: mode === key ? "var(--surface)" : "transparent",
+ border: `1px solid ${mode === key ? "var(--border-bright)" : "transparent"}`,
+ color: mode === key ? "var(--text)" : "var(--text-3)",
+ fontWeight: mode === key ? 600 : 400,
+ }}
+ >
+ <Icon size={11} /> {label}
+ </button>
+ ))}
+ </div>
</header>
<div
@@ -461,7 +1246,7 @@ export default function FarmIntelligence() {
gap: 20,
}}
>
- {/* Fleet summary */}
+ {/* Fleet stats */}
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<FleetStat
label="Total Crops"
@@ -489,77 +1274,172 @@ export default function FarmIntelligence() {
/>
</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}
+ {/* Search / Ask bar */}
+ <div>
+ {/* Crop selector row */}
+ <div
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",
+ gap: 8,
+ marginBottom: 8,
}}
>
- {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}
+ <div
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ }}
+ >
+ {mode === "ask" ? "ASK ABOUT:" : "FILTER BY:"}
+ </div>
+ <CropSelector
+ crops={cropList}
+ selectedCrop={selectedCrop}
+ onSelect={setSelectedCrop}
+ onClear={() => setSelectedCrop(null)}
+ />
+ {selectedCrop && (
+ <div
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 6,
+ padding: "4px 10px",
+ borderRadius: 20,
+ background: "rgba(74,222,128,0.08)",
+ border: "1px solid rgba(74,222,128,0.2)",
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--green)",
+ }}
+ >
+ <Leaf size={10} />
+ Context loaded — {selectedCrop.stage} ·{" "}
+ {selectedCrop.status === "Healthy"
+ ? "✓ Healthy"
+ : selectedCrop.status === "Attention"
+ ? "⚠ Attention"
+ : "✕ Critical"}
+ </div>
+ )}
+ </div>
+
+ {/* Input bar */}
+ <div
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,
+ display: "flex",
+ gap: 8,
+ padding: 8,
+ borderRadius: 14,
+ background: "var(--surface)",
+ border: "1px solid var(--border)",
}}
>
- {loading ? (
- <Activity size={13} className="animate-spin" />
- ) : (
- "Search"
+ <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={
+ mode === "ask"
+ ? selectedCrop
+ ? `Ask anything about ${selectedCrop.crop}…`
+ : "Ask anything about your farm — decisions, trends, comparisons…"
+ : "Search crops — 'Show all Tomato', 'Which are critical?', 'Find flowering stage'…"
+ }
+ style={{
+ flex: 1,
+ background: "transparent",
+ border: "none",
+ outline: "none",
+ fontSize: 14,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text)",
+ caretColor: mode === "ask" ? "#a78bfa" : "var(--green)",
+ }}
+ />
+
+ {textQuery && (
+ <button
+ onClick={() => setTextQuery("")}
+ style={{
+ background: "none",
+ border: "none",
+ cursor: "pointer",
+ color: "var(--text-3)",
+ display: "flex",
+ alignItems: "center",
+ padding: "0 4px",
+ }}
+ >
+ <X size={13} />
+ </button>
)}
- </button>
+
+ <button
+ onClick={() => handleQuery()}
+ disabled={loading}
+ style={{
+ padding: "8px 22px",
+ borderRadius: 10,
+ fontSize: 13,
+ fontWeight: 600,
+ background: loading
+ ? "var(--bg-3)"
+ : mode === "ask"
+ ? "#a78bfa"
+ : "var(--green)",
+ color: loading
+ ? "var(--text-3)"
+ : mode === "ask"
+ ? "#1a0a2e"
+ : "#0c1a0e",
+ border: "none",
+ cursor: loading ? "not-allowed" : "pointer",
+ flexShrink: 0,
+ display: "flex",
+ alignItems: "center",
+ gap: 6,
+ }}
+ >
+ {loading ? (
+ <Activity size={13} className="animate-spin" />
+ ) : mode === "ask" ? (
+ <>
+ <Sparkles size={12} /> Ask
+ </>
+ ) : (
+ <>
+ <Search size={12} /> Search
+ </>
+ )}
+ </button>
+ </div>
</div>
- {/* Transcription badge */}
+ {/* Transcription */}
{transcription && (
<div
className="animate-fade-in"
@@ -580,12 +1460,19 @@ export default function FarmIntelligence() {
{/* Suggestion chips */}
{!hasQueried && (
<div>
- <div className="section-label">QUICK QUERIES</div>
+ <div className="section-label">
+ {selectedCrop
+ ? `SUGGESTED QUESTIONS FOR ${selectedCrop.crop.toUpperCase()}`
+ : "QUICK QUERIES"}
+ </div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
- {SUGGESTIONS.map((s) => (
+ {suggestions.map((s) => (
<button
key={s}
- onClick={() => handleQuery(s)}
+ onClick={() => {
+ setTextQuery(s);
+ handleQuery(s);
+ }}
style={{
padding: "7px 14px",
borderRadius: 20,
@@ -602,15 +1489,23 @@ export default function FarmIntelligence() {
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor =
- "rgba(167,139,250,0.4)";
- e.currentTarget.style.color = "#a78bfa";
+ mode === "ask"
+ ? "rgba(167,139,250,0.4)"
+ : "rgba(74,222,128,0.4)";
+ e.currentTarget.style.color =
+ mode === "ask" ? "#a78bfa" : "var(--green)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
e.currentTarget.style.color = "var(--text-2)";
}}
>
- <Search size={10} /> {s}
+ {mode === "ask" ? (
+ <MessageSquare size={10} />
+ ) : (
+ <Search size={10} />
+ )}{" "}
+ {s}
</button>
))}
</div>
@@ -619,193 +1514,257 @@ export default function FarmIntelligence() {
{/* 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
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
+ {mode === "ask" ? (
+ <>
+ <div
+ className="shimmer"
style={{
- fontSize: 11,
- fontFamily: "DM Mono, monospace",
- color: "var(--text-3)",
+ height: 48,
+ borderRadius: 10,
+ border: "1px solid var(--border)",
}}
- >
- for "{textQuery}"
- </span>
- )}
- {results.length > 0 && (
- <button
- onClick={() => setShowExplain(!showExplain)}
+ />
+ <div
+ className="shimmer"
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)",
+ height: 180,
+ borderRadius: 14,
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)",
+ display: "grid",
+ gridTemplateColumns: "repeat(auto-fill, minmax(320px,1fr))",
+ gap: 14,
}}
>
- <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>
+ {[1, 2, 3].map((i) => (
+ <div
+ key={i}
+ className="shimmer"
+ style={{
+ height: 200,
+ borderRadius: 14,
+ border: "1px solid var(--border)",
+ }}
+ />
+ ))}
</div>
)}
+ </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)",
- }}
- >
+ {/* Results */}
+ {!loading && hasQueried && (
+ <>
+ {/* ── Ask mode: LLM answer ── */}
+ {mode === "ask" && (
+ <>
+ <LLMAnswerBlock
+ answer={llmAnswer}
+ thinking={llmThinking}
+ query={textQuery}
+ cropContext={selectedCrop}
+ />
+
+ {/* Related crops */}
+ {relatedCrops.length > 0 && (
+ <div>
+ <div
+ className="section-label"
+ style={{ marginBottom: 10 }}
+ >
+ <GitBranch
+ size={10}
+ style={{ display: "inline", marginRight: 5 }}
+ />
+ SIMILAR CROPS IN DATABASE
+ </div>
+ <div
+ style={{
+ display: "grid",
+ gridTemplateColumns:
+ "repeat(auto-fill, minmax(260px,1fr))",
+ gap: 12,
+ }}
+ >
+ {relatedCrops.map((r, i) => (
+ <RelatedCropCard
+ key={r.id || i}
+ item={r}
+ score={r.score}
+ />
+ ))}
+ </div>
+ </div>
+ )}
+ </>
+ )}
+
+ {/* Search mode: crop cards */}
+ {mode === "search" && (
+ <>
<div
- style={{
- width: 52,
- height: 52,
- borderRadius: 16,
- background: "var(--bg-3)",
- border: "1px solid var(--border)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- }}
+ style={{ display: "flex", alignItems: "center", gap: 12 }}
>
- <Brain size={22} style={{ color: "var(--text-3)" }} />
+ <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>
+ )}
+ {queryLogic && (
+ <button
+ onClick={() => setShowQueryLogic(!showQueryLogic)}
+ 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} />{" "}
+ {showQueryLogic ? "Hide" : "View"} query logic
+ </button>
+ )}
</div>
- <div style={{ textAlign: "center" }}>
+
+ {showQueryLogic && queryLogic && (
<div
+ className="animate-fade-in"
style={{
- fontSize: 14,
- fontWeight: 600,
- color: "var(--text-2)",
+ padding: 16,
+ borderRadius: 12,
+ background: "var(--bg-3)",
+ border: "1px solid var(--border)",
}}
>
- No crops matched
+ <div className="section-label">QDRANT FILTER</div>
+ <pre
+ style={{
+ fontSize: 12,
+ fontFamily: "DM Mono, monospace",
+ lineHeight: 1.7,
+ whiteSpace: "pre-wrap",
+ color: "var(--text-2)",
+ margin: "8px 0 0",
+ }}
+ >
+ {queryLogic}
+ </pre>
</div>
+ )}
+
+ {results.length === 0 ? (
<div
style={{
- fontSize: 12,
- color: "var(--text-3)",
- marginTop: 6,
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ padding: 48,
+ gap: 16,
+ borderRadius: 14,
+ background: "var(--surface)",
+ border: "1px dashed var(--border)",
}}
>
- 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) => (
+ <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 switch to Ask AI mode for
+ natural language questions.
+ </div>
+ </div>
<button
- key={s}
- onClick={() => handleQuery(s)}
+ onClick={() => setMode("ask")}
style={{
- padding: "6px 12px",
+ display: "flex",
+ alignItems: "center",
+ gap: 6,
+ padding: "8px 16px",
borderRadius: 20,
- fontSize: 11,
+ fontSize: 12,
fontFamily: "DM Mono, monospace",
cursor: "pointer",
- background: "var(--bg-3)",
- border: "1px solid var(--border)",
- color: "var(--text-3)",
+ background: "rgba(167,139,250,0.08)",
+ border: "1px solid rgba(167,139,250,0.2)",
+ color: "#a78bfa",
}}
>
- {s}
+ <Sparkles size={11} /> Try Ask AI instead
</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>
+ </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 */}
+ {/* Empty state */}
{!hasQueried && !loading && (
<div
style={{
@@ -816,7 +1775,6 @@ export default function FarmIntelligence() {
justifyContent: "center",
padding: 48,
gap: 20,
- minHeight: 200,
borderRadius: 16,
background: "var(--surface)",
border: "1px dashed var(--border)",
@@ -836,7 +1794,7 @@ export default function FarmIntelligence() {
>
<Sparkles size={28} style={{ color: "#a78bfa" }} />
</div>
- <div style={{ textAlign: "center", maxWidth: 380 }}>
+ <div style={{ textAlign: "center", maxWidth: 420 }}>
<div
style={{
fontWeight: 700,
@@ -844,7 +1802,9 @@ export default function FarmIntelligence() {
color: "var(--text)",
}}
>
- Ask Demeter anything about your farm
+ {mode === "ask"
+ ? "Ask Demeter anything about your farm"
+ : "Search your crop database"}
</div>
<div
style={{
@@ -854,46 +1814,84 @@ export default function FarmIntelligence() {
lineHeight: 1.6,
}}
>
- Use natural language — English, Hindi, or Hinglish — to search
- your crop database. The AI Supervisor translates your query
- into precise filters.
+ {mode === "ask"
+ ? "Select a specific crop for targeted questions, or ask fleet-wide questions. The AI uses live sensor data, agent decisions, and explanation logs to answer."
+ : "Use natural language to filter crops by type, stage, status or outcome. The supervisor translates your query into precise database 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>
+
+ {mode === "ask" && (
+ <div
+ style={{
+ display: "flex",
+ flexDirection: "column",
+ gap: 10,
+ width: "100%",
+ maxWidth: 440,
+ }}
+ >
+ {[
+ {
+ icon: Cpu,
+ label: "Decision Reasoning",
+ desc: "Why did the agent take this action?",
+ },
+ {
+ icon: BarChart2,
+ label: "Performance Analysis",
+ desc: "How is my crop trending?",
+ },
+ {
+ icon: GitBranch,
+ label: "Comparative Insights",
+ desc: "How does this compare to other crops?",
+ },
+ {
+ icon: Zap,
+ label: "Actionable Advice",
+ desc: "What should I do next?",
+ },
+ ].map(({ icon: Icon, label, desc }) => (
+ <div
+ key={label}
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 12,
+ padding: "10px 16px",
+ borderRadius: 10,
+ background: "var(--bg-3)",
+ border: "1px solid var(--border)",
+ }}
+ >
+ <Icon
+ size={14}
+ style={{ color: "#a78bfa", flexShrink: 0 }}
+ />
+ <div>
+ <div
+ style={{
+ fontSize: 12,
+ fontWeight: 600,
+ color: "var(--text-2)",
+ }}
+ >
+ {label}
+ </div>
+ <div
+ style={{
+ fontSize: 11,
+ color: "var(--text-3)",
+ marginTop: 1,
+ }}
+ >
+ {desc}
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
</div>
)}
</div>