| 1 |
|
−// Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 2 |
|
−/** |
| 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 suit |
| 6 |
|
− * que si l'utilisateur est déjà au bas du fil — zéro sursaut pendant le stream. |
| 7 |
|
− */ |
| 8 |
|
−"use client"; |
| 9 |
|
−import { useEffect, useRef, useState } from "react"; |
| 10 |
|
−import { useLang } from "./LangContext"; |
| 11 |
|
− |
| 12 |
|
−interface Msg { |
| 13 |
|
− role: "user" | "assistant"; |
| 14 |
|
− content: string; |
| 15 |
|
− tools?: string[]; |
| 16 |
|
−} |
| 17 |
|
− |
| 18 |
|
−const 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 |
|
−}; |
| 31 |
|
− |
| 32 |
|
−/* ------------------------------------------------------------ markdown */ |
| 33 |
|
− |
| 34 |
|
−function 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 |
|
− <a |
| 45 |
|
− 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 |
|
−} |
| 61 |
|
− |
| 62 |
|
−/** Rendu markdown minimal et sûr : gras, italique, listes, liens, TABLEAUX, titres. */ |
| 63 |
|
−function 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; |
| 69 |
|
− |
| 70 |
|
− 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 |
|
− }; |
| 84 |
|
− |
| 85 |
|
− const flushTable = () => { |
| 86 |
|
− if (!table.length) return; |
| 87 |
|
− const [head, ...rows] = table; |
| 88 |
|
− blocks.push( |
| 89 |
|
− <div |
| 90 |
|
− 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 |
|
− <th |
| 98 |
|
− 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 |
|
− <td |
| 111 |
|
− 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 |
|
− }; |
| 127 |
|
− |
| 128 |
|
− 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 = t |
| 133 |
|
− .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ée |
| 150 |
|
− 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 |
|
−} |
| 168 |
|
− |
| 169 |
|
−/* ------------------------------------------------------------ composant */ |
| 170 |
|
− |
| 171 |
|
−export 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); |
| 180 |
|
− |
| 181 |
|
− // auto-scroll DU FIL seulement (jamais la page), et seulement si déjà en bas |
| 182 |
|
− useEffect(() => { |
| 183 |
|
− const el = feedRef.current; |
| 184 |
|
− if (el && stickBottom.current) el.scrollTop = el.scrollHeight; |
| 185 |
|
− }, [messages, activeTool]); |
| 186 |
|
− |
| 187 |
|
− const onFeedScroll = () => { |
| 188 |
|
− const el = feedRef.current; |
| 189 |
|
− if (!el) return; |
| 190 |
|
− stickBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 140; |
| 191 |
|
− }; |
| 192 |
|
− |
| 193 |
|
− const suggestions = fr |
| 194 |
|
− ? [ |
| 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 |
|
− ]; |
| 206 |
|
− |
| 207 |
|
− 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; |
| 214 |
|
− |
| 215 |
|
− const history = [...messages, { role: "user" as const, content }]; |
| 216 |
|
− setMessages([...history, { role: "assistant", content: "", tools: [] }]); |
| 217 |
|
− |
| 218 |
|
− 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)); |
| 227 |
|
− |
| 228 |
|
− const reader = res.body.getReader(); |
| 229 |
|
− const decoder = new TextDecoder(); |
| 230 |
|
− let buffer = ""; |
| 231 |
|
− |
| 232 |
|
− 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 |
|
− }); |
| 238 |
|
− |
| 239 |
|
− 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 = fr |
| 295 |
|
− ? "*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 |
|
− } |
| 305 |
|
− |
| 306 |
|
− const toolLabel = (name: string) => |
| 307 |
|
− TOOL_LABELS[name]?.[fr ? "fr" : "en"] ?? name.replace(/_/g, " "); |
| 308 |
|
− |
| 309 |
|
− return ( |
| 310 |
|
− <div className="mx-auto flex w-full max-w-[860px] flex-col"> |
| 311 |
|
− {/* fil de conversation — défilement interne, hauteur stable */} |
| 312 |
|
− <div |
| 313 |
|
− 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-[2px] bg-[var(--ink-deep)] sm:h-16 sm:w-16"> |
| 320 |
|
− <span className="vp-display text-[22px] font-bold text-[var(--accent-bright)] 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 |
|
− {fr |
| 327 |
|
− ? "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 |
|
− <button |
| 333 |
|
− key={s} |
| 334 |
|
− onClick={() => send(s)} |
| 335 |
|
− className="rounded-[2px] border border-[var(--line-strong)] bg-surface px-3.5 py-2 text-[12.5px] font-semibold transition-colors hover:border-accent hover:text-accent-deep" |
| 336 |
|
− > |
| 337 |
|
− {s} |
| 338 |
|
− </button> |
| 339 |
|
− ))} |
| 340 |
|
− </div> |
| 341 |
|
− </div> |
| 342 |
|
− )} |
| 343 |
|
− |
| 344 |
|
− {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-[2px] bg-[var(--ink-deep)] sm:flex"> |
| 353 |
|
− <span className="vp-display text-[12px] font-bold text-[var(--accent-bright)]">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 |
|
− <span |
| 360 |
|
− 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 |
|
− <span |
| 364 |
|
− className={`h-[6px] w-[6px] rounded-full ${ |
| 365 |
|
− busy && i === messages.length - 1 && activeTool === t |
| 366 |
|
− ? "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> |
| 392 |
|
− |
| 393 |
|
− {/* zone de saisie — toujours visible sous le fil */} |
| 394 |
|
− <form |
| 395 |
|
− 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 |
|
− <input |
| 402 |
|
− 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 |
|
− <button |
| 410 |
|
− 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> |
| 419 |
|
− |
| 420 |
|
− <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 |
|
− {fr |
| 422 |
|
− ? "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 |
|
|