增加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

View File

@@ -51,7 +51,9 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.4.0",
"react-markdown": "^10.1.0",
"redis": "^4.6.7",
"remark-gfm": "^4.0.1",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"swiper": "^11.2.8",
@@ -65,6 +67,7 @@
"@commitlint/config-conventional": "^16.2.4",
"@svgr/webpack": "^8.1.0",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^15.0.7",
"@types/bs58": "^5.0.0",

885
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,7 @@ import { CSS } from '@dnd-kit/utilities';
import {
AlertCircle,
AlertTriangle,
Bot,
Check,
CheckCircle,
ChevronDown,
@@ -7641,6 +7642,454 @@ const CustomAdFilterConfig = ({
);
};
// AI配置组件
const AIConfigComponent = ({
config,
refreshConfig,
}: {
config: AdminConfig | null;
refreshConfig: () => Promise<void>;
}) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
// 状态管理
const [enabled, setEnabled] = useState(false);
// 自定义配置
const [customApiKey, setCustomApiKey] = useState('');
const [customBaseURL, setCustomBaseURL] = useState('');
const [customModel, setCustomModel] = useState('');
// 决策模型配置
const [decisionCustomModel, setDecisionCustomModel] = useState('');
// 联网搜索配置
const [enableWebSearch, setEnableWebSearch] = useState(false);
const [webSearchProvider, setWebSearchProvider] = useState<'tavily' | 'serper' | 'serpapi'>('tavily');
const [tavilyApiKey, setTavilyApiKey] = useState('');
const [serperApiKey, setSerperApiKey] = useState('');
const [serpApiKey, setSerpApiKey] = useState('');
// 功能开关
const [enableHomepageEntry, setEnableHomepageEntry] = useState(true);
const [enableVideoCardEntry, setEnableVideoCardEntry] = useState(true);
const [enablePlayPageEntry, setEnablePlayPageEntry] = useState(true);
// 高级设置
const [temperature, setTemperature] = useState(0.7);
const [maxTokens, setMaxTokens] = useState(1000);
const [systemPrompt, setSystemPrompt] = useState('');
// 从配置加载数据
useEffect(() => {
if (config?.AIConfig) {
setEnabled(config.AIConfig.Enabled || false);
setCustomApiKey(config.AIConfig.CustomApiKey || '');
setCustomBaseURL(config.AIConfig.CustomBaseURL || '');
setCustomModel(config.AIConfig.CustomModel || '');
setDecisionCustomModel(config.AIConfig.DecisionCustomModel || '');
setEnableWebSearch(config.AIConfig.EnableWebSearch || false);
setWebSearchProvider(config.AIConfig.WebSearchProvider || 'tavily');
setTavilyApiKey(config.AIConfig.TavilyApiKey || '');
setSerperApiKey(config.AIConfig.SerperApiKey || '');
setSerpApiKey(config.AIConfig.SerpApiKey || '');
setEnableHomepageEntry(config.AIConfig.EnableHomepageEntry !== false);
setEnableVideoCardEntry(config.AIConfig.EnableVideoCardEntry !== false);
setEnablePlayPageEntry(config.AIConfig.EnablePlayPageEntry !== false);
setTemperature(config.AIConfig.Temperature ?? 0.7);
setMaxTokens(config.AIConfig.MaxTokens ?? 1000);
setSystemPrompt(config.AIConfig.SystemPrompt || '');
}
}, [config]);
const handleSave = async () => {
await withLoading('saveAIConfig', async () => {
try {
const response = await fetch('/api/admin/ai', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Enabled: enabled,
Provider: 'custom',
CustomApiKey: customApiKey,
CustomBaseURL: customBaseURL,
CustomModel: customModel,
EnableDecisionModel: true,
DecisionProvider: 'custom',
DecisionCustomModel: decisionCustomModel,
EnableWebSearch: enableWebSearch,
WebSearchProvider: webSearchProvider,
TavilyApiKey: tavilyApiKey,
SerperApiKey: serperApiKey,
SerpApiKey: serpApiKey,
EnableHomepageEntry: enableHomepageEntry,
EnableVideoCardEntry: enableVideoCardEntry,
EnablePlayPageEntry: enablePlayPageEntry,
Temperature: temperature,
MaxTokens: maxTokens,
SystemPrompt: systemPrompt,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || '保存失败');
}
showSuccess('AI配置保存成功', showAlert);
await refreshConfig();
} catch (error) {
showError(error instanceof Error ? error.message : '保存失败', showAlert);
throw error;
}
});
};
return (
<div className='space-y-6'>
{/* 使用说明 */}
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'>
<div className='flex items-center gap-2 mb-2'>
<svg
className='w-5 h-5 text-blue-600 dark:text-blue-400'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
/>
</svg>
<span className='text-sm font-medium text-blue-800 dark:text-blue-300'>
使
</span>
</div>
<div className='text-sm text-blue-700 dark:text-blue-400 space-y-1'>
<p> AI问片功能可以让用户通过AI对话获取影视推荐和信息查询</p>
<p> OpenAIClaude OpenAI API</p>
<p> ,AI会智能判断是否需要联网搜索//TMDB数据</p>
<p> ,AI可以获取最新的影视资讯和信息</p>
<p> AI问片入口</p>
</div>
</div>
{/* 功能开关 */}
<div className='flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'>
<div>
<h3 className='text-sm font-medium text-gray-900 dark:text-gray-100'>
AI问片功能
</h3>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
AI问片入口将不可用
</p>
</div>
<label className='relative inline-flex items-center cursor-pointer'>
<input
type='checkbox'
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className='sr-only peer'
/>
<div className="w-14 h-7 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-green-300 dark:peer-focus:ring-green-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:start-[4px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-6 after:w-6 after:transition-all dark:border-gray-600 peer-checked:bg-green-600"></div>
</label>
</div>
{/* AI模型配置 */}
<div className='space-y-4'>
<h3 className='text-base font-semibold text-gray-900 dark:text-gray-100'>
AI模型配置
</h3>
<p className='text-sm text-gray-500 dark:text-gray-400'>
OpenAI格式的API
</p>
<div className='space-y-4 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg'>
<h4 className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
API
</h4>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
API Key <span className='text-red-500'>*</span>
</label>
<input
type='password'
value={customApiKey}
onChange={(e) => setCustomApiKey(e.target.value)}
placeholder='your-api-key'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'
/>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Base URL <span className='text-red-500'>*</span>
</label>
<input
type='text'
value={customBaseURL}
onChange={(e) => setCustomBaseURL(e.target.value)}
placeholder='https://your-api.example.com/v1'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'
/>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
<span className='text-red-500'>*</span>
</label>
<input
type='text'
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
placeholder='model-name'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'
/>
</div>
</div>
</div>
{/* 决策模型配置 */}
<div className='space-y-4 p-4 border border-gray-200 dark:border-gray-700 rounded-lg'>
<div>
<h4 className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
AI决策模型配置
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
使AI智能判断是否需要联网搜索TMDB数据,(API配置)
</p>
</div>
<div className='space-y-3 p-3 bg-purple-50/50 dark:bg-purple-900/10 rounded-lg'>
<div>
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>
</label>
<input
type='text'
value={decisionCustomModel}
onChange={(e) => setDecisionCustomModel(e.target.value)}
placeholder='gpt-4o-mini (建议使用成本较低的小模型)'
className='w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'
/>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
使,AI决策
</p>
</div>
</div>
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3'>
<p className='text-xs text-blue-700 dark:text-blue-400'>
💡 <strong>:</strong> ,使( gpt-4o-mini)API Key和Base URL配置
</p>
</div>
</div>
{/* 联网搜索配置 */}
<div className='space-y-4 p-4 border border-gray-200 dark:border-gray-700 rounded-lg'>
<div className='flex items-center justify-between'>
<div>
<h4 className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
AI可以搜索最新的影视资讯和信息
</p>
</div>
<label className='relative inline-flex items-center cursor-pointer'>
<input
type='checkbox'
checked={enableWebSearch}
onChange={(e) => setEnableWebSearch(e.target.checked)}
className='sr-only peer'
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
{enableWebSearch && (
<div className='space-y-4 mt-4'>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<select
value={webSearchProvider}
onChange={(e) => setWebSearchProvider(e.target.value as any)}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'
>
<option value='tavily'>Tavily ()</option>
<option value='serper'>Serper.dev</option>
<option value='serpapi'>SerpAPI</option>
</select>
</div>
{webSearchProvider === 'tavily' && (
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Tavily API Key
</label>
<input
type='password'
value={tavilyApiKey}
onChange={(e) => setTavilyApiKey(e.target.value)}
placeholder='tvly-...'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'
/>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
<a href='https://tavily.com' target='_blank' className='text-blue-600 hover:underline'>tavily.com</a>
</p>
</div>
)}
{webSearchProvider === 'serper' && (
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Serper API Key
</label>
<input
type='password'
value={serperApiKey}
onChange={(e) => setSerperApiKey(e.target.value)}
placeholder='your-serper-key'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'
/>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
<a href='https://serper.dev' target='_blank' className='text-blue-600 hover:underline'>serper.dev</a>
</p>
</div>
)}
{webSearchProvider === 'serpapi' && (
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
SerpAPI Key
</label>
<input
type='password'
value={serpApiKey}
onChange={(e) => setSerpApiKey(e.target.value)}
placeholder='your-serpapi-key'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'
/>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
<a href='https://serpapi.com' target='_blank' className='text-blue-600 hover:underline'>serpapi.com</a>
</p>
</div>
)}
</div>
)}
</div>
{/* 入口开关 */}
<div className='space-y-3 p-4 border border-gray-200 dark:border-gray-700 rounded-lg'>
<h4 className='text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3'>
</h4>
{[
{ 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 },
].map((item) => (
<div key={item.key} className='flex items-center justify-between py-2'>
<div>
<div className='text-sm font-medium text-gray-900 dark:text-gray-100'>
{item.label}
</div>
<div className='text-xs text-gray-500 dark:text-gray-400'>
{item.desc}
</div>
</div>
<label className='relative inline-flex items-center cursor-pointer'>
<input
type='checkbox'
checked={item.state}
onChange={(e) => item.setState(e.target.checked)}
className='sr-only peer'
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-green-300 dark:peer-focus:ring-green-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-green-600"></div>
</label>
</div>
))}
</div>
{/* 高级设置 */}
<details className='p-4 border border-gray-200 dark:border-gray-700 rounded-lg'>
<summary className='text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer'>
()
</summary>
<div className='mt-4 space-y-4'>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Temperature ({temperature})
</label>
<input
type='range'
min='0'
max='2'
step='0.1'
value={temperature}
onChange={(e) => setTemperature(parseFloat(e.target.value))}
className='w-full'
/>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
0=2=
</p>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Token数
</label>
<input
type='number'
value={maxTokens}
onChange={(e) => setMaxTokens(parseInt(e.target.value) || 1000)}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'
/>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
rows={4}
placeholder='可自定义AI的角色和行为规则...'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'
/>
</div>
</div>
</details>
{/* 保存按钮 */}
<div className='flex justify-end'>
<button
onClick={handleSave}
disabled={isLoading('saveAIConfig')}
className={isLoading('saveAIConfig') ? buttonStyles.disabled : buttonStyles.success}
>
{isLoading('saveAIConfig') ? '保存中...' : '保存配置'}
</button>
</div>
{/* 通用弹窗组件 */}
<AlertModal
isOpen={alertModal.isOpen}
onClose={hideAlert}
type={alertModal.type}
title={alertModal.title}
message={alertModal.message}
timer={alertModal.timer}
showConfirm={alertModal.showConfirm}
/>
</div>
);
};
// 直播源配置组件
const LiveSourceConfig = ({
config,
@@ -8276,6 +8725,7 @@ function AdminPageClient() {
userConfig: false,
videoSource: false,
openListConfig: false,
aiConfig: false,
liveSource: false,
siteConfig: false,
registrationConfig: false,
@@ -8570,6 +9020,18 @@ function AdminPageClient() {
<OpenListConfigComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* AI配置标签 */}
<CollapsibleTab
title='AI设定'
icon={
<Bot size={20} className='text-gray-600 dark:text-gray-400' />
}
isExpanded={expandedTabs.aiConfig}
onToggle={() => toggleTab('aiConfig')}
>
<AIConfigComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* 分类配置标签 */}
<CollapsibleTab
title='分类配置'

View File

@@ -0,0 +1,200 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') {
return NextResponse.json(
{
error: '不支持本地存储进行管理员配置',
},
{ status: 400 }
);
}
try {
const body = await request.json();
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const username = authInfo.username;
const {
Enabled,
Provider,
OpenAIApiKey,
OpenAIBaseURL,
OpenAIModel,
ClaudeApiKey,
ClaudeModel,
CustomApiKey,
CustomBaseURL,
CustomModel,
EnableDecisionModel,
DecisionProvider,
DecisionOpenAIApiKey,
DecisionOpenAIBaseURL,
DecisionOpenAIModel,
DecisionClaudeApiKey,
DecisionClaudeModel,
DecisionCustomApiKey,
DecisionCustomBaseURL,
DecisionCustomModel,
EnableWebSearch,
WebSearchProvider,
TavilyApiKey,
SerperApiKey,
SerpApiKey,
EnableHomepageEntry,
EnableVideoCardEntry,
EnablePlayPageEntry,
Temperature,
MaxTokens,
SystemPrompt,
} = body as {
Enabled: boolean;
Provider: 'openai' | 'claude' | 'custom';
OpenAIApiKey?: string;
OpenAIBaseURL?: string;
OpenAIModel?: string;
ClaudeApiKey?: string;
ClaudeModel?: string;
CustomApiKey?: string;
CustomBaseURL?: string;
CustomModel?: string;
EnableDecisionModel: boolean;
DecisionProvider?: 'openai' | 'claude' | 'custom';
DecisionOpenAIApiKey?: string;
DecisionOpenAIBaseURL?: string;
DecisionOpenAIModel?: string;
DecisionClaudeApiKey?: string;
DecisionClaudeModel?: string;
DecisionCustomApiKey?: string;
DecisionCustomBaseURL?: string;
DecisionCustomModel?: string;
EnableWebSearch: boolean;
WebSearchProvider?: 'tavily' | 'serper' | 'serpapi';
TavilyApiKey?: string;
SerperApiKey?: string;
SerpApiKey?: string;
EnableHomepageEntry: boolean;
EnableVideoCardEntry: boolean;
EnablePlayPageEntry: boolean;
Temperature?: number;
MaxTokens?: number;
SystemPrompt?: string;
};
// 参数校验
if (
typeof Enabled !== 'boolean' ||
(Provider !== undefined && !['openai', 'claude', 'custom'].includes(Provider)) ||
(OpenAIApiKey !== undefined && typeof OpenAIApiKey !== 'string') ||
(OpenAIBaseURL !== undefined && typeof OpenAIBaseURL !== 'string') ||
(OpenAIModel !== undefined && typeof OpenAIModel !== 'string') ||
(ClaudeApiKey !== undefined && typeof ClaudeApiKey !== 'string') ||
(ClaudeModel !== undefined && typeof ClaudeModel !== 'string') ||
(CustomApiKey !== undefined && typeof CustomApiKey !== 'string') ||
(CustomBaseURL !== undefined && typeof CustomBaseURL !== 'string') ||
(CustomModel !== undefined && typeof CustomModel !== 'string') ||
typeof EnableDecisionModel !== 'boolean' ||
(DecisionProvider !== undefined && !['openai', 'claude', 'custom'].includes(DecisionProvider)) ||
(DecisionOpenAIApiKey !== undefined && typeof DecisionOpenAIApiKey !== 'string') ||
(DecisionOpenAIBaseURL !== undefined && typeof DecisionOpenAIBaseURL !== 'string') ||
(DecisionOpenAIModel !== undefined && typeof DecisionOpenAIModel !== 'string') ||
(DecisionClaudeApiKey !== undefined && typeof DecisionClaudeApiKey !== 'string') ||
(DecisionClaudeModel !== undefined && typeof DecisionClaudeModel !== 'string') ||
(DecisionCustomApiKey !== undefined && typeof DecisionCustomApiKey !== 'string') ||
(DecisionCustomBaseURL !== undefined && typeof DecisionCustomBaseURL !== 'string') ||
(DecisionCustomModel !== undefined && typeof DecisionCustomModel !== 'string') ||
typeof EnableWebSearch !== 'boolean' ||
(WebSearchProvider !== undefined && !['tavily', 'serper', 'serpapi'].includes(WebSearchProvider)) ||
(TavilyApiKey !== undefined && typeof TavilyApiKey !== 'string') ||
(SerperApiKey !== undefined && typeof SerperApiKey !== 'string') ||
(SerpApiKey !== undefined && typeof SerpApiKey !== 'string') ||
typeof EnableHomepageEntry !== 'boolean' ||
typeof EnableVideoCardEntry !== 'boolean' ||
typeof EnablePlayPageEntry !== 'boolean' ||
(Temperature !== undefined && typeof Temperature !== 'number') ||
(MaxTokens !== undefined && typeof MaxTokens !== 'number') ||
(SystemPrompt !== undefined && typeof SystemPrompt !== 'string')
) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
}
const adminConfig = await getConfig();
// 权限校验 - 使用v2用户系统
if (username !== process.env.USERNAME) {
const userInfo = await db.getUserInfoV2(username);
if (!userInfo || userInfo.role !== 'admin' || userInfo.banned) {
return NextResponse.json({ error: '权限不足' }, { status: 401 });
}
}
// 更新缓存中的AI配置
adminConfig.AIConfig = {
Enabled,
Provider,
OpenAIApiKey,
OpenAIBaseURL,
OpenAIModel,
ClaudeApiKey,
ClaudeModel,
CustomApiKey,
CustomBaseURL,
CustomModel,
EnableDecisionModel,
DecisionProvider,
DecisionOpenAIApiKey,
DecisionOpenAIBaseURL,
DecisionOpenAIModel,
DecisionClaudeApiKey,
DecisionClaudeModel,
DecisionCustomApiKey,
DecisionCustomBaseURL,
DecisionCustomModel,
EnableWebSearch,
WebSearchProvider,
TavilyApiKey,
SerperApiKey,
SerpApiKey,
EnableHomepageEntry,
EnableVideoCardEntry,
EnablePlayPageEntry,
Temperature,
MaxTokens,
SystemPrompt,
};
// 写入数据库
await db.saveAdminConfig(adminConfig);
return NextResponse.json(
{ ok: true },
{
headers: {
'Cache-Control': 'no-store', // 不缓存结果
},
}
);
} catch (error) {
console.error('更新AI配置失败:', error);
return NextResponse.json(
{
error: '更新AI配置失败',
details: (error as Error).message,
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,279 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import {
orchestrateDataSources,
VideoContext,
} from '@/lib/ai-orchestrator';
import { getConfig } from '@/lib/config';
export const runtime = 'nodejs';
interface ChatMessage {
role: 'user' | 'assistant';
content: string;
}
interface ChatRequest {
message: string;
context?: VideoContext;
history?: ChatMessage[];
}
/**
* OpenAI兼容的流式聊天请求
*/
async function streamOpenAIChat(
messages: ChatMessage[],
config: {
apiKey: string;
baseURL: string;
model: string;
temperature: number;
maxTokens: number;
}
): Promise<ReadableStream> {
const response = await fetch(`${config.baseURL}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({
model: config.model,
messages,
temperature: config.temperature,
max_tokens: config.maxTokens,
stream: true,
}),
});
if (!response.ok) {
throw new Error(
`OpenAI API error: ${response.status} ${response.statusText}`
);
}
return response.body!;
}
/**
* Claude API流式聊天请求
*/
async function streamClaudeChat(
messages: ChatMessage[],
systemPrompt: string,
config: {
apiKey: string;
model: string;
temperature: number;
maxTokens: number;
}
): Promise<ReadableStream> {
// Claude API格式: 移除system消息,单独传递
const userMessages = messages.filter((m) => m.role !== 'system');
const 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: config.maxTokens,
temperature: config.temperature,
system: systemPrompt,
messages: userMessages,
stream: true,
}),
});
if (!response.ok) {
throw new Error(
`Claude API error: ${response.status} ${response.statusText}`
);
}
return response.body!;
}
/**
* 转换流为SSE格式
*/
function transformToSSE(
stream: ReadableStream,
provider: 'openai' | 'claude' | 'custom'
): ReadableStream {
const reader = stream.getReader();
const decoder = new TextDecoder();
return new ReadableStream({
async start(controller) {
try {
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]') {
controller.enqueue(
new TextEncoder().encode('data: [DONE]\n\n')
);
continue;
}
try {
const json = JSON.parse(data);
// 提取文本内容
let text = '';
if (provider === 'claude') {
// Claude格式
if (json.type === 'content_block_delta') {
text = json.delta?.text || '';
}
} else {
// OpenAI格式
text = json.choices?.[0]?.delta?.content || '';
}
if (text) {
controller.enqueue(
new TextEncoder().encode(`data: ${JSON.stringify({ text })}\n\n`)
);
}
} catch (e) {
console.error('Parse stream chunk error:', e);
}
}
}
}
} catch (error) {
console.error('Stream error:', error);
controller.error(error);
} finally {
controller.close();
}
},
});
}
export async function POST(request: NextRequest) {
try {
// 1. 验证用户登录
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// 2. 获取AI配置
const adminConfig = await getConfig();
const aiConfig = adminConfig.AIConfig;
if (!aiConfig || !aiConfig.Enabled) {
return NextResponse.json(
{ error: 'AI功能未启用' },
{ status: 400 }
);
}
// 3. 解析请求参数
const body = (await request.json()) as ChatRequest;
const { message, context, history = [] } = body;
if (!message || typeof message !== 'string') {
return NextResponse.json(
{ error: '消息内容不能为空' },
{ status: 400 }
);
}
console.log('📨 收到AI聊天请求:', {
message: message.slice(0, 50),
context,
historyLength: history.length,
});
// 4. 使用orchestrator协调数据源
const orchestrationResult = await orchestrateDataSources(
message,
context,
{
enableWebSearch: aiConfig.EnableWebSearch,
webSearchProvider: aiConfig.WebSearchProvider,
tavilyApiKey: aiConfig.TavilyApiKey,
serperApiKey: aiConfig.SerperApiKey,
serpApiKey: aiConfig.SerpApiKey,
// 决策模型配置固定使用自定义provider
enableDecisionModel: aiConfig.EnableDecisionModel,
decisionProvider: 'custom',
decisionApiKey: aiConfig.DecisionCustomApiKey,
decisionBaseURL: aiConfig.DecisionCustomBaseURL,
decisionModel: aiConfig.DecisionCustomModel,
}
);
console.log('🎯 数据协调完成, systemPrompt长度:', orchestrationResult.systemPrompt.length);
// 5. 构建消息列表
const systemPrompt = aiConfig.SystemPrompt
? `${aiConfig.SystemPrompt}\n\n${orchestrationResult.systemPrompt}`
: orchestrationResult.systemPrompt;
const messages: ChatMessage[] = [
{ role: 'user', content: systemPrompt },
{ role: 'assistant', content: '明白了,我会按照要求回答用户的问题。' },
...history,
{ role: 'user', content: message },
];
// 6. 调用自定义API
const temperature = aiConfig.Temperature ?? 0.7;
const maxTokens = aiConfig.MaxTokens ?? 1000;
if (!aiConfig.CustomApiKey || !aiConfig.CustomBaseURL) {
return NextResponse.json(
{ error: '自定义API配置不完整' },
{ status: 400 }
);
}
const stream = await streamOpenAIChat(messages, {
apiKey: aiConfig.CustomApiKey,
baseURL: aiConfig.CustomBaseURL,
model: aiConfig.CustomModel || 'gpt-3.5-turbo',
temperature,
maxTokens,
});
// 7. 转换为SSE格式并返回
const sseStream = transformToSSE(stream, 'openai');
return new NextResponse(sseStream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
} catch (error) {
console.error('❌ AI聊天API错误:', error);
return NextResponse.json(
{
error: 'AI聊天请求失败',
details: (error as Error).message,
},
{ status: 500 }
);
}
}

View File

@@ -49,6 +49,11 @@ export async function GET(request: NextRequest) {
OIDCButtonText: config.SiteConfig.OIDCButtonText || '',
loginBackgroundImage: config.ThemeConfig?.loginBackgroundImage || '',
registerBackgroundImage: config.ThemeConfig?.registerBackgroundImage || '',
// AI配置只暴露功能开关不暴露API密钥等敏感信息
AIEnabled: config.AIConfig?.Enabled || false,
AIEnableHomepageEntry: config.AIConfig?.EnableHomepageEntry || false,
AIEnableVideoCardEntry: config.AIConfig?.EnableVideoCardEntry || false,
AIEnablePlayPageEntry: config.AIConfig?.EnablePlayPageEntry || false,
};
return NextResponse.json(result);
}

View File

@@ -73,6 +73,10 @@ export default async function RootLayout({
let enableOIDCLogin = false;
let enableOIDCRegistration = false;
let oidcButtonText = '';
let aiEnabled = false;
let aiEnableHomepageEntry = false;
let aiEnableVideoCardEntry = false;
let aiEnablePlayPageEntry = false;
let customCategories = [] as {
name: string;
type: 'movie' | 'tv';
@@ -108,6 +112,11 @@ export default async function RootLayout({
enableOIDCLogin = config.SiteConfig.EnableOIDCLogin || false;
enableOIDCRegistration = config.SiteConfig.EnableOIDCRegistration || false;
oidcButtonText = config.SiteConfig.OIDCButtonText || '';
// AI配置
aiEnabled = config.AIConfig?.Enabled || false;
aiEnableHomepageEntry = config.AIConfig?.EnableHomepageEntry || false;
aiEnableVideoCardEntry = config.AIConfig?.EnableVideoCardEntry || false;
aiEnablePlayPageEntry = config.AIConfig?.EnablePlayPageEntry || false;
// 检查是否启用了 OpenList 功能
openListEnabled = !!(
config.OpenListConfig?.Enabled &&
@@ -142,6 +151,10 @@ export default async function RootLayout({
ENABLE_OIDC_LOGIN: enableOIDCLogin,
ENABLE_OIDC_REGISTRATION: enableOIDCRegistration,
OIDC_BUTTON_TEXT: oidcButtonText,
AI_ENABLED: aiEnabled,
AI_ENABLE_HOMEPAGE_ENTRY: aiEnableHomepageEntry,
AI_ENABLE_VIDEOCARD_ENTRY: aiEnableVideoCardEntry,
AI_ENABLE_PLAYPAGE_ENTRY: aiEnablePlayPageEntry,
};
return (

View File

@@ -2,7 +2,7 @@
'use client';
import { ChevronRight } from 'lucide-react';
import { ChevronRight, Bot } from 'lucide-react';
import Link from 'next/link';
import { Suspense, useCallback, useEffect, useRef, useState } from 'react';
@@ -21,6 +21,7 @@ import { useSite } from '@/components/SiteProvider';
import VideoCard from '@/components/VideoCard';
import HttpWarningDialog from '@/components/HttpWarningDialog';
import BannerCarousel from '@/components/BannerCarousel';
import AIChatPanel from '@/components/AIChatPanel';
function HomeClient() {
// 移除了 activeTab 状态,收藏夹功能已移到 UserMenu
@@ -37,6 +38,18 @@ function HomeClient() {
const [showAnnouncement, setShowAnnouncement] = useState(false);
const [showHttpWarning, setShowHttpWarning] = useState(true);
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_HOMEPAGE_ENTRY;
setAiEnabled(enabled);
}
}, []);
// 检查公告弹窗状态
useEffect(() => {
@@ -133,14 +146,27 @@ function HomeClient() {
return (
<PageLayout>
{/* TMDB 热门轮播图 */}
<div className='w-full mb-6 sm:mb-8'>
<div className='w-full mb-4'>
<BannerCarousel />
</div>
<div className='px-2 sm:px-10 py-4 sm:py-8 overflow-visible'>
<div className='px-2 sm:px-10 pb-4 sm:pb-8 overflow-visible'>
<div className='max-w-[95%] mx-auto'>
{/* 首页内容 */}
<>
{/* AI问片入口 */}
{aiEnabled && (
<div className='flex items-center justify-end mb-4'>
<button
onClick={() => setShowAIChat(true)}
className='p-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-colors'
title='AI问片'
>
<Bot size={20} />
</button>
</div>
)}
{/* 继续观看 */}
<ContinueWatching />
@@ -432,6 +458,15 @@ function HomeClient() {
<HttpWarningDialog onClose={() => setShowHttpWarning(false)} />
)}
{/* AI问片面板 */}
{aiEnabled && (
<AIChatPanel
isOpen={showAIChat}
onClose={() => setShowAIChat(false)}
welcomeMessage='你好我是MoonTVPlus的AI影视助手。想看什么电影或剧集需要推荐吗'
/>
)}
{/* 公告弹窗 */}
{showAnnouncement && (
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4'>

View File

@@ -2,7 +2,7 @@
'use client';
import { Heart, Search, X, Cloud } from 'lucide-react';
import { Heart, Search, X, Cloud, Sparkles } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useRef, useState } from 'react';
@@ -48,6 +48,7 @@ import DoubanComments from '@/components/DoubanComments';
import SmartRecommendations from '@/components/SmartRecommendations';
import DanmakuFilterSettings from '@/components/DanmakuFilterSettings';
import Toast, { ToastProps } from '@/components/Toast';
import AIChatPanel from '@/components/AIChatPanel';
import { useEnableComments } from '@/hooks/useEnableComments';
import PansouSearch from '@/components/PansouSearch';
@@ -104,6 +105,20 @@ function PlayPageClient() {
// 网盘搜索弹窗状态
const [showPansouDialog, setShowPansouDialog] = useState(false);
// AI问片状态
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_PLAYPAGE_ENTRY;
setAiEnabled(enabled);
}
}, []);
// 网页全屏状态 - 控制导航栏的显示隐藏
const [isWebFullscreen, setIsWebFullscreen] = useState(false);
@@ -5039,10 +5054,10 @@ function PlayPageClient() {
{/* 第三方应用打开按钮 - 观影室同步状态下隐藏 */}
{videoUrl && !playSync.isInRoom && (
<div className='mt-3 px-2 lg:flex-shrink-0 flex justify-end'>
<div className='mt-3 px-2 lg:flex-shrink-0'>
<div className='bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm rounded-lg p-2 border border-gray-200/50 dark:border-gray-700/50 w-full lg:w-auto overflow-x-auto'>
<div className='flex gap-1.5 justify-between lg:flex-wrap items-center'>
<div className='flex gap-1.5 lg:flex-wrap'>
<div className='flex gap-1.5 flex-nowrap lg:flex-wrap items-center'>
<div className='flex gap-1.5 flex-nowrap lg:flex-wrap'>
{/* 下载按钮 */}
<button
onClick={(e) => {
@@ -5346,6 +5361,19 @@ function PlayPageClient() {
>
<Cloud className='h-6 w-6 text-gray-700 dark:text-gray-300' />
</button>
{/* AI问片按钮 */}
{aiEnabled && detail && (
<button
onClick={(e) => {
e.stopPropagation();
setShowAIChat(true);
}}
className='flex-shrink-0 hover:opacity-80 transition-opacity'
title='AI问片'
>
<Sparkles className='h-6 w-6 text-gray-700 dark:text-gray-300' />
</button>
)}
{/* 豆瓣评分显示 */}
{doubanRating && doubanRating.value > 0 && (
<div className='flex items-center gap-2 text-base font-normal'>
@@ -5603,6 +5631,23 @@ function PlayPageClient() {
</div>
</div>
)}
{/* AI问片面板 */}
{aiEnabled && showAIChat && detail && (
<AIChatPanel
isOpen={showAIChat}
onClose={() => setShowAIChat(false)}
context={{
title: detail.title,
year: detail.year,
douban_id: videoDoubanId !== 0 ? videoDoubanId : undefined,
tmdb_id: detail.tmdb_id,
type: detail.type === 'movie' ? 'movie' : 'tv',
currentEpisode: currentEpisodeIndex + 1,
}}
welcomeMessage={`想了解《${detail.title}》的更多信息吗?我可以帮你查询剧情、演员、评价等。`}
/>
)}
</PageLayout>
);
}

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;
}

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}》的更多信息吗?我可以帮你查询剧情、演员、评价等。`}
/>
)}
</>
);
}

