// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Ka — interface de conversation en streaming avec l'agent IA. * Lit le flux SSE de /api/ka : deltas de texte, activité d'outils, fin. * Le fil a son PROPRE défilement (jamais la page) et l'auto-scroll ne suit * que si l'utilisateur est déjà au bas du fil — zéro sursaut pendant le stream. */ "use client"; import { useEffect, useRef, useState } from "react"; import { useLang } from "./LangContext"; interface Msg { role: "user" | "assistant"; content: string; tools?: string[]; } const TOOL_LABELS: Record = { chercher_plex: { fr: "Recherche dans le registre", en: "Searching the registry" }, evaluer_plex: { fr: "Évaluation du plex", en: "Valuing the plex" }, proforma_investisseur: { fr: "Calcul du pro forma", en: "Building the pro forma" }, comparables_detailles: { fr: "Analyse des comparables", en: "Analyzing comparables" }, indice_marche_plex: { fr: "Lecture du marché des plex", en: "Reading the plex market" }, stats_municipalite: { fr: "Statistiques municipales", en: "Municipal statistics" }, stats_provinciales: { fr: "Statistiques provinciales", en: "Provincial statistics" }, evaluer_parc: { fr: "Évaluation du parc", en: "Evaluating the portfolio" }, comparer_plex: { fr: "Comparaison des plex", en: "Comparing plexes" }, estimation_manuelle: { fr: "Estimation par caractéristiques", en: "Estimating from specs" }, chercher_plex_secteur: { fr: "Balayage du secteur", en: "Scanning the area" }, liens_rapports: { fr: "Préparation des rapports", en: "Preparing reports" }, }; /* ------------------------------------------------------------ markdown */ function inline(s: string, key: string): React.ReactNode[] { const parts: React.ReactNode[] = []; const rx = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|\*\*([^*]+)\*\*|\*([^*]+)\*/g; let last = 0; let m: RegExpExecArray | null; let i = 0; while ((m = rx.exec(s))) { if (m.index > last) parts.push(s.slice(last, m.index)); if (m[1] && m[2]) parts.push( {m[1]} ); else if (m[3]) parts.push({m[3]}); else if (m[4]) parts.push({m[4]}); last = m.index + m[0].length; } if (last < s.length) parts.push(s.slice(last)); return parts; } /** Rendu markdown minimal et sûr : gras, italique, listes, liens, TABLEAUX, titres. */ function renderMd(text: string): React.ReactNode[] { const blocks: React.ReactNode[] = []; const lines = text.split("\n"); let list: string[] = []; let table: string[][] = []; let k = 0; const flushList = () => { if (!list.length) return; blocks.push( ); list = []; }; const flushTable = () => { if (!table.length) return; const [head, ...rows] = table; blocks.push(
{head.map((c, j) => ( ))} {rows.map((row, ri) => ( {row.map((c, j) => ( ))} ))}
{inline(c, `th-${k}-${j}`)}
{inline(c, `td-${k}-${ri}-${j}`)}
); table = []; }; for (const line of lines) { const t = line.trim(); // ligne de tableau |a|b| (ignore la ligne séparatrice |---|---|) if (t.startsWith("|") && t.endsWith("|") && t.length > 2) { const cells = t .slice(1, -1) .split("|") .map((c) => c.trim()); if (!cells.every((c) => /^:?-{2,}:?$/.test(c))) { flushList(); table.push(cells); } continue; } flushTable(); if (/^[-•]\s+/.test(t)) { list.push(t.replace(/^[-•]\s+/, "")); continue; } flushList(); if (t === "" || t === "---") continue; if (t.startsWith("[réf:")) continue; // réf machine pour la continuité — jamais affichée if (/^#{1,4}\s+/.test(t)) { blocks.push(

{inline(t.replace(/^#{1,4}\s+/, ""), `h-${k}`)}

); } else { blocks.push(

{inline(t, `p-${k}`)}

); } } flushList(); flushTable(); return blocks; } /* ------------------------------------------------------------ composant */ export default function KaChat() { const { lang } = useLang(); const fr = lang === "fr"; const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [busy, setBusy] = useState(false); const [activeTool, setActiveTool] = useState(null); const feedRef = useRef(null); const stickBottom = useRef(true); // auto-scroll DU FIL seulement (jamais la page), et seulement si déjà en bas useEffect(() => { const el = feedRef.current; if (el && stickBottom.current) el.scrollTop = el.scrollHeight; }, [messages, activeTool]); const onFeedScroll = () => { const el = feedRef.current; if (!el) return; stickBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 140; }; const suggestions = fr ? [ "Combien vaut le triplex au 2075 rue Grandjean à Québec ?", "Trouve-moi un triplex à Trois-Rivières sous 700 000 $", "Fais-moi le pro forma d'un duplex à Sherbrooke", "Que valent tous les plex du Québec ?", ] : [ "How much is the triplex at 2075 rue Grandjean in Québec City worth?", "Find me a triplex in Trois-Rivières under $700,000", "Build the pro forma for a duplex in Sherbrooke", "What are all Québec plexes worth?", ]; async function send(text: string) { const content = text.trim(); if (!content || busy) return; setInput(""); setBusy(true); setActiveTool(null); stickBottom.current = true; const history = [...messages, { role: "user" as const, content }]; setMessages([...history, { role: "assistant", content: "", tools: [] }]); try { const res = await fetch("/api/ka", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: history.map(({ role, content }) => ({ role, content })), }), }); if (!res.ok || !res.body) throw new Error(String(res.status)); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; const patch = (fn: (m: Msg) => Msg) => setMessages((prev) => { const next = [...prev]; next[next.length - 1] = fn(next[next.length - 1]); return next; }); for (;;) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const events = buffer.split("\n\n"); buffer = events.pop() ?? ""; for (const ev of events) { const line = ev.split("\n").find((l) => l.startsWith("data: ")); if (!line) continue; let data: { type: string; text?: string; name?: string; message?: string; refs?: { id: string; adresse: string; pdf?: string; pdf_pro?: string }[]; }; try { data = JSON.parse(line.slice(6)); } catch { continue; } if (data.type === "text" && data.text) { setActiveTool(null); patch((m) => ({ ...m, content: m.content + data.text })); } else if (data.type === "tool" && data.name) { setActiveTool(data.name); patch((m) => ({ ...m, tools: m.tools?.includes(data.name!) ? m.tools : [...(m.tools ?? []), data.name!], })); } else if (data.type === "refs" && Array.isArray(data.refs)) { const lines = ( data.refs as { id: string; adresse: string; pdf?: string; pdf_pro?: string }[] ) .map( (r) => `[réf: ${r.adresse} → id ${r.id}` + (r.pdf ? ` | rapport standard: ${r.pdf} | rapport pro: ${r.pdf_pro}` : "") + "]" ) .join("\n"); patch((m) => ({ ...m, content: m.content + "\n\n" + lines })); } else if (data.type === "error") { patch((m) => ({ ...m, content: m.content + (m.content ? "\n\n" : "") + `*${data.message}*`, })); } } } } catch { setMessages((prev) => { const next = [...prev]; const last = next[next.length - 1]; if (last?.role === "assistant" && !last.content) { last.content = fr ? "*Connexion interrompue — réessaie dans un instant.*" : "*Connection lost — try again in a moment.*"; } return next; }); } finally { setActiveTool(null); setBusy(false); } } const toolLabel = (name: string) => TOOL_LABELS[name]?.[fr ? "fr" : "en"] ?? name.replace(/_/g, " "); return (
{/* fil de conversation — défilement interne, hauteur stable */}
{messages.length === 0 && (
Ka

{fr ? "Dis-moi quel plex t'intéresse." : "Tell me which plex you're curious about."}

{fr ? "Je fouille 393 867 plex, 91 000 ventes réelles et je calcule des pro forma complets, en direct, pendant qu'on jase." : "I search 393,867 plexes, 91k real sales and build full pro formas, live, while we chat."}

{suggestions.map((s) => ( ))}
)} {messages.map((m, i) => (
{m.role === "user" ? (
{m.content}
) : (
Ka
{m.tools && m.tools.length > 0 && (
{m.tools.map((t) => ( {toolLabel(t)} ))}
)}
{m.content ? ( renderMd(m.content) ) : busy && i === messages.length - 1 ? ( ) : null}
)}
))}
{/* zone de saisie — toujours visible sous le fil */}
{ e.preventDefault(); send(input); }} className="mt-3 flex gap-2 sm:mt-4 sm:gap-2.5" > setInput(e.target.value)} placeholder={fr ? "Adresse ou question…" : "Address or question…"} className="vp-input min-w-0 flex-1" maxLength={2000} aria-label={fr ? "Message à Ka" : "Message to Ka"} />

{fr ? "Ka est une IA (Claude d'Anthropic) — estimations indicatives, pas un évaluateur agréé" : "Ka is an AI (Anthropic's Claude) — indicative estimates, not a chartered appraiser"}

); }