From 4adfef2ad0c0473ede32198145c81445556b92c8 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Mon, 8 Jun 2026 18:08:01 +0500 Subject: [PATCH] feat(site-finder): grounded parcel-chat panel UI (#958) Chat panel on the analysis screen consuming POST /api/v1/chat/ask: preset-intent quick-buttons (summary/what_to_build/why_forecast/risks/ scenarios), history-aware turns (cap 10), advisory badge + grounded_in provenance + llm_used marker. Plain-text answer rendering (no HTML injection); loading/pending/error states with retry. TanStack useMutation (no useEffect-for-HTTP), tokens-only, Lucide icons, TS strict. Types inline (codegen backend unreachable in env); no new dependency. Refs #958 --- .../analysis/[cad]/AnalysisPageContent.tsx | 4 + .../src/components/site-finder/ChatPanel.tsx | 530 ++++++++++++++++++ .../site-finder/__tests__/ChatPanel.test.tsx | 149 +++++ frontend/src/lib/site-finder-api.ts | 61 ++ 4 files changed, 744 insertions(+) create mode 100644 frontend/src/components/site-finder/ChatPanel.tsx create mode 100644 frontend/src/components/site-finder/__tests__/ChatPanel.test.tsx diff --git a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx index c0bcc8f1..68ca08ea 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import Link from "next/link"; import { useQueryClient } from "@tanstack/react-query"; +import { ChatPanel } from "@/components/site-finder/ChatPanel"; import { HorizonSelector } from "@/components/site-finder/HorizonSelector"; import { Section1ParcelInfo } from "@/components/site-finder/analysis/Section1ParcelInfo"; import { Section2NetworksUtilities } from "@/components/site-finder/analysis/Section2NetworksUtilities"; @@ -173,6 +174,9 @@ export function AnalysisPageContent({ cad }: Props) { {/* Section 6 — IMPLEMENTED in 958-B3 (§22 forecast) */} + + {/* Chat — grounded parcel-chat over the §22 forecast (#958) */} + diff --git a/frontend/src/components/site-finder/ChatPanel.tsx b/frontend/src/components/site-finder/ChatPanel.tsx new file mode 100644 index 00000000..6c0d1d55 --- /dev/null +++ b/frontend/src/components/site-finder/ChatPanel.tsx @@ -0,0 +1,530 @@ +"use client"; + +/** + * ChatPanel — grounded parcel-chat for the Site Finder analysis screen (#958). + * + * Consumes POST /api/v1/chat/ask. The thread (user/assistant turns) lives in + * component state; the most recent turns are passed back as `history` (capped at + * MAX_HISTORY_TURNS) so the backend keeps context. Five preset-intent quick + * buttons send a canned question with the matching `intent`. + * + * Send is an ACTION → driven by a TanStack `useMutation` (no useEffect for HTTP, + * per frontend.md). The backend answer is PLAIN TEXT and is rendered as text + * with preserved whitespace — never injected as HTML (XSS rule). + * + * States: + * sending → input disabled, "Отправляем…" affordance + * report_status==="pending" → backend's "run analysis first" message, shown as + * a neutral assistant turn (not an error) + * network error → inline error row with a retry button (re-runs the last ask) + * + * Each assistant answer surfaces: an advisory (advisory===true), a + * grounded_in provenance line ("источник: разделы X · run #N"), and a subtle + * "детерминированный ответ" marker when llm_used===false. + */ + +import { useRef, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { MessageSquare, Send, AlertTriangle } from "lucide-react"; + +import { Badge } from "@/components/ui/Badge"; +import { postChatAsk } from "@/lib/site-finder-api"; +import type { + ChatAskRequest, + ChatAskResponse, + ChatIntent, + ChatMessage, +} from "@/lib/site-finder-api"; + +// ── Props ───────────────────────────────────────────────────────────────────── + +interface Props { + cadNum: string; +} + +// ── Local thread model ───────────────────────────────────────────────────────── + +interface AssistantMeta { + advisory: boolean; + llmUsed: boolean; + reportStatus: ChatAskResponse["report_status"]; + groundedIn: ChatAskResponse["grounded_in"]; +} + +interface ThreadTurn { + id: number; + role: "user" | "assistant"; + content: string; + /** Present only on assistant turns. */ + meta?: AssistantMeta; +} + +// ── Constants ────────────────────────────────────────────────────────────────── + +/** Recent turns sent back as context (cap ~10, per the issue). */ +const MAX_HISTORY_TURNS = 10; +const SCROLL_MARGIN = 72; + +const PRESETS: { intent: ChatIntent; label: string; message: string }[] = [ + { + intent: "summary", + label: "Сводка", + message: "Дай краткую сводку по участку.", + }, + { + intent: "what_to_build", + label: "Что строить", + message: "Что выгодно построить на этом участке?", + }, + { + intent: "why_forecast", + label: "Почему такой прогноз", + message: "Почему прогноз именно такой?", + }, + { intent: "risks", label: "Риски", message: "Какие риски у этого участка?" }, + { + intent: "scenarios", + label: "Сценарии", + message: "Какие сценарии развития возможны?", + }, +]; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +/** Map backend section codes to the provenance line, e.g. "разделы 6.1, 6.4". */ +function provenanceLine(meta: AssistantMeta): string | null { + const g = meta.groundedIn; + if (!g) return null; + const parts: string[] = []; + if (g.sections.length > 0) { + parts.push(`разделы ${g.sections.join(", ")}`); + } + parts.push(`run #${g.run_id.toLocaleString("ru")}`); + return `источник: ${parts.join(" · ")}`; +} + +// ── ChatPanel ────────────────────────────────────────────────────────────────── + +export function ChatPanel({ cadNum }: Props) { + const [thread, setThread] = useState([]); + const [input, setInput] = useState(""); + // Last request kept so a failed send can be retried verbatim. + const lastRequestRef = useRef(null); + const turnIdRef = useRef(0); + + const nextTurnId = () => { + turnIdRef.current += 1; + return turnIdRef.current; + }; + + const mutation = useMutation({ + mutationFn: postChatAsk, + onSuccess: (res) => { + setThread((prev) => [ + ...prev, + { + id: nextTurnId(), + role: "assistant", + content: res.answer, + meta: { + advisory: res.advisory, + llmUsed: res.llm_used, + reportStatus: res.report_status, + groundedIn: res.grounded_in, + }, + }, + ]); + }, + }); + + /** Build history from the thread (recent turns only). */ + function buildHistory(): ChatMessage[] { + return thread + .slice(-MAX_HISTORY_TURNS) + .map((t) => ({ role: t.role, content: t.content })); + } + + /** Append a user turn and fire the ask. */ + function send(message: string, intent?: ChatIntent) { + const trimmed = message.trim(); + if (trimmed === "" || mutation.isPending) return; + + const history = buildHistory(); + setThread((prev) => [ + ...prev, + { id: nextTurnId(), role: "user", content: trimmed }, + ]); + + const req: ChatAskRequest = { + cad_num: cadNum, + message: trimmed, + ...(intent ? { intent } : {}), + ...(history.length > 0 ? { history } : {}), + }; + lastRequestRef.current = req; + mutation.mutate(req); + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const value = input; + setInput(""); + send(value); + } + + function handleRetry() { + const req = lastRequestRef.current; + if (req) mutation.mutate(req); + } + + const isEmpty = thread.length === 0; + + return ( +
+ {/* Header */} +
+

+ + Чат по участку +

+

+ Вопросы по анализу участка. Ответы опираются на рассчитанный прогноз. +

+
+ + {/* Preset-intent quick buttons */} +
+ {PRESETS.map((p) => ( + + ))} +
+ + {/* Message thread */} +
+ {isEmpty && !mutation.isPending && ( +

+ Задайте вопрос или выберите тему выше. +

+ )} + + {thread.map((turn) => + turn.role === "user" ? ( + + ) : ( + + ), + )} + + {mutation.isPending && ( +

+ Отправляем… +

+ )} + + {mutation.isError && ( +
+ + + Не удалось отправить запрос. Проверьте соединение. + + +
+ )} +
+ + {/* Composer */} +
+ +