| 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 | |
| |