初始化项目
This commit is contained in:
267
components/ChatBubble.tsx
Normal file
267
components/ChatBubble.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Role, MessageType, ChatMessage, Language } from '../types';
|
||||
import { User, Bot, BrainCircuit, Volume2, Pause, Sparkles, Download, Copy, Check, Loader2 } from 'lucide-react';
|
||||
import { geminiService, decodeAudioData } from '../services/geminiService';
|
||||
import { processAndDownloadAudio } from '../utils/audioUtils';
|
||||
import { translations } from '../utils/localization';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
interface ChatBubbleProps {
|
||||
message: ChatMessage;
|
||||
language: Language;
|
||||
onUpdateMessage?: (updatedMessage: ChatMessage) => void;
|
||||
onError?: (msg: string) => void;
|
||||
}
|
||||
|
||||
const ChatBubble: React.FC<ChatBubbleProps> = ({ message, language, onUpdateMessage, onError }) => {
|
||||
const isUser = message.role === Role.USER;
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isGeneratingAudio, setIsGeneratingAudio] = useState(false);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const audioSourceRef = useRef<AudioBufferSourceNode | null>(null);
|
||||
|
||||
const t = translations[language].chat;
|
||||
const tCommon = translations[language].common;
|
||||
|
||||
const stopAudio = () => {
|
||||
if (audioSourceRef.current) {
|
||||
audioSourceRef.current.stop();
|
||||
audioSourceRef.current = null;
|
||||
}
|
||||
setIsPlaying(false);
|
||||
};
|
||||
|
||||
const handlePlayAudio = async () => {
|
||||
if (isPlaying) {
|
||||
stopAudio();
|
||||
return;
|
||||
}
|
||||
|
||||
let base64Data = message.metadata?.audioUrl;
|
||||
|
||||
// If no audio cached, generate it on demand
|
||||
if (!base64Data && message.content && message.type === MessageType.TEXT) {
|
||||
try {
|
||||
setIsGeneratingAudio(true);
|
||||
base64Data = await geminiService.generateSpeech(message.content);
|
||||
|
||||
if (!base64Data) throw new Error("Audio generation returned empty");
|
||||
|
||||
// Cache it if parent provided update handler
|
||||
if (onUpdateMessage) {
|
||||
onUpdateMessage({
|
||||
...message,
|
||||
metadata: {
|
||||
...message.metadata,
|
||||
audioUrl: base64Data
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Audio gen failed", e);
|
||||
setIsGeneratingAudio(false);
|
||||
if (onError) onError(translations[language].common.error);
|
||||
return;
|
||||
} finally {
|
||||
setIsGeneratingAudio(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!base64Data) return;
|
||||
|
||||
try {
|
||||
if (!audioContextRef.current) {
|
||||
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
}
|
||||
const ctx = audioContextRef.current;
|
||||
if (ctx.state === 'suspended') await ctx.resume();
|
||||
|
||||
const buffer = await decodeAudioData(base64Data, ctx);
|
||||
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(ctx.destination);
|
||||
source.onended = () => setIsPlaying(false);
|
||||
source.start();
|
||||
audioSourceRef.current = source;
|
||||
setIsPlaying(true);
|
||||
} catch (e) {
|
||||
console.error("Audio playback error", e);
|
||||
setIsPlaying(false);
|
||||
if (onError) onError(translations[language].common.error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadAudio = async () => {
|
||||
let base64Data = message.metadata?.audioUrl;
|
||||
if (!base64Data && message.content && message.type === MessageType.TEXT) {
|
||||
try {
|
||||
setIsGeneratingAudio(true);
|
||||
base64Data = await geminiService.generateSpeech(message.content);
|
||||
if (!base64Data) throw new Error("Audio generation returned empty");
|
||||
|
||||
if (onUpdateMessage) {
|
||||
onUpdateMessage({ ...message, metadata: { ...message.metadata, audioUrl: base64Data } });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (onError) onError(translations[language].common.error);
|
||||
} finally {
|
||||
setIsGeneratingAudio(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (base64Data) {
|
||||
const filename = `sakura_audio_${Date.now()}.wav`;
|
||||
processAndDownloadAudio(base64Data, filename);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
if (message.content) {
|
||||
navigator.clipboard.writeText(message.content);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const isToday = date.getDate() === now.getDate() && date.getMonth() === now.getMonth() && date.getFullYear() === now.getFullYear();
|
||||
|
||||
const timeStr = date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
||||
if (isToday) return timeStr;
|
||||
|
||||
return `${date.toLocaleDateString()} ${timeStr}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex w-full mb-6 animate-fade-in-up ${isUser ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`flex max-w-[95%] md:max-w-[75%] ${isUser ? 'flex-row-reverse' : 'flex-row'} gap-3`}>
|
||||
|
||||
{/* Avatar */}
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 shadow-md transform transition-transform hover:scale-110 ${isUser ? 'bg-indigo-600' : 'bg-pink-500'}`}>
|
||||
{isUser ? <User size={16} className="text-white" /> : <Bot size={16} className="text-white" />}
|
||||
</div>
|
||||
|
||||
{/* Content Bubble */}
|
||||
<div className={`flex flex-col ${isUser ? 'items-end' : 'items-start'} min-w-0 w-full group`}>
|
||||
|
||||
{/* Metadata Badges */}
|
||||
{message.metadata?.isThinking && (
|
||||
<span className="text-[10px] flex items-center gap-1 text-amber-700 bg-amber-50 px-2 py-0.5 rounded-full mb-1 border border-amber-200 animate-pulse font-bold">
|
||||
<BrainCircuit size={10} /> {t.deepThinking}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className={`rounded-2xl p-4 shadow-sm border transition-shadow hover:shadow-md overflow-hidden w-full relative ${
|
||||
isUser
|
||||
? 'bg-indigo-600 text-white rounded-tr-sm border-transparent'
|
||||
: 'bg-white border-pink-100 text-slate-800 rounded-tl-sm'
|
||||
}`}>
|
||||
|
||||
{/* TEXT CONTENT - MARKDOWN RENDERED */}
|
||||
{message.content && (
|
||||
<div className={`
|
||||
text-sm md:text-base leading-relaxed
|
||||
${isUser ? 'prose-invert text-white' : 'prose-slate text-slate-800'}
|
||||
prose prose-p:my-1 prose-headings:my-2 prose-strong:font-bold prose-code:bg-black/10 prose-code:rounded prose-code:px-1 prose-code:py-0.5 prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-pre:rounded-lg max-w-none
|
||||
`}>
|
||||
{message.type === MessageType.TEXT ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
message.content
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* IMAGE CONTENT */}
|
||||
{message.type === MessageType.IMAGE && message.metadata?.imageUrl && (
|
||||
<div className="mt-2 overflow-hidden rounded-lg border border-white/20 animate-scale-in">
|
||||
<img src={message.metadata.imageUrl} alt="Uploaded or Generated" className="max-w-full h-auto object-cover" />
|
||||
</div>
|
||||
)}
|
||||
{message.type === MessageType.TEXT && message.metadata?.imageUrl && (
|
||||
<div className="mt-2 overflow-hidden rounded-lg border border-slate-200 animate-scale-in">
|
||||
<img src={message.metadata.imageUrl} alt="Context" className="max-w-[150px] h-auto object-cover opacity-90 hover:opacity-100 transition-opacity rounded-md" />
|
||||
<div className="text-[10px] opacity-70 p-1">{t.imageAnalyzed}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Bar (Copy, TTS, Download) - Always visible for Text messages or if audioUrl exists */}
|
||||
{(message.type === MessageType.TEXT || message.metadata?.audioUrl) && (
|
||||
<div className={`flex items-center gap-2 mt-3 pt-2 border-t ${isUser ? 'border-white/20' : 'border-slate-100'}`}>
|
||||
|
||||
{/* Play TTS */}
|
||||
<button
|
||||
onClick={handlePlayAudio}
|
||||
disabled={isGeneratingAudio}
|
||||
className={`flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs font-bold transition-colors active:scale-95 ${
|
||||
isUser
|
||||
? 'hover:bg-white/10 text-indigo-100'
|
||||
: 'hover:bg-pink-50 text-pink-500'
|
||||
}`}
|
||||
title={isUser ? t.playUserAudio : t.listenPronunciation}
|
||||
>
|
||||
{isGeneratingAudio ? <Loader2 size={14} className="animate-spin" /> : isPlaying ? <Pause size={14} className="animate-pulse" /> : <Volume2 size={14} />}
|
||||
<span className="opacity-80">{isUser ? t.playUserAudio : t.listenPronunciation}</span>
|
||||
</button>
|
||||
|
||||
{/* Download Audio */}
|
||||
<button
|
||||
onClick={handleDownloadAudio}
|
||||
disabled={isGeneratingAudio}
|
||||
className={`p-1.5 rounded-lg transition-colors active:scale-95 ${
|
||||
isUser
|
||||
? 'hover:bg-white/10 text-indigo-100'
|
||||
: 'hover:bg-slate-100 text-slate-400 hover:text-slate-600'
|
||||
}`}
|
||||
title={tCommon.download}
|
||||
>
|
||||
{isGeneratingAudio ? <Loader2 size={14} className="animate-spin" /> : <Download size={14} />}
|
||||
</button>
|
||||
|
||||
{/* Copy Text */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={`p-1.5 rounded-lg transition-colors active:scale-95 ml-auto flex items-center gap-1 ${
|
||||
isUser
|
||||
? 'hover:bg-white/10 text-indigo-100'
|
||||
: 'hover:bg-slate-100 text-slate-400 hover:text-slate-600'
|
||||
}`}
|
||||
title={tCommon.copy}
|
||||
>
|
||||
{isCopied ? <Check size={14} /> : <Copy size={14} />}
|
||||
{isCopied && <span className="text-[10px]">{tCommon.copied}</span>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer (Timestamp + Model) */}
|
||||
<div className="mt-1 flex items-center justify-between w-full px-1">
|
||||
<span className="text-[10px] text-slate-400 font-medium opacity-70">
|
||||
{formatTime(message.timestamp)}
|
||||
</span>
|
||||
{!isUser && message.model && (
|
||||
<div className="flex items-center gap-1 text-[9px] text-slate-400 font-medium uppercase tracking-wide opacity-70">
|
||||
<Sparkles size={8} /> {tCommon.generatedBy} {message.model.replace('gemini-', '')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatBubble;
|
||||
Reference in New Issue
Block a user