Prmbr/src/app/admin/models/page.tsx
2025-08-27 23:37:14 +08:00

560 lines
20 KiB
TypeScript

'use client'
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { LoadingSpinner } from '@/components/ui/loading-spinner'
import { Badge } from '@/components/ui/badge'
import {
RefreshCw,
Database,
Plus,
DollarSign,
Cpu,
Zap,
Trash2,
Eye,
EyeOff,
AlertCircle
} from 'lucide-react'
interface Model {
id: string
subscriptionPlanId: string
modelId: string
name: string
provider: string
serviceProvider: string
outputType: string
description?: string
maxTokens?: number
inputCostPer1k?: number
outputCostPer1k?: number
supportedFeatures?: Record<string, unknown>
customLimits?: Record<string, unknown>
isActive: boolean
createdAt: string
updatedAt: string
subscriptionPlan: {
id: string
name: string
displayName: string
}
}
interface SubscriptionPlan {
id: string
displayName: string
name: string
}
interface AvailableModel {
modelId: string
name: string
provider: string
serviceProvider: string
outputType: string
description?: string
maxTokens?: number
inputCostPer1k?: number
outputCostPer1k?: number
supportedFeatures?: Record<string, unknown>
metadata?: Record<string, unknown>
}
export default function AdminModelsPage() {
const t = useTranslations('admin')
const [models, setModels] = useState<Model[]>([])
const [plans, setPlans] = useState<SubscriptionPlan[]>([])
const [availableModels, setAvailableModels] = useState<AvailableModel[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isFetching, setIsFetching] = useState(false)
const [isAdding, setIsAdding] = useState(false)
const [selectedPlan, setSelectedPlan] = useState<string>('')
const [selectedModels, setSelectedModels] = useState<string[]>([])
const [showAvailableModels, setShowAvailableModels] = useState(false)
const [selectedServiceProvider, setSelectedServiceProvider] = useState<'openrouter' | 'replicate' | 'uniapi' | 'fal'>('openrouter')
useEffect(() => {
loadInitialData()
}, [])
const loadInitialData = async () => {
try {
setIsLoading(true)
const [modelsRes, plansRes] = await Promise.all([
fetch('/api/admin/models'),
fetch('/api/admin/subscription-plans')
])
if (modelsRes.ok) {
const modelsData = await modelsRes.json()
setModels(modelsData.models || [])
}
if (plansRes.ok) {
const plansData = await plansRes.json()
setPlans(plansData.plans || [])
}
} catch (error) {
console.error('Error loading data:', error)
} finally {
setIsLoading(false)
}
}
const fetchModelsFromProvider = async () => {
if (!selectedPlan) {
alert(t('noPlanSelected'))
return
}
try {
setIsFetching(true)
const response = await fetch('/api/admin/models', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'sync',
planId: selectedPlan,
serviceProvider: selectedServiceProvider
})
})
if (response.ok) {
const result = await response.json()
setAvailableModels(result.availableModels || [])
setShowAvailableModels(true)
setSelectedModels([])
} else {
const error = await response.json()
alert(`Error: ${error.error}`)
}
} catch (error) {
console.error('Error fetching models:', error)
alert(`Failed to fetch models from ${selectedServiceProvider}`)
} finally {
setIsFetching(false)
}
}
const addSelectedModelsToplan = async () => {
if (!selectedPlan || selectedModels.length === 0) {
alert(selectedModels.length === 0 ? t('noModelsSelected') : t('noPlanSelected'))
return
}
try {
setIsAdding(true)
const selectedModelData = availableModels.filter(model =>
selectedModels.includes(model.modelId)
)
const response = await fetch('/api/admin/models', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'add',
planId: selectedPlan,
selectedModels: selectedModelData
})
})
if (response.ok) {
const result = await response.json()
await loadInitialData() // 重新加载数据
setShowAvailableModels(false)
setSelectedModels([])
alert(`${result.models?.length || 0} ${t('modelsAdded')}`)
} else {
const error = await response.json()
alert(`Error: ${error.error}`)
}
} catch (error) {
console.error('Error adding models:', error)
alert('Failed to add models to plan')
} finally {
setIsAdding(false)
}
}
const toggleModelStatus = async (modelId: string, currentStatus: boolean) => {
try {
const response = await fetch('/api/admin/models', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
modelId,
isActive: !currentStatus
})
})
if (response.ok) {
await loadInitialData()
} else {
const error = await response.json()
alert(`Error: ${error.error}`)
}
} catch (error) {
console.error('Error updating model status:', error)
alert('Failed to update model status')
}
}
const removeModel = async (modelId: string) => {
if (!confirm('Are you sure you want to remove this model?')) {
return
}
try {
const response = await fetch(`/api/admin/models?id=${modelId}`, {
method: 'DELETE'
})
if (response.ok) {
await loadInitialData()
} else {
const error = await response.json()
alert(`Error: ${error.error}`)
}
} catch (error) {
console.error('Error removing model:', error)
alert('Failed to remove model')
}
}
const toggleModelSelection = (modelId: string) => {
setSelectedModels(prev =>
prev.includes(modelId)
? prev.filter(id => id !== modelId)
: [...prev, modelId]
)
}
const getModelsByPlan = (planId: string) => {
return models.filter(model => model.subscriptionPlanId === planId)
}
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<LoadingSpinner size="lg" />
<p className="mt-4 text-muted-foreground">{t('loadingModels')}</p>
</div>
</div>
)
}
return (
<div className="container mx-auto px-4 py-8 max-w-7xl">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground mb-2">{t('modelsConfig')}</h1>
<p className="text-muted-foreground">
{t('modelsConfigDesc')}
</p>
</div>
{/* Add Models Section */}
<Card className="p-6 mb-8">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold mb-2 flex items-center">
<Plus className="h-5 w-5 mr-2" />
{t('addModelsTo')} {selectedPlan && plans.find(p => p.id === selectedPlan)?.displayName}
</h2>
<p className="text-muted-foreground">
{t('syncFromOpenRouterDesc')}
</p>
</div>
</div>
<div className="space-y-4">
{/* Plan Selection */}
<div>
<label className="block text-sm font-medium mb-2">{t('selectPlan')}</label>
<select
value={selectedPlan}
onChange={(e) => {
setSelectedPlan(e.target.value)
setShowAvailableModels(false)
setSelectedModels([])
}}
className="w-full md:w-64 bg-background border border-input rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">{t('selectPlanPlaceholder')}</option>
{plans.map(plan => (
<option key={plan.id} value={plan.id}>
{plan.displayName}
</option>
))}
</select>
</div>
{/* Service Provider Selection */}
{selectedPlan && (
<div>
<label className="block text-sm font-medium mb-2">Service Provider</label>
<select
value={selectedServiceProvider}
onChange={(e) => {
setSelectedServiceProvider(e.target.value as 'openrouter' | 'replicate' | 'uniapi' | 'fal')
setShowAvailableModels(false)
setSelectedModels([])
}}
className="w-full md:w-64 bg-background border border-input rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="openrouter">OpenRouter (Text Models)</option>
<option value="replicate">Replicate (Image/Video/Audio Models)</option>
<option value="uniapi">UniAPI (Multi-modal Models)</option>
<option value="fal">Fal.ai (AI Generation Models)</option>
</select>
</div>
)}
{/* Fetch Models Button */}
{selectedPlan && (
<div className="flex items-center space-x-4">
<Button
onClick={fetchModelsFromProvider}
disabled={isFetching}
className="flex items-center space-x-2"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
<span>{isFetching ? `Syncing ${selectedServiceProvider} models...` : `Fetch ${selectedServiceProvider} models`}</span>
</Button>
{showAvailableModels && (
<div className="text-sm text-muted-foreground">
{availableModels.length} models available {selectedModels.length} selected
</div>
)}
</div>
)}
{/* Available Models Selection */}
{showAvailableModels && availableModels.length > 0 && (
<div className="border border-border rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium">{t('availableModels')} ({availableModels.length})</h3>
<Button
onClick={addSelectedModelsToplan}
disabled={selectedModels.length === 0 || isAdding}
size="sm"
>
{isAdding ? (
<>
<LoadingSpinner size="sm" />
<span className="ml-2">{t('addingModels')}</span>
</>
) : (
`${t('addSelectedModels')} (${selectedModels.length})`
)}
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 max-h-96 overflow-y-auto">
{availableModels.map(model => {
const isSelected = selectedModels.includes(model.modelId)
const isAlreadyAdded = models.some(m =>
m.modelId === model.modelId && m.subscriptionPlanId === selectedPlan
)
return (
<div
key={model.modelId}
className={`border rounded-lg p-4 cursor-pointer transition-colors ${
isAlreadyAdded
? 'border-yellow-300 bg-yellow-50 dark:bg-yellow-900/20'
: isSelected
? 'border-primary bg-primary/5'
: 'border-border hover:bg-accent'
}`}
onClick={() => !isAlreadyAdded && toggleModelSelection(model.modelId)}
>
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="mb-2">
<h4 className="font-medium text-base truncate mb-2">{model.name}</h4>
<div className="flex flex-wrap gap-1">
<Badge variant="secondary" className="text-xs">
{model.provider}
</Badge>
<Badge variant="outline" className="text-xs">
{model.outputType}
</Badge>
<Badge variant="outline" className="text-xs">
{model.serviceProvider}
</Badge>
</div>
</div>
<div className="flex items-center space-x-2 text-xs text-muted-foreground">
{model.maxTokens && (
<span className="flex items-center">
<Zap className="h-3 w-3 mr-1" />
{model.maxTokens.toLocaleString()}
</span>
)}
{model.inputCostPer1k && (
<span className="flex items-center">
<DollarSign className="h-3 w-3 mr-1" />
${model.inputCostPer1k.toFixed(4)}
</span>
)}
</div>
{isAlreadyAdded && (
<div className="flex items-center mt-1 text-xs text-yellow-600 dark:text-yellow-400">
<AlertCircle className="h-3 w-3 mr-1" />
Already added
</div>
)}
</div>
{!isAlreadyAdded && (
<input
type="checkbox"
checked={isSelected}
onChange={(e) => e.stopPropagation()}
className="h-4 w-4 text-primary focus:ring-primary border-input rounded"
/>
)}
</div>
</div>
)
})}
</div>
</div>
)}
</div>
</Card>
{/* Current Models by Plan */}
<div className="space-y-6">
{plans.map(plan => {
const planModels = getModelsByPlan(plan.id)
return (
<Card key={plan.id} className="p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-xl font-semibold text-foreground">
{plan.displayName}
</h2>
<p className="text-sm text-muted-foreground">
{planModels.length} {t('planModelsCount')}
</p>
</div>
</div>
{planModels.length === 0 ? (
<div className="text-center py-8">
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground mb-2">{t('noModelsConfigured')}</p>
<p className="text-xs text-muted-foreground">
Select this plan above and fetch models to get started
</p>
</div>
) : (
<div className="space-y-3">
{planModels.map(model => (
<div
key={model.id}
className="border border-border rounded-lg p-4"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-2">
<h3 className="font-medium text-foreground">{model.name}</h3>
<Badge variant="secondary" className="text-xs">
{model.provider}
</Badge>
<Badge variant="outline" className="text-xs">
{model.outputType}
</Badge>
<Badge variant="outline" className="text-xs">
{model.serviceProvider}
</Badge>
<Badge
variant={model.isActive ? "default" : "outline"}
className="text-xs"
>
{model.isActive ? t('modelEnabled') : t('modelDisabled')}
</Badge>
</div>
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
<span className="flex items-center">
<Cpu className="h-3 w-3 mr-1" />
{model.modelId}
</span>
{model.maxTokens && (
<span className="flex items-center">
<Zap className="h-3 w-3 mr-1" />
{model.maxTokens.toLocaleString()} tokens
</span>
)}
{model.inputCostPer1k && (
<span className="flex items-center">
<DollarSign className="h-3 w-3 mr-1" />
${model.inputCostPer1k.toFixed(4)}/1K in
</span>
)}
{model.outputCostPer1k && (
<span className="flex items-center">
<DollarSign className="h-3 w-3 mr-1" />
${model.outputCostPer1k.toFixed(4)}/1K out
</span>
)}
</div>
{model.description && (
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
{model.description}
</p>
)}
</div>
<div className="flex items-center space-x-2">
<Button
size="sm"
variant="outline"
onClick={() => toggleModelStatus(model.id, model.isActive)}
>
{model.isActive ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => removeModel(model.id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}
</div>
)}
</Card>
)
})}
</div>
</div>
)
}