Files
MoonTVPlus/src/components/AIChatPanel.tsx
2025-12-31 23:27:15 +08:00

442 lines
16 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.
/* 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;
}