SPB Git

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%
16.1 KB · 428 lines tsx
Raw Blame History
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