Spaces:
Running
Running
| import React, { useState } from "react"; | |
| import { Copy, Check } from "lucide-react"; | |
| import { toast } from "sonner"; | |
| import EmoEyes from "./EmoEyes"; | |
| import ToolCallCard from "./ToolCallCard"; | |
| import LiveHtmlPreview from "./LiveHtmlPreview"; | |
| import { cleanDisplayText } from "../lib/messageClean"; | |
| import { buildImagePreviewSrc, buildImagePreviewPair } from "../lib/resolveToolPreview"; | |
| const MOOD_LABELS = { | |
| neutre: "Neutre", | |
| amusee: "Amusée", | |
| concentree: "Concentrée", | |
| sarcastique: "Sarcastique", | |
| ironique: "Ironique", | |
| enthousiaste: "Enthousiaste", | |
| agacee: "Agacée", | |
| curieuse: "Curieuse", | |
| pensive: "Pensive", | |
| }; | |
| const RichContent = ({ text, images, showCopyCode = false }) => { | |
| if (images?.length) { | |
| return ( | |
| <div className="space-y-3"> | |
| <div className="flex flex-wrap gap-2"> | |
| {images.map((img, i) => ( | |
| <img | |
| key={i} | |
| src={img.startsWith("data:") ? img : `data:image/jpeg;base64,${img}`} | |
| alt="" | |
| className="max-h-52 rounded-xl em-border object-contain" | |
| style={{ boxShadow: "var(--emo-shadow-sm)" }} | |
| /> | |
| ))} | |
| </div> | |
| {text ? <RichText text={text} showCopyCode={showCopyCode} /> : null} | |
| </div> | |
| ); | |
| } | |
| return <RichText text={text} showCopyCode={showCopyCode} />; | |
| }; | |
| const CodeBlock = ({ inner, lang, showCopyCode }) => { | |
| const [copied, setCopied] = useState(false); | |
| const isHtml = ["html", "htm"].includes((lang || "").toLowerCase()); | |
| const handleCopy = async () => { | |
| try { | |
| await navigator.clipboard.writeText(inner); | |
| setCopied(true); | |
| toast.success("Code copié"); | |
| setTimeout(() => setCopied(false), 2000); | |
| } catch { | |
| toast.error("Impossible de copier"); | |
| } | |
| }; | |
| return ( | |
| <div className="space-y-2"> | |
| <div className="relative"> | |
| {showCopyCode && ( | |
| <button | |
| type="button" | |
| data-testid="markdown-copy-code-btn" | |
| onClick={handleCopy} | |
| className="absolute top-2.5 right-2.5 flex items-center gap-1 px-2 py-1 rounded-lg text-[10px] em-hover-subtle z-10" | |
| style={{ color: copied ? "var(--emo-success-text)" : "var(--emo-text-muted)" }} | |
| title="Copier tout le code" | |
| > | |
| {copied ? <Check size={11} /> : <Copy size={11} />} | |
| <span className="hidden sm:inline">{copied ? "Copié" : "Copier"}</span> | |
| </button> | |
| )} | |
| <pre | |
| className="font-code text-xs overflow-x-auto rounded-xl p-4 my-2" | |
| style={{ | |
| background: "var(--emo-code-bg)", | |
| border: "1px solid var(--emo-border)", | |
| boxShadow: "var(--emo-code-inset)", | |
| }} | |
| > | |
| {lang && ( | |
| <div className="text-[10px] uppercase tracking-[0.15em] text-muted-em mb-2">{lang}</div> | |
| )} | |
| <code style={{ color: "var(--emo-code-text)" }}>{inner}</code> | |
| </pre> | |
| </div> | |
| {showCopyCode && isHtml && inner.trim() && ( | |
| <LiveHtmlPreview path="page.html" title="Aperçu HTML" content={inner} compact={false} /> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| const RichText = ({ text, showCopyCode = false }) => { | |
| if (!text) return null; | |
| const parts = text.split(/(```[\s\S]*?```|!\[[^\]]*\]\([^)]+\))/g); | |
| return ( | |
| <div className="space-y-3"> | |
| {parts.map((part, i) => { | |
| if (part.startsWith("```")) { | |
| const inner = part.replace(/^```(\w*)\n?/, "").replace(/```$/, ""); | |
| const lang = part.match(/^```(\w+)/)?.[1] || ""; | |
| return ( | |
| <CodeBlock key={i} inner={inner} lang={lang} showCopyCode={showCopyCode} /> | |
| ); | |
| } | |
| const imgMatch = part.match(/^!\[([^\]]*)\]\(([^)]+)\)$/); | |
| if (imgMatch) { | |
| const [, alt, src] = imgMatch; | |
| const resolved = src.startsWith("data:") || src.startsWith("http") | |
| ? src | |
| : `data:image/jpeg;base64,${src}`; | |
| return ( | |
| <img | |
| key={i} | |
| src={resolved} | |
| alt={alt || ""} | |
| className="max-w-full rounded-xl em-border my-2" | |
| style={{ maxHeight: 420, objectFit: "contain", boxShadow: "var(--emo-shadow-sm)" }} | |
| /> | |
| ); | |
| } | |
| const segments = part.split(/(`[^`]+`)/g); | |
| return ( | |
| <p key={i} className="leading-relaxed whitespace-pre-wrap"> | |
| {segments.map((seg, j) => { | |
| if (seg.startsWith("`") && seg.endsWith("`")) { | |
| return ( | |
| <code | |
| key={j} | |
| className="font-code text-[0.88em] px-1.5 py-0.5 rounded-md" | |
| style={{ background: "var(--emo-inline-code-bg)", color: "var(--emo-inline-code-text)" }} | |
| > | |
| {seg.slice(1, -1)} | |
| </code> | |
| ); | |
| } | |
| return <span key={j}>{seg}</span>; | |
| })} | |
| </p> | |
| ); | |
| })} | |
| </div> | |
| ); | |
| }; | |
| function normalizeToolEvent(t, i) { | |
| const args = t.arguments || t.args || {}; | |
| const tool = t.tool || t.name; | |
| if (t.tool && t.state && (t.args || t.arguments) && t.inlinePreview) { | |
| return { ...t, tool, args }; | |
| } | |
| const result = { | |
| ok: true, | |
| path: args.path, | |
| url: args.url, | |
| content: args.content, | |
| title: args.title, | |
| }; | |
| let inlinePreview = t.inlinePreview || null; | |
| if (!inlinePreview && tool === "web_search" && t.result?.results?.length) { | |
| inlinePreview = { | |
| type: "browser", | |
| action: "search", | |
| query: t.result.query || args.query || "", | |
| results: (t.result.results || []).slice(0, 8), | |
| }; | |
| } | |
| if ( | |
| !inlinePreview && | |
| ["browser_visit", "browser_open", "web_fetch", "browser_click", "browser_snapshot", "browser_scroll", "browser_press", "browser_type", "browser_fill"].includes(tool) && | |
| (t.result?.url || args.url || t.result?.screenshot_base64) | |
| ) { | |
| inlinePreview = { | |
| type: "browser", | |
| action: tool === "browser_open" ? "control" : "visit", | |
| url: t.result?.url || args.url, | |
| title: t.result?.title, | |
| preview: t.result?.preview || t.result?.text, | |
| screenshot_base64: t.result?.screenshot_base64, | |
| elements: t.result?.elements || [], | |
| session_id: t.result?.session_id || "default", | |
| }; | |
| } | |
| if (!inlinePreview && tool === "edit_file" && args.path && t.result?.content) { | |
| inlinePreview = { | |
| type: "file", | |
| path: args.path, | |
| preview: String(t.result.content).slice(0, 50000), | |
| language: (args.path.split(".").pop() || "").toLowerCase(), | |
| }; | |
| } | |
| if (!inlinePreview && tool === "write_file" && args.path && args.content) { | |
| inlinePreview = { | |
| type: "file", | |
| path: args.path, | |
| preview: String(args.content).slice(0, 50000), | |
| language: (args.path.split(".").pop() || "").toLowerCase(), | |
| }; | |
| } | |
| if (!inlinePreview && tool === "generate_image") { | |
| const res = t.result; | |
| if (res?.ok !== false && (res?.image_url || res?.image_base64 || res?.has_image)) { | |
| inlinePreview = { | |
| type: "image", | |
| image_url: res.image_url, | |
| image_base64: res.image_base64, | |
| mime: res.mime || "image/png", | |
| title: args.prompt || res.prompt || res.subject || "Image générée", | |
| has_image: res.has_image, | |
| }; | |
| } | |
| } | |
| return { | |
| id: t.id || `hist-${i}`, | |
| tool, | |
| args, | |
| state: t.state || "done", | |
| result: t.result || result, | |
| inlinePreview, | |
| }; | |
| } | |
| export const ChatMessage = ({ message, isStreaming, liveHtmlByPath = {}, showCopyCode = false }) => { | |
| const isUser = message.role === "user"; | |
| if (isUser) { | |
| return ( | |
| <div data-testid="chat-message-user" className="flex justify-end emo-msg-animate"> | |
| <div className="emo-bubble-user"> | |
| <RichContent text={cleanDisplayText(message.content)} images={message.images} showCopyCode={showCopyCode} /> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| const mood = message.mood; | |
| return ( | |
| <div | |
| data-testid="chat-message-emo" | |
| className={`flex gap-3 max-w-full emo-msg-animate mode-${message.mode || "tech"}`} | |
| > | |
| <div className="flex-shrink-0 mt-0.5"> | |
| <EmoEyes mode={message.mode || "tech"} mood={mood} thinking={isStreaming} size={44} /> | |
| </div> | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center gap-2 mb-2 flex-wrap"> | |
| <span className="font-heading text-sm font-semibold" style={{ color: "var(--mode-color)" }}> | |
| Émo | |
| </span> | |
| {mood && mood !== "neutre" && ( | |
| <span | |
| data-testid="mood-badge" | |
| className="text-[10px] px-2 py-0.5 rounded-full" | |
| style={{ background: "var(--emo-badge-bg)", color: "var(--emo-text-muted)" }} | |
| > | |
| {MOOD_LABELS[mood] || mood} | |
| </span> | |
| )} | |
| {message.verified === "true" && ( | |
| <span | |
| data-testid="verified-badge" | |
| className="text-[10px] uppercase tracking-wide px-2 py-0.5 rounded-full emo-alert-success" | |
| title="Vérifié" | |
| > | |
| Vérifié | |
| </span> | |
| )} | |
| {message.verified === "partial" && ( | |
| <span | |
| data-testid="partial-badge" | |
| className="text-[10px] uppercase tracking-wide px-2 py-0.5 rounded-full emo-alert-warning" | |
| title="Partiel" | |
| > | |
| Partiel | |
| </span> | |
| )} | |
| {isStreaming && ( | |
| <span className="dot-loading"> | |
| <span></span><span></span><span></span> | |
| </span> | |
| )} | |
| </div> | |
| {((message.tool_calls_live && message.tool_calls_live.length > 0) || | |
| (message.tool_calls && message.tool_calls.length > 0)) && ( | |
| <div className="mb-3 space-y-2"> | |
| {(message.tool_calls_live || message.tool_calls).map((t, i) => ( | |
| <ToolCallCard | |
| key={t.id || i} | |
| event={normalizeToolEvent(t, i)} | |
| liveHtmlByPath={liveHtmlByPath} | |
| showCopyCode={showCopyCode} | |
| /> | |
| ))} | |
| </div> | |
| )} | |
| {(message.content || message.images?.length) && ( | |
| <div className="emo-bubble-assistant" style={{ color: "var(--emo-text)" }}> | |
| <RichContent text={cleanDisplayText(message.content)} images={message.images} showCopyCode={showCopyCode} /> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default ChatMessage; | |