优化ai决策
This commit is contained in:
@@ -121,7 +121,12 @@ function transformToSSE(
|
|||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith('data: ')) {
|
if (line.startsWith('data: ')) {
|
||||||
const data = line.slice(6);
|
const data = line.slice(6).trim();
|
||||||
|
|
||||||
|
// 跳过空数据
|
||||||
|
if (!data) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (data === '[DONE]') {
|
if (data === '[DONE]') {
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
@@ -151,7 +156,10 @@ function transformToSSE(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Parse stream chunk error:', e);
|
// 只在非空数据解析失败时打印错误
|
||||||
|
if (data.length > 0) {
|
||||||
|
console.error('Parse stream chunk error:', e, 'Data:', data.substring(0, 100));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,6 +236,9 @@ export async function POST(request: NextRequest) {
|
|||||||
tavilyApiKey: aiConfig.TavilyApiKey,
|
tavilyApiKey: aiConfig.TavilyApiKey,
|
||||||
serperApiKey: aiConfig.SerperApiKey,
|
serperApiKey: aiConfig.SerperApiKey,
|
||||||
serpApiKey: aiConfig.SerpApiKey,
|
serpApiKey: aiConfig.SerpApiKey,
|
||||||
|
// TMDB 配置
|
||||||
|
tmdbApiKey: adminConfig.SiteConfig.TMDBApiKey,
|
||||||
|
tmdbProxy: adminConfig.SiteConfig.TMDBProxy,
|
||||||
// 决策模型配置(固定使用自定义provider,复用主模型的API配置)
|
// 决策模型配置(固定使用自定义provider,复用主模型的API配置)
|
||||||
enableDecisionModel: aiConfig.EnableDecisionModel,
|
enableDecisionModel: aiConfig.EnableDecisionModel,
|
||||||
decisionProvider: 'custom',
|
decisionProvider: 'custom',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { X, Send, Bot, Loader2, Sparkles } from 'lucide-react';
|
import { X, Send, Bot, Loader2, Sparkles, Trash2 } from 'lucide-react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { VideoContext } from '@/lib/ai-orchestrator';
|
import { VideoContext } from '@/lib/ai-orchestrator';
|
||||||
@@ -26,13 +26,23 @@ export default function AIChatPanel({
|
|||||||
context,
|
context,
|
||||||
welcomeMessage = '你好!我是MoonTVPlus的AI影视助手,有什么可以帮你的吗?',
|
welcomeMessage = '你好!我是MoonTVPlus的AI影视助手,有什么可以帮你的吗?',
|
||||||
}: AIChatPanelProps) {
|
}: AIChatPanelProps) {
|
||||||
|
// 生成sessionStorage的key,基于视频上下文
|
||||||
|
const getStorageKey = () => {
|
||||||
|
if (context?.title) {
|
||||||
|
return `ai-chat-${context.title}-${context.year || ''}-${context.type || ''}`;
|
||||||
|
}
|
||||||
|
return 'ai-chat-general';
|
||||||
|
};
|
||||||
|
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||||
{ role: 'assistant', content: welcomeMessage },
|
{ role: 'assistant', content: welcomeMessage },
|
||||||
]);
|
]);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const contextKeyRef = useRef<string>(getStorageKey());
|
||||||
|
|
||||||
// 自动滚动到底部
|
// 自动滚动到底部
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
@@ -43,9 +53,59 @@ export default function AIChatPanel({
|
|||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
|
// 从sessionStorage加载消息记录
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const storageKey = getStorageKey();
|
||||||
|
const savedMessages = sessionStorage.getItem(storageKey);
|
||||||
|
|
||||||
|
if (savedMessages) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(savedMessages);
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
|
setMessages(parsed);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载聊天记录失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []); // 只在组件挂载时加载一次
|
||||||
|
|
||||||
|
// 保存消息记录到sessionStorage
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const storageKey = getStorageKey();
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(storageKey, JSON.stringify(messages));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存聊天记录失败:', error);
|
||||||
|
}
|
||||||
|
}, [messages, context]); // 消息变化时保存
|
||||||
|
|
||||||
|
// 检测VideoContext变化,清除旧的聊天记录
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const newKey = getStorageKey();
|
||||||
|
if (contextKeyRef.current !== newKey) {
|
||||||
|
// 上下文变化了,清除消息并重置为欢迎消息
|
||||||
|
console.log('视频上下文变化,清除聊天记录');
|
||||||
|
setMessages([{ role: 'assistant', content: welcomeMessage }]);
|
||||||
|
contextKeyRef.current = newKey;
|
||||||
|
}
|
||||||
|
}, [context, welcomeMessage]); // 监听context变化
|
||||||
|
|
||||||
// 自动聚焦输入框和防止背景滚动
|
// 自动聚焦输入框和防止背景滚动
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
|
// 检测是否为移动设备
|
||||||
|
const checkMobile = () => {
|
||||||
|
setIsMobile(window.innerWidth < 768);
|
||||||
|
};
|
||||||
|
checkMobile();
|
||||||
|
|
||||||
// 聚焦输入框
|
// 聚焦输入框
|
||||||
if (inputRef.current) {
|
if (inputRef.current) {
|
||||||
inputRef.current.focus();
|
inputRef.current.focus();
|
||||||
@@ -174,6 +234,20 @@ export default function AIChatPanel({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 清空聊天上下文
|
||||||
|
const handleClearContext = () => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
// 清除sessionStorage
|
||||||
|
const storageKey = getStorageKey();
|
||||||
|
sessionStorage.removeItem(storageKey);
|
||||||
|
|
||||||
|
// 重置消息为欢迎消息
|
||||||
|
setMessages([{ role: 'assistant', content: welcomeMessage }]);
|
||||||
|
|
||||||
|
console.log('已清空聊天上下文');
|
||||||
|
};
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const modalContent = (
|
const modalContent = (
|
||||||
@@ -289,12 +363,20 @@ export default function AIChatPanel({
|
|||||||
{/* 输入区域 */}
|
{/* 输入区域 */}
|
||||||
<div className='border-t border-gray-200 p-4 dark:border-gray-700'>
|
<div className='border-t border-gray-200 p-4 dark:border-gray-700'>
|
||||||
<div className='flex gap-2'>
|
<div className='flex gap-2'>
|
||||||
|
<button
|
||||||
|
onClick={handleClearContext}
|
||||||
|
className='flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border border-gray-300 text-gray-500 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-800'
|
||||||
|
title='清空聊天记录'
|
||||||
|
disabled={isStreaming}
|
||||||
|
>
|
||||||
|
<Trash2 size={20} />
|
||||||
|
</button>
|
||||||
<textarea
|
<textarea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder='输入你的问题... (Shift+Enter换行)'
|
placeholder={isMobile ? '输入你的问题...' : '输入你的问题... (Shift+Enter换行)'}
|
||||||
disabled={isStreaming}
|
disabled={isStreaming}
|
||||||
rows={1}
|
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'
|
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'
|
||||||
|
|||||||
+198
-89
@@ -4,6 +4,9 @@
|
|||||||
* 负责协调AI与联网搜索、豆瓣API、TMDB API之间的数据交互
|
* 负责协调AI与联网搜索、豆瓣API、TMDB API之间的数据交互
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { fetchDoubanData as fetchDoubanAPI } from '@/lib/douban';
|
||||||
|
import { searchTMDB, getTVSeasons } from '@/lib/tmdb.search';
|
||||||
|
|
||||||
export interface VideoContext {
|
export interface VideoContext {
|
||||||
title?: string;
|
title?: string;
|
||||||
year?: string;
|
year?: string;
|
||||||
@@ -87,15 +90,13 @@ export function analyzeIntent(
|
|||||||
else if (isPerson || hasTimeKeyword) type = 'query';
|
else if (isPerson || hasTimeKeyword) type = 'query';
|
||||||
|
|
||||||
// 决定是否需要各个数据源
|
// 决定是否需要各个数据源
|
||||||
// 联网搜索: 对于推荐、查询、时效性问题、演员信息等都应该启用
|
// 联网搜索: 只在真正需要实时信息时启用
|
||||||
// 当用户在观看视频时提问(有context),默认也应该联网以获取最新信息
|
|
||||||
const needWebSearch =
|
const needWebSearch =
|
||||||
hasTimeKeyword ||
|
hasTimeKeyword ||
|
||||||
isPerson ||
|
isPerson ||
|
||||||
message.includes('新闻') ||
|
message.includes('新闻') ||
|
||||||
isRecommendation ||
|
(isRecommendation && hasTimeKeyword) || // 推荐+时效性
|
||||||
type === 'query' ||
|
type === 'query';
|
||||||
(context?.title !== undefined); // 有上下文时默认联网
|
|
||||||
const needDouban =
|
const needDouban =
|
||||||
isRecommendation ||
|
isRecommendation ||
|
||||||
type === 'detail' ||
|
type === 'detail' ||
|
||||||
@@ -197,6 +198,7 @@ async function fetchWebSearch(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取豆瓣数据
|
* 获取豆瓣数据
|
||||||
|
* 服务器端直接调用豆瓣API
|
||||||
*/
|
*/
|
||||||
async function fetchDoubanData(params: {
|
async function fetchDoubanData(params: {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -206,66 +208,86 @@ async function fetchDoubanData(params: {
|
|||||||
type?: string;
|
type?: string;
|
||||||
}): Promise<any> {
|
}): Promise<any> {
|
||||||
try {
|
try {
|
||||||
|
// 1. 通过 ID 获取详情
|
||||||
if (params.id) {
|
if (params.id) {
|
||||||
// 获取详情
|
const url = `https://m.douban.com/rexxar/api/v2/subject/${params.id}`;
|
||||||
const response = await fetch(`/api/douban/detail?id=${params.id}`);
|
console.log('📡 获取豆瓣详情:', params.id);
|
||||||
if (response.ok) {
|
return await fetchDoubanAPI(url);
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
} else if (params.query) {
|
|
||||||
// 搜索
|
|
||||||
const response = await fetch('/api/douban/search', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
query: params.query,
|
|
||||||
type: params.kind || 'movie',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
} else if (params.kind && params.category) {
|
|
||||||
// 分类列表
|
|
||||||
const response = await fetch('/api/douban/categories', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
kind: params.kind,
|
|
||||||
category: params.category,
|
|
||||||
type: params.type || '全部',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
} catch (error) {
|
||||||
console.error('Douban API error:', error);
|
console.error('❌ 豆瓣数据获取失败:', error);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取TMDB数据
|
* 获取TMDB数据
|
||||||
|
* 服务器端直接调用TMDB API
|
||||||
*/
|
*/
|
||||||
async function fetchTMDBData(params: {
|
async function fetchTMDBData(
|
||||||
id?: number;
|
params: {
|
||||||
type?: 'movie' | 'tv';
|
id?: number;
|
||||||
}): Promise<any> {
|
type?: 'movie' | 'tv';
|
||||||
|
},
|
||||||
|
tmdbApiKey?: string,
|
||||||
|
tmdbProxy?: string
|
||||||
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
if (params.id && params.type) {
|
if (!tmdbApiKey) {
|
||||||
const response = await fetch(
|
console.log('⚠️ TMDB API Key 未配置,跳过TMDB数据获取');
|
||||||
`/api/tmdb/detail?id=${params.id}&type=${params.type}`
|
return null;
|
||||||
);
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!params.id || !params.type) {
|
||||||
|
console.log('⚠️ TMDB数据获取参数不完整:', params);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 TMDB API 获取详情
|
||||||
|
// TMDB API: https://api.themoviedb.org/3/{type}/{id}
|
||||||
|
const url = `https://api.themoviedb.org/3/${params.type}/${params.id}?api_key=${tmdbApiKey}&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) {
|
} catch (error) {
|
||||||
console.error('TMDB API error:', error);
|
console.error('❌ TMDB数据获取失败:', error);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -316,6 +338,22 @@ function formatSearchResults(
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理可能被代码块包裹的JSON字符串
|
||||||
|
*/
|
||||||
|
function cleanJsonResponse(content: string): string {
|
||||||
|
// 去除可能的markdown代码块标记
|
||||||
|
let cleaned = content.trim();
|
||||||
|
|
||||||
|
// 移除 ```json 或 ``` 开头
|
||||||
|
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/i, '');
|
||||||
|
|
||||||
|
// 移除 ``` 结尾
|
||||||
|
cleaned = cleaned.replace(/\n?```\s*$/, '');
|
||||||
|
|
||||||
|
return cleaned.trim();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用决策模型判断是否需要调用各个数据源
|
* 使用决策模型判断是否需要调用各个数据源
|
||||||
*/
|
*/
|
||||||
@@ -327,31 +365,50 @@ async function callDecisionModel(
|
|||||||
apiKey: string;
|
apiKey: string;
|
||||||
baseURL?: string;
|
baseURL?: string;
|
||||||
model: string;
|
model: string;
|
||||||
|
},
|
||||||
|
availableDataSources: {
|
||||||
|
webSearch: boolean;
|
||||||
|
douban: boolean;
|
||||||
|
tmdb: boolean;
|
||||||
}
|
}
|
||||||
): Promise<DecisionResult> {
|
): Promise<DecisionResult> {
|
||||||
|
// 构建可用数据源列表
|
||||||
|
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 = `你是一个影视问答决策系统。请分析用户的问题,判断需要调用哪些数据源来回答。
|
const systemPrompt = `你是一个影视问答决策系统。请分析用户的问题,判断需要调用哪些数据源来回答。
|
||||||
|
|
||||||
可用的数据源:
|
当前可用的数据源:
|
||||||
1. **联网搜索** - 获取最新的实时信息(新闻、上映时间、续集信息等)
|
${availableSources.join('\n')}
|
||||||
2. **豆瓣API** - 获取中文影视数据(评分、演员、简介、用户评论等)
|
${availableSources.length === 0 ? '⚠️ 没有可用的数据源,请返回所有字段为false' : ''}
|
||||||
3. **TMDB API** - 获取国际影视数据(详细元数据、相似推荐等)
|
|
||||||
|
|
||||||
请以JSON格式返回决策结果,包含以下字段:
|
请以JSON格式返回决策结果,包含以下字段:
|
||||||
{
|
{
|
||||||
"needWebSearch": boolean, // 是否需要联网搜索
|
"needWebSearch": boolean, // 是否需要联网搜索${!availableDataSources.webSearch ? ' (当前不可用,必须返回false)' : ''}
|
||||||
"needDouban": boolean, // 是否需要豆瓣数据
|
"needDouban": boolean, // 是否需要豆瓣数据${!availableDataSources.douban ? ' (当前不可用,必须返回false)' : ''}
|
||||||
"needTMDB": boolean, // 是否需要TMDB数据
|
"needTMDB": boolean, // 是否需要TMDB数据${!availableDataSources.tmdb ? ' (当前不可用,必须返回false)' : ''}
|
||||||
"webSearchQuery": string, // 如果需要联网,用什么关键词搜索(可选)
|
"webSearchQuery": string, // 如果需要联网,用什么关键词搜索(可选)
|
||||||
"doubanQuery": string, // 如果需要豆瓣,用什么关键词搜索(可选)
|
"doubanQuery": string, // 如果需要豆瓣,用什么关键词搜索(可选)
|
||||||
"reasoning": string // 简要说明决策理由
|
"reasoning": string // 简要说明决策理由
|
||||||
}
|
}
|
||||||
|
|
||||||
决策原则:
|
决策原则:
|
||||||
- 时效性问题(最新、上映时间、续集、播出等)→ 需要联网搜索
|
- **只能选择当前可用的数据源,不可用的数据源必须返回false**
|
||||||
- 推荐类问题 → 优先豆瓣
|
- **优先使用最少的数据源来满足需求,避免不必要的API调用**
|
||||||
- 剧情、演员、评分等静态信息 → 豆瓣或TMDB
|
- 时效性问题(最新、上映时间、续集、播出、更新等)→ 需要联网搜索${!availableDataSources.webSearch ? '(但当前不可用)' : ''}
|
||||||
- 当前视频的详细信息 → 豆瓣+TMDB
|
- 演员/导演相关问题 → 优先豆瓣,如果问"最近作品"则额外联网
|
||||||
- 有疑问时倾向于多调用数据源
|
- 推荐类问题 → 仅豆瓣(如果包含"最新""今年"等时效性关键词则额外联网)
|
||||||
|
- 剧情、评分等静态信息 → 仅豆瓣或TMDB,不需要联网
|
||||||
|
- 当前视频的详细信息(有视频上下文) → 豆瓣+TMDB,通常不需要联网
|
||||||
|
- 新闻、热点、讨论等 → 需要联网搜索${!availableDataSources.webSearch ? '(但当前不可用)' : ''}
|
||||||
|
|
||||||
只返回JSON,不要其他内容。`;
|
只返回JSON,不要其他内容。`;
|
||||||
|
|
||||||
@@ -392,8 +449,11 @@ async function callDecisionModel(
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const content = data.content?.[0]?.text || '';
|
const content = data.content?.[0]?.text || '';
|
||||||
|
|
||||||
|
// 清理可能的代码块标记
|
||||||
|
const cleanedContent = cleanJsonResponse(content);
|
||||||
|
|
||||||
// 提取JSON
|
// 提取JSON
|
||||||
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
const jsonMatch = cleanedContent.match(/\{[\s\S]*\}/);
|
||||||
if (jsonMatch) {
|
if (jsonMatch) {
|
||||||
return JSON.parse(jsonMatch[0]);
|
return JSON.parse(jsonMatch[0]);
|
||||||
}
|
}
|
||||||
@@ -424,19 +484,20 @@ async function callDecisionModel(
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const content = data.choices?.[0]?.message?.content || '{}';
|
const content = data.choices?.[0]?.message?.content || '{}';
|
||||||
return JSON.parse(content);
|
|
||||||
|
// 清理可能的代码块标记
|
||||||
|
const cleanedContent = cleanJsonResponse(content);
|
||||||
|
|
||||||
|
return JSON.parse(cleanedContent);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ 决策模型调用失败:', error);
|
console.error('❌ 决策模型调用失败:', error);
|
||||||
|
// 失败时返回null,由调用方降级到传统意图分析
|
||||||
|
return null as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 失败时返回默认决策(保守策略:都调用)
|
// 不应该到达这里
|
||||||
return {
|
return null as any;
|
||||||
needWebSearch: true,
|
|
||||||
needDouban: true,
|
|
||||||
needTMDB: context?.tmdb_id !== undefined,
|
|
||||||
reasoning: '决策模型调用失败,使用默认策略',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -451,6 +512,9 @@ export async function orchestrateDataSources(
|
|||||||
tavilyApiKey?: string;
|
tavilyApiKey?: string;
|
||||||
serperApiKey?: string;
|
serperApiKey?: string;
|
||||||
serpApiKey?: string;
|
serpApiKey?: string;
|
||||||
|
// TMDB 配置
|
||||||
|
tmdbApiKey?: string;
|
||||||
|
tmdbProxy?: string;
|
||||||
// 决策模型配置
|
// 决策模型配置
|
||||||
enableDecisionModel?: boolean;
|
enableDecisionModel?: boolean;
|
||||||
decisionProvider?: 'openai' | 'claude' | 'custom';
|
decisionProvider?: 'openai' | 'claude' | 'custom';
|
||||||
@@ -462,31 +526,64 @@ export async function orchestrateDataSources(
|
|||||||
let intent: IntentAnalysisResult;
|
let intent: IntentAnalysisResult;
|
||||||
|
|
||||||
// 1. 使用决策模型或传统意图分析
|
// 1. 使用决策模型或传统意图分析
|
||||||
|
let decision: DecisionResult | null = null;
|
||||||
if (config?.enableDecisionModel && config.decisionProvider && config.decisionApiKey && config.decisionModel) {
|
if (config?.enableDecisionModel && config.decisionProvider && config.decisionApiKey && config.decisionModel) {
|
||||||
console.log('🤖 使用决策模型分析...');
|
console.log('🤖 使用决策模型分析...');
|
||||||
|
|
||||||
const decision = await callDecisionModel(userMessage, context, {
|
// 确定哪些数据源是可用的
|
||||||
provider: config.decisionProvider,
|
const hasWebSearchProvider = !!(config.enableWebSearch &&
|
||||||
apiKey: config.decisionApiKey,
|
config.webSearchProvider &&
|
||||||
baseURL: config.decisionBaseURL,
|
(
|
||||||
model: config.decisionModel,
|
(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);
|
console.log('🎯 决策模型结果:', decision);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果决策模型失败或未启用,降级到传统意图分析
|
||||||
|
if (!decision) {
|
||||||
|
if (config?.enableDecisionModel) {
|
||||||
|
console.log('⚠️ 决策模型失败,降级到传统意图分析');
|
||||||
|
}
|
||||||
|
// 传统关键词匹配分析
|
||||||
|
intent = analyzeIntent(userMessage, context);
|
||||||
|
console.log('📊 意图分析结果:', intent);
|
||||||
|
} else {
|
||||||
// 将决策结果转换为 IntentAnalysisResult 格式
|
// 将决策结果转换为 IntentAnalysisResult 格式
|
||||||
|
// 保留决策模型的查询优化
|
||||||
intent = {
|
intent = {
|
||||||
type: decision.needDouban ? 'detail' : 'general',
|
type: decision.needDouban && !decision.needWebSearch ? 'detail' :
|
||||||
|
decision.needWebSearch ? 'query' : 'general',
|
||||||
needWebSearch: decision.needWebSearch,
|
needWebSearch: decision.needWebSearch,
|
||||||
needDouban: decision.needDouban,
|
needDouban: decision.needDouban,
|
||||||
needTMDB: decision.needTMDB,
|
needTMDB: decision.needTMDB,
|
||||||
keywords: decision.webSearchQuery ? [decision.webSearchQuery] : [],
|
keywords: decision.webSearchQuery ? [decision.webSearchQuery] : [],
|
||||||
entities: [],
|
entities: [],
|
||||||
|
mediaType: context?.type,
|
||||||
};
|
};
|
||||||
} else {
|
// 保存优化的查询字符串
|
||||||
// 传统关键词匹配分析
|
(intent as any).optimizedWebSearchQuery = decision.webSearchQuery;
|
||||||
intent = analyzeIntent(userMessage, context);
|
(intent as any).optimizedDoubanQuery = decision.doubanQuery;
|
||||||
console.log('📊 意图分析结果:', intent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 并行获取所需的数据源
|
// 2. 并行获取所需的数据源
|
||||||
@@ -511,7 +608,9 @@ export async function orchestrateDataSources(
|
|||||||
: config.serpApiKey;
|
: config.serpApiKey;
|
||||||
|
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
webSearchPromise = fetchWebSearch(userMessage, provider, apiKey);
|
// 使用决策模型优化的查询,如果没有则使用原始消息
|
||||||
|
const searchQuery = (intent as any).optimizedWebSearchQuery || userMessage;
|
||||||
|
webSearchPromise = fetchWebSearch(searchQuery, provider, apiKey);
|
||||||
dataPromises.push(webSearchPromise);
|
dataPromises.push(webSearchPromise);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -526,6 +625,12 @@ export async function orchestrateDataSources(
|
|||||||
category: '热门',
|
category: '热门',
|
||||||
type: intent.genre || '全部',
|
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) {
|
} else if (context?.title) {
|
||||||
doubanPromise = fetchDoubanData({
|
doubanPromise = fetchDoubanData({
|
||||||
query: context.title,
|
query: context.title,
|
||||||
@@ -540,10 +645,14 @@ export async function orchestrateDataSources(
|
|||||||
|
|
||||||
// TMDB数据
|
// TMDB数据
|
||||||
if (intent.needTMDB && context?.tmdb_id && context?.type) {
|
if (intent.needTMDB && context?.tmdb_id && context?.type) {
|
||||||
tmdbPromise = fetchTMDBData({
|
tmdbPromise = fetchTMDBData(
|
||||||
id: context.tmdb_id,
|
{
|
||||||
type: context.type,
|
id: context.tmdb_id,
|
||||||
});
|
type: context.type,
|
||||||
|
},
|
||||||
|
config?.tmdbApiKey,
|
||||||
|
config?.tmdbProxy
|
||||||
|
);
|
||||||
dataPromises.push(tmdbPromise);
|
dataPromises.push(tmdbPromise);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user