增加ai问片功能

This commit is contained in:
mtvpls
2025-12-29 14:41:59 +08:00
parent 24223a5d27
commit 0a66626545
14 changed files with 3076 additions and 9 deletions
+359
View File
@@ -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;
}
+48 -1
View File
@@ -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}》的更多信息吗?我可以帮你查询剧情、演员、评价等。`}
/>
)}
</>
);
}