demeter

Autonomous Hydroponic Intelligence

frontend/src/pages/FarmIntelligence.jsx (64190B)


   1import { useRef, useState, useMemo, useEffect, useCallback } from "react";
   2import { useT } from "../hooks/useTranslation";
   3import {
   4  Activity,
   5  Mic,
   6  Square,
   7  Brain,
   8  Search,
   9  Sparkles,
  10  Database,
  11  TrendingUp,
  12  TrendingDown,
  13  Minus,
  14  Droplets,
  15  Thermometer,
  16  Wind,
  17  BookOpen,
  18  MessageSquare,
  19  ChevronDown,
  20  Leaf,
  21  X,
  22  GitBranch,
  23  ExternalLink,
  24} from "lucide-react";
  25import { agentService } from "../api/agentApi";
  26import { fetchCropDetails } from "../api/farmApi";
  27import {
  28  parsePythonString,
  29  extractSensors,
  30  deriveCropStatus,
  31  calculateMaturity,
  32  getEffectiveElapsedHours,
  33  getCurrentStage,
  34  getDaysRemaining,
  35  isReadyToHarvest,
  36} from "../utils/dataUtils";
  37import {
  38  AgentActionWidget,
  39  AgentOutcomeWidget,
  40} from "../components/AgentWidgets";
  41import { PageShell, PageHeader } from "../components/ui";
  42import { useFarmData } from "../hooks/useFarmData";
  43
  44// Suggestion banks
  45const getGlobalSuggestions = (t) => [
  46  t("sug_g1"),
  47  t("sug_g2"),
  48  t("sug_g3"),
  49  t("sug_g4"),
  50  t("sug_g5"),
  51  t("sug_g6"),
  52];
  53const getCropSuggestions = (crop, t) => [
  54  t("sug_c1", { crop }),
  55  t("sug_c2", { crop }),
  56  t("sug_c3", { crop }),
  57  t("sug_c4", { crop }),
  58  t("sug_c5", { crop }),
  59  t("sug_c6", { crop }),
  60];
  61
  62// Thinking block renderer
  63function ThinkingBlock({ text, t }) {
  64  const [open, setOpen] = useState(false);
  65  if (!text) return null;
  66  return (
  67    <div
  68      style={{
  69        borderRadius: 10,
  70        border: "1px solid rgba(167,139,250,0.2)",
  71        background: "rgba(167,139,250,0.04)",
  72        overflow: "hidden",
  73        marginBottom: 14,
  74      }}
  75    >
  76      <button
  77        onClick={() => setOpen((o) => !o)}
  78        style={{
  79          width: "100%",
  80          display: "flex",
  81          alignItems: "center",
  82          gap: 8,
  83          padding: "9px 14px",
  84          background: "transparent",
  85          border: "none",
  86          cursor: "pointer",
  87          textAlign: "left",
  88        }}
  89      >
  90        <Brain size={11} style={{ color: "#a78bfa", flexShrink: 0 }} />
  91        <span
  92          style={{
  93            fontSize: 10,
  94            fontFamily: "DM Mono, monospace",
  95            color: "#a78bfa",
  96            flex: 1,
  97          }}
  98        >
  99          {t("intel_thinking")} {open ? "▲" : "▼"}
 100        </span>
 101        <span
 102          style={{
 103            fontSize: 9,
 104            fontFamily: "DM Mono, monospace",
 105            color: "var(--text-3)",
 106          }}
 107        >
 108          {t("intel_steps", { n: text.split("\n").filter(Boolean).length })}
 109        </span>
 110      </button>
 111      {open && (
 112        <div
 113          style={{
 114            padding: "0 14px 12px",
 115            borderTop: "1px solid rgba(167,139,250,0.15)",
 116          }}
 117        >
 118          <pre
 119            style={{
 120              fontSize: 11,
 121              fontFamily: "DM Mono, monospace",
 122              lineHeight: 1.75,
 123              whiteSpace: "pre-wrap",
 124              color: "var(--text-3)",
 125              margin: "10px 0 0",
 126            }}
 127          >
 128            {text}
 129          </pre>
 130        </div>
 131      )}
 132    </div>
 133  );
 134}
 135
 136// LLM Answer block
 137function LLMAnswerBlock({
 138  answer,
 139  thinking,
 140  cropContext,
 141  referencedCrops,
 142  t,
 143  td,
 144}) {
 145  if (!answer) return null;
 146
 147  return (
 148    <div
 149      className="animate-fade-in"
 150      style={{
 151        borderRadius: 14,
 152        background: "var(--surface)",
 153        border: "1px solid rgba(167,139,250,0.25)",
 154        overflow: "hidden",
 155        flexShrink: 0,
 156      }}
 157    >
 158      {/* Header */}
 159      <div
 160        style={{
 161          padding: "12px 18px",
 162          borderBottom: "1px solid var(--border)",
 163          background: "rgba(167,139,250,0.06)",
 164          display: "flex",
 165          alignItems: "center",
 166          gap: 10,
 167        }}
 168      >
 169        <div
 170          style={{
 171            width: 26,
 172            height: 26,
 173            borderRadius: 6,
 174            background: "rgba(167,139,250,0.15)",
 175            border: "1px solid rgba(167,139,250,0.3)",
 176            display: "flex",
 177            alignItems: "center",
 178            justifyContent: "center",
 179            flexShrink: 0,
 180          }}
 181        >
 182          <Sparkles size={12} style={{ color: "#a78bfa" }} />
 183        </div>
 184        <div style={{ flex: 1 }}>
 185          <div
 186            style={{
 187              fontSize: 11,
 188              fontFamily: "DM Mono, monospace",
 189              color: "#a78bfa",
 190              fontWeight: 600,
 191            }}
 192          >
 193            {t("intel_demeter")}
 194          </div>
 195          {cropContext && (
 196            <div
 197              style={{
 198                fontSize: 10,
 199                fontFamily: "DM Mono, monospace",
 200                color: "var(--text-3)",
 201                marginTop: 1,
 202              }}
 203            >
 204              {t("intel_context")}: {td(cropContext.crop)} ·{" "}
 205              {cropContext.cropId}
 206            </div>
 207          )}
 208        </div>
 209        <div
 210          style={{
 211            fontSize: 9,
 212            fontFamily: "DM Mono, monospace",
 213            color: "var(--text-3)",
 214            padding: "2px 8px",
 215            borderRadius: 20,
 216            background: "var(--bg-3)",
 217            border: "1px solid var(--border)",
 218          }}
 219        >
 220          {cropContext ? t("intel_crop_aware") : t("intel_fleet_wide")}
 221        </div>
 222      </div>
 223
 224      <div style={{ padding: 18 }}>
 225        <ThinkingBlock text={thinking} t={t} />
 226        <div
 227          style={{
 228            fontSize: 13,
 229            fontFamily: "DM Mono, monospace",
 230            color: "var(--text-2)",
 231            lineHeight: 1.8,
 232            whiteSpace: "pre-wrap",
 233          }}
 234        >
 235          {answer}
 236        </div>
 237
 238        {/* Referenced crops inline mention */}
 239        {referencedCrops && referencedCrops.length > 0 && (
 240          <div
 241            style={{
 242              marginTop: 14,
 243              padding: "8px 12px",
 244              borderRadius: 8,
 245              background: "rgba(167,139,250,0.06)",
 246              border: "1px solid rgba(167,139,250,0.15)",
 247              display: "flex",
 248              alignItems: "center",
 249              gap: 8,
 250              flexWrap: "wrap",
 251            }}
 252          >
 253            <span
 254              style={{
 255                fontSize: 10,
 256                fontFamily: "DM Mono, monospace",
 257                color: "var(--text-3)",
 258              }}
 259            >
 260              <ExternalLink
 261                size={9}
 262                style={{ display: "inline", marginRight: 4 }}
 263              />
 264              {t("intel_data_from")}:
 265            </span>
 266            {referencedCrops.map((c, i) => (
 267              <span
 268                key={i}
 269                style={{
 270                  fontSize: 10,
 271                  fontFamily: "DM Mono, monospace",
 272                  padding: "2px 8px",
 273                  borderRadius: 12,
 274                  background: "rgba(167,139,250,0.12)",
 275                  color: "#a78bfa",
 276                  border: "1px solid rgba(167,139,250,0.25)",
 277                }}
 278              >
 279                {td(c.crop)} · {c.cropId}
 280              </span>
 281            ))}
 282          </div>
 283        )}
 284      </div>
 285    </div>
 286  );
 287}
 288
 289// Related crops card
 290function RelatedCropCard({ item, score, t, td }) {
 291  const p = item.payload || {};
 292  const s = extractSensors(p);
 293  const status = deriveCropStatus(p);
 294
 295  const statusColor =
 296    status === "Healthy"
 297      ? "var(--green)"
 298      : status === "Attention"
 299        ? "var(--amber)"
 300        : "var(--red)";
 301
 302  const scoreColor =
 303    score > 0.8
 304      ? "var(--green)"
 305      : score > 0.6
 306        ? "var(--amber)"
 307        : "var(--text-3)";
 308
 309  return (
 310    <div
 311      style={{
 312        borderRadius: 12,
 313        padding: "14px 16px",
 314        background: "var(--bg-3)",
 315        border: "1px solid var(--border)",
 316        display: "flex",
 317        flexDirection: "column",
 318        gap: 10,
 319      }}
 320    >
 321      <div
 322        style={{
 323          display: "flex",
 324          alignItems: "center",
 325          justifyContent: "space-between",
 326        }}
 327      >
 328        <div>
 329          <div style={{ fontWeight: 700, fontSize: 13, color: "var(--text)" }}>
 330            {td(p.crop) || t("common_unknown")}
 331          </div>
 332          <div
 333            style={{
 334              fontSize: 10,
 335              fontFamily: "DM Mono, monospace",
 336              color: "var(--text-3)",
 337              marginTop: 2,
 338            }}
 339          >
 340            {p.crop_id || "-"} · Seq #{p.sequence_number || 1}
 341          </div>
 342        </div>
 343        <div
 344          style={{
 345            display: "flex",
 346            flexDirection: "column",
 347            alignItems: "flex-end",
 348            gap: 4,
 349          }}
 350        >
 351          <span
 352            style={{
 353              fontSize: 9,
 354              fontFamily: "DM Mono, monospace",
 355              padding: "2px 7px",
 356              borderRadius: 20,
 357              color: statusColor,
 358              background: `${statusColor}18`,
 359              border: `1px solid ${statusColor}30`,
 360            }}
 361          >
 362            {td(status.toUpperCase())}
 363          </span>
 364          {score !== undefined && (
 365            <span
 366              style={{
 367                fontSize: 9,
 368                fontFamily: "DM Mono, monospace",
 369                color: scoreColor,
 370              }}
 371            >
 372              {t("intel_match", { n: (score * 100).toFixed(0) })}
 373            </span>
 374          )}
 375        </div>
 376      </div>
 377
 378      {/* Mini sensor row */}
 379      <div style={{ display: "flex", gap: 8 }}>
 380        {[
 381          { label: "pH", value: s.ph, color: "var(--green)" },
 382          { label: "EC", value: s.ec, color: "var(--amber)" },
 383          { label: "T", value: `${s.temp}°`, color: "var(--blue)" },
 384          { label: "H", value: `${s.humidity}%`, color: "#a78bfa" },
 385        ].map(({ label, value, color }) => (
 386          <div
 387            key={label}
 388            style={{
 389              flex: 1,
 390              textAlign: "center",
 391              padding: "5px 4px",
 392              borderRadius: 6,
 393              background: "var(--surface)",
 394              border: "1px solid var(--border)",
 395            }}
 396          >
 397            <div
 398              style={{
 399                fontSize: 9,
 400                color: "var(--text-3)",
 401                fontFamily: "DM Mono, monospace",
 402              }}
 403            >
 404              {label}
 405            </div>
 406            <div
 407              style={{
 408                fontSize: 11,
 409                fontWeight: 700,
 410                color,
 411                fontFamily: "DM Mono, monospace",
 412                marginTop: 1,
 413              }}
 414            >
 415              {value}
 416            </div>
 417          </div>
 418        ))}
 419      </div>
 420
 421      {/* Outcome */}
 422      {p.outcome && p.outcome !== "PENDING_OBSERVATION" && (
 423        <AgentOutcomeWidget outcome={p.outcome} rewardScore={p.reward_score} />
 424      )}
 425    </div>
 426  );
 427}
 428
 429// Main insight card (search results)
 430function InsightCard({ result, idx, t, td }) {
 431  const p = result.payload || {};
 432  const s = extractSensors(p);
 433  const status = deriveCropStatus(p);
 434
 435  const statusColors = {
 436    Healthy: {
 437      color: "var(--green)",
 438      bg: "rgba(74,222,128,0.1)",
 439      border: "rgba(74,222,128,0.25)",
 440    },
 441    Attention: {
 442      color: "var(--amber)",
 443      bg: "rgba(245,158,11,0.1)",
 444      border: "rgba(245,158,11,0.25)",
 445    },
 446    Critical: {
 447      color: "var(--red)",
 448      bg: "rgba(248,113,113,0.1)",
 449      border: "rgba(248,113,113,0.25)",
 450    },
 451  };
 452  const sc = statusColors[status] || statusColors.Healthy;
 453
 454  return (
 455    <div
 456      className="card-hover animate-fade-up"
 457      style={{
 458        borderRadius: 14,
 459        padding: 18,
 460        background: "var(--surface)",
 461        border: "1px solid var(--border)",
 462        animationDelay: `${idx * 60}ms`,
 463      }}
 464    >
 465      {/* Header */}
 466      <div
 467        style={{
 468          display: "flex",
 469          alignItems: "center",
 470          justifyContent: "space-between",
 471          marginBottom: 14,
 472        }}
 473      >
 474        <div>
 475          <div style={{ fontWeight: 700, fontSize: 15, color: "var(--text)" }}>
 476            {td(p.crop) || t("common_unknown")}
 477          </div>
 478          <div
 479            style={{
 480              fontSize: 11,
 481              fontFamily: "DM Mono, monospace",
 482              color: "var(--text-3)",
 483              marginTop: 2,
 484            }}
 485          >
 486            {p.crop_id || "-"} · Seq #{p.sequence_number || 1}
 487          </div>
 488        </div>
 489        <div
 490          style={{
 491            display: "flex",
 492            flexDirection: "column",
 493            alignItems: "flex-end",
 494            gap: 4,
 495          }}
 496        >
 497          <span
 498            style={{
 499              fontSize: 10,
 500              fontFamily: "DM Mono, monospace",
 501              padding: "3px 8px",
 502              borderRadius: 20,
 503              background: sc.bg,
 504              color: sc.color,
 505              border: `1px solid ${sc.border}`,
 506            }}
 507          >
 508            {td(status.toUpperCase())}
 509          </span>
 510          {p.stage && (
 511            <span
 512              style={{
 513                fontSize: 9,
 514                fontFamily: "DM Mono, monospace",
 515                padding: "2px 7px",
 516                borderRadius: 4,
 517                background: "var(--bg-3)",
 518                color: "var(--text-3)",
 519                border: "1px solid var(--border)",
 520              }}
 521            >
 522              {td(p.stage)}
 523            </span>
 524          )}
 525          {result.score !== undefined && result.score < 1 && (
 526            <span
 527              style={{
 528                fontSize: 9,
 529                fontFamily: "DM Mono, monospace",
 530                color:
 531                  result.score > 0.8
 532                    ? "var(--green)"
 533                    : result.score > 0.6
 534                      ? "var(--amber)"
 535                      : "var(--text-3)",
 536              }}
 537            >
 538              {t("intel_match", { n: (result.score * 100).toFixed(0) })}
 539            </span>
 540          )}
 541        </div>
 542      </div>
 543
 544      {/* Sensors */}
 545      <div
 546        style={{
 547          display: "grid",
 548          gridTemplateColumns: "repeat(4,1fr)",
 549          gap: 8,
 550          marginBottom: 14,
 551        }}
 552      >
 553        {[
 554          { icon: Droplets, label: "pH", value: s.ph, color: "var(--green)" },
 555          { icon: Activity, label: "EC", value: s.ec, color: "var(--amber)" },
 556          {
 557            icon: Thermometer,
 558            label: "Temp",
 559            value: s.temp + "°",
 560            color: "var(--blue)",
 561          },
 562          {
 563            icon: Wind,
 564            label: "Humidity",
 565            value: s.humidity + "%",
 566            color: "#a78bfa",
 567          },
 568        ].map(({ icon: Icon, label, value, color }) => (
 569          <div
 570            key={label}
 571            style={{
 572              borderRadius: 8,
 573              padding: "7px 8px",
 574              textAlign: "center",
 575              background: "var(--bg-3)",
 576            }}
 577          >
 578            <Icon
 579              size={10}
 580              style={{ color, margin: "0 auto 3px", display: "block" }}
 581            />
 582            <div className="sensor-label" style={{ marginBottom: 2 }}>
 583              {label}
 584            </div>
 585            <div
 586              style={{
 587                fontSize: 13,
 588                fontWeight: 700,
 589                fontFamily: "DM Mono, monospace",
 590                color,
 591              }}
 592            >
 593              {value}
 594            </div>
 595          </div>
 596        ))}
 597      </div>
 598
 599      {/* Outcome */}
 600      {p.outcome && p.outcome !== "PENDING_OBSERVATION" && (
 601        <AgentOutcomeWidget
 602          outcome={p.outcome}
 603          rewardScore={p.reward_score}
 604          strategicIntent={p.strategic_intent}
 605        />
 606      )}
 607
 608      {/* Last action */}
 609      {p.action_taken && p.action_taken !== "PENDING_ACTION" && (
 610        <div style={{ marginTop: 12 }}>
 611          <div
 612            className="section-label"
 613            style={{ fontSize: 9, marginBottom: 8 }}
 614          >
 615            {t("intel_last_cmd")}
 616          </div>
 617          <AgentActionWidget actionTaken={p.action_taken} compact />
 618        </div>
 619      )}
 620    </div>
 621  );
 622}
 623
 624// Fleet stat pill
 625function FleetStat({ label, value, color, icon: Icon }) {
 626  return (
 627    <div
 628      style={{
 629        display: "flex",
 630        alignItems: "center",
 631        gap: 10,
 632        padding: "12px 18px",
 633        borderRadius: 12,
 634        background: "var(--surface)",
 635        border: "1px solid var(--border)",
 636        flex: 1,
 637        minWidth: 120,
 638      }}
 639    >
 640      <div
 641        style={{
 642          width: 32,
 643          height: 32,
 644          borderRadius: 8,
 645          background: `${color}18`,
 646          border: `1px solid ${color}30`,
 647          display: "flex",
 648          alignItems: "center",
 649          justifyContent: "center",
 650          flexShrink: 0,
 651        }}
 652      >
 653        <Icon size={14} style={{ color }} />
 654      </div>
 655      <div>
 656        <div
 657          style={{
 658            fontSize: 18,
 659            fontWeight: 700,
 660            fontFamily: "DM Mono, monospace",
 661            color,
 662            lineHeight: 1,
 663          }}
 664        >
 665          {value}
 666        </div>
 667        <div style={{ fontSize: 10, color: "var(--text-3)", marginTop: 3 }}>
 668          {label}
 669        </div>
 670      </div>
 671    </div>
 672  );
 673}
 674
 675// Crop selector dropdown
 676function CropSelector({ crops, selectedCrop, onSelect, onClear, t, td }) {
 677  const [open, setOpen] = useState(false);
 678  const ref = useRef(null);
 679
 680  useEffect(() => {
 681    if (!open) return;
 682    const handler = (e) => {
 683      if (ref.current && !ref.current.contains(e.target)) setOpen(false);
 684    };
 685    document.addEventListener("mousedown", handler);
 686    return () => document.removeEventListener("mousedown", handler);
 687  }, [open]);
 688
 689  return (
 690    <div ref={ref} style={{ position: "relative" }}>
 691      <button
 692        onClick={() => setOpen((o) => !o)}
 693        style={{
 694          display: "flex",
 695          alignItems: "center",
 696          gap: 7,
 697          padding: "7px 12px",
 698          borderRadius: 10,
 699          fontSize: 12,
 700          fontFamily: "DM Mono, monospace",
 701          cursor: "pointer",
 702          background: selectedCrop ? "rgba(74,222,128,0.1)" : "var(--surface)",
 703          border: `1px solid ${selectedCrop ? "rgba(74,222,128,0.35)" : "var(--border)"}`,
 704          color: selectedCrop ? "var(--green)" : "var(--text-2)",
 705          flexShrink: 0,
 706          minWidth: 160,
 707          justifyContent: "space-between",
 708          whiteSpace: "nowrap",
 709        }}
 710      >
 711        <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
 712          <Leaf size={12} style={{ flexShrink: 0 }} />
 713          <span
 714            style={{
 715              maxWidth: 110,
 716              overflow: "hidden",
 717              textOverflow: "ellipsis",
 718            }}
 719          >
 720            {selectedCrop
 721              ? `${td(selectedCrop.crop)} · ${selectedCrop.cropId}`
 722              : t("intel_all_crops_filter")}
 723          </span>
 724        </div>
 725        <div style={{ display: "flex", alignItems: "center", gap: 4 }}>
 726          {selectedCrop && (
 727            <span
 728              onClick={(e) => {
 729                e.stopPropagation();
 730                onClear();
 731              }}
 732              style={{
 733                display: "flex",
 734                alignItems: "center",
 735                color: "var(--text-3)",
 736                cursor: "pointer",
 737              }}
 738            >
 739              <X size={11} />
 740            </span>
 741          )}
 742          <ChevronDown size={11} style={{ opacity: 0.6 }} />
 743        </div>
 744      </button>
 745
 746      {open && (
 747        <div
 748          className="animate-fade-in"
 749          style={{
 750            position: "absolute",
 751            top: "calc(100% + 6px)",
 752            left: 0,
 753            zIndex: 50,
 754            minWidth: 240,
 755            borderRadius: 12,
 756            background: "var(--surface)",
 757            border: "1px solid var(--border)",
 758            boxShadow: "0 8px 32px rgba(0,0,0,0.3)",
 759            overflow: "hidden",
 760          }}
 761        >
 762          <div
 763            style={{
 764              padding: "8px 12px 6px",
 765              borderBottom: "1px solid var(--border)",
 766            }}
 767          >
 768            <div
 769              style={{
 770                fontSize: 9,
 771                fontFamily: "DM Mono, monospace",
 772                color: "var(--text-3)",
 773              }}
 774            >
 775              {t("intel_select_crop", { n: crops.length })}
 776            </div>
 777          </div>
 778
 779          <div style={{ maxHeight: 260, overflowY: "auto" }}>
 780            {/* All option */}
 781            <div
 782              onClick={() => {
 783                onClear();
 784                setOpen(false);
 785              }}
 786              style={{
 787                padding: "9px 14px",
 788                cursor: "pointer",
 789                display: "flex",
 790                alignItems: "center",
 791                gap: 10,
 792                background: !selectedCrop
 793                  ? "rgba(74,222,128,0.07)"
 794                  : "transparent",
 795                borderBottom: "1px solid var(--border)",
 796              }}
 797              onMouseEnter={(e) =>
 798                (e.currentTarget.style.background = "rgba(255,255,255,0.04)")
 799              }
 800              onMouseLeave={(e) =>
 801                (e.currentTarget.style.background = !selectedCrop
 802                  ? "rgba(74,222,128,0.07)"
 803                  : "transparent")
 804              }
 805            >
 806              <Database size={12} style={{ color: "var(--text-3)" }} />
 807              <div>
 808                <div
 809                  style={{
 810                    fontSize: 12,
 811                    fontFamily: "DM Mono, monospace",
 812                    color: "var(--text-2)",
 813                  }}
 814                >
 815                  {t("intel_all_crops_filter")}
 816                </div>
 817                <div style={{ fontSize: 10, color: "var(--text-3)" }}>
 818                  {t("intel_fleet_query")}
 819                </div>
 820              </div>
 821            </div>
 822
 823            {crops.map((c) => {
 824              const isSelected = selectedCrop?.cropId === c.cropId;
 825              const statusColor =
 826                c.status === "Healthy"
 827                  ? "var(--green)"
 828                  : c.status === "Attention"
 829                    ? "var(--amber)"
 830                    : "var(--red)";
 831              return (
 832                <div
 833                  key={c.cropId}
 834                  onClick={() => {
 835                    onSelect(c);
 836                    setOpen(false);
 837                  }}
 838                  style={{
 839                    padding: "9px 14px",
 840                    cursor: "pointer",
 841                    display: "flex",
 842                    alignItems: "center",
 843                    gap: 10,
 844                    background: isSelected
 845                      ? "rgba(74,222,128,0.07)"
 846                      : "transparent",
 847                    borderBottom: "1px solid rgba(255,255,255,0.04)",
 848                  }}
 849                  onMouseEnter={(e) =>
 850                    (e.currentTarget.style.background =
 851                      "rgba(255,255,255,0.04)")
 852                  }
 853                  onMouseLeave={(e) =>
 854                    (e.currentTarget.style.background = isSelected
 855                      ? "rgba(74,222,128,0.07)"
 856                      : "transparent")
 857                  }
 858                >
 859                  <div
 860                    style={{
 861                      width: 8,
 862                      height: 8,
 863                      borderRadius: "50%",
 864                      background: statusColor,
 865                      flexShrink: 0,
 866                    }}
 867                  />
 868                  <div style={{ flex: 1 }}>
 869                    <div
 870                      style={{
 871                        fontSize: 12,
 872                        fontFamily: "DM Mono, monospace",
 873                        color: "var(--text)",
 874                        fontWeight: isSelected ? 700 : 400,
 875                      }}
 876                    >
 877                      {td(c.crop)}
 878                    </div>
 879                    <div
 880                      style={{
 881                        fontSize: 10,
 882                        fontFamily: "DM Mono, monospace",
 883                        color: "var(--text-3)",
 884                        marginTop: 1,
 885                      }}
 886                    >
 887                      {c.cropId} · {td(c.stage)}
 888                    </div>
 889                  </div>
 890                  <span
 891                    style={{
 892                      fontSize: 9,
 893                      fontFamily: "DM Mono, monospace",
 894                      color: statusColor,
 895                    }}
 896                  >
 897                    {td(c.status.toUpperCase())}
 898                  </span>
 899                </div>
 900              );
 901            })}
 902          </div>
 903        </div>
 904      )}
 905    </div>
 906  );
 907}
 908
 909// MAIN
 910export default function FarmIntelligence() {
 911  const { t, td, lang } = useT();
 912  const [textQuery, setTextQuery] = useState("");
 913  const [loading, setLoading] = useState(false);
 914  const [results, setResults] = useState([]);
 915  const [relatedCrops, setRelatedCrops] = useState([]);
 916  const [referencedCrops, setReferencedCrops] = useState([]);
 917  const [transcription, setTranscription] = useState("");
 918  const [hasQueried, setHasQueried] = useState(false);
 919  const [llmAnswer, setLlmAnswer] = useState("");
 920  const [llmThinking, setLlmThinking] = useState("");
 921  const [queryLogic, setQueryLogic] = useState("");
 922  const [showQueryLogic, setShowQueryLogic] = useState(false);
 923  const [isRecording, setIsRecording] = useState(false);
 924  const [toast, setToast] = useState(null);
 925  const [selectedCrop, setSelectedCrop] = useState(null);
 926  const [mode, setMode] = useState("ask"); // "search" | "ask"
 927
 928  const mediaRecorderRef = useRef(null);
 929  const chunksRef = useRef([]);
 930
 931  const { dashboard } = useFarmData();
 932
 933  // Build crop list from dashboard
 934  const cropList = useMemo(() => {
 935    if (!dashboard?.length) return [];
 936    return dashboard.map((d) => ({
 937      crop: d.payload?.crop || t("common_unknown"),
 938      cropId: d.payload?.crop_id || d.id,
 939      stage: d.payload?.stage || "",
 940      status: deriveCropStatus(d.payload),
 941      payload: d.payload,
 942    }));
 943  }, [dashboard, t]);
 944
 945  const showToast = useCallback((msg, type = "success") => {
 946    setToast({ msg, type });
 947    setTimeout(() => setToast(null), 3000);
 948  }, []);
 949
 950  const fleetStats = useMemo(
 951    () => ({
 952      total: dashboard?.length || 0,
 953      healthy: (dashboard || []).filter(
 954        (d) => deriveCropStatus(d.payload) === "Healthy",
 955      ).length,
 956      attention: (dashboard || []).filter(
 957        (d) => deriveCropStatus(d.payload) === "Attention",
 958      ).length,
 959      critical: (dashboard || []).filter(
 960        (d) => deriveCropStatus(d.payload) === "Critical",
 961      ).length,
 962    }),
 963    [dashboard],
 964  );
 965
 966  // Build rich context for LLM
 967  const buildLLMContext = useCallback(
 968    (cropCtx, similarCrops = [], cropHistory = []) => {
 969      const allCrops = dashboard || [];
 970
 971      if (cropCtx) {
 972        const p = cropCtx.payload || {};
 973        const sensors = extractSensors(p);
 974
 975        // Build similar crops context section
 976        const similarSection =
 977          similarCrops.length > 0
 978            ? `\nSIMILAR CROPS (cosine similarity via vector search):\n` +
 979              similarCrops
 980                .map((sc) => {
 981                  const sp = sc.payload || {};
 982                  const ss = extractSensors(sp);
 983                  return `  - ${sp.crop || "?"} (${sp.crop_id || sc.id}): pH=${ss.ph}, EC=${ss.ec}, T=${ss.temp}°, H=${ss.humidity}%, Stage=${sp.stage}, Status=${deriveCropStatus(sp)}, Outcome=${sp.outcome || "Pending"}, Score=${(sc.score * 100).toFixed(0)}%`;
 984                })
 985                .join("\n")
 986            : "";
 987
 988        const historySection =
 989          cropHistory.length > 1
 990            ? `\nACTION HISTORY (last ${cropHistory.length} cycles, newest first):\n` +
 991              cropHistory
 992                .map((h) => {
 993                  const p = h.payload || {};
 994                  const rawAction = p.action_taken;
 995                  const actionStr =
 996                    !rawAction || rawAction === "PENDING_ACTION"
 997                      ? "None"
 998                      : typeof rawAction === "object"
 999                        ? JSON.stringify(rawAction)
1000                        : (() => {
1001                            const parsed = parsePythonString(rawAction);
1002                            return parsed && typeof parsed === "object"
1003                              ? JSON.stringify(parsed)
1004                              : rawAction;
1005                          })();
1006                  const explanation =
1007                    p.explanation_log &&
1008                    p.explanation_log !== "PENDING_ANALYSIS"
1009                      ? p.explanation_log.trim()
1010                      : null;
1011                  const stratIntent = p.strategic_intent || null;
1012                  return [
1013                    `  Seq #${p.sequence_number ?? "?"} | Stage: ${p.stage || "?"} | Reward: ${p.reward_score ?? "N/A"}`,
1014                    `    Action:  ${actionStr}`,
1015                    `    Outcome: ${p.outcome && p.outcome !== "PENDING_OBSERVATION" ? p.outcome : "Pending"}`,
1016                    stratIntent ? `    Intent:  ${stratIntent}` : null,
1017                    explanation ? `    Explain: ${explanation}` : null,
1018                  ]
1019                    .filter(Boolean)
1020                    .join("\n");
1021                })
1022                .join("\n\n")
1023            : "";
1024
1025        const elapsedH = getEffectiveElapsedHours(p);
1026        const maturity = calculateMaturity(p);
1027        const currentStage = getCurrentStage(p) || p.stage || "Unknown";
1028        const daysRemaining = getDaysRemaining(p);
1029        const readyToHarvest = isReadyToHarvest(p);
1030
1031        return `CROP CONTEXT:
1032- Crop: ${p.crop || cropCtx.crop}
1033- Batch ID: ${p.crop_id || cropCtx.cropId}
1034- Growth Stage: ${currentStage}
1035- Maturity: ${maturity}%${readyToHarvest ? " ⚠️ READY TO HARVEST" : ""}
1036- Simulated Age: ${Math.round(elapsedH)} hours (${Math.floor(elapsedH / 24)} days)
1037- Days Until Harvest: ${daysRemaining !== null ? daysRemaining : "N/A"}
1038- Sequence Number: ${p.sequence_number || "-"}
1039- Last Updated: ${p.last_updated ? new Date(p.last_updated).toLocaleString() : "Unknown"}
1040LATEST SENSOR READINGS:
1041- pH: ${sensors.ph}
1042- EC: ${sensors.ec} dS/m
1043- Temperature: ${sensors.temp}°C
1044- Humidity: ${sensors.humidity}%
1045LATEST AGENT DECISION:
1046- Action Taken: ${(() => {
1047          const a = p.action_taken;
1048          if (!a || a === "PENDING_ACTION") return "None recorded";
1049          if (typeof a === "object") return JSON.stringify(a, null, 2);
1050          const parsed = parsePythonString(a);
1051          return parsed && typeof parsed === "object"
1052            ? JSON.stringify(parsed, null, 2)
1053            : a;
1054        })()}
1055- Outcome: ${p.outcome && p.outcome !== "PENDING_OBSERVATION" ? p.outcome : "Pending"}
1056- Reward Score: ${p.reward_score ?? "N/A"}
1057- Strategic Intent: ${p.strategic_intent || "N/A"}
1058EXPLANATION LOG:
1059${p.explanation_log && p.explanation_log !== "PENDING_ANALYSIS" ? p.explanation_log : "Not yet generated."}
1060${similarSection}${historySection}
1061FLEET OVERVIEW (for comparison):
1062- Total crops: ${fleetStats.total}
1063- Healthy: ${fleetStats.healthy}, Needs Attention: ${fleetStats.attention}, Critical: ${fleetStats.critical}`.trim();
1064      } else {
1065        // Fleet-wide: pass ALL crops (capped at 20 for prompt size)
1066        const cropSummaries = allCrops
1067          .slice(0, 20)
1068          .map((d) => {
1069            const p = d.payload || {};
1070            const s = extractSensors(p);
1071            const maturity = calculateMaturity(p);
1072            const stage = getCurrentStage(p) || p.stage || "Unknown";
1073            const ready = isReadyToHarvest(p);
1074            return `  - ${p.crop || "?"} (${p.crop_id || d.id}): Stage=${stage}, Maturity=${maturity}%${ready ? " [HARVEST READY]" : ""}, pH=${s.ph}, EC=${s.ec}, T=${s.temp}°, H=${s.humidity}%, Status=${deriveCropStatus(p)}, Outcome=${p.outcome || "Pending"}`;
1075          })
1076          .join("\n");
1077        return `FLEET OVERVIEW:
1078- Total crops: ${fleetStats.total}
1079- Healthy: ${fleetStats.healthy}, Needs Attention: ${fleetStats.attention}, Critical: ${fleetStats.critical}
1080ALL CROPS (${allCrops.length} total):
1081${cropSummaries || "No crops in database."}
1082SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim();
1083      }
1084    },
1085    [dashboard, fleetStats],
1086  );
1087
1088  // Ask LLM
1089  const handleAsk = useCallback(
1090    async (q) => {
1091      const query = q || textQuery;
1092      if (!query.trim()) return;
1093      setLoading(true);
1094      setHasQueried(true);
1095      setTextQuery(query);
1096      setLlmAnswer("");
1097      setLlmThinking("");
1098      setResults([]);
1099      setRelatedCrops([]);
1100      setReferencedCrops([]);
1101      setTranscription("");
1102
1103      try {
1104        let similarCrops = [];
1105
1106        if (selectedCrop) {
1107          // Use Qdrant vector search to find cosine-similar crops
1108          try {
1109            const searchData = await agentService.querySimilarCrops(
1110              selectedCrop.cropId,
1111              selectedCrop.crop,
1112              selectedCrop.payload,
1113            );
1114            if (searchData.status === "success" && searchData.results?.length) {
1115              similarCrops = searchData.results.slice(0, 5).map((r) => ({
1116                id: r.id,
1117                score: r.score || 0,
1118                payload: r.payload,
1119              }));
1120              setRelatedCrops(similarCrops.slice(0, 3));
1121            }
1122          } catch (e) {
1123            console.warn("Similar crop search failed:", e);
1124          }
1125        }
1126
1127        let cropHistory = [];
1128        if (selectedCrop) {
1129          try {
1130            const hist = await fetchCropDetails(selectedCrop.cropId);
1131            // hist is sorted desc by sequence_number, take last 10 for context
1132            cropHistory = (hist || []).slice(0, 10);
1133          } catch (e) {
1134            console.warn("History fetch failed:", e);
1135          }
1136        }
1137
1138        const context = buildLLMContext(
1139          selectedCrop,
1140          similarCrops,
1141          cropHistory,
1142        );
1143        const data = await agentService.askDemeter(query, context, lang);
1144
1145        setLlmThinking(data.thinking || "");
1146        setLlmAnswer(data.answer || t("intel_no_response"));
1147
1148        // Parse which crops the answer references (by crop_id mentioned in answer)
1149        if (!selectedCrop && data.answer) {
1150          const mentioned = cropList.filter(
1151            (c) =>
1152              data.answer.toLowerCase().includes(c.crop.toLowerCase()) ||
1153              data.answer.includes(c.cropId),
1154          );
1155          if (mentioned.length > 0) {
1156            setReferencedCrops(mentioned.slice(0, 6));
1157          }
1158        }
1159      } catch (e) {
1160        console.error(e);
1161        showToast(t("intel_llm_fail_toast"), "error");
1162        setLlmAnswer(t("intel_llm_fail"));
1163      } finally {
1164        setLoading(false);
1165      }
1166    },
1167    [textQuery, selectedCrop, cropList, buildLLMContext, lang, showToast, t],
1168  );
1169
1170  // Search
1171  const handleSearch = useCallback(
1172    async (q) => {
1173      const query = q || textQuery;
1174      if (!query.trim()) return;
1175      setLoading(true);
1176      setHasQueried(true);
1177      setTextQuery(query);
1178      setResults([]);
1179      setLlmAnswer("");
1180      setLlmThinking("");
1181      setRelatedCrops([]);
1182      setReferencedCrops([]);
1183      setQueryLogic("");
1184      setTranscription("");
1185
1186      try {
1187        // Build the search payload - include selectedCrop context so backend
1188        // can bias Qdrant filter results toward that crop's embedding
1189        const data = await agentService.queryText(query, selectedCrop?.cropId);
1190
1191        if (data.results) {
1192          setResults(
1193            data.results.map((r) => ({
1194              id: r.id,
1195              score: r.score || 1,
1196              payload: r.payload,
1197            })),
1198          );
1199        }
1200
1201        if (data.query_logic)
1202          setQueryLogic(JSON.stringify(data.query_logic, null, 2));
1203
1204        // If a crop is selected, also show cosine-similar crops in a sidebar section
1205        if (selectedCrop) {
1206          try {
1207            const simData = await agentService.querySimilarCrops(
1208              selectedCrop.cropId,
1209              selectedCrop.crop,
1210              selectedCrop.payload,
1211            );
1212            if (simData.status === "success" && simData.results?.length) {
1213              setRelatedCrops(
1214                simData.results.slice(0, 3).map((r) => ({
1215                  id: r.id,
1216                  score: r.score || 0,
1217                  payload: r.payload,
1218                })),
1219              );
1220            }
1221          } catch (e) {
1222            console.warn("Similar crop search failed:", e);
1223          }
1224        }
1225      } catch {
1226        showToast(t("intel_search_fail_toast"), "error");
1227      } finally {
1228        setLoading(false);
1229      }
1230    },
1231    [textQuery, selectedCrop, showToast, t],
1232  );
1233
1234  const handleQuery = useCallback(
1235    (q) => {
1236      if (mode === "ask") return handleAsk(q);
1237      return handleSearch(q);
1238    },
1239    [mode, handleAsk, handleSearch],
1240  );
1241
1242  // Voice recording
1243  const startRecording = async () => {
1244    try {
1245      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
1246      mediaRecorderRef.current = new MediaRecorder(stream);
1247      chunksRef.current = [];
1248      mediaRecorderRef.current.ondataavailable = (e) => {
1249        if (e.data.size > 0) chunksRef.current.push(e.data);
1250      };
1251      mediaRecorderRef.current.onstop = async () => {
1252        const blob = new Blob(chunksRef.current, { type: "audio/webm" });
1253        setLoading(true);
1254        try {
1255          const data = await agentService.queryAudio(blob);
1256          if (data.transcription) {
1257            setTranscription(data.transcription);
1258            setTextQuery(data.transcription);
1259
1260            // In ask mode: hand off transcription to ask handler
1261            if (mode === "ask") {
1262              await handleAsk(data.transcription);
1263            } else {
1264              // In search mode: if backend returned results use them, else re-query
1265              if (data.results && data.results.length > 0) {
1266                setResults(
1267                  data.results.map((r) => ({
1268                    id: r.id,
1269                    score: r.score || 1,
1270                    payload: r.payload,
1271                  })),
1272                );
1273                if (data.query_logic)
1274                  setQueryLogic(JSON.stringify(data.query_logic, null, 2));
1275                setHasQueried(true);
1276              } else {
1277                // Fallback: run text search with the transcription
1278                await handleSearch(data.transcription);
1279              }
1280            }
1281          }
1282        } finally {
1283          setLoading(false);
1284        }
1285        stream.getTracks().forEach((trk) => trk.stop());
1286      };
1287      mediaRecorderRef.current.start();
1288      setIsRecording(true);
1289    } catch {
1290      showToast("Microphone access denied", "error");
1291    }
1292  };
1293
1294  const stopRecording = () => {
1295    if (mediaRecorderRef.current && isRecording) {
1296      mediaRecorderRef.current.stop();
1297      setIsRecording(false);
1298    }
1299  };
1300
1301  // Clear transcription banner when user manually types
1302  const handleInputChange = (e) => {
1303    setTextQuery(e.target.value);
1304    if (transcription) setTranscription("");
1305  };
1306
1307  const suggestions = selectedCrop
1308    ? getCropSuggestions(td(selectedCrop.crop), t)
1309    : getGlobalSuggestions(t);
1310
1311  return (
1312    <PageShell>
1313      {toast && (
1314        <div
1315          className="animate-fade-in"
1316          style={{
1317            position: "fixed",
1318            top: 16,
1319            right: 16,
1320            zIndex: 50,
1321            padding: "10px 16px",
1322            borderRadius: 12,
1323            fontSize: 13,
1324            fontFamily: "DM Mono, monospace",
1325            background:
1326              toast.type === "error"
1327                ? "rgba(248,113,113,0.15)"
1328                : "rgba(74,222,128,0.15)",
1329            border: `1px solid ${toast.type === "error" ? "rgba(248,113,113,0.4)" : "rgba(74,222,128,0.4)"}`,
1330            color: toast.type === "error" ? "var(--red)" : "var(--green)",
1331          }}
1332        >
1333          {toast.msg}
1334        </div>
1335      )}
1336
1337      <main
1338        style={{
1339          flex: 1,
1340          display: "flex",
1341          flexDirection: "column",
1342          overflow: "hidden",
1343        }}
1344      >
1345        {/* Header */}
1346        <PageHeader
1347          title={t("intel_title")}
1348          subtitle={t("intel_subtitle")}
1349          icon={Sparkles}
1350          iconColor="#a78bfa"
1351          iconBg="rgba(167,139,250,0.1)"
1352        >
1353          {/* Mode toggle */}
1354          <div
1355            style={{
1356              marginLeft: "auto",
1357              display: "flex",
1358              gap: 0,
1359              borderRadius: 9,
1360              background: "var(--bg-3)",
1361              border: "1px solid var(--border)",
1362              padding: 3,
1363            }}
1364          >
1365            {[
1366              { key: "ask", label: t("intel_ask_ai"), icon: MessageSquare },
1367              { key: "search", label: t("intel_search"), icon: Search },
1368            ].map(({ key, label, icon: Icon }) => (
1369              <button
1370                key={key}
1371                onClick={() => {
1372                  setMode(key);
1373                  setHasQueried(false);
1374                  setResults([]);
1375                  setLlmAnswer("");
1376                  setLlmThinking("");
1377                  setRelatedCrops([]);
1378                  setReferencedCrops([]);
1379                  setQueryLogic("");
1380                }}
1381                style={{
1382                  display: "flex",
1383                  alignItems: "center",
1384                  gap: 5,
1385                  padding: "5px 14px",
1386                  borderRadius: 7,
1387                  fontSize: 12,
1388                  fontFamily: "DM Mono, monospace",
1389                  cursor: "pointer",
1390                  background: mode === key ? "var(--surface)" : "transparent",
1391                  border: `1px solid ${mode === key ? "var(--border-bright)" : "transparent"}`,
1392                  color: mode === key ? "var(--text)" : "var(--text-3)",
1393                  fontWeight: mode === key ? 600 : 400,
1394                }}
1395              >
1396                <Icon size={11} /> {label}
1397              </button>
1398            ))}
1399          </div>
1400        </PageHeader>
1401
1402        <div
1403          key={mode}
1404          className="animate-fade-in"
1405          style={{
1406            flex: 1,
1407            overflowY: "auto",
1408            padding: 24,
1409            display: "flex",
1410            flexDirection: "column",
1411            gap: 20,
1412          }}
1413        >
1414          {/* Fleet stats */}
1415          <div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
1416            <FleetStat
1417              label={t("intel_total_crops")}
1418              value={fleetStats.total}
1419              color="var(--text-2)"
1420              icon={Database}
1421            />
1422            <FleetStat
1423              label={t("intel_healthy")}
1424              value={fleetStats.healthy}
1425              color="var(--green)"
1426              icon={TrendingUp}
1427            />
1428            <FleetStat
1429              label={t("intel_needs_attention")}
1430              value={fleetStats.attention}
1431              color="var(--amber)"
1432              icon={Minus}
1433            />
1434            <FleetStat
1435              label={t("intel_critical")}
1436              value={fleetStats.critical}
1437              color="var(--red)"
1438              icon={TrendingDown}
1439            />
1440          </div>
1441
1442          {/* Search / Ask bar */}
1443          <div>
1444            {/* Crop selector row */}
1445            <div
1446              style={{
1447                display: "flex",
1448                alignItems: "center",
1449                gap: 8,
1450                marginBottom: 8,
1451              }}
1452            >
1453              <div
1454                style={{
1455                  fontSize: 10,
1456                  fontFamily: "DM Mono, monospace",
1457                  color: "var(--text-3)",
1458                }}
1459              >
1460                {mode === "ask" ? t("intel_ask_about") : t("intel_filter_by")}
1461              </div>
1462              <CropSelector
1463                crops={cropList}
1464                selectedCrop={selectedCrop}
1465                onSelect={(c) => {
1466                  setSelectedCrop(c);
1467                  // Reset results so user re-queries with crop context
1468                  setHasQueried(false);
1469                  setResults([]);
1470                  setLlmAnswer("");
1471                  setRelatedCrops([]);
1472                  setReferencedCrops([]);
1473                }}
1474                onClear={() => {
1475                  setSelectedCrop(null);
1476                  setHasQueried(false);
1477                  setResults([]);
1478                  setLlmAnswer("");
1479                  setRelatedCrops([]);
1480                  setReferencedCrops([]);
1481                }}
1482                t={t}
1483                td={td}
1484              />
1485            </div>
1486
1487            {/* Input bar */}
1488            <div
1489              style={{
1490                display: "flex",
1491                gap: 8,
1492                padding: 8,
1493                borderRadius: 14,
1494                background: "var(--surface)",
1495                border: "1px solid var(--border)",
1496              }}
1497            >
1498              <button
1499                onClick={isRecording ? stopRecording : startRecording}
1500                style={{
1501                  width: 38,
1502                  height: 38,
1503                  borderRadius: 8,
1504                  flexShrink: 0,
1505                  cursor: "pointer",
1506                  background: isRecording
1507                    ? "rgba(248,113,113,0.15)"
1508                    : "var(--bg-3)",
1509                  border: `1px solid ${isRecording ? "rgba(248,113,113,0.4)" : "var(--border)"}`,
1510                  color: isRecording ? "var(--red)" : "var(--text-3)",
1511                  display: "flex",
1512                  alignItems: "center",
1513                  justifyContent: "center",
1514                }}
1515              >
1516                {isRecording ? <Square size={14} /> : <Mic size={14} />}
1517              </button>
1518
1519              <input
1520                value={textQuery}
1521                onChange={handleInputChange}
1522                onKeyDown={(e) => e.key === "Enter" && handleQuery()}
1523                placeholder={
1524                  mode === "ask"
1525                    ? selectedCrop
1526                      ? t("intel_ask_placeholder_crop", {
1527                          crop: td(selectedCrop.crop),
1528                        })
1529                      : t("intel_ask_placeholder_fleet")
1530                    : selectedCrop
1531                      ? t("intel_search_placeholder_crop", {
1532                          crop: td(selectedCrop.crop),
1533                        }) || t("intel_search_placeholder")
1534                      : t("intel_search_placeholder")
1535                }
1536                style={{
1537                  flex: 1,
1538                  background: "transparent",
1539                  border: "none",
1540                  outline: "none",
1541                  fontSize: 14,
1542                  fontFamily: "DM Mono, monospace",
1543                  color: "var(--text)",
1544                  caretColor: mode === "ask" ? "#a78bfa" : "var(--green)",
1545                }}
1546              />
1547
1548              {textQuery && (
1549                <button
1550                  onClick={() => {
1551                    setTextQuery("");
1552                    setTranscription("");
1553                  }}
1554                  style={{
1555                    background: "none",
1556                    border: "none",
1557                    cursor: "pointer",
1558                    color: "var(--text-3)",
1559                    display: "flex",
1560                    alignItems: "center",
1561                    padding: "0 4px",
1562                  }}
1563                >
1564                  <X size={13} />
1565                </button>
1566              )}
1567
1568              <button
1569                onClick={() => handleQuery()}
1570                disabled={loading}
1571                style={{
1572                  padding: "8px 22px",
1573                  borderRadius: 10,
1574                  fontSize: 13,
1575                  fontWeight: 600,
1576                  background: loading
1577                    ? "var(--bg-3)"
1578                    : mode === "ask"
1579                      ? "#a78bfa"
1580                      : "var(--green)",
1581                  color: loading
1582                    ? "var(--text-3)"
1583                    : mode === "ask"
1584                      ? "#1a0a2e"
1585                      : "#0c1a0e",
1586                  border: "none",
1587                  cursor: loading ? "not-allowed" : "pointer",
1588                  flexShrink: 0,
1589                  display: "flex",
1590                  alignItems: "center",
1591                  gap: 6,
1592                }}
1593              >
1594                {loading ? (
1595                  <Activity size={13} className="animate-spin" />
1596                ) : mode === "ask" ? (
1597                  <>
1598                    <Sparkles size={12} /> {t("intel_ask_ai").split(" ")[0]}
1599                  </>
1600                ) : (
1601                  <>
1602                    <Search size={12} /> {t("intel_search")}
1603                  </>
1604                )}
1605              </button>
1606            </div>
1607          </div>
1608
1609          {/* Transcription banner */}
1610          {transcription && !loading && (
1611            <div
1612              className="animate-fade-in"
1613              style={{
1614                padding: "8px 14px",
1615                borderRadius: 8,
1616                background: "rgba(167,139,250,0.08)",
1617                border: "1px solid rgba(167,139,250,0.2)",
1618                fontSize: 12,
1619                fontFamily: "DM Mono, monospace",
1620                color: "#a78bfa",
1621                display: "flex",
1622                alignItems: "center",
1623                gap: 8,
1624              }}
1625            >
1626              <span style={{ flex: 1 }}>🎙 Heard: "{transcription}"</span>
1627              <button
1628                onClick={() => setTranscription("")}
1629                style={{
1630                  background: "none",
1631                  border: "none",
1632                  cursor: "pointer",
1633                  color: "rgba(167,139,250,0.6)",
1634                  display: "flex",
1635                  alignItems: "center",
1636                  padding: 0,
1637                }}
1638              >
1639                <X size={11} />
1640              </button>
1641            </div>
1642          )}
1643
1644          {/* Suggestion chips */}
1645          {!hasQueried && (
1646            <div>
1647              <div className="section-label">
1648                {selectedCrop
1649                  ? t("intel_suggested_crop", {
1650                      crop: td(selectedCrop.crop).toUpperCase(),
1651                    })
1652                  : t("intel_suggested_global")}
1653              </div>
1654              <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
1655                {suggestions.map((s) => (
1656                  <button
1657                    key={s}
1658                    onClick={() => {
1659                      setTextQuery(s);
1660                      handleQuery(s);
1661                    }}
1662                    style={{
1663                      padding: "7px 14px",
1664                      borderRadius: 20,
1665                      fontSize: 12,
1666                      fontFamily: "DM Mono, monospace",
1667                      cursor: "pointer",
1668                      background: "var(--surface)",
1669                      border: "1px solid var(--border)",
1670                      color: "var(--text-2)",
1671                      display: "flex",
1672                      alignItems: "center",
1673                      gap: 5,
1674                      transition: "all 0.15s",
1675                    }}
1676                    onMouseEnter={(e) => {
1677                      e.currentTarget.style.borderColor =
1678                        mode === "ask"
1679                          ? "rgba(167,139,250,0.4)"
1680                          : "rgba(74,222,128,0.4)";
1681                      e.currentTarget.style.color =
1682                        mode === "ask" ? "#a78bfa" : "var(--green)";
1683                    }}
1684                    onMouseLeave={(e) => {
1685                      e.currentTarget.style.borderColor = "var(--border)";
1686                      e.currentTarget.style.color = "var(--text-2)";
1687                    }}
1688                  >
1689                    {mode === "ask" ? (
1690                      <MessageSquare size={10} />
1691                    ) : (
1692                      <Search size={10} />
1693                    )}{" "}
1694                    {s}
1695                  </button>
1696                ))}
1697              </div>
1698            </div>
1699          )}
1700
1701          {/* Loading skeleton */}
1702          {loading && (
1703            <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
1704              {mode === "ask" ? (
1705                <>
1706                  <div
1707                    className="shimmer"
1708                    style={{
1709                      height: 48,
1710                      borderRadius: 10,
1711                      border: "1px solid var(--border)",
1712                    }}
1713                  />
1714                  <div
1715                    className="shimmer"
1716                    style={{
1717                      height: 180,
1718                      borderRadius: 14,
1719                      border: "1px solid var(--border)",
1720                    }}
1721                  />
1722                </>
1723              ) : (
1724                <div
1725                  style={{
1726                    display: "grid",
1727                    gridTemplateColumns: "repeat(auto-fill, minmax(320px,1fr))",
1728                    gap: 14,
1729                  }}
1730                >
1731                  {[1, 2, 3].map((i) => (
1732                    <div
1733                      key={i}
1734                      className="shimmer"
1735                      style={{
1736                        height: 200,
1737                        borderRadius: 14,
1738                        border: "1px solid var(--border)",
1739                      }}
1740                    />
1741                  ))}
1742                </div>
1743              )}
1744            </div>
1745          )}
1746
1747          {/* Results */}
1748          {!loading && hasQueried && (
1749            <>
1750              {/* ── Ask mode: LLM answer ── */}
1751              {mode === "ask" && (
1752                <>
1753                  <LLMAnswerBlock
1754                    answer={llmAnswer}
1755                    thinking={llmThinking}
1756                    cropContext={selectedCrop}
1757                    referencedCrops={referencedCrops}
1758                    t={t}
1759                    td={td}
1760                  />
1761
1762                  {/* Similar crops */}
1763                  {relatedCrops.length > 0 && (
1764                    <div>
1765                      <div
1766                        className="section-label"
1767                        style={{ marginBottom: 10 }}
1768                      >
1769                        <GitBranch
1770                          size={10}
1771                          style={{ display: "inline", marginRight: 5 }}
1772                        />
1773                        {t("intel_similar_crops")}
1774                      </div>
1775                      <div
1776                        style={{
1777                          display: "grid",
1778                          gridTemplateColumns:
1779                            "repeat(auto-fill, minmax(260px,1fr))",
1780                          gap: 12,
1781                        }}
1782                      >
1783                        {relatedCrops.map((r, i) => (
1784                          <RelatedCropCard
1785                            key={r.id || i}
1786                            item={r}
1787                            score={r.score}
1788                            t={t}
1789                            td={td}
1790                          />
1791                        ))}
1792                      </div>
1793                    </div>
1794                  )}
1795                </>
1796              )}
1797
1798              {/* Search mode: crop cards */}
1799              {mode === "search" && (
1800                <>
1801                  <div
1802                    style={{ display: "flex", alignItems: "center", gap: 12 }}
1803                  >
1804                    <div className="section-label" style={{ margin: 0 }}>
1805                      {results.length > 0
1806                        ? t("intel_results_found", { n: results.length })
1807                        : t("intel_no_results")}
1808                    </div>
1809                    {queryLogic && (
1810                      <button
1811                        onClick={() => setShowQueryLogic(!showQueryLogic)}
1812                        style={{
1813                          marginLeft: "auto",
1814                          display: "flex",
1815                          alignItems: "center",
1816                          gap: 5,
1817                          fontSize: 11,
1818                          fontFamily: "DM Mono, monospace",
1819                          padding: "4px 10px",
1820                          borderRadius: 7,
1821                          cursor: "pointer",
1822                          background: "var(--surface)",
1823                          border: "1px solid var(--border)",
1824                          color: "var(--text-3)",
1825                        }}
1826                      >
1827                        <BookOpen size={11} />{" "}
1828                        {showQueryLogic
1829                          ? t("intel_hide_logic")
1830                          : t("intel_view_logic")}
1831                      </button>
1832                    )}
1833                  </div>
1834                  {showQueryLogic && queryLogic && (
1835                    <div
1836                      className="animate-fade-in"
1837                      style={{
1838                        padding: 16,
1839                        borderRadius: 12,
1840                        background: "var(--bg-3)",
1841                        border: "1px solid var(--border)",
1842                      }}
1843                    >
1844                      <div className="section-label">
1845                        {t("intel_logic_header")}
1846                      </div>
1847                      <pre
1848                        style={{
1849                          fontSize: 12,
1850                          fontFamily: "DM Mono, monospace",
1851                          lineHeight: 1.7,
1852                          whiteSpace: "pre-wrap",
1853                          color: "var(--text-2)",
1854                          margin: "8px 0 0",
1855                        }}
1856                      >
1857                        {queryLogic}
1858                      </pre>
1859                    </div>
1860                  )}
1861
1862                  {results.length === 0 ? (
1863                    <div
1864                      style={{
1865                        display: "flex",
1866                        flexDirection: "column",
1867                        alignItems: "center",
1868                        justifyContent: "center",
1869                        padding: 48,
1870                        gap: 16,
1871                        borderRadius: 14,
1872                        background: "var(--surface)",
1873                        border: "1px dashed var(--border)",
1874                      }}
1875                    >
1876                      <button
1877                        onClick={() => setMode("ask")}
1878                        style={{
1879                          display: "flex",
1880                          alignItems: "center",
1881                          gap: 6,
1882                          padding: "8px 16px",
1883                          borderRadius: 20,
1884                          fontSize: 12,
1885                          fontFamily: "DM Mono, monospace",
1886                          cursor: "pointer",
1887                          background: "rgba(167,139,250,0.08)",
1888                          border: "1px solid rgba(167,139,250,0.2)",
1889                          color: "#a78bfa",
1890                        }}
1891                      >
1892                        <Sparkles size={11} /> {t("intel_try_ask")}
1893                      </button>
1894                    </div>
1895                  ) : (
1896                    <>
1897                      <div
1898                        style={{
1899                          display: "grid",
1900                          gridTemplateColumns:
1901                            "repeat(auto-fill, minmax(320px,1fr))",
1902                          gap: 14,
1903                        }}
1904                      >
1905                        {results.map((r, i) => (
1906                          <InsightCard
1907                            key={r.id}
1908                            result={r}
1909                            idx={i}
1910                            t={t}
1911                            td={td}
1912                          />
1913                        ))}
1914                      </div>
1915
1916                      {/* Similar crops section below search results when crop selected */}
1917                      {relatedCrops.length > 0 && selectedCrop && (
1918                        <div>
1919                          <div
1920                            className="section-label"
1921                            style={{ marginBottom: 10 }}
1922                          >
1923                            <GitBranch
1924                              size={10}
1925                              style={{ display: "inline", marginRight: 5 }}
1926                            />
1927                            {t("intel_similar_crops")} · {td(selectedCrop.crop)}
1928                          </div>
1929                          <div
1930                            style={{
1931                              display: "grid",
1932                              gridTemplateColumns:
1933                                "repeat(auto-fill, minmax(260px,1fr))",
1934                              gap: 12,
1935                            }}
1936                          >
1937                            {relatedCrops.map((r, i) => (
1938                              <RelatedCropCard
1939                                key={r.id || i}
1940                                item={r}
1941                                score={r.score}
1942                                t={t}
1943                                td={td}
1944                              />
1945                            ))}
1946                          </div>
1947                        </div>
1948                      )}
1949                    </>
1950                  )}
1951                </>
1952              )}
1953            </>
1954          )}
1955
1956          {/* Empty state */}
1957          {!hasQueried && !loading && results.length === 0 && (
1958            <div
1959              style={{
1960                display: "flex",
1961                flexDirection: "column",
1962                alignItems: "center",
1963                justifyContent: "center",
1964                padding: 64,
1965                gap: 16,
1966                borderRadius: 14,
1967                background: "var(--surface)",
1968                border: "1px dashed var(--border)",
1969                marginTop: 20,
1970              }}
1971            >
1972              <div
1973                style={{
1974                  width: 56,
1975                  height: 56,
1976                  borderRadius: 16,
1977                  background:
1978                    mode === "ask"
1979                      ? "rgba(167,139,250,0.12)"
1980                      : "rgba(74,222,128,0.12)",
1981                  border: `1px solid ${mode === "ask" ? "rgba(167,139,250,0.3)" : "rgba(74,222,128,0.3)"}`,
1982                  display: "flex",
1983                  alignItems: "center",
1984                  justifyContent: "center",
1985                }}
1986              >
1987                {mode === "ask" ? (
1988                  <Sparkles size={24} style={{ color: "#a78bfa" }} />
1989                ) : (
1990                  <Search size={24} style={{ color: "var(--green)" }} />
1991                )}
1992              </div>
1993              <div style={{ textAlign: "center", maxWidth: 400 }}>
1994                <div
1995                  style={{
1996                    fontWeight: 700,
1997                    fontSize: 16,
1998                    color: "var(--text)",
1999                    marginBottom: 8,
2000                  }}
2001                >
2002                  {mode === "ask"
2003                    ? t("intel_empty_ask_title")
2004                    : t("intel_empty_search_title")}
2005                </div>
2006                <div
2007                  style={{
2008                    fontSize: 13,
2009                    color: "var(--text-3)",
2010                    lineHeight: 1.6,
2011                  }}
2012                >
2013                  {mode === "ask"
2014                    ? t("intel_empty_ask_desc")
2015                    : t("intel_empty_search_desc")}
2016                </div>
2017              </div>
2018            </div>
2019          )}
2020        </div>
2021      </main>
2022    </PageShell>
2023  );
2024}