View File

@@ -112,6 +112,46 @@ export interface AdminConfig {
ResourceCount?: number; // 资源数量
ScanInterval?: number; // 定时扫描间隔分钟0表示关闭最低60分钟
};
AIConfig?: {
Enabled: boolean; // 是否启用AI问片功能
Provider: 'openai' | 'claude' | 'custom'; // AI服务提供商
// OpenAI配置
OpenAIApiKey?: string;
OpenAIBaseURL?: string; // 自定义API地址如Azure、国内代理等
OpenAIModel?: string; // 模型名称如gpt-4, gpt-3.5-turbo
// Claude配置
ClaudeApiKey?: string;
ClaudeModel?: string; // 模型名称如claude-3-opus-20240229
// 自定义配置兼容OpenAI格式的API
CustomApiKey?: string;
CustomBaseURL?: string;
CustomModel?: string;
// 决策模型配置
EnableDecisionModel: boolean; // 是否启用决策模型用AI判断是否需要联网/数据源)
DecisionProvider?: 'openai' | 'claude' | 'custom'; // 决策模型提供商
DecisionOpenAIApiKey?: string;
DecisionOpenAIBaseURL?: string;
DecisionOpenAIModel?: string;
DecisionClaudeApiKey?: string;
DecisionClaudeModel?: string;
DecisionCustomApiKey?: string;
DecisionCustomBaseURL?: string;
DecisionCustomModel?: string;
// 联网搜索配置
EnableWebSearch: boolean; // 是否启用联网搜索
WebSearchProvider?: 'tavily' | 'serper' | 'serpapi'; // 搜索服务提供商
TavilyApiKey?: string; // Tavily API密钥
SerperApiKey?: string; // Serper.dev API密钥
SerpApiKey?: string; // SerpAPI密钥
// 功能开关
EnableHomepageEntry: boolean; // 首页入口开关
EnableVideoCardEntry: boolean; // VideoCard入口开关
EnablePlayPageEntry: boolean; // 播放页入口开关
// 高级设置
Temperature?: number; // AI温度参数0-2默认0.7
MaxTokens?: number; // 最大回复token数默认1000
SystemPrompt?: string; // 自定义系统提示词
};
}
export interface AdminConfigResult {

694
src/lib/ai-orchestrator.ts Normal file
View File

@@ -0,0 +1,694 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* AI数据源协调器
* 负责协调AI与联网搜索、豆瓣API、TMDB API之间的数据交互
*/
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';
// 决定是否需要各个数据源
// 联网搜索: 对于推荐、查询、时效性问题、演员信息等都应该启用
// 当用户在观看视频时提问(有context),默认也应该联网以获取最新信息
const needWebSearch =
hasTimeKeyword ||
isPerson ||
message.includes('新闻') ||
isRecommendation ||
type === 'query' ||
(context?.title !== undefined); // 有上下文时默认联网
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<any> {
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;
}
}
/**
* 获取豆瓣数据
*/
async function fetchDoubanData(params: {
id?: number;
query?: string;
kind?: string;
category?: string;
type?: string;
}): Promise<any> {
try {
if (params.id) {
// 获取详情
const response = await fetch(`/api/douban/detail?id=${params.id}`);
if (response.ok) {
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();
}
}
} catch (error) {
console.error('Douban API error:', error);
}
return null;
}
/**
* 获取TMDB数据
*/
async function fetchTMDBData(params: {
id?: number;
type?: 'movie' | 'tv';
}): Promise<any> {
try {
if (params.id && params.type) {
const response = await fetch(
`/api/tmdb/detail?id=${params.id}&type=${params.type}`
);
if (response.ok) {
return await response.json();
}
}
} catch (error) {
console.error('TMDB API error:', 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 ''
}
/**
* 使用决策模型判断是否需要调用各个数据源
*/
async function callDecisionModel(
userMessage: string,
context: VideoContext | undefined,
config: {
provider: 'openai' | 'claude' | 'custom';
apiKey: string;
baseURL?: string;
model: string;
}
): Promise<DecisionResult> {
const systemPrompt = `你是一个影视问答决策系统。请分析用户的问题,判断需要调用哪些数据源来回答。
可用的数据源:
1. **联网搜索** - 获取最新的实时信息(新闻、上映时间、续集信息等)
2. **豆瓣API** - 获取中文影视数据(评分、演员、简介、用户评论等)
3. **TMDB API** - 获取国际影视数据(详细元数据、相似推荐等)
请以JSON格式返回决策结果包含以下字段
{
"needWebSearch": boolean, // 是否需要联网搜索
"needDouban": boolean, // 是否需要豆瓣数据
"needTMDB": boolean, // 是否需要TMDB数据
"webSearchQuery": string, // 如果需要联网,用什么关键词搜索(可选)
"doubanQuery": string, // 如果需要豆瓣,用什么关键词搜索(可选)
"reasoning": string // 简要说明决策理由
}
决策原则:
- 时效性问题(最新、上映时间、续集、播出等)→ 需要联网搜索
- 推荐类问题 → 优先豆瓣
- 剧情、演员、评分等静态信息 → 豆瓣或TMDB
- 当前视频的详细信息 → 豆瓣+TMDB
- 有疑问时倾向于多调用数据源
只返回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 || '';
// 提取JSON
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
} else {
// OpenAI 或 自定义 (OpenAI兼容格式)
const baseURL = 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 || '{}';
return JSON.parse(content);
}
} catch (error) {
console.error('❌ 决策模型调用失败:', error);
}
// 失败时返回默认决策(保守策略:都调用)
return {
needWebSearch: true,
needDouban: true,
needTMDB: context?.tmdb_id !== undefined,
reasoning: '决策模型调用失败,使用默认策略',
};
}
/**
* 主协调函数
*/
export async function orchestrateDataSources(
userMessage: string,
context?: VideoContext,
config?: {
enableWebSearch: boolean;
webSearchProvider?: 'tavily' | 'serper' | 'serpapi';
tavilyApiKey?: string;
serperApiKey?: string;
serpApiKey?: string;
// 决策模型配置
enableDecisionModel?: boolean;
decisionProvider?: 'openai' | 'claude' | 'custom';
decisionApiKey?: string;
decisionBaseURL?: string;
decisionModel?: string;
}
): Promise<OrchestrationResult> {
let intent: IntentAnalysisResult;
// 1. 使用决策模型或传统意图分析
if (config?.enableDecisionModel && config.decisionProvider && config.decisionApiKey && config.decisionModel) {
console.log('🤖 使用决策模型分析...');
const decision = await callDecisionModel(userMessage, context, {
provider: config.decisionProvider,
apiKey: config.decisionApiKey,
baseURL: config.decisionBaseURL,
model: config.decisionModel,
});
console.log('🎯 决策模型结果:', decision);
// 将决策结果转换为 IntentAnalysisResult 格式
intent = {
type: decision.needDouban ? 'detail' : 'general',
needWebSearch: decision.needWebSearch,
needDouban: decision.needDouban,
needTMDB: decision.needTMDB,
keywords: decision.webSearchQuery ? [decision.webSearchQuery] : [],
entities: [],
};
} else {
// 传统关键词匹配分析
intent = analyzeIntent(userMessage, context);
console.log('📊 意图分析结果:', intent);
}
// 2. 并行获取所需的数据源
const dataPromises: Promise<any>[] = [];
let webSearchPromise: Promise<any> | null = null;
let doubanPromise: Promise<any> | null = null;
let tmdbPromise: Promise<any> | 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) {
webSearchPromise = fetchWebSearch(userMessage, 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 (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,
});
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. 构建系统提示词
let systemPrompt = `你是 MoonTVPlus 的 AI 影视助手,专门帮助用户发现和了解影视内容。
## 你的能力
- 提供影视推荐基于豆瓣热门榜单和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,
};
}

View File

@@ -85,7 +85,7 @@ const config: Config = {
},
},
},
plugins: [require('@tailwindcss/forms')],
plugins: [require('@tailwindcss/forms'), require('@tailwindcss/typography')],
} satisfies Config;
export default config;