134 lines
4.5 KiB
TypeScript
134 lines
4.5 KiB
TypeScript
import { memo } from "react";
|
|
import { User, Sparkles, AlertTriangle } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
Reasoning,
|
|
ReasoningTrigger,
|
|
ReasoningContent,
|
|
} from "@/components/ai-elements/reasoning";
|
|
import { MarkdownRenderer } from "@/components/MarkdownRenderer";
|
|
import { MentionRenderer } from "@/lib/ir/mention-renderer";
|
|
import { getProviderIcon } from "./provider-icon";
|
|
import { isVisibleToolCall } from "./tool-utils";
|
|
import { ToolCallList } from "./tool-call-list";
|
|
import { useCodePreview } from "./code-preview-context";
|
|
import type { Message } from "./types";
|
|
|
|
interface MessageBubbleProps {
|
|
msg: Message;
|
|
modelProvider?: string;
|
|
}
|
|
|
|
export const MessageBubble = memo(function MessageBubble({
|
|
msg,
|
|
modelProvider,
|
|
}: MessageBubbleProps) {
|
|
const isUser = msg.role === "user";
|
|
const { openCodePreview } = useCodePreview();
|
|
const hasToolCalls =
|
|
!isUser &&
|
|
msg.tool_calls &&
|
|
msg.tool_calls.some((tc) => isVisibleToolCall(tc.name));
|
|
const isError = !isUser && /^Error:|I encountered an error/i.test(msg.content);
|
|
|
|
return (
|
|
<div
|
|
className={cn("flex gap-3", isUser ? "justify-end" : "justify-start")}
|
|
role="article"
|
|
aria-label={isUser ? "Your message" : "Assistant message"}
|
|
>
|
|
{!isUser && <AIAvatar providerName={modelProvider} />}
|
|
|
|
<div className="min-w-0 max-w-[85%] space-y-2">
|
|
{/* Reasoning content */}
|
|
{msg.reasoning_content && (
|
|
<Reasoning defaultOpen={false}>
|
|
<ReasoningTrigger />
|
|
<ReasoningContent>{msg.reasoning_content}</ReasoningContent>
|
|
</Reasoning>
|
|
)}
|
|
|
|
{/* Tool calls */}
|
|
{hasToolCalls && <ToolCallList toolCalls={msg.tool_calls!} />}
|
|
|
|
{/* Message content */}
|
|
<div
|
|
className={cn(
|
|
"relative overflow-hidden text-sm leading-relaxed shadow-sm",
|
|
isUser
|
|
? "rounded-2xl rounded-tr-sm bg-primary text-primary-foreground px-4 py-2.5"
|
|
: isError
|
|
? "rounded-2xl rounded-tl-sm bg-destructive/[0.06] text-foreground border border-destructive/20 px-4 py-2.5"
|
|
: "rounded-2xl rounded-tl-sm bg-muted/70 text-foreground border border-border/40 px-4 py-2.5",
|
|
)}
|
|
>
|
|
{isUser ? (
|
|
<div className="whitespace-pre-wrap break-words">
|
|
<MentionRenderer content={msg.content} />
|
|
</div>
|
|
) : isError ? (
|
|
<div className="flex items-start gap-2.5">
|
|
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-destructive/60" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-[13px] font-medium text-destructive/80">Something went wrong</p>
|
|
<div className="mt-1 text-destructive/60">
|
|
<MarkdownRenderer
|
|
content={msg.content.replace(/^(Error:\s*|I encountered an error while processing your request:\s*)/i, "")}
|
|
onOpenCodePanel={(payload) =>
|
|
openCodePreview({ ...payload, id: crypto.randomUUID() })
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<MarkdownRenderer
|
|
content={msg.content}
|
|
onOpenCodePanel={(payload) =>
|
|
openCodePreview({ ...payload, id: crypto.randomUUID() })
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{isUser && <UserAvatar />}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
function AIAvatar({ providerName }: { providerName?: string }) {
|
|
const Icon = providerName ? getProviderIcon(providerName) : null;
|
|
|
|
if (Icon) {
|
|
return (
|
|
<span
|
|
className="mt-0.5 grid size-8 shrink-0 place-items-center rounded-full bg-gradient-to-br from-primary/15 to-primary/5 ring-1 ring-primary/10 shadow-sm"
|
|
aria-hidden="true"
|
|
>
|
|
<Icon className="size-4 text-primary/70" />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<span
|
|
className="mt-0.5 grid size-8 shrink-0 place-items-center rounded-full bg-gradient-to-br from-primary/20 to-primary/5 ring-1 ring-primary/10 shadow-sm"
|
|
aria-hidden="true"
|
|
>
|
|
<Sparkles className="size-3.5 text-primary/60" />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function UserAvatar() {
|
|
return (
|
|
<span
|
|
className="mt-0.5 grid size-8 shrink-0 place-items-center rounded-full bg-gradient-to-br from-muted to-muted/50 ring-1 ring-border/40 shadow-sm"
|
|
aria-hidden="true"
|
|
>
|
|
<User className="size-3.5 text-muted-foreground/70" />
|
|
</span>
|
|
);
|
|
}
|