后端: - 审核结果拆分为 4 个独立维度 (法规合规/平台规则/品牌安全/Brief匹配度) - 卖点优先级从 required:bool 改为三级 (core/recommended/reference) - AI 语义匹配卖点覆盖 + AI 整体 Brief 匹配度分析 - BriefMatchDetail 评分详情 (覆盖率+亮点+问题点) - min_selling_points 代理商可配置最少卖点数 + Alembic 迁移 - AI 语境复核过滤误报 - Brief AI 解析 + 规则 AI 解析 - AI 未配置/异常时通知品牌方 - 种子数据更新 (新格式审核结果+brief_match_detail) 前端: - 三端审核页面展示四维度评分卡片 - 卖点编辑改为三级优先级选择器 - BriefMatchDetail 展示 (覆盖率进度条+亮点+问题) - min_selling_points 配置 UI - AI 配置页未配置时静默处理 - 文件预览/下载/签名 URL 优化 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
461 lines
17 KiB
TypeScript
461 lines
17 KiB
TypeScript
'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' },
|
||
]
|
||
|
||
// Mock 可用模型列表
|
||
const mockModels: Record<string, ModelInfo[]> = {
|
||
text: [
|
||
{ id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' },
|
||
{ id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4' },
|
||
{ id: 'gpt-4o', name: 'GPT-4o' },
|
||
{ id: 'deepseek-chat', name: 'DeepSeek Chat' },
|
||
],
|
||
vision: [
|
||
{ id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' },
|
||
{ id: 'gpt-4o', name: 'GPT-4o' },
|
||
],
|
||
audio: [
|
||
{ id: 'whisper-large-v3', name: 'Whisper Large V3' },
|
||
{ id: 'whisper-medium', name: 'Whisper Medium' },
|
||
],
|
||
}
|
||
|
||
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 [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)
|
||
setLlmModel(config.models.text)
|
||
setVisionModel(config.models.vision)
|
||
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])
|
||
|
||
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: llmModel, vision: visionModel, audio: asrModel },
|
||
})
|
||
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: llmModel, vision: visionModel, audio: asrModel },
|
||
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)}
|
||
>
|
||
{(availableModels.text || []).map(model => (
|
||
<option key={model.id} value={model.id}>{model.name}</option>
|
||
))}
|
||
</select>
|
||
<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)}
|
||
>
|
||
{(availableModels.vision || []).map(model => (
|
||
<option key={model.id} value={model.id}>{model.name}</option>
|
||
))}
|
||
</select>
|
||
<p className="text-xs text-text-tertiary mt-2">用于画面语义分析、场景/风险识别</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)}
|
||
>
|
||
{(availableModels.audio || []).map(model => (
|
||
<option key={model.id} value={model.id}>{model.name}</option>
|
||
))}
|
||
</select>
|
||
<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>
|
||
)
|
||
}
|