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
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
530 lines
16 KiB
TypeScript
530 lines
16 KiB
TypeScript
"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>
|
||
);
|
||
}
|