增加ai问片功能
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
/* 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 } 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) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||
{ role: 'assistant', content: welcomeMessage },
|
||||
]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// 自动滚动到底部
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
// 自动聚焦输入框和防止背景滚动
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// 聚焦输入框
|
||||
if (inputRef.current) {
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
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]}>
|
||||
{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'>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder='输入你的问题... (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;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,react-hooks/exhaustive-deps,@typescript-eslint/no-empty-function */
|
||||
|
||||
import { ExternalLink, Heart, Link, PlayCircleIcon, Radio, Trash2 } from 'lucide-react';
|
||||
import { ExternalLink, Heart, Link, PlayCircleIcon, Radio, Sparkles, Trash2 } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, {
|
||||
@@ -26,6 +26,7 @@ import { useLongPress } from '@/hooks/useLongPress';
|
||||
|
||||
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
|
||||
import MobileActionSheet from '@/components/MobileActionSheet';
|
||||
import AIChatPanel from '@/components/AIChatPanel';
|
||||
|
||||
export interface VideoCardProps {
|
||||
id?: string;
|
||||
@@ -100,6 +101,18 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showMobileActions, setShowMobileActions] = useState(false);
|
||||
const [searchFavorited, setSearchFavorited] = useState<boolean | null>(null); // 搜索结果的收藏状态
|
||||
const [showAIChat, setShowAIChat] = useState(false);
|
||||
const [aiEnabled, setAiEnabled] = useState(false);
|
||||
|
||||
// 检查AI功能是否启用
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const enabled =
|
||||
(window as any).RUNTIME_CONFIG?.AI_ENABLED &&
|
||||
(window as any).RUNTIME_CONFIG?.AI_ENABLE_VIDEOCARD_ENTRY;
|
||||
setAiEnabled(enabled);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 可外部修改的可控字段
|
||||
const [dynamicEpisodes, setDynamicEpisodes] = useState<number | undefined>(
|
||||
@@ -540,6 +553,20 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
});
|
||||
}
|
||||
|
||||
// AI问片功能
|
||||
if (aiEnabled && actualTitle) {
|
||||
actions.push({
|
||||
id: 'ai-chat',
|
||||
label: 'AI问片',
|
||||
icon: <Sparkles size={20} />,
|
||||
onClick: () => {
|
||||
setShowMobileActions(false); // 关闭菜单
|
||||
setShowAIChat(true);
|
||||
},
|
||||
color: 'default' as const,
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}, [
|
||||
config,
|
||||
@@ -555,6 +582,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
handleClick,
|
||||
handleToggleFavorite,
|
||||
handleDeleteRecord,
|
||||
handlePlayInNewTab,
|
||||
aiEnabled,
|
||||
actualTitle,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -1355,6 +1385,23 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
totalEpisodes={actualEpisodes}
|
||||
origin={origin}
|
||||
/>
|
||||
|
||||
{/* AI问片面板 */}
|
||||
{aiEnabled && showAIChat && (
|
||||
<AIChatPanel
|
||||
isOpen={showAIChat}
|
||||
onClose={() => setShowAIChat(false)}
|
||||
context={{
|
||||
title: actualTitle,
|
||||
year: actualYear,
|
||||
douban_id: actualDoubanId,
|
||||
tmdb_id,
|
||||
type: actualSearchType as 'movie' | 'tv',
|
||||
currentEpisode,
|
||||
}}
|
||||
welcomeMessage={`想了解《${actualTitle}》的更多信息吗?我可以帮你查询剧情、演员、评价等。`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user