"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 */}