gendesign/frontend/src/components/site-finder/ChatPanel.tsx
Light1YT 4adfef2ad0
All checks were successful
CI / changes (push) Successful in 6s
CI / backend-tests (push) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (push) Successful in 45s
CI / frontend-tests (pull_request) Successful in 42s
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m43s
Deploy / deploy (push) Successful in 1m4s
CI / changes (pull_request) Successful in 6s
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
2026-06-08 18:08:01 +05:00

530 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 <Badge> (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<ThreadTurn[]>([]);
const [input, setInput] = useState("");
// Last request kept so a failed send can be retried verbatim.
const lastRequestRef = useRef<ChatAskRequest | null>(null);
const turnIdRef = useRef(0);
const nextTurnId = () => {
turnIdRef.current += 1;
return turnIdRef.current;
};
const mutation = useMutation<ChatAskResponse, Error, ChatAskRequest>({
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 (
<section
id="chat-panel"
aria-label="Чат по участку"
style={{
scrollMarginTop: SCROLL_MARGIN,
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: 20,
}}
>
{/* Header */}
<header style={{ marginBottom: 12 }}>
<h2
style={{
margin: 0,
fontSize: 18,
fontWeight: 600,
color: "var(--fg-primary)",
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<MessageSquare size={20} strokeWidth={1.5} aria-hidden />
Чат по участку
</h2>
<p
style={{
margin: "4px 0 0",
fontSize: 13,
color: "var(--fg-secondary)",
}}
>
Вопросы по анализу участка. Ответы опираются на рассчитанный прогноз.
</p>
</header>
{/* Preset-intent quick buttons */}
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 8,
marginBottom: 16,
}}
>
{PRESETS.map((p) => (
<button
key={p.intent}
type="button"
onClick={() => send(p.message, p.intent)}
disabled={mutation.isPending}
style={{
padding: "6px 12px",
fontSize: 13,
fontWeight: 500,
color: "var(--accent)",
background: "var(--accent-soft)",
border: "1px solid transparent",
borderRadius: 6,
cursor: mutation.isPending ? "default" : "pointer",
opacity: mutation.isPending ? 0.6 : 1,
}}
>
{p.label}
</button>
))}
</div>
{/* Message thread */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 12,
marginBottom: 16,
}}
>
{isEmpty && !mutation.isPending && (
<p
style={{
margin: 0,
padding: "16px 0",
fontSize: 13,
color: "var(--fg-tertiary)",
}}
>
Задайте вопрос или выберите тему выше.
</p>
)}
{thread.map((turn) =>
turn.role === "user" ? (
<UserBubble key={turn.id} content={turn.content} />
) : (
<AssistantBubble
key={turn.id}
content={turn.content}
meta={turn.meta}
/>
),
)}
{mutation.isPending && (
<p
style={{
margin: 0,
fontSize: 13,
color: "var(--fg-tertiary)",
}}
>
Отправляем
</p>
)}
{mutation.isError && (
<div
role="alert"
style={{
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: 12,
padding: "12px 16px",
background: "var(--danger-soft)",
border: "1px solid var(--danger)",
borderRadius: 12,
fontSize: 13,
color: "var(--danger)",
}}
>
<span
style={{ display: "inline-flex", alignItems: "center", gap: 6 }}
>
<AlertTriangle size={16} strokeWidth={1.5} aria-hidden />
Не удалось отправить запрос. Проверьте соединение.
</span>
<button
type="button"
onClick={handleRetry}
style={{
padding: "4px 12px",
fontSize: 13,
fontWeight: 500,
color: "var(--danger)",
background: "var(--bg-card)",
border: "1px solid var(--danger)",
borderRadius: 6,
cursor: "pointer",
}}
>
Повторить
</button>
</div>
)}
</div>
{/* Composer */}
<form
onSubmit={handleSubmit}
style={{ display: "flex", alignItems: "flex-end", gap: 8 }}
>
<label htmlFor="chat-input" style={visuallyHidden}>
Сообщение
</label>
<textarea
id="chat-input"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
const value = input;
setInput("");
send(value);
}
}}
rows={2}
placeholder="Спросите про участок…"
disabled={mutation.isPending}
style={{
flex: 1,
resize: "vertical",
minHeight: 44,
padding: "8px 12px",
fontSize: 14,
lineHeight: 1.4,
color: "var(--fg-primary)",
background: "var(--bg-card)",
border: "1px solid var(--border-strong)",
borderRadius: 8,
fontFamily: "inherit",
}}
/>
<button
type="submit"
aria-label="Отправить сообщение"
disabled={mutation.isPending || input.trim() === ""}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "10px 16px",
fontSize: 14,
fontWeight: 500,
color: "var(--fg-on-dark)",
background: "var(--accent)",
border: "1px solid transparent",
borderRadius: 8,
cursor:
mutation.isPending || input.trim() === "" ? "default" : "pointer",
opacity: mutation.isPending || input.trim() === "" ? 0.6 : 1,
whiteSpace: "nowrap",
}}
>
<Send size={16} strokeWidth={1.5} aria-hidden />
Отправить
</button>
</form>
</section>
);
}
// ── Bubbles ────────────────────────────────────────────────────────────────────
const visuallyHidden: React.CSSProperties = {
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clip: "rect(0 0 0 0)",
whiteSpace: "nowrap",
border: 0,
};
function UserBubble({ content }: { content: string }) {
return (
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<div
style={{
maxWidth: "85%",
padding: "10px 14px",
background: "var(--accent-soft)",
border: "1px solid transparent",
borderRadius: 12,
fontSize: 14,
lineHeight: 1.5,
color: "var(--fg-primary)",
// Plain text: preserve the user's line breaks without HTML injection.
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{content}
</div>
</div>
);
}
function AssistantBubble({
content,
meta,
}: {
content: string;
meta?: AssistantMeta;
}) {
const isPending = meta?.reportStatus === "pending";
const provenance = meta ? provenanceLine(meta) : null;
return (
<div style={{ display: "flex", justifyContent: "flex-start" }}>
<div
style={{
maxWidth: "85%",
padding: "10px 14px",
background: "var(--bg-card-alt)",
border: `1px solid ${
isPending ? "var(--warn)" : "var(--border-card)"
}`,
borderRadius: 12,
}}
>
{/* Badge row */}
{meta && (meta.advisory || isPending || !meta.llmUsed) && (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 6,
marginBottom: 8,
}}
>
{isPending && (
<Badge variant="warning" size="sm">
Прогноз не рассчитан
</Badge>
)}
{meta.advisory && (
<Badge
variant="warning"
size="sm"
icon={<AlertTriangle size={12} strokeWidth={1.5} />}
>
Advisory
</Badge>
)}
{!meta.llmUsed && (
<Badge variant="neutral" size="sm">
Детерминированный ответ
</Badge>
)}
</div>
)}
{/* Answer — PLAIN TEXT (backend returns plain text; no HTML injection). */}
<div
style={{
fontSize: 14,
lineHeight: 1.5,
color: "var(--fg-primary)",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{content}
</div>
{/* grounded_in provenance line */}
{provenance && (
<p
style={{
margin: "8px 0 0",
fontSize: 12,
lineHeight: 1.5,
color: "var(--fg-tertiary)",
fontVariantNumeric: "tabular-nums",
}}
>
{provenance}
</p>
)}
</div>
</div>
);
}