import { useCallback, useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import type { ChatMessage, ChatSource, EncSessionPayload, EncTitlePayload, MpChatTurn, PersonDetail, Uppdrag } from "../types"; import { getSession, getSessionHeaders, upsertSession, createSnapshot } from "../api"; import { decryptJson, encryptJson } from "../crypto"; import { useAuth } from "../context/AuthContext"; import { useLLMSettings } from "../context/LLMSettingsContext"; import { convertMarkdownToHtml, getMpPhotoUrl } from "../utils/markdown"; import { chatAnswerToMarkdown } from "../utils/copyMarkdown"; import { copyToClipboard } from "../utils/clipboard"; import { CopyMarkdownButton } from "./CopyMarkdownButton"; import { useTalkDrawer } from "../context/TalkDrawerContext"; const TIMEOUT_MS = 360_000; type Props = { person: PersonDetail; initialTalkId?: string; sessionId?: string; }; // ── Flip-card MP bubble ─────────────────────────────────────────────────────── type MpBubbleProps = { photoUrl: string; answerHtml: string; /** Raw markdown of the same answer; absent on chats saved before markdown copy existed. */ answerMarkdown?: string; sources: ChatSource[]; }; function MpBubble({ photoUrl, answerHtml, answerMarkdown, sources }: MpBubbleProps) { const [flipped, setFlipped] = useState(false); const [closing, setClosing] = useState(false); const navigate = useNavigate(); const { openTalk } = useTalkDrawer(); const hasSources = sources.length > 0; const toggleFlip = () => { setClosing(true); setTimeout(() => { setFlipped(f => !f); setClosing(false); }, 180); }; const handleAnswerClick = (e: React.MouseEvent) => { const target = (e.target as HTMLElement).closest("a"); const href = target?.getAttribute("href"); if (href?.startsWith("/talk/")) { e.preventDefault(); openTalk(href.slice("/talk/".length)); } else if (href?.startsWith("/motion/")) { e.preventDefault(); openTalk(`documents/${href.slice("/motion/".length)}`); } else if (href?.startsWith("/mp/")) { e.preventDefault(); navigate(href); } }; return (
{ (e.currentTarget as HTMLImageElement).style.display = "none"; }} /> {!flipped ? (
{hasSources && ( )} {answerMarkdown && ( chatAnswerToMarkdown(answerMarkdown, sources)} /> )}
) : (
Källor
{sources.map((src, i) => (
{src.person_id && ( { (e.currentTarget as HTMLImageElement).style.display = "none"; }} /> )}
{src.speaker && {src.speaker}} {src.date && {src.date}}
{src.snippet && (

{src.snippet.replace(/\*\*(.*?)\*\*/g, "$1")}

)} {src.url_video && ( Öppna anförande ↗ )}
))}
)}
); } // ── Organ name translations ─────────────────────────────────────────────────── const ORGAN_NAMES: Record = { kam: "Kammaren", eu: "EU-nämnden", upu: "Utrikespolitiska committee_recommendation", au: "Arbetsmarknadsutskottet", civ: "Civilutskottet", fiu: "Finansutskottet", fru: "Försvarsutskottet", ju: "Justitieutskottet", kru: "Kulturutskottet", ku: "Konstitutionsutskottet", mju: "Miljö- och jordbruksutskottet", nu: "Näringsutskottet", sfu: "Socialförsäkringsutskottet", sou: "Socialutskottet", sku: "Skatteutskottet", tru: "Trafikutskottet", uu: "Utrikesutskottet", uu2: "Utrikesutskottet", agu: "Arbetsgivarutskottet", bou: "Bostadsutskottet", }; function formatDate(d: string | null | undefined): string { if (!d) return "–"; return d.slice(0, 10).replace(/-/g, "\u2011"); } // ── Uppdrag section ─────────────────────────────────────────────────────────── function UppdragSection({ uppdrag }: { uppdrag: Uppdrag[] }) { const [open, setOpen] = useState(false); const sorted = [...uppdrag].sort((a, b) => { const af = a.from ?? ""; const bf = b.from ?? ""; return bf.localeCompare(af); }); return (
{open && (
{sorted.map((u, i) => { const organName = u.organ_kod ? (ORGAN_NAMES[u.organ_kod.toLowerCase()] ?? u.organ_kod.toUpperCase()) : null; return (
{formatDate(u.from)} – {formatDate(u.tom)}
{u.roll_kod ?? u.status} {organName && {organName}} {u.uppgift && u.uppgift !== u.roll_kod && ( {u.uppgift} )}
); })}
)}
); } // ── Main panel ──────────────────────────────────────────────────────────────── export function MpChatPanel({ person, initialTalkId, sessionId }: Props) { const { user, dek } = useAuth(); const { providerOverride } = useLLMSettings(); const [turns, setTurns] = useState([]); const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [isPending, setIsPending] = useState(false); // Live, speculative preview of the answer currently streaming in — provisional, // superseded by the fully-processed answerHtml once the "answer" event lands. const [streamingText, setStreamingText] = useState(""); const [shareToast, setShareToast] = useState<"copying" | "copied" | "error" | null>(null); const messagesEndRef = useRef(null); const textareaRef = useRef(null); const lastSavedTurnsRef = useRef(""); const first_name = person.first_name || person.name.split(" ")[0]; const photoUrl = person.image_url_medium?.replace("http://", "https://") || getMpPhotoUrl(person.person_id); const isActive = person.status === "Tjänstgörande riksdagsledamot"; useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [turns]); // Logged in: restore a saved (encrypted) MP chat, e.g. opened from "Mina // chattar". Anonymous MP chats are save-only, as before. useEffect(() => { if (!sessionId || !dek) return; getSession(sessionId) .then(async (data) => { if (!data?.enc_payload || data.session_type !== "mp") return; const payload = await decryptJson(dek, data.enc_payload); if ((payload.turns ?? []).length > 0) { lastSavedTurnsRef.current = JSON.stringify(payload.turns); setTurns(payload.turns as MpChatTurn[]); setMessages(payload.llm_messages ?? []); } }) .catch(() => {}); }, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (!sessionId || isPending) return; const hasReady = turns.some(t => t.status === "ready"); if (!hasReady) return; const snapshot = JSON.stringify(turns); if (snapshot === lastSavedTurnsRef.current) return; lastSavedTurnsRef.current = snapshot; if (user && dek) { // Encrypt everything, including WHICH MP was talked to. const payload: EncSessionPayload = { llm_messages: messages, turns, focus_ids: [], person_id: person.person_id, initial_speech_id: initialTalkId ?? null, }; const titlePayload: EncTitlePayload = { title: `${person.name}: ${(turns[0]?.question ?? "").slice(0, 60)}`, person_id: person.person_id, }; Promise.all([encryptJson(dek, payload), encryptJson(dek, titlePayload)]) .then(([enc_payload, enc_title]) => upsertSession(sessionId, { session_type: "mp", enc_payload, enc_title }), ) .catch(() => {}); } else { upsertSession(sessionId, { session_type: "mp", person_id: person.person_id, initial_speech_id: initialTalkId, llm_messages: messages, turns, focus_ids: [], }).catch(() => {}); } }, [turns, isPending, sessionId, person.person_id, person.name, initialTalkId, messages, user, dek]); const handleShare = async () => { const readyTurns = turns.filter(t => t.status === "ready"); if (!readyTurns.length) return; setShareToast("copying"); try { const snapshotId = await createSnapshot({ session_type: "mp", person_id: person.person_id, initial_speech_id: initialTalkId, llm_messages: messages, turns: readyTurns.map(t => ({ question: t.question, answerHtml: t.answerHtml ?? "", sources: t.sources ?? [], })), }); await copyToClipboard(`${window.location.origin}/fork/${snapshotId}`); setShareToast("copied"); } catch { setShareToast("error"); } finally { setTimeout(() => setShareToast(null), 2500); } }; const submitPrompt = useCallback(async (prompt: string) => { const trimmed = prompt.trim(); if (!trimmed || isPending) return; const turnId = crypto.randomUUID ? crypto.randomUUID() : `turn-${Date.now()}`; const nextMessages: ChatMessage[] = [...messages, { role: "user", content: trimmed }]; setMessages(nextMessages); setTurns(prev => [...prev, { id: turnId, question: trimmed, status: "pending" }]); setIsPending(true); setStreamingText(""); setInput(""); if (textareaRef.current) textareaRef.current.style.height = "auto"; const controller = new AbortController(); let timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS); const resetTimeout = () => { clearTimeout(timeoutId); timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS); }; try { const response = await fetch("/api/chat/mp/stream", { method: "POST", headers: { "Content-Type": "application/json", ...getSessionHeaders() }, body: JSON.stringify({ messages: nextMessages, person_id: person.person_id, initial_speech_id: initialTalkId ?? null, provider_override: providerOverride, }), signal: controller.signal, }); if (!response.ok) { const maybeJson = await response.json().catch(() => null); throw new Error(maybeJson?.detail ?? `Serverfel (${response.status})`); } const reader = response.body!.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; resetTimeout(); buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; for (const line of lines) { if (!line.startsWith("data: ")) continue; const event = JSON.parse(line.slice(6)); if (event.type === "answer_delta") { const chunk: string = event.text ?? ""; if (chunk) setStreamingText(prev => prev + chunk); } else if (event.type === "answer_delta_retract") { // The iteration that looked like the final answer turned out to // end in a tool call after all — discard the speculative preview. setStreamingText(""); } else if (event.type === "answer") { const sources: ChatSource[] = event.sources ?? []; const answerHtml = convertMarkdownToHtml(event.answer ?? "", sources); setStreamingText(""); setTurns(prev => prev.map(t => t.id === turnId ? { ...t, status: "ready", answer: event.answer ?? "", answerHtml, sources } : t ) ); setMessages(prev => [...prev, { role: "assistant", content: event.answer ?? "" }]); return; } else if (event.type === "error") { throw new Error(event.message ?? "Okänt serverfel"); } } } throw new Error("Anslutningen avslutades utan svar."); } catch (err: any) { const isTimeout = err?.name === "AbortError"; const msg = isTimeout ? "Chatten tog för lång tid och avbröts." : err?.message || "Kunde inte hämta svar."; setTurns(prev => prev.map(t => t.id === turnId ? { ...t, status: "error", errorMessage: msg } : t ) ); } finally { clearTimeout(timeoutId); setIsPending(false); setStreamingText(""); } }, [isPending, messages, person.person_id, initialTalkId, providerOverride]); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submitPrompt(input); } }; const resizeTextarea = (el: HTMLTextAreaElement) => { el.style.height = "auto"; el.style.height = `${el.scrollHeight}px`; }; return (
{/* Profile hero */}
{person.name} { (e.currentTarget as HTMLImageElement).style.display = "none"; }} />

{person.name}

{person.party && ( {person.party} )} {isActive && ( Tjänstgörande )}
{person.constituency && ( <>
Valkrets
{person.constituency}
)} {person.birth_year && ( <>
Född
{person.birth_year}
)} {person.status && !isActive && ( <>
Status
{person.status}
)}
{/* Uppdrag */} {person.uppdrag && person.uppdrag.length > 0 && ( )} {/* Chat section */}

Chatta med {first_name}

Digital assistent – inte den riktiga {first_name}. Svaren bygger på anföranden i riksdagen. Experimentell
{turns.length === 0 && (
Ställ en fråga till {first_name}!
)} {turns.map(turn => (
{turn.question}
{turn.status === "pending" && (
{ (e.currentTarget as HTMLImageElement).style.display = "none"; }} /> {streamingText ? ( // Live, speculative preview — plain text, no markdown yet; // replaced by the real MpBubble once "answer" arrives.
{streamingText}
) : (
)}
)} {turn.status === "error" && (
{ (e.currentTarget as HTMLImageElement).style.display = "none"; }} />

{turn.errorMessage}

)} {turn.status === "ready" && turn.answerHtml && ( )}
))}