gitdataai/src/page/workspace/workplan/chat/message-bubble.tsx

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>
);
}