spb/vrai-prix Public
Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
TypeScript 96.7%
CSS 3.2%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Ka — interface de conversation en streaming avec l'agent IA.4 * Lit le flux SSE de /api/ka : deltas de texte, activité d'outils, fin.5 * Le fil a son PROPRE défilement (jamais la page) et l'auto-scroll ne suit6 * que si l'utilisateur est déjà au bas du fil — zéro sursaut pendant le stream.7 */8"use client";9import { useEffect, useRef, useState } from "react";10import { useLang } from "./LangContext";1112interface Msg {13 role: "user" | "assistant";14 content: string;15 tools?: string[];16}1718const TOOL_LABELS: Record<string, { fr: string; en: string }> = {19 chercher_propriete: { fr: "Recherche dans le registre", en: "Searching the registry" },20 evaluer_propriete: { fr: "Évaluation en cours", en: "Running the valuation" },21 comparables_detailles: { fr: "Analyse des comparables", en: "Analyzing comparables" },22 indice_marche: { fr: "Lecture de l'indice de marché", en: "Reading the market index" },23 stats_municipalite: { fr: "Statistiques municipales", en: "Municipal statistics" },24 stats_provinciales: { fr: "Statistiques provinciales", en: "Provincial statistics" },25 evaluer_parc: { fr: "Évaluation du parc", en: "Evaluating the portfolio" },26 comparer_proprietes: { fr: "Comparaison des propriétés", en: "Comparing properties" },27 estimation_manuelle: { fr: "Estimation par caractéristiques", en: "Estimating from specs" },28 chercher_proprietes_secteur: { fr: "Balayage du secteur", en: "Scanning the area" },29 liens_rapports: { fr: "Préparation des rapports", en: "Preparing reports" },30};3132/* ------------------------------------------------------------ markdown */3334function inline(s: string, key: string): React.ReactNode[] {35 const parts: React.ReactNode[] = [];36 const rx = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|\*\*([^*]+)\*\*|\*([^*]+)\*/g;37 let last = 0;38 let m: RegExpExecArray | null;39 let i = 0;40 while ((m = rx.exec(s))) {41 if (m.index > last) parts.push(s.slice(last, m.index));42 if (m[1] && m[2])43 parts.push(44 <a45 key={`${key}-${i++}`}46 href={m[2]}47 target="_blank"48 rel="noopener noreferrer"49 className="font-bold underline decoration-[var(--lime)] decoration-2 underline-offset-2"50 >51 {m[1]}52 </a>53 );54 else if (m[3]) parts.push(<strong key={`${key}-${i++}`}>{m[3]}</strong>);55 else if (m[4]) parts.push(<em key={`${key}-${i++}`}>{m[4]}</em>);56 last = m.index + m[0].length;57 }58 if (last < s.length) parts.push(s.slice(last));59 return parts;60}6162/** Rendu markdown minimal et sûr : gras, italique, listes, liens, TABLEAUX, titres. */63function renderMd(text: string): React.ReactNode[] {64 const blocks: React.ReactNode[] = [];65 const lines = text.split("\n");66 let list: string[] = [];67 let table: string[][] = [];68 let k = 0;6970 const flushList = () => {71 if (!list.length) return;72 blocks.push(73 <ul key={`ul-${k++}`} className="my-1.5 space-y-1 pl-1">74 {list.map((li, j) => (75 <li key={j} className="flex gap-2">76 <span className="mt-[7px] h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--lime)]" />77 <span className="min-w-0">{inline(li, `li-${k}-${j}`)}</span>78 </li>79 ))}80 </ul>81 );82 list = [];83 };8485 const flushTable = () => {86 if (!table.length) return;87 const [head, ...rows] = table;88 blocks.push(89 <div90 key={`tw-${k++}`}91 className="my-2 max-w-full overflow-x-auto rounded-lg border-[1.5px] border-[var(--line)] bg-surface"92 >93 <table className="w-full border-collapse text-[12px]">94 <thead>95 <tr>96 {head.map((c, j) => (97 <th98 key={j}99 className="vp-mono whitespace-nowrap border-b-[1.5px] border-[var(--line)] bg-surface-2 px-2.5 py-1.5 text-left text-[9px] font-bold uppercase tracking-[0.08em] text-ink-3"100 >101 {inline(c, `th-${k}-${j}`)}102 </th>103 ))}104 </tr>105 </thead>106 <tbody>107 {rows.map((row, ri) => (108 <tr key={ri}>109 {row.map((c, j) => (110 <td111 key={j}112 className={`whitespace-nowrap px-2.5 py-1.5 align-top ${113 ri < rows.length - 1 ? "border-b border-[var(--line)]" : ""114 }`}115 >116 {inline(c, `td-${k}-${ri}-${j}`)}117 </td>118 ))}119 </tr>120 ))}121 </tbody>122 </table>123 </div>124 );125 table = [];126 };127128 for (const line of lines) {129 const t = line.trim();130 // ligne de tableau |a|b| (ignore la ligne séparatrice |---|---|)131 if (t.startsWith("|") && t.endsWith("|") && t.length > 2) {132 const cells = t133 .slice(1, -1)134 .split("|")135 .map((c) => c.trim());136 if (!cells.every((c) => /^:?-{2,}:?$/.test(c))) {137 flushList();138 table.push(cells);139 }140 continue;141 }142 flushTable();143 if (/^[-•]\s+/.test(t)) {144 list.push(t.replace(/^[-•]\s+/, ""));145 continue;146 }147 flushList();148 if (t === "" || t === "---") continue;149 if (t.startsWith("[réf:")) continue; // réf machine pour la continuité — jamais affichée150 if (/^#{1,4}\s+/.test(t)) {151 blocks.push(152 <p key={`h-${k++}`} className="vp-display mt-2 text-[15px] font-bold">153 {inline(t.replace(/^#{1,4}\s+/, ""), `h-${k}`)}154 </p>155 );156 } else {157 blocks.push(158 <p key={`p-${k++}`} className="my-1.5">159 {inline(t, `p-${k}`)}160 </p>161 );162 }163 }164 flushList();165 flushTable();166 return blocks;167}168169/* ------------------------------------------------------------ composant */170171export default function KaChat() {172 const { lang } = useLang();173 const fr = lang === "fr";174 const [messages, setMessages] = useState<Msg[]>([]);175 const [input, setInput] = useState("");176 const [busy, setBusy] = useState(false);177 const [activeTool, setActiveTool] = useState<string | null>(null);178 const feedRef = useRef<HTMLDivElement>(null);179 const stickBottom = useRef(true);180181 // auto-scroll DU FIL seulement (jamais la page), et seulement si déjà en bas182 useEffect(() => {183 const el = feedRef.current;184 if (el && stickBottom.current) el.scrollTop = el.scrollHeight;185 }, [messages, activeTool]);186187 const onFeedScroll = () => {188 const el = feedRef.current;189 if (!el) return;190 stickBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 140;191 };192193 const suggestions = fr194 ? [195 "Combien vaut le 861 route Elgin à Saint-Pamphile ?",196 "Le marché des condos monte-t-il au Québec ?",197 "Trouve-moi une maison à Lévis sous 450 000 $",198 "Que vaut tout l'immobilier du Québec ?",199 ]200 : [201 "How much is 861 route Elgin in Saint-Pamphile worth?",202 "Is the Québec condo market going up?",203 "Find me a house in Lévis under $450,000",204 "What is all Québec real estate worth?",205 ];206207 async function send(text: string) {208 const content = text.trim();209 if (!content || busy) return;210 setInput("");211 setBusy(true);212 setActiveTool(null);213 stickBottom.current = true;214215 const history = [...messages, { role: "user" as const, content }];216 setMessages([...history, { role: "assistant", content: "", tools: [] }]);217218 try {219 const res = await fetch("/api/ka", {220 method: "POST",221 headers: { "Content-Type": "application/json" },222 body: JSON.stringify({223 messages: history.map(({ role, content }) => ({ role, content })),224 }),225 });226 if (!res.ok || !res.body) throw new Error(String(res.status));227228 const reader = res.body.getReader();229 const decoder = new TextDecoder();230 let buffer = "";231232 const patch = (fn: (m: Msg) => Msg) =>233 setMessages((prev) => {234 const next = [...prev];235 next[next.length - 1] = fn(next[next.length - 1]);236 return next;237 });238239 for (;;) {240 const { done, value } = await reader.read();241 if (done) break;242 buffer += decoder.decode(value, { stream: true });243 const events = buffer.split("\n\n");244 buffer = events.pop() ?? "";245 for (const ev of events) {246 const line = ev.split("\n").find((l) => l.startsWith("data: "));247 if (!line) continue;248 let data: {249 type: string;250 text?: string;251 name?: string;252 message?: string;253 refs?: { id: string; adresse: string; pdf?: string; pdf_pro?: string }[];254 };255 try {256 data = JSON.parse(line.slice(6));257 } catch {258 continue;259 }260 if (data.type === "text" && data.text) {261 setActiveTool(null);262 patch((m) => ({ ...m, content: m.content + data.text }));263 } else if (data.type === "tool" && data.name) {264 setActiveTool(data.name);265 patch((m) => ({266 ...m,267 tools: m.tools?.includes(data.name!) ? m.tools : [...(m.tools ?? []), data.name!],268 }));269 } else if (data.type === "refs" && Array.isArray(data.refs)) {270 const lines = (271 data.refs as { id: string; adresse: string; pdf?: string; pdf_pro?: string }[]272 )273 .map(274 (r) =>275 `[réf: ${r.adresse} → id ${r.id}` +276 (r.pdf ? ` | rapport standard: ${r.pdf} | rapport pro: ${r.pdf_pro}` : "") +277 "]"278 )279 .join("\n");280 patch((m) => ({ ...m, content: m.content + "\n\n" + lines }));281 } else if (data.type === "error") {282 patch((m) => ({283 ...m,284 content: m.content + (m.content ? "\n\n" : "") + `*${data.message}*`,285 }));286 }287 }288 }289 } catch {290 setMessages((prev) => {291 const next = [...prev];292 const last = next[next.length - 1];293 if (last?.role === "assistant" && !last.content) {294 last.content = fr295 ? "*Connexion interrompue — réessaie dans un instant.*"296 : "*Connection lost — try again in a moment.*";297 }298 return next;299 });300 } finally {301 setActiveTool(null);302 setBusy(false);303 }304 }305306 const toolLabel = (name: string) =>307 TOOL_LABELS[name]?.[fr ? "fr" : "en"] ?? name.replace(/_/g, " ");308309 return (310 <div className="mx-auto flex w-full max-w-[860px] flex-col">311 {/* fil de conversation — défilement interne, hauteur stable */}312 <div313 ref={feedRef}314 onScroll={onFeedScroll}315 className="vp-card flex h-[56dvh] flex-col gap-4 overflow-y-auto overscroll-contain p-3.5 sm:h-[58vh] sm:gap-5 sm:p-6"316 >317 {messages.length === 0 && (318 <div className="my-auto text-center">319 <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border-[1.5px] border-ink bg-ink shadow-[5px_5px_0_var(--lime)] sm:h-16 sm:w-16">320 <span className="vp-display text-[22px] font-bold text-lime sm:text-[26px]">Ka</span>321 </div>322 <p className="vp-display px-2 text-[17px] font-bold sm:text-[19px]">323 {fr ? "Dis-moi quelle propriété t'intéresse." : "Tell me which property you're curious about."}324 </p>325 <p className="mx-auto mt-1.5 max-w-[440px] px-3 text-[13px] text-ink-2 sm:text-[13.5px]">326 {fr327 ? "Je fouille 3,7 millions d'unités d'évaluation et 745 000 ventes réelles, en direct, pendant qu'on jase."328 : "I search 3.7M assessment units and 745k real sales, live, while we chat."}329 </p>330 <div className="mx-auto mt-4 flex max-w-[560px] flex-col items-stretch gap-2 px-1 sm:mt-5 sm:flex-row sm:flex-wrap sm:justify-center">331 {suggestions.map((s) => (332 <button333 key={s}334 onClick={() => send(s)}335 className="rounded-full border-[1.5px] border-ink bg-surface px-3.5 py-2 text-[12.5px] font-semibold transition-all hover:bg-lime-soft hover:shadow-[3px_3px_0_var(--ink)] active:translate-y-[1px]"336 >337 {s}338 </button>339 ))}340 </div>341 </div>342 )}343344 {messages.map((m, i) => (345 <div key={i} className={m.role === "user" ? "flex justify-end" : "flex justify-start"}>346 {m.role === "user" ? (347 <div className="max-w-[88%] rounded-2xl rounded-br-md border-[1.5px] border-ink bg-ink px-3.5 py-2.5 text-[13.5px] font-medium text-paper sm:text-[14px]">348 {m.content}349 </div>350 ) : (351 <div className="flex w-full max-w-full gap-2 sm:max-w-[94%] sm:gap-3">352 <div className="mt-0.5 hidden h-8 w-8 shrink-0 items-center justify-center rounded-lg border-[1.5px] border-ink bg-ink sm:flex">353 <span className="vp-display text-[12px] font-bold text-lime">Ka</span>354 </div>355 <div className="min-w-0 flex-1">356 {m.tools && m.tools.length > 0 && (357 <div className="mb-1.5 flex flex-wrap gap-1.5">358 {m.tools.map((t) => (359 <span360 key={t}361 className="vp-mono inline-flex items-center gap-1.5 rounded-full border border-[var(--line)] bg-surface-2 px-2.5 py-[3px] text-[9.5px] uppercase tracking-[0.05em] text-ink-2"362 >363 <span364 className={`h-[6px] w-[6px] rounded-full ${365 busy && i === messages.length - 1 && activeTool === t366 ? "animate-pulse bg-[var(--lime)]"367 : "bg-[var(--green)]"368 }`}369 />370 {toolLabel(t)}371 </span>372 ))}373 </div>374 )}375 <div className="text-[13.5px] leading-relaxed sm:text-[14px]">376 {m.content ? (377 renderMd(m.content)378 ) : busy && i === messages.length - 1 ? (379 <span className="inline-flex gap-1 py-1">380 <span className="h-2 w-2 animate-bounce rounded-full bg-ink-3 [animation-delay:0ms]" />381 <span className="h-2 w-2 animate-bounce rounded-full bg-ink-3 [animation-delay:120ms]" />382 <span className="h-2 w-2 animate-bounce rounded-full bg-ink-3 [animation-delay:240ms]" />383 </span>384 ) : null}385 </div>386 </div>387 </div>388 )}389 </div>390 ))}391 </div>392393 {/* zone de saisie — toujours visible sous le fil */}394 <form395 onSubmit={(e) => {396 e.preventDefault();397 send(input);398 }}399 className="mt-3 flex gap-2 sm:mt-4 sm:gap-2.5"400 >401 <input402 value={input}403 onChange={(e) => setInput(e.target.value)}404 placeholder={fr ? "Adresse ou question…" : "Address or question…"}405 className="vp-input min-w-0 flex-1"406 maxLength={2000}407 aria-label={fr ? "Message à Ka" : "Message to Ka"}408 />409 <button410 type="submit"411 disabled={busy || !input.trim()}412 className="btn btn-primary shrink-0 px-4 disabled:opacity-50 sm:px-5"413 aria-label={fr ? "Envoyer" : "Send"}414 >415 <span className="hidden sm:inline">{busy ? (fr ? "Ka réfléchit…" : "Thinking…") : fr ? "Envoyer" : "Send"}</span>416 <span className="sm:hidden">{busy ? "…" : "↑"}</span>417 </button>418 </form>419420 <p className="vp-mono mt-2.5 px-2 text-center text-[9.5px] uppercase leading-relaxed tracking-[0.08em] text-ink-3 sm:mt-3 sm:text-[10px]">421 {fr422 ? "Ka est une IA (Claude d'Anthropic) — estimations indicatives, pas un évaluateur agréé"423 : "Ka is an AI (Anthropic's Claude) — indicative estimates, not a chartered appraiser"}424 </p>425 </div>426 );427}428