claude-code-cchui/src/components/features/MessageBubble.tsx
gaoziman ac5f555163 refactor(UI): 整合认证功能并优化界面
- 布局集成 AuthProvider 和 Toaster 组件
- 更新应用标题为 LionCode
- 侧边栏集成用户信息展示
- 设置页面支持已登录用户
- 用户菜单添加登出功能
- 优化全局样式
2025-12-19 22:37:19 +08:00

218 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState } from 'react';
import { Copy, ThumbsUp, ThumbsDown, RefreshCw, ChevronDown, ChevronUp, Brain, Loader2, AlertCircle, Check } from 'lucide-react';
import { Avatar } from '@/components/ui/Avatar';
import { AILogo } from '@/components/ui/AILogo';
import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer';
import { CodeExecutionResult, PyodideLoading } from '@/components/features/CodeExecutionResult';
import { cn } from '@/lib/utils';
import type { Message, User, ToolResult } from '@/types';
interface MessageBubbleProps {
message: Message;
user?: User;
thinkingContent?: string;
isStreaming?: boolean;
error?: string;
/** 代码执行产生的图片Base64 */
images?: string[];
/** Pyodide 加载状态 */
pyodideStatus?: {
stage: 'loading' | 'ready' | 'error';
message: string;
progress?: number;
};
}
export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, pyodideStatus }: MessageBubbleProps) {
const isUser = message.role === 'user';
const [thinkingExpanded, setThinkingExpanded] = useState(false);
const [copied, setCopied] = useState(false);
// 复制消息内容
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(message.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('Failed to copy:', error);
}
};
if (isUser) {
return (
<div className="flex justify-end items-start gap-3 mb-8 animate-fade-in">
<div className="max-w-[70%]">
<div className="bg-[var(--color-message-user)] text-[var(--color-text-primary)] px-4 py-3 rounded-[18px] text-base leading-relaxed">
{message.content}
</div>
</div>
{user && <Avatar name={user.name} size="md" />}
</div>
);
}
return (
<div className="flex items-start gap-4 mb-8 animate-fade-in">
{/* AI 图标 */}
<div className="flex-shrink-0 mt-4">
<AILogo size={28} />
</div>
{/* 消息内容 */}
<div className="flex-1 max-w-full">
{/* 思考内容 */}
{thinkingContent && (
<div className="mb-3">
<button
onClick={() => setThinkingExpanded(!thinkingExpanded)}
className="inline-flex items-center gap-2 px-3 py-2 bg-purple-50 text-purple-700 rounded-lg text-sm hover:bg-purple-100 transition-colors"
>
<Brain size={16} />
<span></span>
{thinkingExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
{thinkingExpanded && (
<div className="mt-2 p-4 bg-purple-50 border border-purple-200 rounded-lg">
<pre className="text-sm text-purple-800 whitespace-pre-wrap font-mono">
{thinkingContent}
</pre>
</div>
)}
</div>
)}
{/* 错误提示 */}
{error && (
<div className="mb-3 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
<AlertCircle size={20} className="text-red-500 flex-shrink-0 mt-0.5" />
<div className="text-sm text-red-700">{error}</div>
</div>
)}
{/* 主要内容 */}
<div className="bg-[var(--color-message-assistant-bg)] border border-[var(--color-message-assistant-border)] rounded-2xl px-5 py-4 shadow-sm">
<div className="text-sm text-[var(--color-text-primary)] leading-[1.75]">
{message.content ? (
<MarkdownRenderer content={message.content} />
) : isStreaming ? (
<div className="flex items-center gap-2 text-[var(--color-text-tertiary)]">
<Loader2 size={16} className="animate-spin" />
<span>...</span>
</div>
) : null}
</div>
{/* 工具调用结果 - 代码执行图片展示 */}
{message.toolResults && message.toolResults.length > 0 && (
<div className="mt-4">
{message.toolResults.map((result, index) => (
<ToolResultDisplay key={index} result={result} />
))}
</div>
)}
{/* Pyodide 加载状态 */}
{pyodideStatus && (
<div className="mt-4">
<PyodideLoading
stage={pyodideStatus.stage}
message={pyodideStatus.message}
progress={pyodideStatus.progress}
/>
</div>
)}
{/* 代码执行图片(从 props 传入) */}
{images && images.length > 0 && (
<CodeExecutionResult
images={images}
success={true}
/>
)}
{/* 流式状态指示器 */}
{isStreaming && message.content && (
<div className="flex items-center gap-2 mt-3 text-sm text-[var(--color-text-tertiary)]">
<Loader2 size={14} className="animate-spin" />
<span>...</span>
</div>
)}
{/* 操作按钮(非流式状态下显示) */}
{!isStreaming && message.content && (
<>
<div className="flex items-center gap-1 mt-4 pt-3">
<button
onClick={handleCopy}
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
title={copied ? '已复制' : '复制'}
>
{copied ? <Check size={16} className="text-green-500" /> : <Copy size={16} />}
</button>
<ActionButton icon={ThumbsUp} title="好的回答" />
<ActionButton icon={ThumbsDown} title="不好的回答" />
<ActionButton icon={RefreshCw} title="重新生成" />
</div>
{/* 免责声明 */}
<div className="text-xs text-[var(--color-text-tertiary)] text-right mt-2">
LionCode can make mistakes
</div>
</>
)}
</div>
</div>
</div>
);
}
interface ActionButtonProps {
icon: React.ComponentType<{ size?: number }>;
title: string;
onClick?: () => void;
}
function ActionButton({ icon: Icon, title, onClick }: ActionButtonProps) {
return (
<button
onClick={onClick}
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
title={title}
>
<Icon size={16} />
</button>
);
}
/**
* 工具调用结果显示组件
* 专门处理代码执行结果和图片显示
*/
interface ToolResultDisplayProps {
result: ToolResult;
}
function ToolResultDisplay({ result }: ToolResultDisplayProps) {
// 只有代码执行工具才显示图片
if (result.toolName !== 'code_execution') {
return null;
}
// 如果没有图片,不显示
if (!result.images || result.images.length === 0) {
return null;
}
return (
<CodeExecutionResult
images={result.images}
engine={result.engine}
executionTime={result.executionTime}
success={!result.isError}
/>
);
}