gitdataai/src/app/chat/ChatMessageBubble.tsx

619 lines
20 KiB
TypeScript

import {
Copy,
Check,
Sparkles,
ClipboardList,
Pencil,
RefreshCw,
GitFork,
FolderGit2,
} from "lucide-react"
import { memo, useState, useMemo } from "react"
import { useNavigate } from "react-router-dom"
import { useCurrentUserQuery } from "@/hooks/useAuth"
import {
useChatStreamRunner,
useEditMessageMutation,
useForkMessageMutation,
useMessageVersionsQuery,
useResendMessageMutation,
useSwitchMessageVersionMutation,
} from "@/hooks/useAiChatQuery"
import { IrRenderer } from "@/lib/ir/renderer"
import {
parseContentBlocks,
extractAnswerText,
extractFullText,
} from "@/lib/ir/parser"
import type {
IrContentBlock,
IrToolCallNode,
IrToolResultNode,
} from "@/lib/ir/types"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import {
Reasoning,
ReasoningTrigger,
ReasoningContent,
} from "@/components/ai-elements/reasoning"
import type { MessageResponse } from "@/hooks/useAiChatQuery"
import { getModelIcon } from "@/lib/icons/modelIcons"
import { ToolCallBlock } from "@/components/chat/ToolCallBlock"
import { Badge } from "@/components/ui/badge"
import { useChatPage } from "./ChatPageContext"
import { parseSlashContextMetadata } from "./chatSlashContext"
interface ChatMessageBubbleProps {
message: MessageResponse
conversationId: string
onRegenerate?: (newMessageId: string) => void
setIsStreaming: (value: boolean) => void
}
const AVATAR_COLORS = [
"#6366f1",
"#8b5cf6",
"#d946ef",
"#ec4899",
"#f43f5e",
"#ef4444",
"#f97316",
"#eab308",
"#22c55e",
"#14b8a6",
"#06b6d4",
"#3b82f6",
"#2563eb",
"#7c3aed",
"#c026d3",
]
function hashColor(str: string): string {
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash)
}
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length]
}
const PROSE_CLASS =
"prose prose-sm dark:prose-invert max-w-none [&_p]:leading-[1.55] [&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1 [&_li]:my-0 [&_h1]:mt-2 [&_h2]:mt-2 [&_h3]:mt-2 [&_pre]:my-1.5 [&_blockquote]:my-1"
export const ChatMessageBubble = memo(function ChatMessageBubble({
message,
conversationId,
onRegenerate,
setIsStreaming,
}: ChatMessageBubbleProps) {
const isUser = message.role === "user"
const [copied, setCopied] = useState<"answer" | "full" | false>(false)
const [isEditing, setIsEditing] = useState(false)
const [editText, setEditText] = useState("")
const [showVersions, setShowVersions] = useState(false)
const { data: user } = useCurrentUserQuery()
const editMutation = useEditMessageMutation()
const resendMutation = useResendMessageMutation()
const forkMutation = useForkMessageMutation()
const switchVersionMutation = useSwitchMessageVersionMutation()
const runStream = useChatStreamRunner(setIsStreaming)
const navigate = useNavigate()
const { scope, projectName } = useChatPage()
// Parse content into IrContentBlock[] (handles both old and future formats)
const blocks: IrContentBlock[] = useMemo(
() => (isUser ? [] : parseContentBlocks(message.content)),
[isUser, message.content]
)
// User message plain text
const userText =
typeof message.content === "string"
? message.content
: message.content && typeof message.content === "object"
? String((message.content as Record<string, unknown>).content ?? "")
: ""
const plainText = isUser ? userText : extractAnswerText(blocks)
const hasThinking = blocks.some((b) => b.role === "thinking")
const selectedContexts = useMemo(
() => parseSlashContextMetadata(message.metadata),
[message.metadata]
)
// Fetch versions when showing version switcher
const versionsQuery = useMessageVersionsQuery(conversationId, message.id)
const handleCopyAnswer = () => {
const text = isUser ? plainText : extractAnswerText(blocks)
if (text) {
navigator.clipboard.writeText(text)
setCopied("answer")
setTimeout(() => setCopied(false), 2000)
}
}
const handleCopyFull = () => {
const text = isUser ? plainText : extractFullText(blocks)
if (text) {
navigator.clipboard.writeText(text)
setCopied("full")
setTimeout(() => setCopied(false), 2000)
}
}
const handleStartEdit = () => {
setEditText(plainText)
setIsEditing(true)
}
const handleCancelEdit = () => {
setIsEditing(false)
setEditText("")
}
const handleSaveEdit = async () => {
if (!editText.trim()) return
try {
const newMsg = await editMutation.mutateAsync({
conversationId,
messageId: message.id,
content: editText.trim(),
})
setIsEditing(false)
onRegenerate?.(newMsg.id)
await runStream(conversationId, newMsg.id)
} catch (err) {
console.error("Failed to edit message:", err)
}
}
const handleRegenerate = async () => {
try {
const newMsg = await resendMutation.mutateAsync({
conversationId,
messageId: message.id,
})
onRegenerate?.(newMsg.id)
await runStream(conversationId, newMsg.id)
} catch (err) {
console.error("Failed to regenerate:", err)
}
}
const handleSwitchVersion = async (versionNumber: number) => {
try {
await switchVersionMutation.mutateAsync({
conversationId,
messageId: message.id,
versionNumber,
})
setShowVersions(false)
} catch (err) {
console.error("Failed to switch version:", err)
}
}
const handleFork = async () => {
try {
const fork = await forkMutation.mutateAsync({
conversationId,
messageId: message.id,
})
if (scope === "project" && projectName) {
navigate(`/${projectName}/chat/${fork.id}`)
} else {
navigate(`/me/chat/${fork.id}`)
}
} catch (err) {
console.error("Failed to fork:", err)
}
}
return (
<div className="group mx-auto flex w-full max-w-3xl gap-4 rounded-2xl border border-transparent px-4 py-4">
{/* Avatar */}
<div className="shrink-0 pt-0.5">
{isUser ? (
<Avatar className="h-7 w-7 rounded-full">
<AvatarImage src={user?.avatar_url ?? undefined} />
<AvatarFallback
className="rounded-full text-[10px] font-semibold"
style={{
backgroundColor: hashColor(user?.username || "user"),
color: "var(--text-inverse)",
}}
>
{(user?.display_name || user?.username || "U")[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
) : (
<ModelAvatar modelName={message.model} size={28} />
)}
</div>
{/* Content */}
<div className="min-w-0 flex-1">
{/* Sender name */}
<div className="mb-2 flex flex-wrap items-center gap-2">
<span
className="text-xs font-semibold"
style={{ color: "var(--text-primary)" }}
>
{isUser
? user?.display_name || user?.username || "You"
: message.model || "Assistant"}
</span>
<span className="text-[11px]" style={{ color: "var(--text-muted)" }}>
{new Date(message.created_at).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</span>
{!isUser && message.version_number > 1 && (
<Badge
variant="outline"
className="rounded-full px-2 py-0.5 text-[10px] tracking-[0.16em] uppercase"
>
v{message.version_number}
</Badge>
)}
</div>
{/* Interleaved blocks — thinking (collapsible) + answer (IrRenderer) */}
<div className="text-sm" style={{ color: "var(--text-primary)" }}>
{isUser && selectedContexts.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{selectedContexts.map((context) => (
<Badge
key={`${context.kind}:${context.id}`}
variant="outline"
className="h-auto gap-1 rounded-full px-2.5 py-1"
>
{context.kind === "repo" ? (
<FolderGit2 className="h-3 w-3" />
) : (
<Sparkles className="h-3 w-3" />
)}
<span>{context.label}</span>
<span className="text-[10px] uppercase opacity-70">
{context.kind}
</span>
</Badge>
))}
</div>
)}
{isUser && isEditing ? (
<div className="flex flex-col gap-2 rounded-2xl border border-[var(--border-subtle)] bg-[var(--surface-ground)] p-3">
<textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
className="w-full resize-none rounded-xl p-3 text-[14px] outline-none"
style={{
backgroundColor: "var(--surface-elevated)",
border: "1px solid var(--accent)",
color: "var(--text-primary)",
lineHeight: "1.6",
minHeight: "60px",
}}
autoFocus
rows={3}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSaveEdit()
}
if (e.key === "Escape") {
handleCancelEdit()
}
}}
/>
<div className="flex items-center justify-end gap-2">
<button
onClick={handleCancelEdit}
className="rounded-full px-3 py-1.5 text-[12px] transition-colors"
style={{
color: "var(--text-muted)",
border: "1px solid var(--border-default)",
}}
>
Cancel
</button>
<button
onClick={handleSaveEdit}
disabled={!editText.trim() || editMutation.isPending}
className="rounded-full px-3 py-1.5 text-[12px] font-medium transition-colors"
style={{
backgroundColor: editText.trim()
? "var(--accent)"
: "var(--muted)",
color: editText.trim()
? "var(--text-inverse)"
: "var(--text-muted)",
}}
>
{editMutation.isPending ? "Saving..." : "Save & Submit"}
</button>
</div>
</div>
) : isUser ? (
<p className="whitespace-pre-wrap">{plainText}</p>
) : (
blocks.map((b, i) => {
if (b.role === "thinking") {
// Thinking content rendered by Reasoning/Streamdown (not IrRenderer)
const thinkingText = b.nodes
.filter((n) => n.type === "text")
.map((n) => (n as { content: string }).content)
.join("")
return (
<Reasoning key={i} defaultOpen={false}>
<ReasoningTrigger />
<ReasoningContent>{thinkingText}</ReasoningContent>
</Reasoning>
)
}
if (b.role === "tool_call") {
// Tool call visualization
const toolCallNode = b.nodes.find(
(n) => n.type === "tool_call"
) as IrToolCallNode | undefined
if (toolCallNode && toolCallNode.tool === "call_sub_agent") {
return (
<ToolCallBlock
key={i}
toolName={toolCallNode.tool}
args={toolCallNode.args}
status="ok"
conversationId={conversationId}
/>
)
}
return null
}
if (b.role === "tool_result") {
const toolResultNode = b.nodes.find(
(n) => n.type === "tool_result"
) as IrToolResultNode | undefined
if (
toolResultNode &&
toolResultNode.tool === "call_sub_agent"
) {
return (
<ToolCallBlock
key={i}
toolName={toolResultNode.tool}
args={{
role: toolResultNode.role,
task: toolResultNode.task,
}}
status={toolResultNode.status}
result={toolResultNode.content}
childrenId={toolResultNode.children_id}
subAgentOutput={toolResultNode.content}
conversationId={conversationId}
/>
)
}
return null
}
return (
<div key={i} className={i > 0 ? "mt-3" : ""}>
<IrRenderer nodes={b.nodes} className={PROSE_CLASS} />
</div>
)
})
)}
</div>
{/* Version indicator */}
{message.version_number > 1 && (
<button
onClick={() => setShowVersions(!showVersions)}
className="mt-2 inline-flex items-center gap-2 rounded-full border border-[var(--border-subtle)] bg-[var(--surface-secondary)] px-2.5 py-1 text-[11px] text-muted-foreground"
>
<span className="font-medium text-[var(--text-primary)]">
v{message.version_number}
</span>
<span>{showVersions ? "Hide versions" : "View all versions"}</span>
</button>
)}
{/* Version switcher */}
{showVersions &&
versionsQuery.data &&
versionsQuery.data.length > 1 && (
<div className="mt-2 rounded-2xl border border-[var(--border-subtle)] bg-[var(--surface-elevated)] p-2">
{versionsQuery.data.map((v) => (
<button
key={v.id}
onClick={() => handleSwitchVersion(v.version_number)}
className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left"
style={{
color:
v.version_number === message.version_number
? "var(--accent)"
: "var(--text-muted)",
fontSize: "12px",
}}
>
<Badge
variant={
v.version_number === message.version_number
? "secondary"
: "outline"
}
className="rounded-full px-2 py-0.5 text-[10px]"
>
v{v.version_number}
</Badge>
<span className="flex-1 truncate">
{typeof v.content === "string"
? v.content.slice(0, 50)
: String(v.content).slice(0, 50)}
</span>
<span className="text-[11px] opacity-60">
{new Date(v.created_at).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</button>
))}
</div>
)}
{/* User message actions — edit & regenerate */}
{isUser && !isEditing && (
<div className="mt-2 flex flex-wrap items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={handleStartEdit}
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px]"
style={{ color: "var(--text-muted)" }}
>
<Pencil className="size-3" />
Edit
</button>
{onRegenerate && (
<button
onClick={handleRegenerate}
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px]"
style={{ color: "var(--text-muted)" }}
>
<RefreshCw className="size-3" />
Regenerate
</button>
)}
</div>
)}
{/* AI message actions — copy & fork */}
{!isUser && (
<div className="mt-2 flex flex-wrap items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={handleCopyAnswer}
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px]"
style={{ color: "var(--text-muted)" }}
>
{copied === "answer" ? (
<Check className="size-3" />
) : (
<Copy className="size-3" />
)}
{copied === "answer" ? "Copied" : "Copy"}
</button>
{hasThinking && (
<button
onClick={handleCopyFull}
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px]"
style={{ color: "var(--text-muted)" }}
>
{copied === "full" ? (
<Check className="size-3" />
) : (
<ClipboardList className="size-3" />
)}
{copied === "full" ? "Copied" : "Copy with reasoning"}
</button>
)}
<button
onClick={handleFork}
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px]"
style={{ color: "var(--text-muted)" }}
>
<GitFork className="size-3" />
Fork
</button>
</div>
)}
{/* Token usage */}
{!isUser &&
(message.input_tokens != null || message.output_tokens != null) && (
<div className="mt-2 flex flex-wrap gap-1.5">
{message.input_tokens != null && (
<Badge
variant="outline"
className="rounded-full px-2 py-0.5 text-[10px]"
>
{message.input_tokens} in
</Badge>
)}
{message.output_tokens != null && (
<Badge
variant="outline"
className="rounded-full px-2 py-0.5 text-[10px]"
>
{message.output_tokens} out
</Badge>
)}
{(message.input_tokens ?? 0) + (message.output_tokens ?? 0) >
0 && (
<Badge
variant="secondary"
className="rounded-full px-2 py-0.5 text-[10px]"
>
{(message.input_tokens ?? 0) + (message.output_tokens ?? 0)}{" "}
total
</Badge>
)}
</div>
)}
</div>
</div>
)
})
// ─── Model Avatar ─────────────────────────────────────────────────────
function ModelAvatar({
modelName,
size = 28,
}: {
modelName?: string | null
size?: number
}) {
if (!modelName) {
return (
<div
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
style={{
width: size,
height: size,
backgroundColor: "var(--accent-muted)",
}}
>
<Sparkles
className="h-3.5 w-3.5"
style={{
color: "var(--accent)",
width: size * 0.5,
height: size * 0.5,
}}
/>
</div>
)
}
const Icon = getModelIcon(modelName)
if (Icon) {
return (
<div className="shrink-0" style={{ width: size, height: size }}>
<Icon.Avatar size={size} />
</div>
)
}
return (
<div
className="flex shrink-0 items-center justify-center rounded-full font-bold"
style={{
width: size,
height: size,
backgroundColor: hashColor(modelName),
fontSize: Math.max(10, size * 0.35),
color: "var(--text-inverse)",
}}
>
{modelName[0]?.toUpperCase() || "?"}
</div>
)
}