emo-online-api / emo /frontend /src /components /ChatMessage.jsx
Emo Online
Deploy Emo API
dd87944
Raw
History Blame Contribute Delete
10.6 kB
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;