Your Name bbc8a4f641 feat: 添加 AI 服务状态监控和警告功能
- AI 配置页面添加服务健康状态显示(正常/降级/异常)
- 服务异常时显示红色警告卡片,展示错误信息和队列任务数
- 侧边栏 AI 配置入口添加红点警告徽章支持
- DesktopLayout 支持传递 aiServiceError 状态

AI 调用风险处理方案:
- 自动重试 3 次
- 失败后加入队列,后台定时重试
- 页面显示服务状态警告

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 19:37:20 +08:00

472 lines
18 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 } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { SuccessTag, ErrorTag, PendingTag } from '@/components/ui/Tag'
import {
Bot,
Eye,
Mic,
Settings,
CheckCircle,
XCircle,
Loader2,
Info,
Shield,
AlertTriangle,
RefreshCw,
Clock
} from 'lucide-react'
// AI 服务状态类型
type ServiceStatus = 'healthy' | 'degraded' | 'error' | 'unknown'
interface AIServiceHealth {
status: ServiceStatus
lastChecked: string | null
lastError: string | null
failedCount: number // 连续失败次数
queuedTasks: number // 队列中等待的任务数
}
// AI 提供商选项
const providerOptions = [
{ value: 'oneapi', label: 'OneAPI 中转服务' },
{ value: 'anthropic', label: 'Anthropic Claude' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'deepseek', label: 'DeepSeek' },
{ value: 'custom', label: '自定义' },
]
// 模拟可用模型列表
const availableModels = {
llm: [
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5', tags: ['推荐', '高性能'] },
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4', tags: ['性价比'] },
{ value: 'gpt-4o', label: 'GPT-4o', tags: ['文字', '视觉'] },
{ value: 'deepseek-chat', label: 'DeepSeek Chat', tags: ['高性价比'] },
],
vision: [
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5', tags: ['推荐'] },
{ value: 'gpt-4o', label: 'GPT-4o', tags: ['视觉'] },
{ value: 'doubao-seed-1.6-thinking-vision', label: '豆包 Vision', tags: ['中文优化'] },
],
asr: [
{ value: 'whisper-large-v3', label: 'Whisper Large V3', tags: ['推荐'] },
{ value: 'whisper-medium', label: 'Whisper Medium', tags: ['快速'] },
{ value: 'paraformer-zh', label: '达摩院 Paraformer', tags: ['中文优化'] },
],
}
type TestResult = {
llm: 'idle' | 'testing' | 'success' | 'failed'
vision: 'idle' | 'testing' | 'success' | 'failed'
asr: 'idle' | 'testing' | 'success' | 'failed'
}
export default function AIConfigPage() {
const [provider, setProvider] = useState('oneapi')
const [baseUrl, setBaseUrl] = useState('https://oneapi.intelligrow.cn')
const [apiKey, setApiKey] = useState('')
const [showApiKey, setShowApiKey] = 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 [testResults, setTestResults] = useState<TestResult>({
llm: 'idle',
vision: 'idle',
asr: 'idle',
})
// AI 服务健康状态(模拟数据,实际从后端获取)
const [serviceHealth, setServiceHealth] = useState<AIServiceHealth>({
status: 'healthy',
lastChecked: '2026-02-06 16:30:00',
lastError: null,
failedCount: 0,
queuedTasks: 0,
})
// 模拟检查服务状态
const checkServiceHealth = async () => {
// 实际应该调用后端 API
// const response = await fetch('/api/v1/ai-config/health')
// setServiceHealth(await response.json())
// 模拟:随机返回不同状态用于演示
const now = new Date().toLocaleString('zh-CN')
setServiceHealth({
status: 'healthy',
lastChecked: now,
lastError: null,
failedCount: 0,
queuedTasks: 0,
})
}
// 页面加载时检查服务状态
useEffect(() => {
checkServiceHealth()
}, [])
const handleTestConnection = async () => {
// 模拟测试连接
setTestResults({ llm: 'testing', vision: 'testing', asr: 'testing' })
// 模拟延迟
await new Promise(resolve => setTimeout(resolve, 1500))
setTestResults(prev => ({ ...prev, llm: 'success' }))
await new Promise(resolve => setTimeout(resolve, 1000))
setTestResults(prev => ({ ...prev, vision: 'success' }))
await new Promise(resolve => setTimeout(resolve, 800))
setTestResults(prev => ({ ...prev, asr: 'success' }))
}
const handleSave = () => {
alert('配置已保存')
}
const getTestStatusIcon = (status: string) => {
switch (status) {
case 'testing':
return <Loader2 size={16} className="text-blue-500 animate-spin" />
case 'success':
return <CheckCircle size={16} className="text-green-500" />
case 'failed':
return <XCircle size={16} className="text-red-500" />
default:
return null
}
}
// 获取服务状态配置
const getServiceStatusConfig = (status: ServiceStatus) => {
switch (status) {
case 'healthy':
return {
label: '服务正常',
color: 'text-accent-green',
bgColor: 'bg-accent-green/15',
borderColor: 'border-accent-green/30',
icon: CheckCircle,
}
case 'degraded':
return {
label: '服务降级',
color: 'text-accent-amber',
bgColor: 'bg-accent-amber/15',
borderColor: 'border-accent-amber/30',
icon: AlertTriangle,
}
case 'error':
return {
label: '服务异常',
color: 'text-accent-coral',
bgColor: 'bg-accent-coral/15',
borderColor: 'border-accent-coral/30',
icon: XCircle,
}
default:
return {
label: '状态未知',
color: 'text-text-tertiary',
bgColor: 'bg-bg-elevated',
borderColor: 'border-border-subtle',
icon: Info,
}
}
}
const statusConfig = getServiceStatusConfig(serviceHealth.status)
const StatusIcon = statusConfig.icon
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>
{/* 服务状态标签 */}
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-lg ${statusConfig.bgColor} border ${statusConfig.borderColor}`}>
<StatusIcon size={16} className={statusConfig.color} />
<span className={`text-sm font-medium ${statusConfig.color}`}>{statusConfig.label}</span>
</div>
</div>
{/* 服务异常警告 */}
{(serviceHealth.status === 'error' || serviceHealth.status === 'degraded') && (
<div className={`p-4 rounded-lg border ${serviceHealth.status === 'error' ? 'bg-accent-coral/10 border-accent-coral/30' : 'bg-accent-amber/10 border-accent-amber/30'}`}>
<div className="flex items-start gap-3">
<AlertTriangle size={20} className={serviceHealth.status === 'error' ? 'text-accent-coral' : 'text-accent-amber'} />
<div className="flex-1">
<p className={`font-medium ${serviceHealth.status === 'error' ? 'text-accent-coral' : 'text-accent-amber'}`}>
{serviceHealth.status === 'error' ? 'AI 服务异常' : 'AI 服务降级'}
</p>
<p className="text-sm text-text-secondary mt-1">
{serviceHealth.lastError || '部分 AI 功能可能不可用,系统已自动将任务加入重试队列。'}
</p>
{serviceHealth.queuedTasks > 0 && (
<p className="text-sm text-text-tertiary mt-1">
<span className="font-medium text-text-primary">{serviceHealth.queuedTasks}</span>
</p>
)}
{serviceHealth.failedCount > 0 && (
<p className="text-sm text-text-tertiary mt-1">
<span className="font-medium text-text-primary">{serviceHealth.failedCount}</span>
</p>
)}
</div>
<Button variant="secondary" size="sm" onClick={checkServiceHealth}>
<RefreshCw size={14} />
</Button>
</div>
</div>
)}
{/* 最后检查时间 */}
{serviceHealth.lastChecked && (
<div className="flex items-center gap-2 text-xs text-text-tertiary">
<Clock size={12} />
<span>: {serviceHealth.lastChecked}</span>
<button
type="button"
onClick={checkServiceHealth}
className="text-accent-indigo hover:underline"
>
</button>
</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">
OneAPIAnthropic ClaudeOpenAIDeepSeek
</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(testResults.llm)}
</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.llm.map(model => (
<option key={model.value} value={model.value}>
{model.label} [{model.tags.join(', ')}]
</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(testResults.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.value} value={model.value}>
{model.label} [{model.tags.join(', ')}]
</option>
))}
</select>
<p className="text-xs text-text-tertiary mt-2">/Logo CV </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(testResults.asr)}
</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.asr.map(model => (
<option key={model.value} value={model.value}>
{model.label} [{model.tags.join(', ')}]
</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="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}>
</Button>
</div>
</div>
)
}