import type { ThreadEntry, ToolCallEntry, PlanDisplayEntry } from '../../src/lib/types'; import { cn } from '../../src/lib/utils'; import { UserBubble, AssistantBubble } from './MessageBubble'; import { ToolCallGroup } from './ToolCallGroup'; import { PlanDisplay } from './PlanView'; import { Conversation, ConversationContent, ConversationEmptyState, ConversationScrollButtons, } from '../ai-elements/conversation'; // ============================================================================= // 统一聊天视图 — Anthropic 编辑式排版 // 无气泡间距,用垂直 rhythm 区分消息块 // ============================================================================= interface ChatViewProps { entries: ThreadEntry[]; isLoading?: boolean; onPermissionRespond?: (requestId: string, optionId: string | null, optionKind: string | null) => void; emptyTitle?: string; emptyDescription?: string; } export function ChatView({ entries, isLoading = false, onPermissionRespond, emptyTitle = '开始对话', emptyDescription = '输入消息开始聊天', }: ChatViewProps) { // 将相邻的 ToolCallEntry 合并为一组 const grouped = groupToolCalls(entries); const hasMessages = entries.length > 0; // 检查是否正在加载(最后一个条目是用户消息) const showThinking = isLoading && entries.length > 0 && entries[entries.length - 1]?.type === 'user_message'; return ( {!hasMessages ? ( ) : ( <> {grouped.map((item, i) => { if (item.type === 'single') { return (
); } // 工具调用组 — 紧贴在助手消息下方 return (
); })} {/* 思考指示器 — Anthropic 打字动画 */} {showThinking && (
)} )} e.type === 'user_message')} />
); } // ============================================================================= // 间距逻辑 — 用户消息前后间距大,工具调用紧贴 // ============================================================================= function entrySpacing(entries: ThreadEntry[], index: number): string { const entry = entries[index]; // 用户消息前后大留白 — Claude.ai 式宽松间距 if (entry?.type === 'user_message') { return 'pt-10 pb-3'; } // 助手消息 — 工具调用紧贴,否则多留白 if (entry?.type === 'assistant_message') { const next = entries[index + 1]; if (next?.type === 'tool_call') { return 'pt-3 pb-1'; } return 'pt-3 pb-8'; } // Plan 条目 if (entry?.type === 'plan') { return 'pt-3 pb-3'; } return 'py-2'; } // ============================================================================= // 单条目渲染器 // ============================================================================= function EntryRenderer({ entry, isLoading, onPermissionRespond, }: { entry: ThreadEntry; isLoading: boolean; onPermissionRespond?: (requestId: string, optionId: string | null, optionKind: string | null) => void; }) { switch (entry.type) { case 'user_message': return ; case 'assistant_message': return ; case 'tool_call': return ; case 'plan': return ; default: return null; } } // ============================================================================= // 工具调用分组逻辑 // ============================================================================= type GroupedItem = { type: 'single'; entry: ThreadEntry } | { type: 'tool_group'; entries: ToolCallEntry[] }; function groupToolCalls(entries: ThreadEntry[]): GroupedItem[] { const result: GroupedItem[] = []; let currentToolGroup: ToolCallEntry[] = []; const flushToolGroup = () => { if (currentToolGroup.length === 1) { result.push({ type: 'single', entry: currentToolGroup[0] }); } else if (currentToolGroup.length > 1) { result.push({ type: 'tool_group', entries: currentToolGroup }); } currentToolGroup = []; }; for (const entry of entries) { if (entry.type === 'tool_call') { currentToolGroup.push(entry); } else { flushToolGroup(); result.push({ type: 'single', entry }); } } flushToolGroup(); return result; }