diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index d6745d0..c1d772a 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -10327,6 +10327,7 @@ const AIConfigComponent = ({ const [enableHomepageEntry, setEnableHomepageEntry] = useState(true); const [enableVideoCardEntry, setEnableVideoCardEntry] = useState(true); const [enablePlayPageEntry, setEnablePlayPageEntry] = useState(true); + const [enableAIComments, setEnableAIComments] = useState(false); // 权限控制 const [allowRegularUsers, setAllowRegularUsers] = useState(true); @@ -10357,6 +10358,7 @@ const AIConfigComponent = ({ setEnableHomepageEntry(config.AIConfig.EnableHomepageEntry !== false); setEnableVideoCardEntry(config.AIConfig.EnableVideoCardEntry !== false); setEnablePlayPageEntry(config.AIConfig.EnablePlayPageEntry !== false); + setEnableAIComments(config.AIConfig.EnableAIComments || false); setAllowRegularUsers(config.AIConfig.AllowRegularUsers !== false); setTemperature(config.AIConfig.Temperature ?? 0.7); setMaxTokens(config.AIConfig.MaxTokens ?? 1000); @@ -10390,6 +10392,7 @@ const AIConfigComponent = ({ EnableHomepageEntry: enableHomepageEntry, EnableVideoCardEntry: enableVideoCardEntry, EnablePlayPageEntry: enablePlayPageEntry, + EnableAIComments: enableAIComments, AllowRegularUsers: allowRegularUsers, Temperature: temperature, MaxTokens: maxTokens, @@ -10659,6 +10662,7 @@ const AIConfigComponent = ({ { key: 'homepage', label: '首页入口', desc: '在首页显示AI问片入口', state: enableHomepageEntry, setState: setEnableHomepageEntry }, { key: 'videocard', label: '视频卡片入口', desc: '在视频卡片菜单中显示AI问片选项', state: enableVideoCardEntry, setState: setEnableVideoCardEntry }, { key: 'playpage', label: '播放页入口', desc: '在视频播放页显示AI问片功能', state: enablePlayPageEntry, setState: setEnablePlayPageEntry }, + { key: 'aicomments', label: 'AI评论功能', desc: '在播放页生成AI评论(独立于豆瓣评论)', state: enableAIComments, setState: setEnableAIComments }, ].map((item) => (
diff --git a/src/app/api/admin/ai/route.ts b/src/app/api/admin/ai/route.ts index 07e7425..7858cc4 100644 --- a/src/app/api/admin/ai/route.ts +++ b/src/app/api/admin/ai/route.ts @@ -57,6 +57,7 @@ export async function POST(request: NextRequest) { EnableHomepageEntry, EnableVideoCardEntry, EnablePlayPageEntry, + EnableAIComments, AllowRegularUsers, Temperature, MaxTokens, @@ -93,6 +94,7 @@ export async function POST(request: NextRequest) { EnableHomepageEntry: boolean; EnableVideoCardEntry: boolean; EnablePlayPageEntry: boolean; + EnableAIComments: boolean; AllowRegularUsers: boolean; Temperature?: number; MaxTokens?: number; @@ -132,6 +134,7 @@ export async function POST(request: NextRequest) { typeof EnableHomepageEntry !== 'boolean' || typeof EnableVideoCardEntry !== 'boolean' || typeof EnablePlayPageEntry !== 'boolean' || + typeof EnableAIComments !== 'boolean' || typeof AllowRegularUsers !== 'boolean' || (Temperature !== undefined && typeof Temperature !== 'number') || (MaxTokens !== undefined && typeof MaxTokens !== 'number') || @@ -181,6 +184,7 @@ export async function POST(request: NextRequest) { EnableHomepageEntry, EnableVideoCardEntry, EnablePlayPageEntry, + EnableAIComments, AllowRegularUsers, Temperature, MaxTokens, diff --git a/src/app/api/ai-comments/route.ts b/src/app/api/ai-comments/route.ts new file mode 100644 index 0000000..3e03452 --- /dev/null +++ b/src/app/api/ai-comments/route.ts @@ -0,0 +1,113 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { generateAIComments, AIComment } from '@/lib/ai-comment-generator'; +import { getConfig } from '@/lib/config'; + +export const runtime = 'nodejs'; + +interface AICommentsResponse { + comments: AIComment[]; + total: number; + movieName: string; + isAiGenerated: true; + generatedAt: string; +} + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const movieName = searchParams.get('name'); + const movieInfo = searchParams.get('info'); + const count = parseInt(searchParams.get('count') || '10'); + + // 参数验证 + if (!movieName) { + return NextResponse.json( + { error: '缺少影片名称参数' }, + { status: 400 } + ); + } + + if (count < 1 || count > 50) { + return NextResponse.json( + { error: '评论数量必须在1-50之间' }, + { status: 400 } + ); + } + + // 读取AI配置 + const config = await getConfig(); + const aiConfig = config.AIConfig; + + // 检查AI功能是否启用 + if (!aiConfig?.Enabled) { + return NextResponse.json( + { error: 'AI功能未启用' }, + { status: 403 } + ); + } + + // 检查AI评论功能是否启用 + if (!aiConfig?.EnableAIComments) { + return NextResponse.json( + { error: 'AI评论功能未启用' }, + { status: 403 } + ); + } + + // 检查必要的配置 + if (!aiConfig.CustomApiKey || !aiConfig.CustomBaseURL || !aiConfig.CustomModel) { + return NextResponse.json( + { error: 'AI配置不完整,请在管理面板配置' }, + { status: 500 } + ); + } + + // 生成AI评论 + const comments = await generateAIComments({ + movieName, + movieInfo: movieInfo || undefined, + count, + aiConfig: { + CustomApiKey: aiConfig.CustomApiKey, + CustomBaseURL: aiConfig.CustomBaseURL, + CustomModel: aiConfig.CustomModel, + Temperature: aiConfig.Temperature, + MaxTokens: aiConfig.MaxTokens, + EnableWebSearch: aiConfig.EnableWebSearch, + WebSearchProvider: aiConfig.WebSearchProvider, + TavilyApiKey: aiConfig.TavilyApiKey, + SerperApiKey: aiConfig.SerperApiKey, + SerpApiKey: aiConfig.SerpApiKey, + }, + }); + + // 返回结果 + const response: AICommentsResponse = { + comments, + total: comments.length, + movieName, + isAiGenerated: true, + generatedAt: new Date().toISOString(), + }; + + return NextResponse.json(response, { + headers: { + 'Cache-Control': 'public, max-age=3600, s-maxage=3600', // 缓存1小时 + }, + }); + } catch (error) { + console.error('AI评论生成失败:', error); + + // 返回友好的错误信息 + const errorMessage = error instanceof Error ? error.message : 'AI评论生成失败'; + + return NextResponse.json( + { + error: errorMessage, + details: process.env.NODE_ENV === 'development' ? String(error) : undefined + }, + { status: 500 } + ); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 517cf73..e0eea95 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -84,6 +84,7 @@ export default async function RootLayout({ let aiEnableHomepageEntry = false; let aiEnableVideoCardEntry = false; let aiEnablePlayPageEntry = false; + let aiEnableComments = false; let aiDefaultMessageNoVideo = ''; let aiDefaultMessageWithVideo = ''; let enableMovieRequest = true; @@ -133,6 +134,7 @@ export default async function RootLayout({ aiEnableHomepageEntry = config.AIConfig?.EnableHomepageEntry || false; aiEnableVideoCardEntry = config.AIConfig?.EnableVideoCardEntry || false; aiEnablePlayPageEntry = config.AIConfig?.EnablePlayPageEntry || false; + aiEnableComments = config.AIConfig?.EnableAIComments || false; aiDefaultMessageNoVideo = config.AIConfig?.DefaultMessageNoVideo || ''; aiDefaultMessageWithVideo = config.AIConfig?.DefaultMessageWithVideo || ''; // 求片功能配置 @@ -198,6 +200,9 @@ export default async function RootLayout({ AI_ENABLE_HOMEPAGE_ENTRY: aiEnableHomepageEntry, AI_ENABLE_VIDEOCARD_ENTRY: aiEnableVideoCardEntry, AI_ENABLE_PLAYPAGE_ENTRY: aiEnablePlayPageEntry, + AIConfig: { + EnableAIComments: aiEnableComments, + }, AI_DEFAULT_MESSAGE_NO_VIDEO: aiDefaultMessageNoVideo, AI_DEFAULT_MESSAGE_WITH_VIDEO: aiDefaultMessageWithVideo, ENABLE_MOVIE_REQUEST: enableMovieRequest, diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 64a7313..68c2df7 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -50,9 +50,11 @@ import { getTMDBImageUrl } from '@/lib/tmdb.search'; import { DanmakuFilterConfig, EpisodeFilterConfig, SearchResult } from '@/lib/types'; import { base58Decode, getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils'; import { useEnableComments } from '@/hooks/useEnableComments'; +import { useEnableAIComments } from '@/hooks/useEnableAIComments'; import { usePlaySync } from '@/hooks/usePlaySync'; import AIChatPanel from '@/components/AIChatPanel'; +import AIComments from '@/components/AIComments'; import CorrectDialog from '@/components/CorrectDialog'; import DanmakuFilterSettings from '@/components/DanmakuFilterSettings'; import DetailPanel from '@/components/DetailPanel'; @@ -87,6 +89,7 @@ function PlayPageClient() { const router = useRouter(); const searchParams = useSearchParams(); const enableComments = useEnableComments(); + const enableAIComments = useEnableAIComments(); const { addDownloadTask } = useDownload(); const { siteName } = useSite(); @@ -8719,6 +8722,28 @@ function PlayPageClient() {
)} + + {/* AI评论区域 */} + {videoTitle && enableAIComments && ( +
+
+ {/* 标题 */} +
+

+ + + + AI生成评论 +

+
+ + {/* 评论内容 */} +
+ +
+
+
+ )} )} diff --git a/src/components/AIComments.tsx b/src/components/AIComments.tsx new file mode 100644 index 0000000..2e4935e --- /dev/null +++ b/src/components/AIComments.tsx @@ -0,0 +1,275 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; + +interface AIComment { + id: string; + userName: string; + userAvatar: string; + rating: number | null; + content: string; + time: string; + votes: number; + isAiGenerated: true; +} + +interface AICommentsProps { + movieName: string; + movieInfo?: string; +} + +export default function AIComments({ movieName, movieInfo }: AICommentsProps) { + const [comments, setComments] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [hasStartedLoading, setHasStartedLoading] = useState(false); + + const fetchComments = useCallback(async () => { + try { + console.log('正在生成AI评论...'); + setLoading(true); + setError(null); + + const params = new URLSearchParams({ + name: movieName, + count: '10', + }); + + if (movieInfo) { + params.append('info', movieInfo); + } + + const response = await fetch(`/api/ai-comments?${params.toString()}`); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || '生成AI评论失败'); + } + + const data = await response.json(); + console.log('AI评论生成成功:', data.comments.length); + + setComments(data.comments); + } catch (err) { + console.error('生成AI评论失败:', err); + setError(err instanceof Error ? err.message : '生成AI评论失败'); + } finally { + setLoading(false); + } + }, [movieName, movieInfo]); + + useEffect(() => { + // 重置状态当 movieName 变化时 + setHasStartedLoading(false); + setComments([]); + setLoading(false); + setError(null); + }, [movieName]); + + const startLoading = () => { + console.log('开始生成AI评论'); + setHasStartedLoading(true); + fetchComments(); + }; + + const regenerate = () => { + console.log('重新生成AI评论'); + fetchComments(); + }; + + // 星级渲染 + const renderStars = (rating: number | null) => { + if (rating === null) return null; + + return ( +
+ {[1, 2, 3, 4, 5].map((star) => ( + + + + ))} +
+ ); + }; + + // 初始状态:显示生成按钮 + if (!hasStartedLoading) { + return ( +
+
+ + + +

点击生成AI评论

+

+ 基于影片信息和网络资料生成 +

+
+ +
+ ); + } + + if (loading && comments.length === 0) { + return ( +
+
+ AI正在生成评论... + + 这可能需要几秒钟 + +
+ ); + } + + if (error && comments.length === 0) { + return ( +
+
+

{error}

+

+ 请检查管理面板的AI配置是否正确 +

+ +
+ ); + } + + return ( +
+ {/* 头部统计和操作 */} +
+
+ 已生成 {comments.length} 条AI评论 +
+ +
+ + {/* 评论列表 */} +
+ {comments.map((comment) => ( +
+ {/* 用户信息 */} +
+ {/* 头像 */} +
+ {comment.userName} +
+ + {/* 用户名和评分 */} +
+
+ + {comment.userName} + + {renderStars(comment.rating)} + {/* AI标识 */} + + + + + AI生成 + +
+ + {/* 时间 */} +
+ {comment.time} +
+
+ + {/* 有用数 */} + {comment.votes > 0 && ( +
+ + + + {comment.votes} +
+ )} +
+ + {/* 评论内容 */} +
+ {comment.content} +
+
+ ))} +
+ + {/* 提示信息 */} +
+ 以上评论由AI基于影片信息和网络资料生成,仅供参考 +
+
+ ); +} diff --git a/src/hooks/useEnableAIComments.ts b/src/hooks/useEnableAIComments.ts new file mode 100644 index 0000000..dd4ab0c --- /dev/null +++ b/src/hooks/useEnableAIComments.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from 'react'; + +interface RuntimeConfig { + AIConfig?: { + EnableAIComments?: boolean; + }; +} + +export function useEnableAIComments(): boolean { + const [enableAIComments, setEnableAIComments] = useState(false); + + useEffect(() => { + // 在客户端获取运行时配置 + if (typeof window !== 'undefined') { + const runtimeConfig = (window as any).RUNTIME_CONFIG as RuntimeConfig; + setEnableAIComments(runtimeConfig?.AIConfig?.EnableAIComments ?? false); + } + }, []); + + return enableAIComments; +} diff --git a/src/lib/ai-comment-generator.ts b/src/lib/ai-comment-generator.ts new file mode 100644 index 0000000..2f3a3d4 --- /dev/null +++ b/src/lib/ai-comment-generator.ts @@ -0,0 +1,284 @@ +// AI评论生成核心逻辑 + +export interface AIComment { + id: string; + userName: string; + userAvatar: string; + rating: number | null; + content: string; + time: string; + votes: number; + isAiGenerated: true; +} + +interface GenerateCommentsParams { + movieName: string; + movieInfo?: string; + count?: number; + aiConfig: { + CustomApiKey: string; + CustomBaseURL: string; + CustomModel: string; + Temperature?: number; + MaxTokens?: number; + EnableWebSearch?: boolean; + WebSearchProvider?: 'tavily' | 'serper' | 'serpapi'; + TavilyApiKey?: string; + SerperApiKey?: string; + SerpApiKey?: string; + }; +} + +interface CommentData { + content: string; + rating: number | null; + sentiment: 'positive' | 'neutral' | 'negative'; +} + +// 生成评论的Prompt +function buildCommentPrompt( + movieName: string, + movieInfo?: string, + searchResults?: string, + count: number = 10 +): string { + return `你是一个影评生成助手。请生成真实自然的观众评论。 + +影片:${movieName} +${movieInfo ? `简介:${movieInfo}` : ''} +${searchResults ? `\n网络评价参考:\n${searchResults}` : ''} + +任务要求: +1. 生成${count}条观众评论 +2. 每条评论50-200字,口语化、自然 +3. 观点多样化:有好评、中评、差评,比例大约6:3:1 +4. 可以包含: + - 个人观影感受和情感共鸣 + - 对演员演技的评价 + - 对剧情、节奏、画面的看法 + - 与其他作品的对比 + - 推荐或不推荐的理由 +5. 避免: + - 过于专业的影评术语 + - 千篇一律的表达 + - 明显的AI痕迹 + - 重复的内容 + +请直接输出JSON数组格式,不要有其他文字: +[ + { + "content": "评论内容", + "rating": 4, + "sentiment": "positive" + } +] + +注意:rating为1-5的整数或null(表示未评分),sentiment为positive/neutral/negative之一。`; +} + +// 联网搜索影片资料 +async function searchMovieInfo( + movieName: string, + aiConfig: GenerateCommentsParams['aiConfig'] +): Promise { + if (!aiConfig.EnableWebSearch) { + return ''; + } + + try { + const provider = aiConfig.WebSearchProvider || 'tavily'; + let searchResults = ''; + + if (provider === 'tavily' && aiConfig.TavilyApiKey) { + const response = await fetch('https://api.tavily.com/search', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + api_key: aiConfig.TavilyApiKey, + query: `${movieName} 影评 评价`, + max_results: 5, + }), + }); + + if (response.ok) { + const data = await response.json(); + searchResults = data.results + ?.map((r: any) => r.content) + .join('\n') + .slice(0, 1000); + } + } else if (provider === 'serper' && aiConfig.SerperApiKey) { + const response = await fetch('https://google.serper.dev/search', { + method: 'POST', + headers: { + 'X-API-KEY': aiConfig.SerperApiKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + q: `${movieName} 影评 评价`, + num: 5, + }), + }); + + if (response.ok) { + const data = await response.json(); + searchResults = data.organic + ?.map((r: any) => r.snippet) + .join('\n') + .slice(0, 1000); + } + } else if (provider === 'serpapi' && aiConfig.SerpApiKey) { + const response = await fetch( + `https://serpapi.com/search?q=${encodeURIComponent(movieName + ' 影评 评价')}&api_key=${aiConfig.SerpApiKey}&num=5` + ); + + if (response.ok) { + const data = await response.json(); + searchResults = data.organic_results + ?.map((r: any) => r.snippet) + .join('\n') + .slice(0, 1000); + } + } + + return searchResults; + } catch (error) { + console.error('搜索影片资料失败:', error); + return ''; + } +} + +// 调用AI生成评论 +export async function generateAIComments( + params: GenerateCommentsParams +): Promise { + const { movieName, movieInfo, count = 10, aiConfig } = params; + + try { + // 1. 联网搜索影片资料(如果启用) + const searchResults = await searchMovieInfo(movieName, aiConfig); + + // 2. 构建Prompt + const prompt = buildCommentPrompt(movieName, movieInfo, searchResults, count); + + // 3. 调用AI API + const response = await fetch(`${aiConfig.CustomBaseURL}/chat/completions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${aiConfig.CustomApiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: aiConfig.CustomModel, + messages: [ + { + role: 'system', + content: + '你是一个专业的影评生成助手,擅长生成真实自然的观众评论。', + }, + { + role: 'user', + content: prompt, + }, + ], + temperature: aiConfig.Temperature ?? 0.8, + max_tokens: aiConfig.MaxTokens ?? 2000, + }), + }); + + if (!response.ok) { + throw new Error(`AI API调用失败: ${response.status}`); + } + + const data = await response.json(); + const content = data.choices?.[0]?.message?.content; + + if (!content) { + throw new Error('AI返回内容为空'); + } + + // 4. 解析AI返回的JSON + let commentsData: CommentData[]; + try { + // 尝试提取JSON(可能被markdown代码块包裹) + const jsonMatch = content.match(/\[[\s\S]*\]/); + if (jsonMatch) { + commentsData = JSON.parse(jsonMatch[0]); + } else { + commentsData = JSON.parse(content); + } + } catch (parseError) { + console.error('解析AI返回的JSON失败:', content); + throw new Error('AI返回格式错误'); + } + + // 5. 转换为AIComment格式 + const aiComments: AIComment[] = commentsData.map((comment, index) => { + const timestamp = Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000; // 随机过去30天内 + const date = new Date(timestamp); + + return { + id: `ai-${Date.now()}-${index}`, + userName: generateUserName(index), + userAvatar: generateAvatar(index), + rating: comment.rating, + content: comment.content, + time: formatTime(date), + votes: generateVotes(comment.sentiment), + isAiGenerated: true, + }; + }); + + return aiComments; + } catch (error) { + console.error('AI评论生成失败:', error); + throw error; + } +} + +// 生成虚拟用户名 +function generateUserName(index: number): string { + const prefixes = [ + '影迷', + '观众', + '电影爱好者', + '剧迷', + '路人', + '网友', + '看客', + ]; + const prefix = prefixes[index % prefixes.length]; + return `${prefix}${Math.floor(Math.random() * 9000) + 1000}`; +} + +// 生成头像URL(使用DiceBear API) +function generateAvatar(seed: number): string { + const styles = ['avataaars', 'bottts', 'personas', 'micah']; + const style = styles[seed % styles.length]; + return `https://api.dicebear.com/7.x/${style}/svg?seed=${seed}`; +} + +// 格式化时间 +function formatTime(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; +} + +// 根据情感生成点赞数 +function generateVotes(sentiment: string): number { + if (sentiment === 'positive') { + return Math.floor(Math.random() * 100) + 20; // 20-120 + } else if (sentiment === 'neutral') { + return Math.floor(Math.random() * 50) + 5; // 5-55 + } else { + return Math.floor(Math.random() * 30); // 0-30 + } +}