spb/valoplex Public
ValoPlex — moteur d'évaluation spécialisé pour les plex au Québec, petit frère de Vrai-Prix.
TypeScript 90.3%
Python 7.1%
CSS 2.5%
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_plex: { fr: "Recherche dans le registre", en: "Searching the registry" },20 evaluer_plex: { fr: "Évaluation du plex", en: "Valuing the plex" },21 proforma_investisseur: { fr: "Calcul du pro forma", en: "Building the pro forma" },22 comparables_detailles: { fr: "Analyse des comparables", en: "Analyzing comparables" },23 indice_marche_plex: { fr: "Lecture du marché des plex", en: "Reading the plex market" },24 stats_municipalite: { fr: "Statistiques municipales", en: "Municipal statistics" },25 stats_provinciales: { fr: "Statistiques provinciales", en: "Provincial statistics" },26 evaluer_parc: { fr: "Évaluation du parc", en: "Evaluating the portfolio" },27 comparer_plex: { fr: "Comparaison des plex", en: "Comparing plexes" },28 estimation_manuelle: { fr: "Estimation par caractéristiques", en: "Estimating from specs" },29 chercher_plex_secteur: { fr: "Balayage du secteur", en: "Scanning the area" },30 liens_rapports: { fr: "Préparation des rapports", en: "Preparing reports" },31};3233/* ------------------------------------------------------------ markdown */3435function inline(s: string, key: string): React.ReactNode[] {36 const parts: React.ReactNode[] = [];37 const rx = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|\*\*([^*]+)\*\*|\*([^*]+)\*/g;38 let last = 0;39 let m: RegExpExecArray | null;40 let i = 0;41 while ((m = rx.exec(s))) {42 if (m.index > last) parts.push(s.slice(last, m.index));43 if (m[1] && m[2])44 parts.push(45 <a46 key={`${key}-${i++}`}47 href={m[2]}48 target="_blank"49 rel="noopener noreferrer"50 className="font-bold underline decoration-[var(--lime)] decoration-2 underline-offset-2"51 >52 {m[1]}53 </a>54 );55 else if (m[3]) parts.push(<strong key={`${key}-${i++}`}>{m[3]}</strong>);56 else if (m[4]) parts.push(<em key={`${key}-${i++}`}>{m[4]}</em>);57 last = m.index + m[0].length;58 }59 if (last < s.length) parts.push(s.slice(last));60 return parts;61}6263/** Rendu markdown minimal et sûr : gras, italique, listes, liens, TABLEAUX, titres. */64function renderMd(text: string): React.ReactNode[] {65 const blocks: React.ReactNode[] = [];66 const lines = text.split("\n");67 let list: string[] = [];68 let table: string[][] = [];69 let k = 0;7071 const flushList = () => {72 if (!list.length) return;73 blocks.push(74 <ul key={`ul-${k++}`} className="my-1.5 space-y-1 pl-1">75 {list.map((li, j) => (76 <li key={j} className="flex gap-2">77 <span className="mt-[7px] h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--lime)]" />78 <span className="min-w-0">{inline(li, `li-${k}-${j}`)}</span>79 </li>80 ))}81 </ul>82 );83 list = [];84 };8586 const flushTable = () => {87 if (!table.length) return;88 const [head, ...rows] = table;89 blocks.push(90 <div91 key={`tw-${k++}`}92 className="my-2 max-w-full overflow-x-auto rounded-lg border-[1.5px] border-[var(--line)] bg-surface"93 >94 <table className="w-full border-collapse text-[12px]">95 <thead>96 <tr>97 {head.map((c, j) => (98 <th99 key={j}100 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"101 >102 {inline(c, `th-${k}-${j}`)}103 </th>104 ))}105 </tr>106 </thead>107 <tbody>108 {rows.map((row, ri) => (109 <tr key={ri}>110 {row.map((c, j) => (111 <td112 key={j}113 className={`whitespace-nowrap px-2.5 py-1.5 align-top ${114 ri < rows.length - 1 ? "border-b border-[var(--line)]" : ""115 }`}116 >117 {inline(c, `td-${k}-${ri}-${j}`)}118 </td>119 ))}120 </tr>121 ))}122 </tbody>123 </table>124 </div>125 );126 table = [];127 };128129 for (const line of lines) {130 const t = line.trim();131 // ligne de tableau |a|b| (ignore la ligne séparatrice |---|---|)132 if (t.startsWith("|") && t.endsWith("|") && t.length > 2) {133 const cells = t134 .slice(1, -1)135 .split("|")136 .map((c) => c.trim());137 if (!cells.every((c) => /^:?-{2,}:?$/.test(c))) {138 flushList();139 table.push(cells);140 }141 continue;142 }143 flushTable();144 if (/^[-•]\s+/.test(t)) {145 list.push(t.replace(/^[-•]\s+/, ""));146 continue;147 }148 flushList();149 if (t === "" || t === "---") continue;150 if (t.startsWith("[réf:")) continue; // réf machine pour la continuité — jamais affichée151 if (/^#{1,4}\s+/.test(t)) {152 blocks.push(153 <p key={`h-${k++}`} className="vp-display mt-2 text-[15px] font-bold">154 {inline(t.replace(/^#{1,4}\s+/, ""), `h-${k}`)}155 </p>156 );157 } else {158 blocks.push(159 <p key={`p-${k++}`} className="my-1.5">160 {inline(t, `p-${k}`)}161 </p>162 );163 }164 }165 flushList();166 flushTable();167 return blocks;168}169170/* ------------------------------------------------------------ composant */171172export default function KaChat() {173 const { lang } = useLang();174 const fr = lang === "fr";175 const [messages, setMessages] = useState<Msg[]>([]);176 const [input, setInput] = useState("");177 const [busy, setBusy] = useState(false);178 const [activeTool, setActiveTool] = useState<string | null>(null);179 const feedRef = useRef<HTMLDivElement>(null);180 const stickBottom = useRef(true);181182 // auto-scroll DU FIL seulement (jamais la page), et seulement si déjà en bas183 useEffect(() => {184 const el = feedRef.current;185 if (el && stickBottom.current) el.scrollTop = el.scrollHeight;186 }, [messages, activeTool]);187188 const onFeedScroll = () => {189 const el = feedRef.current;190 if (!el) return;191 stickBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 140;192 };193194 const suggestions = fr195 ? [196 "Combien vaut le triplex au 2075 rue Grandjean à Québec ?",197 "Trouve-moi un triplex à Trois-Rivières sous 700 000 $",198 "Fais-moi le pro forma d'un duplex à Sherbrooke",199 "Que valent tous les plex du Québec ?",200 ]201 : [202 "How much is the triplex at 2075 rue Grandjean in Québec City worth?",203 "Find me a triplex in Trois-Rivières under $700,000",204 "Build the pro forma for a duplex in Sherbrooke",205 "What are all Québec plexes worth?",206 ];207208 async function send(text: string) {209 const content = text.trim();210 if (!content || busy) return;211 setInput("");212 setBusy(true);213 setActiveTool(null);214 stickBottom.current = true;215216 const history = [...messages, { role: "user" as const, content }];217 setMessages([...history, { role: "assistant", content: "", tools: [] }]);218219 try {220 const res = await fetch("/api/ka", {221 method: "POST",222 headers: { "Content-Type": "application/json" },223 body: JSON.stringify({224 messages: history.map(({ role, content }) => ({ role, content })),225 }),226 });227 if (!res.ok || !res.body) throw new Error(String(res.status));228229 const reader = res.body.getReader();230 const decoder = new TextDecoder();231 let buffer = "";232233 const patch = (fn: (m: Msg) => Msg) =>234 setMessages((prev) => {235 const next = [...prev];236 next[next.length - 1] = fn(next[next.length - 1]);237 return next;238 });239240 for (;;) {241 const { done, value } = await reader.read();242 if (done) break;243 buffer += decoder.decode(value, { stream: true });244 const events = buffer.split("\n\n");245 buffer = events.pop() ?? "";246 for (const ev of events) {247 const line = ev.split("\n").find((l) => l.startsWith("data: "));248 if (!line) continue;249 let data: {250 type: string;251 text?: string;252 name?: string;253 message?: string;254 refs?: { id: string; adresse: string; pdf?: string; pdf_pro?: string }[];255 };256 try {257 data = JSON.parse(line.slice(6));258 } catch {259 continue;260 }261 if (data.type === "text" && data.text) {262 setActiveTool(null);263 patch((m) => ({ ...m, content: m.content + data.text }));264 } else if (data.type === "tool" && data.name) {265 setActiveTool(data.name);266 patch((m) => ({267 ...m,268 tools: m.tools?.includes(data.name!) ? m.tools : [...(m.tools ?? []), data.name!],269 }));270 } else if (data.type === "refs" && Array.isArray(data.refs)) {271 const lines = (272 data.refs as { id: string; adresse: string; pdf?: string; pdf_pro?: string }[]273 )274 .map(275 (r) =>276 `[réf: ${r.adresse} → id ${r.id}` +277 (r.pdf ? ` | rapport standard: ${r.pdf} | rapport pro: ${r.pdf_pro}` : "") +278 "]"279 )280 .join("\n");281 patch((m) => ({ ...m, content: m.content + "\n\n" + lines }));282 } else if (data.type === "error") {283 patch((m) => ({284 ...m,285 content: m.content + (m.content ? "\n\n" : "") + `*${data.message}*`,286 }));287 }288 }289 }290 } catch {291 setMessages((prev) => {292 const next = [...prev];293 const last = next[next.length - 1];294 if (last?.role === "assistant" && !last.content) {295 last.content = fr296 ? "*Connexion interrompue — réessaie dans un instant.*"297 : "*Connection lost — try again in a moment.*";298 }299 return next;300 });301 } finally {302 setActiveTool(null);303 setBusy(false);304 }305 }306307 const toolLabel = (name: string) =>308 TOOL_LABELS[name]?.[fr ? "fr" : "en"] ?? name.replace(/_/g, " ");309310 return (311 <div className="mx-auto flex w-full max-w-[860px] flex-col">312 {/* fil de conversation — défilement interne, hauteur stable */}313 <div314 ref={feedRef}315 onScroll={onFeedScroll}316 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"317 >318 {messages.length === 0 && (319 <div className="my-auto text-center">320 <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">321 <span className="vp-display text-[22px] font-bold text-lime sm:text-[26px]">Ka</span>322 </div>323 <p className="vp-display px-2 text-[17px] font-bold sm:text-[19px]">324 {fr ? "Dis-moi quel plex t'intéresse." : "Tell me which plex you're curious about."}325 </p>326 <p className="mx-auto mt-1.5 max-w-[440px] px-3 text-[13px] text-ink-2 sm:text-[13.5px]">327 {fr328 ? "Je fouille 393 867 plex, 91 000 ventes réelles et je calcule des pro forma complets, en direct, pendant qu'on jase."329 : "I search 393,867 plexes, 91k real sales and build full pro formas, live, while we chat."}330 </p>331 <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">332 {suggestions.map((s) => (333 <button334 key={s}335 onClick={() => send(s)}336 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]"337 >338 {s}339 </button>340 ))}341 </div>342 </div>343 )}344345 {messages.map((m, i) => (346 <div key={i} className={m.role === "user" ? "flex justify-end" : "flex justify-start"}>347 {m.role === "user" ? (348 <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]">349 {m.content}350 </div>351 ) : (352 <div className="flex w-full max-w-full gap-2 sm:max-w-[94%] sm:gap-3">353 <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">354 <span className="vp-display text-[12px] font-bold text-lime">Ka</span>355 </div>356 <div className="min-w-0 flex-1">357 {m.tools && m.tools.length > 0 && (358 <div className="mb-1.5 flex flex-wrap gap-1.5">359 {m.tools.map((t) => (360 <span361 key={t}362 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"363 >364 <span365 className={`h-[6px] w-[6px] rounded-full ${366 busy && i === messages.length - 1 && activeTool === t367 ? "animate-pulse bg-[var(--lime)]"368 : "bg-[var(--green)]"369 }`}370 />371 {toolLabel(t)}372 </span>373 ))}374 </div>375 )}376 <div className="text-[13.5px] leading-relaxed sm:text-[14px]">377 {m.content ? (378 renderMd(m.content)379 ) : busy && i === messages.length - 1 ? (380 <span className="inline-flex gap-1 py-1">381 <span className="h-2 w-2 animate-bounce rounded-full bg-ink-3 [animation-delay:0ms]" />382 <span className="h-2 w-2 animate-bounce rounded-full bg-ink-3 [animation-delay:120ms]" />383 <span className="h-2 w-2 animate-bounce rounded-full bg-ink-3 [animation-delay:240ms]" />384 </span>385 ) : null}386 </div>387 </div>388 </div>389 )}390 </div>391 ))}392 </div>393394 {/* zone de saisie — toujours visible sous le fil */}395 <form396 onSubmit={(e) => {397 e.preventDefault();398 send(input);399 }}400 className="mt-3 flex gap-2 sm:mt-4 sm:gap-2.5"401 >402 <input403 value={input}404 onChange={(e) => setInput(e.target.value)}405 placeholder={fr ? "Adresse ou question…" : "Address or question…"}406 className="vp-input min-w-0 flex-1"407 maxLength={2000}408 aria-label={fr ? "Message à Ka" : "Message to Ka"}409 />410 <button411 type="submit"412 disabled={busy || !input.trim()}413 className="btn btn-primary shrink-0 px-4 disabled:opacity-50 sm:px-5"414 aria-label={fr ? "Envoyer" : "Send"}415 >416 <span className="hidden sm:inline">{busy ? (fr ? "Ka réfléchit…" : "Thinking…") : fr ? "Envoyer" : "Send"}</span>417 <span className="sm:hidden">{busy ? "…" : "↑"}</span>418 </button>419 </form>420421 <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]">422 {fr423 ? "Ka est une IA (Claude d'Anthropic) — estimations indicatives, pas un évaluateur agréé"424 : "Ka is an AI (Anthropic's Claude) — indicative estimates, not a chartered appraiser"}425 </p>426 </div>427 );428}429