You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
612 lines
27 KiB
612 lines
27 KiB
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<HTMLDivElement>) => { |
|
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 ( |
|
<div className={`mp-chat__bubble mp-chat__bubble--mp mp-bubble-card${closing ? " mp-bubble-card--closing" : ""}`}> |
|
<img |
|
className="mp-chat__bubble-avatar" |
|
src={photoUrl} |
|
alt="" |
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }} |
|
/> |
|
|
|
{!flipped ? ( |
|
<div className="mp-bubble-card__front"> |
|
<div |
|
className="mp-chat__answer" |
|
dangerouslySetInnerHTML={{ __html: answerHtml }} |
|
onClick={handleAnswerClick} |
|
/> |
|
<div className="mp-bubble-card__front-actions"> |
|
{hasSources && ( |
|
<button |
|
className="mp-bubble-card__flip-btn" |
|
onClick={toggleFlip} |
|
title="Visa källor" |
|
aria-label={`Visa ${sources.length} källo${sources.length === 1 ? "r" : "r"}`} |
|
> |
|
📎 {sources.length} käll{sources.length === 1 ? "a" : "or"} |
|
</button> |
|
)} |
|
{answerMarkdown && ( |
|
<CopyMarkdownButton |
|
getMarkdown={() => chatAnswerToMarkdown(answerMarkdown, sources)} |
|
/> |
|
)} |
|
</div> |
|
</div> |
|
) : ( |
|
<div className="mp-bubble-card__back"> |
|
<div className="mp-bubble-card__back-header"> |
|
<button |
|
className="mp-bubble-card__flip-btn mp-bubble-card__flip-btn--back" |
|
onClick={toggleFlip} |
|
aria-label="Tillbaka till svaret" |
|
> |
|
← Tillbaka |
|
</button> |
|
<span className="mp-bubble-card__sources-label">Källor</span> |
|
</div> |
|
<div className="mp-bubble-card__sources"> |
|
{sources.map((src, i) => ( |
|
<div key={`${src._id}-${i}`} className="mp-bubble-card__source"> |
|
<div className="mp-bubble-card__source-meta"> |
|
{src.person_id && ( |
|
<img |
|
className="mp-bubble-card__source-avatar" |
|
src={getMpPhotoUrl(src.person_id)} |
|
alt="" |
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }} |
|
/> |
|
)} |
|
<div> |
|
{src.speaker && <span className="mp-bubble-card__source-speaker">{src.speaker}</span>} |
|
{src.date && <span className="mp-bubble-card__source-date">{src.date}</span>} |
|
</div> |
|
</div> |
|
{src.snippet && ( |
|
<p className="mp-bubble-card__source-snippet"> |
|
{src.snippet.replace(/\*\*(.*?)\*\*/g, "$1")} |
|
</p> |
|
)} |
|
{src.url_video && ( |
|
<a |
|
href={src.url_video} |
|
className="mp-bubble-card__source-link" |
|
target="_blank" |
|
rel="noreferrer" |
|
> |
|
Öppna anförande ↗ |
|
</a> |
|
)} |
|
</div> |
|
))} |
|
</div> |
|
</div> |
|
)} |
|
</div> |
|
); |
|
} |
|
|
|
// ── Organ name translations ─────────────────────────────────────────────────── |
|
|
|
const ORGAN_NAMES: Record<string, string> = { |
|
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 ( |
|
<div className="mp-profile__uppdrag panel"> |
|
<button |
|
className="mp-profile__uppdrag-toggle" |
|
onClick={() => setOpen(o => !o)} |
|
aria-expanded={open} |
|
> |
|
<span className="mp-profile__uppdrag-title">Uppdrag i riksdagen</span> |
|
<span className="mp-profile__uppdrag-count">{uppdrag.length}</span> |
|
<span className="mp-profile__uppdrag-chevron">{open ? "▲" : "▼"}</span> |
|
</button> |
|
|
|
{open && ( |
|
<div className="mp-profile__uppdrag-list"> |
|
{sorted.map((u, i) => { |
|
const organName = u.organ_kod |
|
? (ORGAN_NAMES[u.organ_kod.toLowerCase()] ?? u.organ_kod.toUpperCase()) |
|
: null; |
|
return ( |
|
<div key={i} className="mp-profile__uppdrag-row"> |
|
<div className="mp-profile__uppdrag-year"> |
|
{formatDate(u.from)} – {formatDate(u.tom)} |
|
</div> |
|
<div className="mp-profile__uppdrag-body"> |
|
<span className="mp-profile__uppdrag-role">{u.roll_kod ?? u.status}</span> |
|
{organName && <span className="mp-profile__uppdrag-committee">{organName}</span>} |
|
{u.uppgift && u.uppgift !== u.roll_kod && ( |
|
<span className="mp-profile__uppdrag-uppgift">{u.uppgift}</span> |
|
)} |
|
</div> |
|
</div> |
|
); |
|
})} |
|
</div> |
|
)} |
|
</div> |
|
); |
|
} |
|
|
|
// ── Main panel ──────────────────────────────────────────────────────────────── |
|
|
|
export function MpChatPanel({ person, initialTalkId, sessionId }: Props) { |
|
const { user, dek } = useAuth(); |
|
const { providerOverride } = useLLMSettings(); |
|
const [turns, setTurns] = useState<MpChatTurn[]>([]); |
|
const [messages, setMessages] = useState<ChatMessage[]>([]); |
|
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<HTMLDivElement>(null); |
|
const textareaRef = useRef<HTMLTextAreaElement>(null); |
|
const lastSavedTurnsRef = useRef<string>(""); |
|
|
|
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<EncSessionPayload>(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<HTMLTextAreaElement>) => { |
|
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 ( |
|
<div className="mp-profile"> |
|
{/* Profile hero */} |
|
<div className="mp-profile__hero panel"> |
|
<img |
|
className="mp-profile__photo" |
|
src={photoUrl} |
|
alt={person.name} |
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }} |
|
/> |
|
<div className="mp-profile__info"> |
|
<div className="mp-profile__name-row"> |
|
<h1 className="mp-profile__name">{person.name}</h1> |
|
{person.party && ( |
|
<span className="party-chip" data-party={person.party} style={{ "--party-color": `var(--party-${person.party ?? ""})` } as React.CSSProperties}> |
|
{person.party} |
|
</span> |
|
)} |
|
{isActive && ( |
|
<span className="mp-profile__active-badge">Tjänstgörande</span> |
|
)} |
|
</div> |
|
<dl className="mp-profile__meta"> |
|
{person.constituency && ( |
|
<> |
|
<dt>Valkrets</dt> |
|
<dd>{person.constituency}</dd> |
|
</> |
|
)} |
|
{person.birth_year && ( |
|
<> |
|
<dt>Född</dt> |
|
<dd>{person.birth_year}</dd> |
|
</> |
|
)} |
|
{person.status && !isActive && ( |
|
<> |
|
<dt>Status</dt> |
|
<dd>{person.status}</dd> |
|
</> |
|
)} |
|
</dl> |
|
</div> |
|
</div> |
|
|
|
{/* Uppdrag */} |
|
{person.uppdrag && person.uppdrag.length > 0 && ( |
|
<UppdragSection uppdrag={person.uppdrag} /> |
|
)} |
|
|
|
{/* Chat section */} |
|
<div className="mp-profile__chat panel"> |
|
<div className="mp-profile__chat-header"> |
|
<h2 className="mp-profile__chat-title">Chatta med {first_name}</h2> |
|
<div className="mp-profile__chat-disclaimer"> |
|
<span>Digital assistent – inte den riktiga {first_name}. Svaren bygger på anföranden i riksdagen.</span> |
|
<span className="mp-chat__experimental">Experimentell</span> |
|
</div> |
|
</div> |
|
|
|
<div className="mp-chat__messages" aria-live="polite"> |
|
{turns.length === 0 && ( |
|
<div className="mp-chat__empty"> |
|
Ställ en fråga till {first_name}! |
|
</div> |
|
)} |
|
|
|
{turns.map(turn => ( |
|
<div key={turn.id} className="mp-chat__turn"> |
|
<div className="mp-chat__bubble mp-chat__bubble--user"> |
|
{turn.question} |
|
</div> |
|
|
|
{turn.status === "pending" && ( |
|
<div className="mp-chat__bubble mp-chat__bubble--mp"> |
|
<img |
|
className="mp-chat__bubble-avatar" |
|
src={photoUrl} |
|
alt="" |
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }} |
|
/> |
|
{streamingText ? ( |
|
// Live, speculative preview — plain text, no markdown yet; |
|
// replaced by the real MpBubble once "answer" arrives. |
|
<div className="mp-chat__typing-preview" style={{ whiteSpace: "pre-wrap" }}> |
|
{streamingText} |
|
</div> |
|
) : ( |
|
<div className="mp-chat__typing"> |
|
<span className="chat-loading__spinner" /> |
|
</div> |
|
)} |
|
</div> |
|
)} |
|
|
|
{turn.status === "error" && ( |
|
<div className="mp-chat__bubble mp-chat__bubble--mp"> |
|
<img |
|
className="mp-chat__bubble-avatar" |
|
src={photoUrl} |
|
alt="" |
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }} |
|
/> |
|
<p className="mp-chat__error">{turn.errorMessage}</p> |
|
</div> |
|
)} |
|
|
|
{turn.status === "ready" && turn.answerHtml && ( |
|
<MpBubble |
|
photoUrl={photoUrl} |
|
answerHtml={turn.answerHtml} |
|
answerMarkdown={turn.answer} |
|
sources={turn.sources ?? []} |
|
/> |
|
)} |
|
</div> |
|
))} |
|
|
|
<div ref={messagesEndRef} /> |
|
</div> |
|
|
|
<div className="mp-chat__input-row"> |
|
<textarea |
|
ref={textareaRef} |
|
className="mp-chat__input" |
|
placeholder={`Skriv en fråga till ${first_name}…`} |
|
value={input} |
|
disabled={isPending} |
|
rows={1} |
|
onChange={e => { |
|
setInput(e.target.value); |
|
resizeTextarea(e.target); |
|
}} |
|
onKeyDown={handleKeyDown} |
|
/> |
|
<button |
|
className="mp-chat__send" |
|
disabled={isPending || !input.trim()} |
|
onClick={() => submitPrompt(input)} |
|
aria-label="Skicka" |
|
> |
|
{isPending ? <span className="chat-loading__spinner" /> : "→"} |
|
</button> |
|
</div> |
|
</div> |
|
|
|
{turns.some(t => t.status === "ready") && ( |
|
<div className="chat-share-row"> |
|
<button |
|
type="button" |
|
className="secondary-button chat-share-btn" |
|
onClick={handleShare} |
|
disabled={shareToast === "copying"} |
|
> |
|
{shareToast === "copying" ? "Skapar länk…" : "Dela konversation"} |
|
</button> |
|
</div> |
|
)} |
|
|
|
{shareToast === "copied" && ( |
|
<div className="share-toast share-toast--success">Länk kopierad!</div> |
|
)} |
|
{shareToast === "error" && ( |
|
<div className="share-toast share-toast--error">Kunde inte kopiera länken.</div> |
|
)} |
|
</div> |
|
); |
|
}
|
|
|