2025-09-28 20:14:57 +08:00

245 lines
7.9 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.

import React, { useRef, useCallback, useState, useEffect } from "react";
import { ChevronsRight, ChevronDown, X } from 'lucide-react';
import { Switch } from 'antd';
import { MessageRenderer } from "./MessageRenderer";
import { InputBar } from "./InputBar";
import { useMessages } from "./useMessages";
import { DateDivider } from "./DateDivider";
import { LoadMoreButton } from "./LoadMoreButton";
import { ChatMessage } from "./types";
import { useDeviceType } from '@/hooks/useDeviceType';
interface SmartChatBoxProps {
isSmartChatBoxOpen: boolean;
setIsSmartChatBoxOpen: (v: boolean) => void;
projectId: string;
userId: number;
previewVideoUrl?: string | null;
previewVideoId?: string | null;
onClearPreview?: () => void;
setIsFocusChatInput?: (v: boolean) => void;
aiEditingResult?: any;
/** 新消息回调:用于外层处理未展开时的气泡提示 */
onNewMessage?: (snippet: string) => void;
}
interface MessageGroup {
date: number;
messages: ChatMessage[];
}
function BackToLatestButton({ onClick }: { onClick: () => void }) {
return (
<button
onClick={onClick}
className="fixed bottom-24 right-4 bg-blue-500 hover:bg-blue-400 text-white rounded-full p-2 shadow-lg"
title="返回最新消息"
>
<ChevronDown size={20} />
</button>
);
}
export default function SmartChatBox({
isSmartChatBoxOpen,
setIsSmartChatBoxOpen,
projectId,
userId,
previewVideoUrl,
previewVideoId,
onClearPreview,
setIsFocusChatInput,
aiEditingResult,
onNewMessage
}: SmartChatBoxProps) {
// 消息列表引用
const listRef = useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const { isMobile, isTablet, isDesktop } = useDeviceType();
// 检查是否滚动到底部
const checkIfAtBottom = useCallback(() => {
if (listRef.current) {
const { scrollHeight, scrollTop, clientHeight } = listRef.current;
// 考虑一个小的误差范围10px
const isBottom = scrollHeight - scrollTop - clientHeight <= 10;
setIsAtBottom(isBottom);
}
}, []);
// 处理滚动事件
const handleScroll = useCallback(() => {
checkIfAtBottom();
}, [checkIfAtBottom]);
useEffect(() => {
console.log('previewVideoUrl', previewVideoUrl);
console.log('previewVideoId', previewVideoId);
}, [previewVideoUrl, previewVideoId]);
// 监听滚动事件
useEffect(() => {
const listElement = listRef.current;
if (listElement) {
listElement.addEventListener('scroll', handleScroll);
return () => {
listElement.removeEventListener('scroll', handleScroll);
};
}
}, [handleScroll]);
// 处理消息更新时的滚动
const handleMessagesUpdate = useCallback((shouldScroll: boolean) => {
if (shouldScroll && listRef.current) {
listRef.current.scrollTo({ top: listRef.current.scrollHeight, behavior: "smooth" });
}
// 更新底部状态
checkIfAtBottom();
}, [checkIfAtBottom]);
// 使用消息管理 hook
const [
{ messages, isLoading, error, hasMore, loadMoreMessages, backToLatest, isViewingHistory },
{ sendMessage },
{ enabled: systemPush, toggle: toggleSystemPush }
] = useMessages({
config: { projectId, userId },
onMessagesUpdate: handleMessagesUpdate
});
// 监听消息新增向外层抛出前10个字符的文本片段
const prevLenRef = useRef<number>(0);
useEffect(() => {
const len = messages.length;
if (len > prevLenRef.current && len > 0) {
const last = messages[len - 1];
// 提取第一个文本块
const textBlock = last.blocks.find(b => (b as any).type === 'text') as any;
const text = textBlock?.text || '';
if (text && onNewMessage) {
const snippet = text.slice(0, 40);
onNewMessage(snippet);
}
}
prevLenRef.current = len;
}, [messages, onNewMessage]);
// 监听智能剪辑结果,自动发送消息到聊天框
// useEffect(() => {
// if (aiEditingResult && isSmartChatBoxOpen) {
// const resultMessage = `🎉 AI智能剪辑完成
// 📊 剪辑统计:
// • 视频时长:${aiEditingResult.duration || '未知'}秒
// • 剪辑片段:${aiEditingResult.clips || '未知'}个
// • 处理结果:${aiEditingResult.message || '剪辑成功'}
// 🎬 最终视频已生成,您可以在工作流中查看和下载。`;
// // 自动发送系统消息
// sendMessage([{
// type: 'text',
// content: resultMessage
// }]);
// }
// }, [aiEditingResult, isSmartChatBoxOpen, sendMessage]);
// 按日期分组消息
const groupedMessages = React.useMemo(() => {
const groups: MessageGroup[] = [];
messages.forEach(message => {
const messageDate = new Date(message.createdAt).setHours(0, 0, 0, 0);
const existingGroup = groups.find(group => {
const groupDate = new Date(group.date).setHours(0, 0, 0, 0);
return groupDate === messageDate;
});
if (existingGroup) {
existingGroup.messages.push(message);
} else {
groups.push({
date: messageDate,
messages: [message]
});
}
});
return groups.sort((a, b) => a.date - b.date);
}, [messages]);
return (
<div className={`${isMobile ? '' : 'h-full'} w-full text-gray-100 flex flex-col backdrop-blur-lg bg-black/30 border border-white/20 shadow-xl relative`} data-alt="smart-chat-box"
style={{
maxHeight: isMobile ? 'calc(100vh - 5.5rem)' : '',
}}>
{/* Header */}
<div className={`px-4 py-3 border-b border-white/10 flex items-center justify-between ${isMobile ? 'sticky top-0 bg-[#141414] z-[1]' : ''}`} data-alt="chat-header">
<div className="font-semibold flex items-center gap-2">
<span>Chat</span>
{/* System push toggle */}
<Switch
checkedChildren="On"
unCheckedChildren="Off"
checked={systemPush}
onChange={toggleSystemPush}
className="ml-2"
/>
</div>
<div className="text-xs opacity-70">
<X
className="w-6 h-6 cursor-pointer"
onClick={() => setIsSmartChatBoxOpen(false)}
/>
</div>
</div>
{/* Message list */}
<div ref={listRef} className="flex-1 overflow-y-auto p-4" data-alt="message-list">
{/* Load more button */}
{hasMore && (
<LoadMoreButton onClick={loadMoreMessages} loading={isLoading} />
)}
{/* Messages grouped by date */}
<div className="space-y-3 pb-28">
{groupedMessages.map((group) => (
<React.Fragment key={group.date}>
<DateDivider timestamp={group.date} />
{group.messages.map((message) => (
<MessageRenderer key={message.id} msg={message} sendMessage={sendMessage} />
))}
</React.Fragment>
))}
</div>
{/* Loading indicator */}
{isLoading && (
<div className="flex justify-start space-x-1 p-2">
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce [animation-delay:-0.3s]"></span>
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce [animation-delay:-0.15s]"></span>
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></span>
</div>
)}
{/* Back to latest button */}
{isViewingHistory && !isAtBottom && (
<BackToLatestButton onClick={backToLatest} />
)}
</div>
{/* Input */}
<InputBar
onSend={sendMessage}
setVideoPreview={(url, id) => {
if (url === previewVideoUrl && id === previewVideoId) {
onClearPreview?.();
}
}}
initialVideoUrl={previewVideoUrl || undefined}
initialVideoId={previewVideoId || undefined}
setIsFocusChatInput={setIsFocusChatInput}
/>
</div>
);
}