增加ai问片功能
This commit is contained in:
@@ -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>• 支持 OpenAI、Claude 和自定义兼容 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='分类配置'
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
+38
-3
@@ -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'>
|
||||
|
||||
+49
-4
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user