442 lines
16 KiB
TypeScript
442 lines
16 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||
'use client';
|
||
|
||
import React, { useState, useRef, useEffect } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
import { X, Send, Bot, Loader2, Sparkles, Trash2 } from 'lucide-react';
|
||
import ReactMarkdown from 'react-markdown';
|
||
import remarkGfm from 'remark-gfm';
|
||
import { VideoContext } from '@/lib/ai-orchestrator';
|
||
|
||
interface ChatMessage {
|
||
role: 'user' | 'assistant';
|
||
content: string;
|
||
}
|
||
|
||
interface AIChatPanelProps {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
context?: VideoContext;
|
||
welcomeMessage?: string;
|
||
}
|
||
|
||
export default function AIChatPanel({
|
||
isOpen,
|
||
onClose,
|
||
context,
|
||
welcomeMessage = '你好!我是MoonTVPlus的AI影视助手,有什么可以帮你的吗?',
|
||
}: AIChatPanelProps) {
|
||
// 生成sessionStorage的key,基于视频上下文
|
||
const getStorageKey = () => {
|
||
if (context?.title) {
|
||
return `ai-chat-${context.title}-${context.year || ''}-${context.type || ''}`;
|
||
}
|
||
return 'ai-chat-general';
|
||
};
|
||
|
||
const [messages, setMessages] = useState<ChatMessage[]>([
|
||
{ role: 'assistant', content: welcomeMessage },
|
||
]);
|
||
const [input, setInput] = useState('');
|
||
const [isStreaming, setIsStreaming] = useState(false);
|
||
const [isMobile, setIsMobile] = useState(false);
|
||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||
const contextKeyRef = useRef<string>(getStorageKey());
|
||
|
||
// 自动滚动到底部
|
||
const scrollToBottom = () => {
|
||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||
};
|
||
|
||
useEffect(() => {
|
||
scrollToBottom();
|
||
}, [messages]);
|
||
|
||
// 从sessionStorage加载消息记录
|
||
useEffect(() => {
|
||
if (typeof window === 'undefined') return;
|
||
|
||
const storageKey = getStorageKey();
|
||
const savedMessages = sessionStorage.getItem(storageKey);
|
||
|
||
if (savedMessages) {
|
||
try {
|
||
const parsed = JSON.parse(savedMessages);
|
||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||
setMessages(parsed);
|
||
}
|
||
} catch (error) {
|
||
console.error('加载聊天记录失败:', error);
|
||
}
|
||
}
|
||
}, []); // 只在组件挂载时加载一次
|
||
|
||
// 保存消息记录到sessionStorage
|
||
useEffect(() => {
|
||
if (typeof window === 'undefined') return;
|
||
|
||
const storageKey = getStorageKey();
|
||
try {
|
||
sessionStorage.setItem(storageKey, JSON.stringify(messages));
|
||
} catch (error) {
|
||
console.error('保存聊天记录失败:', error);
|
||
}
|
||
}, [messages, context]); // 消息变化时保存
|
||
|
||
// 检测VideoContext变化,清除旧的聊天记录
|
||
useEffect(() => {
|
||
if (typeof window === 'undefined') return;
|
||
|
||
const newKey = getStorageKey();
|
||
if (contextKeyRef.current !== newKey) {
|
||
// 上下文变化了,清除消息并重置为欢迎消息
|
||
console.log('视频上下文变化,清除聊天记录');
|
||
setMessages([{ role: 'assistant', content: welcomeMessage }]);
|
||
contextKeyRef.current = newKey;
|
||
}
|
||
}, [context, welcomeMessage]); // 监听context变化
|
||
|
||
// 自动聚焦输入框和防止背景滚动
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
// 检测是否为移动设备
|
||
const checkMobile = () => {
|
||
setIsMobile(window.innerWidth < 768);
|
||
};
|
||
checkMobile();
|
||
|
||
// 只在非移动设备上聚焦输入框
|
||
if (inputRef.current && window.innerWidth >= 768) {
|
||
inputRef.current.focus();
|
||
}
|
||
|
||
// 防止背景滚动
|
||
const originalOverflow = document.body.style.overflow;
|
||
const originalPaddingRight = document.body.style.paddingRight;
|
||
|
||
// 获取滚动条宽度
|
||
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
||
|
||
document.body.style.overflow = 'hidden';
|
||
document.body.style.paddingRight = `${scrollbarWidth}px`;
|
||
|
||
return () => {
|
||
document.body.style.overflow = originalOverflow;
|
||
document.body.style.paddingRight = originalPaddingRight;
|
||
};
|
||
}
|
||
}, [isOpen]);
|
||
|
||
const handleSendMessage = async () => {
|
||
if (!input.trim() || isStreaming) return;
|
||
|
||
const userMessage = input.trim();
|
||
setInput('');
|
||
|
||
// 添加用户消息
|
||
setMessages((prev) => [...prev, { role: 'user', content: userMessage }]);
|
||
|
||
// 开始流式响应
|
||
setIsStreaming(true);
|
||
|
||
// 先添加一个空的助手消息用于流式更新或显示错误
|
||
setMessages((prev) => [...prev, { role: 'assistant', content: '' }]);
|
||
|
||
try {
|
||
const response = await fetch('/api/ai/chat', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
message: userMessage,
|
||
context,
|
||
history: messages.filter((m) => m.role !== 'assistant' || m.content !== welcomeMessage),
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json().catch(() => ({}));
|
||
const errorMsg = errorData.error || errorData.details || `请求失败 (${response.status})`;
|
||
throw new Error(errorMsg);
|
||
}
|
||
|
||
// 处理流式响应
|
||
const reader = response.body?.getReader();
|
||
const decoder = new TextDecoder();
|
||
|
||
if (!reader) {
|
||
throw new Error('无法读取响应流');
|
||
}
|
||
|
||
let assistantMessage = '';
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
const chunk = decoder.decode(value, { stream: true });
|
||
const lines = chunk.split('\n').filter((line) => line.trim() !== '');
|
||
|
||
for (const line of lines) {
|
||
if (line.startsWith('data: ')) {
|
||
const data = line.slice(6);
|
||
|
||
if (data === '[DONE]') {
|
||
break;
|
||
}
|
||
|
||
try {
|
||
const json = JSON.parse(data);
|
||
const text = json.text || '';
|
||
|
||
if (text) {
|
||
assistantMessage += text;
|
||
|
||
// 更新最后一条消息
|
||
setMessages((prev) => {
|
||
const newMessages = [...prev];
|
||
newMessages[newMessages.length - 1] = {
|
||
role: 'assistant',
|
||
content: assistantMessage,
|
||
};
|
||
return newMessages;
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.error('解析SSE数据失败:', e);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('发送消息失败:', error);
|
||
|
||
// 更新最后一条空消息为错误消息
|
||
setMessages((prev) => {
|
||
const newMessages = [...prev];
|
||
newMessages[newMessages.length - 1] = {
|
||
role: 'assistant',
|
||
content: `❌ 抱歉,出现了错误:\n\n${(error as Error).message}\n\n请检查:\n- AI服务配置是否正确\n- API密钥是否有效\n- 网络连接是否正常`,
|
||
};
|
||
return newMessages;
|
||
});
|
||
} finally {
|
||
setIsStreaming(false);
|
||
}
|
||
};
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleSendMessage();
|
||
}
|
||
};
|
||
|
||
// 清空聊天上下文
|
||
const handleClearContext = () => {
|
||
if (typeof window === 'undefined') return;
|
||
|
||
// 清除sessionStorage
|
||
const storageKey = getStorageKey();
|
||
sessionStorage.removeItem(storageKey);
|
||
|
||
// 重置消息为欢迎消息
|
||
setMessages([{ role: 'assistant', content: welcomeMessage }]);
|
||
|
||
console.log('已清空聊天上下文');
|
||
};
|
||
|
||
if (!isOpen) return null;
|
||
|
||
const modalContent = (
|
||
<div
|
||
className='fixed inset-0 z-[1002] flex items-center justify-center bg-black/50 backdrop-blur-sm overflow-hidden'
|
||
onClick={(e) => {
|
||
// 点击遮罩层关闭弹窗
|
||
if (e.target === e.currentTarget) {
|
||
onClose();
|
||
}
|
||
}}
|
||
>
|
||
<div className='relative mx-4 my-auto flex h-[85vh] sm:h-[80vh] max-h-[90vh] sm:max-h-[600px] w-full max-w-3xl flex-col rounded-2xl bg-white shadow-2xl dark:bg-gray-900'>
|
||
{/* 头部 */}
|
||
<div className='flex items-center justify-between border-b border-gray-200 p-4 dark:border-gray-700'>
|
||
<div className='flex items-center gap-3'>
|
||
<div className='flex h-10 w-10 items-center justify-center rounded-full bg-purple-500'>
|
||
<Sparkles size={20} className='text-white' />
|
||
</div>
|
||
<div>
|
||
<h2 className='text-lg font-semibold text-gray-900 dark:text-white'>
|
||
AI影视助手
|
||
</h2>
|
||
{context?.title && (
|
||
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||
正在讨论: {context.title}
|
||
{context.year && ` (${context.year})`}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
className='rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800'
|
||
>
|
||
<X size={20} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* 消息列表 */}
|
||
<div className='flex-1 overflow-y-auto p-4'>
|
||
<div className='space-y-4'>
|
||
{messages.map((message, index) => (
|
||
<div
|
||
key={index}
|
||
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||
>
|
||
<div
|
||
className={`flex max-w-[80%] gap-3 ${message.role === 'user' ? 'flex-row-reverse' : 'flex-row'}`}
|
||
>
|
||
{/* 头像 */}
|
||
<div
|
||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${
|
||
message.role === 'user'
|
||
? 'bg-blue-500'
|
||
: 'bg-purple-500'
|
||
}`}
|
||
>
|
||
{message.role === 'user' ? (
|
||
<span className='text-xs font-semibold text-white'>
|
||
U
|
||
</span>
|
||
) : (
|
||
<Bot size={16} className='text-white' />
|
||
)}
|
||
</div>
|
||
|
||
{/* 消息内容 */}
|
||
<div
|
||
className={`rounded-2xl px-4 py-2 ${
|
||
message.role === 'user'
|
||
? 'bg-blue-500 text-white'
|
||
: 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white'
|
||
}`}
|
||
>
|
||
{message.role === 'user' ? (
|
||
<p className='whitespace-pre-wrap break-words text-sm leading-relaxed'>
|
||
{message.content}
|
||
</p>
|
||
) : (
|
||
<div className='prose prose-sm max-w-none dark:prose-invert prose-p:my-2 prose-p:leading-relaxed prose-pre:bg-gray-800 prose-pre:text-gray-100 dark:prose-pre:bg-gray-900 prose-code:text-purple-600 dark:prose-code:text-purple-400 prose-code:bg-purple-50 dark:prose-code:bg-purple-900/20 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-strong:text-gray-900 dark:prose-strong:text-white prose-ul:my-2 prose-ol:my-2 prose-li:my-1'>
|
||
<ReactMarkdown remarkPlugins={[remarkGfm as any]}>
|
||
{message.content}
|
||
</ReactMarkdown>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{/* 加载指示器 */}
|
||
{isStreaming && (
|
||
<div className='flex justify-start'>
|
||
<div className='flex max-w-[80%] gap-3'>
|
||
<div className='flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-purple-500'>
|
||
<Bot size={16} className='text-white' />
|
||
</div>
|
||
<div className='flex items-center gap-2 rounded-2xl bg-gray-100 px-4 py-2 dark:bg-gray-800'>
|
||
<Loader2 size={16} className='animate-spin text-gray-500' />
|
||
<span className='text-sm text-gray-500 dark:text-gray-400'>
|
||
AI正在思考...
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div ref={messagesEndRef} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* 输入区域 */}
|
||
<div className='border-t border-gray-200 p-4 dark:border-gray-700'>
|
||
<div className='flex gap-2'>
|
||
<button
|
||
onClick={handleClearContext}
|
||
className='flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border border-gray-300 text-gray-500 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-800'
|
||
title='清空聊天记录'
|
||
disabled={isStreaming}
|
||
>
|
||
<Trash2 size={20} />
|
||
</button>
|
||
<textarea
|
||
ref={inputRef}
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder={isMobile ? '输入你的问题...' : '输入你的问题... (Shift+Enter换行)'}
|
||
disabled={isStreaming}
|
||
rows={1}
|
||
className='flex-1 resize-none rounded-xl border border-gray-300 bg-white px-4 py-3 text-sm text-gray-900 placeholder-gray-400 transition-colors focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 dark:focus:border-purple-400'
|
||
style={{
|
||
minHeight: '48px',
|
||
maxHeight: '120px',
|
||
}}
|
||
onInput={(e) => {
|
||
const target = e.target as HTMLTextAreaElement;
|
||
target.style.height = 'auto';
|
||
target.style.height = `${Math.min(target.scrollHeight, 120)}px`;
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={handleSendMessage}
|
||
disabled={!input.trim() || isStreaming}
|
||
className='flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-purple-500 text-white transition-colors hover:bg-purple-600 disabled:cursor-not-allowed disabled:opacity-50'
|
||
>
|
||
{isStreaming ? (
|
||
<Loader2 size={20} className='animate-spin' />
|
||
) : (
|
||
<Send size={20} />
|
||
)}
|
||
</button>
|
||
</div>
|
||
|
||
{/* 快捷提示 */}
|
||
{messages.length === 1 && !isStreaming && (
|
||
<div className='mt-3 flex flex-wrap gap-2'>
|
||
<button
|
||
onClick={() => setInput('推荐一些高分电影')}
|
||
className='rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-600 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'
|
||
>
|
||
推荐高分电影
|
||
</button>
|
||
<button
|
||
onClick={() => setInput('最近有什么新电影上映?')}
|
||
className='rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-600 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'
|
||
>
|
||
最新上映
|
||
</button>
|
||
{context?.title && (
|
||
<button
|
||
onClick={() =>
|
||
setInput(`${context.title}讲的是什么故事?`)
|
||
}
|
||
className='rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-600 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'
|
||
>
|
||
剧情介绍
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
return typeof window !== 'undefined'
|
||
? createPortal(modalContent, document.body)
|
||
: null;
|
||
}
|