import { useState, Fragment, memo } from "react"; import { IconLoader3, IconGitFork } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import { Content } from "@/lib/content"; import { Markdown } from "./Markdown"; import { ToolCallCard } from "./ToolRenderers"; import { CopyButton } from "./CopyButton"; import { ThinkingBlock } from "./ThinkingBlock"; import { CompactionCard } from "./CompactionCard"; import { MediaThumbnail } from "./MediaThumbnail"; import { MediaPreviewModal } from "./MediaPreviewModal"; import { InlineError } from "./InlineError"; import { PlanCard } from "./PlanCard"; import { StreamingConfirmDialog } from "./StreamingConfirmDialog"; import { Button } from "@/components/ui/button"; import { toast } from "@/components/ui/sonner"; import { useChatStore } from "@/stores"; import { bridge } from "@/services"; import type { ChatMessage as ChatMessageType, UIStep, UIStepItem } from "@/stores/chat.store"; import type { ContentPart } from "shared/legacy-sdk"; interface ChatMessageProps { message: ChatMessageType; /** 0-indexed turn number for this message */ turnIndex?: number; isStreaming?: boolean; } function ThinkingIndicator() { return (
Processing…
); } function SteerBubble({ content }: { content: string | ContentPart[] }) { const text = typeof content === "string" ? content : Content.getText(content); return (

{text}

); } function StepItemRenderer({ item }: { item: UIStepItem }) { switch (item.type) { case "thinking": return ; case "text": return ; case "tool_use": return ; case "compaction": return ; case "steer": return ; default: return null; } } function StepContent({ step, showConnector }: { step: UIStep; showConnector?: boolean }) { const hasItems = step.items.length > 0; const hasToolOrThinking = step.items.some((item) => item.type === "tool_use" || item.type === "thinking" || item.type === "compaction"); const showIndicator = hasToolOrThinking; const hasActiveItem = step.items.some((item) => (item.type === "text" || item.type === "thinking") && !item.finished); if (!hasItems) { return null; } return (
{showIndicator ? (
{showConnector && (
)}
) : (
)}
{step.items.map((item, idx) => ( ))}
); } function MessageMedia({ images, videos, onPreview }: { images: string[]; videos: string[]; onPreview: (src: string) => void }) { if (images.length === 0 && videos.length === 0) { return null; } return (
{images.map((src, idx) => ( onPreview(src)} /> ))} {videos.map((src, idx) => ( onPreview(src)} /> ))}
); } interface StepGroup { planMode: boolean; steps: UIStep[]; startIndex: number; } function groupStepsByPlanMode(steps: UIStep[]): StepGroup[] { const groups: StepGroup[] = []; for (let i = 0; i < steps.length; i++) { const isPlan = steps[i].planMode === true; const last = groups.at(-1); if (last && last.planMode === isPlan) { last.steps.push(steps[i]); } else { groups.push({ planMode: isPlan, steps: [steps[i]], startIndex: i }); } } return groups; } interface ForkButtonProps { turnIndex: number; className?: string; } function ForkButton({ turnIndex, className }: ForkButtonProps) { const [showConfirm, setShowConfirm] = useState(false); const [isForking, setIsForking] = useState(false); const sessionId = useChatStore((s) => s.sessionId); const isStreaming = useChatStore((s) => s.isStreaming); const loadSession = useChatStore((s) => s.loadSession); const handleFork = () => { if (!sessionId || turnIndex < 0) return; setShowConfirm(true); }; const doFork = async () => { if (!sessionId) return; setIsForking(true); try { const result = await bridge.forkSession(sessionId, turnIndex); if (result) { // Load the forked session const events = await bridge.loadSessionHistory(result.sessionId); await loadSession(result.sessionId, events); } } catch (error) { toast.error(`Failed to fork conversation: ${error instanceof Error ? error.message : String(error)}`); } finally { setIsForking(false); setShowConfirm(false); } }; return ( <> { void doFork(); }} confirmLoading={isForking} confirmDisabled={isForking} cancelDisabled={isForking} /> ); } function UserMessage({ message }: { message: ChatMessageType }) { const [previewMedia, setPreviewMedia] = useState(null); const displayContent = Content.getText(message.content); const images = Content.getImages(message.content); const videos = Content.getVideos(message.content); return (
{displayContent && ( // FIX: removed whitespace-pre-wrap — it conflicted with ReactMarkdown's // block-level elements (

,

    ,
  1. ), doubling vertical spacing. // ReactMarkdown already handles paragraph breaks from \n\n.
    )}
setPreviewMedia(null)} />
); } function AssistantMessage({ message, turnIndex, isStreaming }: { message: ChatMessageType; turnIndex?: number; isStreaming?: boolean }) { const [previewMedia, setPreviewMedia] = useState(null); const isCompacting = useChatStore((s) => s.isCompacting); const steps = message.steps || []; const hasSteps = steps.length > 0; const images = Content.getImages(message.content); const videos = Content.getVideos(message.content); const stepHasIndicator = steps.map((step) => step.items.some((item) => item.type === "tool_use" || item.type === "thinking" || item.type === "compaction")); const contentToCopy = (() => { if (!hasSteps) { return typeof message.content === "string" ? message.content : ""; } const lastStep = steps[steps.length - 1]; const textItems = lastStep.items.filter((item) => item.type === "text"); if (textItems.length > 0) { return textItems.map((item) => (item as { type: "text"; content: string }).content).join("\n"); } return typeof message.content === "string" ? message.content : ""; })(); if (!isStreaming && !hasMessageContent(message) && !message.inlineError) { return null; } const displayContent = typeof message.content === "string" ? message.content : ""; const isShowingInlineError = message.inlineError && !isStreaming; return (
K
Kimi
{hasSteps && groupStepsByPlanMode(steps).map((group, gi) => { const totalSteps = steps.length; const stepsContent = group.steps.map((step, i) => { const globalIndex = group.startIndex + i; const isLastInGroup = i === group.steps.length - 1; const isLastOverall = globalIndex === totalSteps - 1; const hasIndicator = stepHasIndicator[globalIndex]; const hasNextIndicator = stepHasIndicator.slice(globalIndex + 1).some(Boolean); const showConnector = hasIndicator && hasNextIndicator && !isLastInGroup && !isLastOverall; return ; }); if (group.planMode) { return {stepsContent}; } return {stepsContent}; })} {!hasSteps && displayContent && } {(images.length > 0 || videos.length > 0) && (
)}
{/* 内嵌错误显示 */} {isShowingInlineError && message.inlineError && (
)}
{isStreaming && !isShowingInlineError && !isCompacting && }
{!isStreaming && contentToCopy.trim().length > 0 && (
{message.forkable !== false && turnIndex !== undefined && turnIndex >= 0 && }
)}
setPreviewMedia(null)} />
); } function hasMessageContent(message: ChatMessageType): boolean { if (!Content.isEmpty(message.content)) { return true; } return message.steps?.some((s) => s.items.length > 0) ?? false; } export const ChatMessage = memo(function ChatMessage({ message, turnIndex, isStreaming }: ChatMessageProps) { if (message.role === "user") { return ; } return ; });