SPB Git

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%
16.2 KB · 429 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_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