增加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
+462
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='分类配置'