288 lines
7.9 KiB
TypeScript
288 lines
7.9 KiB
TypeScript
// AI评论生成核心逻辑
|
||
|
||
import { normalizeApiBaseUrl } from '@/lib/url';
|
||
|
||
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<string> {
|
||
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<AIComment[]> {
|
||
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 baseURL = normalizeApiBaseUrl(aiConfig.CustomBaseURL);
|
||
const response = await fetch(`${baseURL}/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
|
||
}
|
||
}
|