Your Name 0b3dfa3c52 feat: AI 审核自动驳回 + 功效词可配置 + UI 修复
- AI 自动驳回:法规/品牌安全 HIGH 违规或总分<40 自动打回上传阶段
- 功效词可配置:从硬编码改为品牌方在规则页面自行管理
- 驳回通知:AI 驳回时只通知达人,含具体原因
- 达人端:脚本/视频页面展示 AI 驳回原因 + 重新上传入口
- 规则页面:新增"功效词"分类
- 种子数据:新增 6 条默认功效词
- 其他:代理商管理下拉修复、AI 配置模型列表扩展、视觉模型标签修正、规则编辑放开限制

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 20:24:32 +08:00

561 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { useToast } from '@/components/ui/Toast'
import {
Bot,
Eye,
Mic,
Settings,
CheckCircle,
XCircle,
Loader2,
Info,
Shield,
AlertTriangle,
RefreshCw,
Clock
} from 'lucide-react'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import type { AIProvider, AIConfigResponse, ConnectionTestResponse, ModelInfo } from '@/types/ai-config'
// AI 提供商选项
const providerOptions: { value: AIProvider | string; label: string }[] = [
{ value: 'oneapi', label: 'OneAPI 中转服务' },
{ value: 'anthropic', label: 'Anthropic Claude' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'deepseek', label: 'DeepSeek' },
{ value: 'qwen', label: '通义千问' },
{ value: 'doubao', label: '豆包' },
{ value: 'zhipu', label: '智谱' },
{ value: 'moonshot', label: 'Moonshot' },
]
// 预设可用模型列表
const mockModels: Record<string, ModelInfo[]> = {
text: [
// Anthropic Claude
{ id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' },
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' },
{ id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4' },
{ id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5' },
// OpenAI
{ id: 'gpt-4o', name: 'GPT-4o' },
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini' },
{ id: 'gpt-4-turbo', name: 'GPT-4 Turbo' },
{ id: 'o1', name: 'o1' },
{ id: 'o3-mini', name: 'o3-mini' },
// Google
{ id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash' },
{ id: 'gemini-2.0-pro', name: 'Gemini 2.0 Pro' },
{ id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro' },
// DeepSeek
{ id: 'deepseek-chat', name: 'DeepSeek V3' },
{ id: 'deepseek-reasoner', name: 'DeepSeek R1' },
// 通义千问
{ id: 'qwen-max', name: '通义千问 Max' },
{ id: 'qwen-plus', name: '通义千问 Plus' },
{ id: 'qwen-turbo', name: '通义千问 Turbo' },
// 豆包
{ id: 'doubao-pro-256k', name: '豆包 Pro 256K' },
{ id: 'doubao-pro-32k', name: '豆包 Pro 32K' },
// 智谱
{ id: 'glm-4-plus', name: 'GLM-4 Plus' },
{ id: 'glm-4', name: 'GLM-4' },
// Moonshot
{ id: 'moonshot-v1-128k', name: 'Moonshot V1 128K' },
{ id: 'moonshot-v1-32k', name: 'Moonshot V1 32K' },
],
vision: [
// Anthropic Claude (原生多模态)
{ id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' },
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' },
{ id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4' },
{ id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5' },
// OpenAI
{ id: 'gpt-4o', name: 'GPT-4o' },
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini' },
// Google
{ id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash' },
{ id: 'gemini-2.0-pro', name: 'Gemini 2.0 Pro' },
{ id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro' },
// 通义千问 VL
{ id: 'qwen-vl-max', name: '通义千问 VL Max' },
{ id: 'qwen-vl-plus', name: '通义千问 VL Plus' },
// 智谱
{ id: 'glm-4v-plus', name: 'GLM-4V Plus' },
{ id: 'glm-4v', name: 'GLM-4V' },
],
audio: [
{ id: 'whisper-large-v3', name: 'Whisper Large V3' },
{ id: 'whisper-large-v3-turbo', name: 'Whisper Large V3 Turbo' },
{ id: 'whisper-medium', name: 'Whisper Medium' },
{ id: 'sensevoice-v1', name: 'SenseVoice V1 (阿里)' },
{ id: 'paraformer-realtime-v2', name: 'Paraformer V2 (阿里)' },
],
}
type TestStatus = 'idle' | 'testing' | 'success' | 'failed'
function ConfigSkeleton() {
return (
<div className="space-y-6 animate-pulse">
<div className="h-32 bg-bg-elevated rounded-lg" />
<div className="h-48 bg-bg-elevated rounded-lg" />
<div className="h-32 bg-bg-elevated rounded-lg" />
</div>
)
}
export default function AIConfigPage() {
const toast = useToast()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [provider, setProvider] = useState<string>('oneapi')
const [baseUrl, setBaseUrl] = useState('https://oneapi.intelligrow.cn')
const [apiKey, setApiKey] = useState('')
const [showApiKey, setShowApiKey] = useState(false)
const [isConfigured, setIsConfigured] = useState(false)
const [llmModel, setLlmModel] = useState('claude-opus-4-5-20251101')
const [visionModel, setVisionModel] = useState('claude-opus-4-5-20251101')
const [asrModel, setAsrModel] = useState('whisper-large-v3')
const [temperature, setTemperature] = useState(0.7)
const [maxTokens, setMaxTokens] = useState(2000)
const [availableModels, setAvailableModels] = useState<Record<string, ModelInfo[]>>(mockModels)
const [customLlmModel, setCustomLlmModel] = useState('')
const [customVisionModel, setCustomVisionModel] = useState('')
const [customAsrModel, setCustomAsrModel] = useState('')
const [testResults, setTestResults] = useState<Record<string, { status: TestStatus; latency?: number; error?: string }>>({
text: { status: 'idle' },
vision: { status: 'idle' },
audio: { status: 'idle' },
})
const loadConfig = useCallback(async () => {
if (USE_MOCK) {
setLoading(false)
return
}
try {
const config = await api.getAIConfig()
setProvider(config.provider)
setBaseUrl(config.base_url)
setApiKey('') // API key is masked, don't fill it
setIsConfigured(config.is_configured)
const models = availableModels
// 如果后端返回的模型不在预设列表中,设为自定义
if (config.models.text && !(models.text || []).some(m => m.id === config.models.text)) {
setLlmModel('__custom__')
setCustomLlmModel(config.models.text)
} else {
setLlmModel(config.models.text)
}
if (config.models.vision && !(models.vision || []).some(m => m.id === config.models.vision)) {
setVisionModel('__custom__')
setCustomVisionModel(config.models.vision)
} else {
setVisionModel(config.models.vision)
}
if (config.models.audio && !(models.audio || []).some(m => m.id === config.models.audio)) {
setAsrModel('__custom__')
setCustomAsrModel(config.models.audio)
} else {
setAsrModel(config.models.audio)
}
setTemperature(config.parameters.temperature)
setMaxTokens(config.parameters.max_tokens)
if (config.available_models && Object.keys(config.available_models).length > 0) {
setAvailableModels(config.available_models)
}
} catch (err: any) {
// 后端 404 返回 "AI 服务未配置" → Axios 拦截器转为 Error(message)
// 这是正常的"尚未配置"状态,不弹错误
const msg = err?.message || ''
if (msg.includes('未配置')) {
setIsConfigured(false)
} else {
console.error('Failed to load AI config:', err)
toast.error('加载 AI 配置失败')
}
} finally {
setLoading(false)
}
}, [toast])
useEffect(() => { loadConfig() }, [loadConfig])
// 获取实际使用的模型 ID自定义时用输入值
const getActualModel = (selected: string, custom: string) =>
selected === '__custom__' ? custom : selected
const handleTestConnection = async () => {
setTestResults({
text: { status: 'testing' },
vision: { status: 'testing' },
audio: { status: 'testing' },
})
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 1500))
setTestResults(prev => ({ ...prev, text: { status: 'success', latency: 320 } }))
await new Promise(resolve => setTimeout(resolve, 1000))
setTestResults(prev => ({ ...prev, vision: { status: 'success', latency: 450 } }))
await new Promise(resolve => setTimeout(resolve, 800))
setTestResults(prev => ({ ...prev, audio: { status: 'success', latency: 280 } }))
return
}
try {
const result: ConnectionTestResponse = await api.testAIConnection({
provider: provider as AIProvider,
base_url: baseUrl,
api_key: apiKey || '***', // use existing key if not changed
models: { text: getActualModel(llmModel, customLlmModel), vision: getActualModel(visionModel, customVisionModel), audio: getActualModel(asrModel, customAsrModel) },
})
const newResults: Record<string, { status: TestStatus; latency?: number; error?: string }> = {}
for (const [key, r] of Object.entries(result.results)) {
newResults[key] = {
status: r.success ? 'success' : 'failed',
latency: r.latency_ms ?? undefined,
error: r.error ?? undefined,
}
}
setTestResults(prev => ({ ...prev, ...newResults }))
if (result.success) {
toast.success(result.message)
} else {
toast.error(result.message)
}
} catch (err) {
toast.error('连接测试失败')
setTestResults({
text: { status: 'failed', error: '请求失败' },
vision: { status: 'failed', error: '请求失败' },
audio: { status: 'failed', error: '请求失败' },
})
}
}
const handleSave = async () => {
setSaving(true)
try {
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 500))
} else {
await api.updateAIConfig({
provider: provider as AIProvider,
base_url: baseUrl,
api_key: apiKey || '***',
models: { text: getActualModel(llmModel, customLlmModel), vision: getActualModel(visionModel, customVisionModel), audio: getActualModel(asrModel, customAsrModel) },
parameters: { temperature, max_tokens: maxTokens },
})
}
toast.success('配置已保存')
} catch (err) {
toast.error('保存失败')
} finally {
setSaving(false)
}
}
const getTestStatusIcon = (key: string) => {
const result = testResults[key]
if (!result) return null
switch (result.status) {
case 'testing':
return <Loader2 size={16} className="text-blue-500 animate-spin" />
case 'success':
return (
<span className="flex items-center gap-1">
<CheckCircle size={16} className="text-green-500" />
{result.latency && <span className="text-xs text-text-tertiary">{result.latency}ms</span>}
</span>
)
case 'failed':
return (
<span className="flex items-center gap-1">
<XCircle size={16} className="text-red-500" />
{result.error && <span className="text-xs text-accent-coral">{result.error}</span>}
</span>
)
default:
return null
}
}
if (loading) {
return (
<div className="space-y-6 max-w-4xl">
<h1 className="text-2xl font-bold text-text-primary">AI </h1>
<ConfigSkeleton />
</div>
)
}
return (
<div className="space-y-6 max-w-4xl">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">AI </h1>
<p className="text-sm text-text-secondary mt-1"> AI </p>
</div>
{isConfigured && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-accent-green/15 border border-accent-green/30">
<CheckCircle size={16} className="text-accent-green" />
<span className="text-sm font-medium text-accent-green"></span>
</div>
)}
</div>
{/* 配置继承提示 */}
<div className="p-4 bg-accent-indigo/10 rounded-lg border border-accent-indigo/30">
<div className="flex items-start gap-3">
<Info size={20} className="text-accent-indigo flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-accent-indigo font-medium"></p>
<p className="text-sm text-accent-indigo/80 mt-1">
使
</p>
</div>
</div>
</div>
{/* AI 提供商 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bot size={18} className="text-blue-500" />
AI
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1"></label>
<select
className="w-full px-3 py-2 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
value={provider}
onChange={(e) => setProvider(e.target.value)}
>
{providerOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<p className="text-xs text-text-tertiary mt-1">
使 OneAPI 便 AI
</p>
</div>
</CardContent>
</Card>
{/* 模型配置 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings size={18} className="text-purple-500" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* 文字处理模型 */}
<div className="p-4 bg-bg-elevated rounded-lg">
<div className="flex items-center gap-2 mb-3">
<Bot size={16} className="text-accent-indigo" />
<span className="font-medium text-text-primary"> (LLM)</span>
{getTestStatusIcon('text')}
</div>
<select
className="w-full px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
value={llmModel}
onChange={(e) => { setLlmModel(e.target.value); if (e.target.value !== '__custom__') setCustomLlmModel('') }}
>
{(availableModels.text || []).map(model => (
<option key={model.id} value={model.id}>{model.name} ({model.id})</option>
))}
<option value="__custom__">...</option>
</select>
{llmModel === '__custom__' && (
<input
type="text"
className="w-full mt-2 px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
value={customLlmModel}
onChange={(e) => setCustomLlmModel(e.target.value)}
placeholder="输入模型 ID如 deepseek-chat"
/>
)}
<p className="text-xs text-text-tertiary mt-2"> Brief </p>
</div>
{/* 视觉理解模型 */}
<div className="p-4 bg-bg-elevated rounded-lg">
<div className="flex items-center gap-2 mb-3">
<Eye size={16} className="text-accent-green" />
<span className="font-medium text-text-primary"> (Vision)</span>
{getTestStatusIcon('vision')}
</div>
<select
className="w-full px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
value={visionModel}
onChange={(e) => { setVisionModel(e.target.value); if (e.target.value !== '__custom__') setCustomVisionModel('') }}
>
{(availableModels.vision || []).map(model => (
<option key={model.id} value={model.id}>{model.name} ({model.id})</option>
))}
<option value="__custom__">...</option>
</select>
{visionModel === '__custom__' && (
<input
type="text"
className="w-full mt-2 px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
value={customVisionModel}
onChange={(e) => setCustomVisionModel(e.target.value)}
placeholder="输入模型 ID如 gpt-4o"
/>
)}
<p className="text-xs text-text-tertiary mt-2"> logo</p>
</div>
{/* 音频解析模型 */}
<div className="p-4 bg-bg-elevated rounded-lg">
<div className="flex items-center gap-2 mb-3">
<Mic size={16} className="text-orange-400" />
<span className="font-medium text-text-primary"> (ASR)</span>
{getTestStatusIcon('audio')}
</div>
<select
className="w-full px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
value={asrModel}
onChange={(e) => { setAsrModel(e.target.value); if (e.target.value !== '__custom__') setCustomAsrModel('') }}
>
{(availableModels.audio || []).map(model => (
<option key={model.id} value={model.id}>{model.name} ({model.id})</option>
))}
<option value="__custom__">...</option>
</select>
{asrModel === '__custom__' && (
<input
type="text"
className="w-full mt-2 px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
value={customAsrModel}
onChange={(e) => setCustomAsrModel(e.target.value)}
placeholder="输入模型 ID如 whisper-large-v3"
/>
)}
<p className="text-xs text-text-tertiary mt-2"></p>
</div>
</CardContent>
</Card>
{/* 连接配置 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Base URL</label>
<input
type="text"
className="w-full px-3 py-2 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://api.openai.com/v1"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">API Key</label>
<div className="flex gap-2">
<input
type={showApiKey ? 'text' : 'password'}
className="flex-1 px-3 py-2 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={isConfigured ? '留空使用已保存的密钥' : 'sk-...'}
/>
<Button
variant="secondary"
size="sm"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? '隐藏' : '显示'}
</Button>
</div>
</div>
</CardContent>
</Card>
{/* 生成参数 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-text-primary">Temperature</label>
<span className="text-sm text-text-secondary">{temperature}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.1"
value={temperature}
onChange={(e) => setTemperature(parseFloat(e.target.value))}
className="w-full h-2 bg-bg-elevated rounded-lg appearance-none cursor-pointer accent-accent-indigo"
/>
<div className="flex justify-between text-xs text-text-tertiary mt-1">
<span> (0)</span>
<span> (1)</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-2">Max Tokens</label>
<input
type="number"
className="w-32 px-3 py-2 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
value={maxTokens}
onChange={(e) => setMaxTokens(parseInt(e.target.value))}
min="100"
max="8000"
/>
</div>
</CardContent>
</Card>
{/* 安全说明 */}
<div className="p-4 bg-bg-elevated rounded-lg border border-border-subtle">
<div className="flex items-start gap-3">
<Shield size={20} className="text-text-tertiary flex-shrink-0 mt-0.5" />
<div className="text-sm text-text-secondary">
<p className="font-medium text-text-primary mb-1"></p>
<ul className="space-y-1 text-xs">
<li> API Key 使 AES-256-GCM </li>
<li> API 使 HTTPS</li>
<li> /</li>
<li> </li>
</ul>
</div>
</div>
</div>
{/* 操作按钮 */}
<div className="flex items-center justify-between pt-4 border-t border-border-subtle">
<Button variant="secondary" onClick={handleTestConnection}>
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <><Loader2 size={16} className="animate-spin" /> ...</> : '保存配置'}
</Button>
</div>
</div>
)
}