/* eslint-disable @typescript-eslint/no-explicit-any */ /** * AI数据源协调器 * 负责协调AI与联网搜索、豆瓣API、TMDB API之间的数据交互 */ import { fetchDoubanData as fetchDoubanAPI } from '@/lib/douban'; import { getNextApiKey } from '@/lib/tmdb.client'; import { normalizeApiBaseUrl } from '@/lib/url'; export interface VideoContext { title?: string; year?: string; douban_id?: number; tmdb_id?: number; type?: 'movie' | 'tv'; currentEpisode?: number; } export interface IntentAnalysisResult { type: 'recommendation' | 'query' | 'detail' | 'general'; mediaType?: 'movie' | 'tv' | 'variety' | 'anime'; genre?: string; needWebSearch: boolean; needDouban: boolean; needTMDB: boolean; keywords: string[]; entities: Array<{ type: string; value: string }>; } export interface DecisionResult { needWebSearch: boolean; needDouban: boolean; needTMDB: boolean; webSearchQuery?: string; doubanQuery?: string; reasoning?: string; } export interface OrchestrationResult { systemPrompt: string; webSearchResults?: any; doubanData?: any; tmdbData?: any; } /** * 分析用户意图 */ export function analyzeIntent( message: string, context?: VideoContext ): IntentAnalysisResult { const lowerMessage = message.toLowerCase(); // 时效性关键词 - 需要最新信息的问题 const timeKeywords = [ '最新', '今年', '2024', '2025', '即将', '上映', '新出', '什么时候', '何时', '几时', '播出', '更新', '下一季', '第二季', '第三季', '续集', '下季', '下部' ]; const hasTimeKeyword = timeKeywords.some((k) => message.includes(k)); // 推荐类关键词 const recommendKeywords = ['推荐', '有什么', '好看', '值得', '介绍']; const isRecommendation = recommendKeywords.some((k) => message.includes(k)); // 演员/导演关键词 const personKeywords = ['演员', '导演', '主演', '出演', '作品']; const isPerson = personKeywords.some((k) => message.includes(k)); // 剧情相关关键词 const plotKeywords = ['讲什么', '剧情', '故事', '内容', '讲的是']; const isPlotQuery = plotKeywords.some((k) => message.includes(k)); // 媒体类型判断 let mediaType: 'movie' | 'tv' | 'variety' | 'anime' | undefined; if (message.includes('电影')) mediaType = 'movie'; else if (message.includes('电视剧') || message.includes('剧集')) mediaType = 'tv'; else if (message.includes('综艺')) mediaType = 'variety'; else if (message.includes('动漫') || message.includes('动画')) mediaType = 'anime'; else if (context?.type) mediaType = context.type; // 类型判断 let type: IntentAnalysisResult['type'] = 'general'; if (isRecommendation) type = 'recommendation'; else if (context?.title && (isPlotQuery || lowerMessage.includes('这部'))) type = 'detail'; else if (isPerson || hasTimeKeyword) type = 'query'; // 决定是否需要各个数据源 // 联网搜索: 只在真正需要实时信息时启用 const needWebSearch = hasTimeKeyword || isPerson || message.includes('新闻') || (isRecommendation && hasTimeKeyword) || // 推荐+时效性 type === 'query'; const needDouban = isRecommendation || type === 'detail' || (context?.douban_id !== undefined && context.douban_id > 0); const needTMDB = type === 'detail' || (context?.tmdb_id !== undefined && context.tmdb_id > 0); return { type, mediaType, needWebSearch, needDouban, needTMDB, keywords: timeKeywords.filter((k) => message.includes(k)), entities: extractEntities(message), }; } /** * 提取实体(简化版,基于关键词匹配) */ function extractEntities(message: string): Array<{ type: string; value: string }> { const entities: Array<{ type: string; value: string }> = []; // 简单的人名匹配(中文2-4字) const personPattern = /([一-龥]{2,4})(的|是|演|导)/g; let match; while ((match = personPattern.exec(message)) !== null) { entities.push({ type: 'person', value: match[1] }); } return entities; } /** * 获取联网搜索结果 */ async function fetchWebSearch( query: string, provider: 'tavily' | 'serper' | 'serpapi', apiKey: string ): Promise { try { if (provider === 'tavily') { const response = await fetch('https://api.tavily.com/search', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ api_key: apiKey, query, search_depth: 'basic', include_domains: ['douban.com', 'imdb.com', 'themoviedb.org', 'mtime.com'], max_results: 5, }), }); if (!response.ok) { throw new Error(`Tavily API error: ${response.status}`); } return await response.json(); } else if (provider === 'serper') { const response = await fetch('https://google.serper.dev/search', { method: 'POST', headers: { 'X-API-KEY': apiKey, 'Content-Type': 'application/json', }, body: JSON.stringify({ q: query, num: 5, }), }); if (!response.ok) { throw new Error(`Serper API error: ${response.status}`); } return await response.json(); } else if (provider === 'serpapi') { const response = await fetch( `https://serpapi.com/search?engine=google&q=${encodeURIComponent(query)}&api_key=${apiKey}&num=5` ); if (!response.ok) { throw new Error(`SerpAPI error: ${response.status}`); } return await response.json(); } } catch (error) { console.error('Web search error:', error); return null; } } /** * 获取豆瓣数据 * 服务器端直接调用豆瓣API */ async function fetchDoubanData(params: { id?: number; query?: string; kind?: string; category?: string; type?: string; }): Promise { try { // 1. 通过 ID 获取详情 if (params.id) { const url = `https://m.douban.com/rexxar/api/v2/subject/${params.id}`; console.log('📡 获取豆瓣详情:', params.id); return await fetchDoubanAPI(url); } // 2. 通过分类获取热门列表 if (params.kind && params.category && params.type) { const url = `https://m.douban.com/rexxar/api/v2/subject/recent_hot/${params.kind}?start=0&limit=20&category=${encodeURIComponent(params.category)}&type=${encodeURIComponent(params.type)}`; console.log('📡 获取豆瓣分类:', params.kind, params.category, params.type); return await fetchDoubanAPI(url); } // 3. 通过搜索查询 if (params.query) { const kind = params.kind || 'movie'; const url = `https://movie.douban.com/j/search_subjects?type=${kind}&tag=${encodeURIComponent(params.query)}&sort=recommend&page_limit=20&page_start=0`; console.log('📡 搜索豆瓣:', params.query, kind); return await fetchDoubanAPI(url); } console.log('⚠️ 豆瓣数据获取参数不完整:', params); return null; } catch (error) { console.error('❌ 豆瓣数据获取失败:', error); return null; } } /** * 获取TMDB数据 * 服务器端直接调用TMDB API */ async function fetchTMDBData( params: { id?: number; type?: 'movie' | 'tv'; }, tmdbApiKey?: string, tmdbProxy?: string, tmdbReverseProxy?: string ): Promise { try { const actualKey = getNextApiKey(tmdbApiKey || ''); if (!actualKey) { console.log('⚠️ TMDB API Key 未配置,跳过TMDB数据获取'); return null; } if (!params.id || !params.type) { console.log('⚠️ TMDB数据获取参数不完整:', params); return null; } // 使用反代代理或默认 Base URL const baseUrl = tmdbReverseProxy || 'https://api.themoviedb.org'; // 使用 TMDB API 获取详情 // TMDB API: https://api.themoviedb.org/3/{type}/{id} const url = `${baseUrl}/3/${params.type}/${params.id}?api_key=${actualKey}&language=zh-CN&append_to_response=keywords,similar`; console.log('📡 获取TMDB详情:', params.type, params.id); const fetchOptions: any = tmdbProxy ? { // 如果有代理,使用 node-fetch 和代理 signal: AbortSignal.timeout(15000), } : { signal: AbortSignal.timeout(15000), }; const response = await fetch(url, fetchOptions); if (!response.ok) { console.error('❌ TMDB API 请求失败:', response.status, response.statusText); return null; } return await response.json(); } catch (error) { console.error('❌ TMDB数据获取失败:', error); return null; } } /** * 格式化搜索结果为文本 */ function formatSearchResults( results: any, provider: 'tavily' | 'serper' | 'serpapi' ): string { if (!results) return ''; try { if (provider === 'tavily' && results.results) { return results.results .map( (r: any) => ` 标题: ${r.title} 内容: ${r.content} 来源: ${r.url} ` ) .join('\n'); } else if (provider === 'serper' && results.organic) { return results.organic .map( (r: any) => ` 标题: ${r.title} 摘要: ${r.snippet} 来源: ${r.link} ` ) .join('\n'); } else if (provider === 'serpapi' && results.organic_results) { return results.organic_results .map( (r: any) => ` 标题: ${r.title} 摘要: ${r.snippet} 来源: ${r.link} ` ) .join('\n'); } } catch (error) { console.error('Format search results error:', error); } return '' } /** * 清理可能被代码块包裹的JSON字符串 */ function cleanJsonResponse(content: string): string { let cleaned = content.trim(); // 尝试提取代码块中的内容(支持前面有说明文字的情况) const codeBlockMatch = cleaned.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/i); if (codeBlockMatch) { cleaned = codeBlockMatch[1].trim(); } else { // 如果没有代码块,尝试提取第一个 { 到最后一个 } 之间的内容 const jsonMatch = cleaned.match(/\{[\s\S]*\}/); if (jsonMatch) { cleaned = jsonMatch[0]; } } return cleaned.trim(); } /** * 使用决策模型判断是否需要调用各个数据源 */ async function callDecisionModel( userMessage: string, context: VideoContext | undefined, config: { provider: 'openai' | 'claude' | 'custom'; apiKey: string; baseURL?: string; model: string; }, availableDataSources: { webSearch: boolean; douban: boolean; tmdb: boolean; } ): Promise { // 构建可用数据源列表 const availableSources: string[] = []; if (availableDataSources.webSearch) { availableSources.push('1. **联网搜索** - 获取最新的实时信息(新闻、上映时间、续集信息等)'); } if (availableDataSources.douban) { availableSources.push('2. **豆瓣API** - 获取中文影视数据(评分、演员、简介、用户评论等)'); } if (availableDataSources.tmdb) { availableSources.push('3. **TMDB API** - 获取国际影视数据(详细元数据、相似推荐等)'); } const systemPrompt = `你是一个影视问答决策系统。请分析用户的问题,判断需要调用哪些数据源来回答。 当前可用的数据源: ${availableSources.join('\n')} ${availableSources.length === 0 ? '⚠️ 没有可用的数据源,请返回所有字段为false' : ''} 请以JSON格式返回决策结果,包含以下字段: { "needWebSearch": boolean, // 是否需要联网搜索${!availableDataSources.webSearch ? ' (当前不可用,必须返回false)' : ''} "needDouban": boolean, // 是否需要豆瓣数据${!availableDataSources.douban ? ' (当前不可用,必须返回false)' : ''} "needTMDB": boolean, // 是否需要TMDB数据${!availableDataSources.tmdb ? ' (当前不可用,必须返回false)' : ''} "webSearchQuery": string, // 如果需要联网,用什么关键词搜索(可选) "doubanQuery": string, // 如果需要豆瓣,用什么关键词搜索(可选) "reasoning": string // 简要说明决策理由 } 决策原则: - **只能选择当前可用的数据源,不可用的数据源必须返回false** - **优先使用最少的数据源来满足需求,避免不必要的API调用** - 时效性问题(最新、上映时间、续集、播出、更新等)→ 需要联网搜索${!availableDataSources.webSearch ? '(但当前不可用)' : ''} - 演员/导演相关问题 → 优先豆瓣,如果问"最近作品"则额外联网 - 推荐类问题 → 仅豆瓣(如果包含"最新""今年"等时效性关键词则额外联网) - 剧情、评分等静态信息 → 仅豆瓣或TMDB,不需要联网 - 当前视频的详细信息(有视频上下文) → 豆瓣+TMDB,通常不需要联网 - 新闻、热点、讨论等 → 需要联网搜索${!availableDataSources.webSearch ? '(但当前不可用)' : ''} 只返回JSON,不要其他内容。`; let contextInfo = ''; if (context?.title) { contextInfo = `\n\n当前视频上下文:\n- 标题:${context.title}`; if (context.year) contextInfo += `\n- 年份:${context.year}`; if (context.type) contextInfo += `\n- 类型:${context.type === 'movie' ? '电影' : '电视剧'}`; if (context.currentEpisode) contextInfo += `\n- 当前集数:第${context.currentEpisode}集`; } const userPrompt = `用户问题:${userMessage}${contextInfo}`; try { let response: Response; if (config.provider === 'claude') { response = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': config.apiKey, 'anthropic-version': '2023-06-01', }, body: JSON.stringify({ model: config.model, max_tokens: 500, temperature: 0, system: systemPrompt, messages: [{ role: 'user', content: userPrompt }], }), }); if (!response.ok) { throw new Error(`Claude API error: ${response.status}`); } const data = await response.json(); const content = data.content?.[0]?.text || ''; // 清理可能的代码块标记 const cleanedContent = cleanJsonResponse(content); // 提取JSON const jsonMatch = cleanedContent.match(/\{[\s\S]*\}/); if (jsonMatch) { return JSON.parse(jsonMatch[0]); } } else { // OpenAI 或 自定义 (OpenAI兼容格式) const baseURL = normalizeApiBaseUrl( config.baseURL || 'https://api.openai.com/v1' ); response = await fetch(`${baseURL}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.apiKey}`, }, body: JSON.stringify({ model: config.model, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }, ], temperature: 0, max_tokens: 500, response_format: { type: 'json_object' }, }), }); if (!response.ok) { throw new Error(`OpenAI API error: ${response.status}`); } const data = await response.json(); const content = data.choices?.[0]?.message?.content || '{}'; // 清理可能的代码块标记 const cleanedContent = cleanJsonResponse(content); return JSON.parse(cleanedContent); } } catch (error) { console.error('❌ 决策模型调用失败:', error); // 失败时返回null,由调用方降级到传统意图分析 return null as any; } // 不应该到达这里 return null as any; } /** * 主协调函数 */ export async function orchestrateDataSources( userMessage: string, context?: VideoContext, config?: { enableWebSearch: boolean; webSearchProvider?: 'tavily' | 'serper' | 'serpapi'; tavilyApiKey?: string; serperApiKey?: string; serpApiKey?: string; // TMDB 配置 tmdbApiKey?: string; tmdbProxy?: string; tmdbReverseProxy?: string; // 决策模型配置 enableDecisionModel?: boolean; decisionProvider?: 'openai' | 'claude' | 'custom'; decisionApiKey?: string; decisionBaseURL?: string; decisionModel?: string; } ): Promise { let intent: IntentAnalysisResult; // 1. 使用决策模型或传统意图分析 let decision: DecisionResult | null = null; if (config?.enableDecisionModel && config.decisionProvider && config.decisionApiKey && config.decisionModel) { console.log('🤖 使用决策模型分析...'); // 确定哪些数据源是可用的 const hasWebSearchProvider = !!(config.enableWebSearch && config.webSearchProvider && ( (config.webSearchProvider === 'tavily' && config.tavilyApiKey) || (config.webSearchProvider === 'serper' && config.serperApiKey) || (config.webSearchProvider === 'serpapi' && config.serpApiKey) )); const hasTMDB = !!(config.tmdbApiKey); decision = await callDecisionModel( userMessage, context, { provider: config.decisionProvider, apiKey: config.decisionApiKey, baseURL: config.decisionBaseURL, model: config.decisionModel, }, { webSearch: hasWebSearchProvider, douban: true, // 豆瓣始终可用(服务器端直接调用) tmdb: hasTMDB, } ); console.log('🎯 决策模型结果:', decision); } // 如果决策模型失败或未启用,降级到传统意图分析 if (!decision) { if (config?.enableDecisionModel) { console.log('⚠️ 决策模型失败,降级到传统意图分析'); } // 传统关键词匹配分析 intent = analyzeIntent(userMessage, context); console.log('📊 意图分析结果:', intent); } else { // 将决策结果转换为 IntentAnalysisResult 格式 // 保留决策模型的查询优化 intent = { type: decision.needDouban && !decision.needWebSearch ? 'detail' : decision.needWebSearch ? 'query' : 'general', needWebSearch: decision.needWebSearch, needDouban: decision.needDouban, needTMDB: decision.needTMDB, keywords: decision.webSearchQuery ? [decision.webSearchQuery] : [], entities: [], mediaType: context?.type, }; // 保存优化的查询字符串 (intent as any).optimizedWebSearchQuery = decision.webSearchQuery; (intent as any).optimizedDoubanQuery = decision.doubanQuery; } // 2. 并行获取所需的数据源 const dataPromises: Promise[] = []; let webSearchPromise: Promise | null = null; let doubanPromise: Promise | null = null; let tmdbPromise: Promise | null = null; // 联网搜索 if ( intent.needWebSearch && config?.enableWebSearch && config.webSearchProvider ) { const provider = config.webSearchProvider; const apiKey = provider === 'tavily' ? config.tavilyApiKey : provider === 'serper' ? config.serperApiKey : config.serpApiKey; if (apiKey) { // 使用决策模型优化的查询,如果没有则使用原始消息 const searchQuery = (intent as any).optimizedWebSearchQuery || userMessage; webSearchPromise = fetchWebSearch(searchQuery, provider, apiKey); dataPromises.push(webSearchPromise); } } // 豆瓣数据 if (intent.needDouban) { if (context?.douban_id) { doubanPromise = fetchDoubanData({ id: context.douban_id }); } else if (intent.type === 'recommendation') { doubanPromise = fetchDoubanData({ kind: intent.mediaType || 'movie', category: '热门', type: intent.genre || '全部', }); } else if ((intent as any).optimizedDoubanQuery) { // 使用决策模型优化的豆瓣查询 doubanPromise = fetchDoubanData({ query: (intent as any).optimizedDoubanQuery, kind: intent.mediaType || context?.type, }); } else if (context?.title) { doubanPromise = fetchDoubanData({ query: context.title, kind: context.type, }); } if (doubanPromise) { dataPromises.push(doubanPromise); } } // TMDB数据 if (intent.needTMDB && context?.tmdb_id && context?.type) { tmdbPromise = fetchTMDBData( { id: context.tmdb_id, type: context.type, }, config?.tmdbApiKey, config?.tmdbProxy, config?.tmdbReverseProxy ); dataPromises.push(tmdbPromise); } // 3. 等待所有数据获取完成 const results = await Promise.allSettled(dataPromises); let webSearchData = null; let doubanData = null; let tmdbData = null; let resultIndex = 0; if (webSearchPromise) { const result = results[resultIndex++]; if (result.status === 'fulfilled') { webSearchData = result.value; } } if (doubanPromise) { const result = results[resultIndex++]; if (result.status === 'fulfilled') { doubanData = result.value; } } if (tmdbPromise) { const result = results[resultIndex++]; if (result.status === 'fulfilled') { tmdbData = result.value; } } // 4. 构建系统提示词 // 获取UTC+8时区的日期 const now = new Date(); const utc8Date = new Date(now.getTime() + 8 * 60 * 60 * 1000); const today = utc8Date.toISOString().split('T')[0]; // YYYY-MM-DD格式 let systemPrompt = `你是 MoonTVPlus 的 AI 影视助手,专门帮助用户发现和了解影视内容。 ## 当前日期 ${today} ## 你的能力 - 提供影视推荐(基于豆瓣热门榜单和TMDB数据) - 回答影视相关问题(剧情、演员、评分等) - 搜索最新影视资讯(如果启用了联网搜索) ## 回复要求 1. 语言风格:友好、专业、简洁 2. 信息来源:优先使用提供的数据,诚实告知数据不足 3. 推荐理由:说明为什么值得看,包括评分、类型、特色等 4. 格式清晰:使用分段、列表等让内容易读 `; // 添加联网搜索结果 if (webSearchData && config?.webSearchProvider) { const formattedSearch = formatSearchResults( webSearchData, config.webSearchProvider ); if (formattedSearch) { systemPrompt += `\n## 【联网搜索结果】(最新实时信息)\n${formattedSearch}\n`; } } // 添加豆瓣数据 if (doubanData) { systemPrompt += `\n## 【豆瓣数据】(权威中文评分和信息)\n`; if (doubanData.list) { // 列表数据 systemPrompt += `推荐列表(${doubanData.list.length}部):\n${JSON.stringify( doubanData.list.slice(0, 10).map((item: any) => ({ title: item.title, rating: item.rating, year: item.year, genres: item.genres, directors: item.directors, actors: item.actors, })), null, 2 )}\n`; } else if (doubanData.items) { // 搜索结果 systemPrompt += `搜索结果:\n${JSON.stringify( doubanData.items.slice(0, 5), null, 2 )}\n`; } else { // 详情数据 systemPrompt += JSON.stringify( { title: doubanData.title, rating: doubanData.rating, year: doubanData.year, genres: doubanData.genres, directors: doubanData.directors, actors: doubanData.actors, intro: doubanData.intro, reviews: doubanData.reviews?.slice(0, 2), }, null, 2 ); systemPrompt += '\n'; } } // 添加TMDB数据 if (tmdbData) { systemPrompt += `\n## 【TMDB数据】(国际数据和详细元信息)\n`; systemPrompt += JSON.stringify( { title: tmdbData.title || tmdbData.name, overview: tmdbData.overview, vote_average: tmdbData.vote_average, genres: tmdbData.genres, keywords: tmdbData.keywords, similar: tmdbData.similar?.slice(0, 5), }, null, 2 ); systemPrompt += '\n'; } // 添加当前视频上下文 if (context?.title) { systemPrompt += `\n## 【当前视频上下文】\n`; systemPrompt += `用户正在浏览: ${context.title}`; if (context.year) systemPrompt += ` (${context.year})`; if (context.currentEpisode) { systemPrompt += `,当前第 ${context.currentEpisode} 集`; } systemPrompt += '\n'; } systemPrompt += `\n## 数据来源优先级 1. 如果有联网搜索结果,优先使用其最新信息 2. 豆瓣数据提供中文评价和评分(更适合中文用户) 3. TMDB数据更国际化,提供关键词和相似推荐 4. 如果多个数据源有冲突,以联网搜索为准 5. 如果数据不足以回答问题,诚实告知用户 现在请回答用户的问题。`; console.log('📝 生成的系统提示词长度:', systemPrompt.length); return { systemPrompt, webSearchResults: webSearchData, doubanData, tmdbData, }; }