add model admin
This commit is contained in:
parent
52562b5aa7
commit
f4de70302b
@ -278,7 +278,41 @@
|
|||||||
"allPromptsDesc": "Review all shared prompts",
|
"allPromptsDesc": "Review all shared prompts",
|
||||||
"loadingAdmin": "Loading admin panel...",
|
"loadingAdmin": "Loading admin panel...",
|
||||||
"loadingDashboard": "Loading dashboard statistics...",
|
"loadingDashboard": "Loading dashboard statistics...",
|
||||||
"loadingPrompts": "Loading prompts for review..."
|
"loadingPrompts": "Loading prompts for review...",
|
||||||
|
"modelsConfig": "AI Models Configuration",
|
||||||
|
"modelsConfigDesc": "Manage AI models and configure which models are available for each subscription plan",
|
||||||
|
"syncModels": "Sync Models",
|
||||||
|
"syncFromOpenRouter": "Sync from OpenRouter",
|
||||||
|
"syncFromOpenRouterDesc": "Fetch the latest available models from your OpenRouter account",
|
||||||
|
"addModelsTo": "Add Models to",
|
||||||
|
"selectPlan": "Select Plan",
|
||||||
|
"selectPlanPlaceholder": "Choose a subscription plan...",
|
||||||
|
"fetchModels": "Fetch Models",
|
||||||
|
"addSelectedModels": "Add Selected Models",
|
||||||
|
"modelsSynced": "models synced successfully",
|
||||||
|
"modelsAdded": "models added to plan",
|
||||||
|
"noModelsSelected": "Please select at least one model",
|
||||||
|
"noPlanSelected": "Please select a plan first",
|
||||||
|
"availableModels": "Available Models",
|
||||||
|
"currentModels": "Current Models",
|
||||||
|
"modelDetails": "Model Details",
|
||||||
|
"provider": "Provider",
|
||||||
|
"maxTokens": "Max Tokens",
|
||||||
|
"inputCost": "Input Cost",
|
||||||
|
"outputCost": "Output Cost",
|
||||||
|
"perThousandTokens": "per 1K tokens",
|
||||||
|
"enabledModels": "Enabled Models",
|
||||||
|
"disabledModels": "Disabled Models",
|
||||||
|
"toggleStatus": "Toggle Status",
|
||||||
|
"removeModel": "Remove Model",
|
||||||
|
"modelEnabled": "Enabled",
|
||||||
|
"modelDisabled": "Disabled",
|
||||||
|
"loadingModels": "Loading models configuration...",
|
||||||
|
"syncingModels": "Syncing models from OpenRouter...",
|
||||||
|
"addingModels": "Adding models to plan...",
|
||||||
|
"noModelsConfigured": "No models configured",
|
||||||
|
"clickSyncModels": "Click \"Sync Models\" to fetch available models from OpenRouter",
|
||||||
|
"planModelsCount": "models available"
|
||||||
},
|
},
|
||||||
"plaza": {
|
"plaza": {
|
||||||
"title": "Prompt Plaza",
|
"title": "Prompt Plaza",
|
||||||
|
@ -278,7 +278,41 @@
|
|||||||
"allPromptsDesc": "审核所有共享提示词",
|
"allPromptsDesc": "审核所有共享提示词",
|
||||||
"loadingAdmin": "加载管理员后台中...",
|
"loadingAdmin": "加载管理员后台中...",
|
||||||
"loadingDashboard": "加载统计数据中...",
|
"loadingDashboard": "加载统计数据中...",
|
||||||
"loadingPrompts": "加载审核提示词中..."
|
"loadingPrompts": "加载审核提示词中...",
|
||||||
|
"modelsConfig": "AI 模型配置",
|
||||||
|
"modelsConfigDesc": "管理 AI 模型并配置每个订阅套餐可用的模型",
|
||||||
|
"syncModels": "同步模型",
|
||||||
|
"syncFromOpenRouter": "从 OpenRouter 同步",
|
||||||
|
"syncFromOpenRouterDesc": "从您的 OpenRouter 账户获取最新的可用模型",
|
||||||
|
"addModelsTo": "添加模型到",
|
||||||
|
"selectPlan": "选择套餐",
|
||||||
|
"selectPlanPlaceholder": "选择一个订阅套餐...",
|
||||||
|
"fetchModels": "获取模型",
|
||||||
|
"addSelectedModels": "添加选中模型",
|
||||||
|
"modelsSynced": "个模型同步成功",
|
||||||
|
"modelsAdded": "个模型已添加到套餐",
|
||||||
|
"noModelsSelected": "请至少选择一个模型",
|
||||||
|
"noPlanSelected": "请先选择一个套餐",
|
||||||
|
"availableModels": "可用模型",
|
||||||
|
"currentModels": "当前模型",
|
||||||
|
"modelDetails": "模型详情",
|
||||||
|
"provider": "提供商",
|
||||||
|
"maxTokens": "最大令牌数",
|
||||||
|
"inputCost": "输入成本",
|
||||||
|
"outputCost": "输出成本",
|
||||||
|
"perThousandTokens": "每千令牌",
|
||||||
|
"enabledModels": "启用的模型",
|
||||||
|
"disabledModels": "禁用的模型",
|
||||||
|
"toggleStatus": "切换状态",
|
||||||
|
"removeModel": "移除模型",
|
||||||
|
"modelEnabled": "已启用",
|
||||||
|
"modelDisabled": "已禁用",
|
||||||
|
"loadingModels": "加载模型配置中...",
|
||||||
|
"syncingModels": "从 OpenRouter 同步模型中...",
|
||||||
|
"addingModels": "添加模型到套餐中...",
|
||||||
|
"noModelsConfigured": "未配置模型",
|
||||||
|
"clickSyncModels": "点击「同步模型」从 OpenRouter 获取可用模型",
|
||||||
|
"planModelsCount": "个可用模型"
|
||||||
},
|
},
|
||||||
"plaza": {
|
"plaza": {
|
||||||
"title": "提示词广场",
|
"title": "提示词广场",
|
||||||
|
@ -68,6 +68,7 @@ model SubscriptionPlan {
|
|||||||
// 关联关系
|
// 关联关系
|
||||||
users User[]
|
users User[]
|
||||||
subscriptions Subscription[]
|
subscriptions Subscription[]
|
||||||
|
models Model[]
|
||||||
|
|
||||||
@@map("subscription_plans")
|
@@map("subscription_plans")
|
||||||
}
|
}
|
||||||
@ -204,3 +205,29 @@ model Subscription {
|
|||||||
|
|
||||||
@@map("subscriptions")
|
@@map("subscriptions")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AI 模型配置表 - 每个模型记录直接关联一个套餐
|
||||||
|
model Model {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
subscriptionPlanId String // 关联的套餐 ID
|
||||||
|
modelId String // OpenRouter 的模型 ID,如 "openai/gpt-4"
|
||||||
|
name String // 显示名称,如 "GPT-4"
|
||||||
|
provider String // 提供商,如 "OpenAI"
|
||||||
|
description String? // 模型描述
|
||||||
|
maxTokens Int? // 最大 token 数
|
||||||
|
inputCostPer1k Float? // 输入成本(每1K tokens)
|
||||||
|
outputCostPer1k Float? // 输出成本(每1K tokens)
|
||||||
|
supportedFeatures Json? // 支持的特性(如 function_calling, vision 等)
|
||||||
|
metadata Json? // 其他元数据
|
||||||
|
customLimits Json? // 自定义限制(如每日调用次数、最大 tokens 等)
|
||||||
|
isActive Boolean @default(true) // 是否启用
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// 关联关系
|
||||||
|
subscriptionPlan SubscriptionPlan @relation(fields: [subscriptionPlanId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
// 同一个套餐内不能有相同的模型ID
|
||||||
|
@@unique([subscriptionPlanId, modelId])
|
||||||
|
@@map("models")
|
||||||
|
}
|
||||||
|
519
src/app/admin/models/page.tsx
Normal file
519
src/app/admin/models/page.tsx
Normal file
@ -0,0 +1,519 @@
|
|||||||
|
'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
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 fetchModelsFromOpenRouter = 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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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 OpenRouter')
|
||||||
|
} 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>
|
||||||
|
|
||||||
|
{/* Fetch Models Button */}
|
||||||
|
{selectedPlan && (
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Button
|
||||||
|
onClick={fetchModelsFromOpenRouter}
|
||||||
|
disabled={isFetching}
|
||||||
|
className="flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
|
||||||
|
<span>{isFetching ? t('syncingModels') : t('fetchModels')}</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 lg:grid-cols-3 gap-3 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-3 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="flex items-center space-x-2 mb-1">
|
||||||
|
<h4 className="font-medium text-sm truncate">{model.name}</h4>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{model.provider}
|
||||||
|
</Badge>
|
||||||
|
</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={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>
|
||||||
|
)
|
||||||
|
}
|
@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from '@/components/ui/card'
|
||||||
import { Users, FileText, Share, CheckCircle, Database } from 'lucide-react'
|
import { Users, FileText, Share, CheckCircle, Database, Cpu } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
interface AdminStats {
|
interface AdminStats {
|
||||||
@ -154,6 +154,25 @@ export default function AdminDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/admin/models"
|
||||||
|
className="group block p-3 lg:p-4 rounded-lg border border-border hover:border-primary/30 hover:bg-accent/50 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-md bg-purple-50 dark:bg-purple-900/30 group-hover:bg-purple-100 dark:group-hover:bg-purple-900/50 transition-colors">
|
||||||
|
<Cpu className="h-4 w-4 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-medium text-foreground group-hover:text-purple-600 transition-colors">
|
||||||
|
AI Models Config
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
Manage AI models and plan configurations
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/debug/cache"
|
href="/debug/cache"
|
||||||
className="group block p-3 lg:p-4 rounded-lg border border-border hover:border-primary/30 hover:bg-accent/50 transition-all duration-200"
|
className="group block p-3 lg:p-4 rounded-lg border border-border hover:border-primary/30 hover:bg-accent/50 transition-all duration-200"
|
||||||
|
182
src/app/api/admin/models/route.ts
Normal file
182
src/app/api/admin/models/route.ts
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { OpenRouterService } from '@/lib/openrouter'
|
||||||
|
|
||||||
|
// GET /api/admin/models - 获取所有模型(按套餐分组)
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const models = await prisma.model.findMany({
|
||||||
|
include: {
|
||||||
|
subscriptionPlan: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
displayName: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ subscriptionPlan: { sortOrder: 'asc' } },
|
||||||
|
{ provider: 'asc' },
|
||||||
|
{ name: 'asc' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ models })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching models:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch models' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/admin/models - 为指定套餐添加模型
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { action, planId, selectedModels } = await request.json()
|
||||||
|
|
||||||
|
if (action === 'sync') {
|
||||||
|
if (!planId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Plan ID is required for sync action' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证套餐是否存在
|
||||||
|
const plan = await prisma.subscriptionPlan.findUnique({
|
||||||
|
where: { id: planId }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!plan) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Subscription plan not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 OpenRouter 获取可用模型
|
||||||
|
const openRouterService = new OpenRouterService()
|
||||||
|
const openRouterModels = await openRouterService.getAvailableModels()
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Models fetched successfully',
|
||||||
|
availableModels: openRouterModels.map(model =>
|
||||||
|
openRouterService.transformModelForDB(model)
|
||||||
|
),
|
||||||
|
planId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'add') {
|
||||||
|
if (!planId || !selectedModels || !Array.isArray(selectedModels)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Plan ID and selected models are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量创建模型记录
|
||||||
|
const results = []
|
||||||
|
for (const modelData of selectedModels) {
|
||||||
|
const result = await prisma.model.upsert({
|
||||||
|
where: {
|
||||||
|
subscriptionPlanId_modelId: {
|
||||||
|
subscriptionPlanId: planId,
|
||||||
|
modelId: modelData.modelId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
...modelData,
|
||||||
|
subscriptionPlanId: planId,
|
||||||
|
updatedAt: new Date()
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
...modelData,
|
||||||
|
subscriptionPlanId: planId
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
subscriptionPlan: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
results.push(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: `Successfully added ${results.length} models to ${planId} plan`,
|
||||||
|
models: results
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid action' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling models:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to process models request' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/admin/models - 更新模型状态
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { modelId, isActive } = await request.json()
|
||||||
|
|
||||||
|
if (!modelId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Model ID is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = await prisma.model.update({
|
||||||
|
where: { id: modelId },
|
||||||
|
data: { isActive },
|
||||||
|
include: {
|
||||||
|
subscriptionPlan: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ model })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating model:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to update model' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/admin/models - 删除模型
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const modelId = searchParams.get('id')
|
||||||
|
|
||||||
|
if (!modelId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Model ID is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.model.delete({
|
||||||
|
where: { id: modelId }
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Model deleted successfully' })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting model:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to delete model' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
104
src/lib/openrouter.ts
Normal file
104
src/lib/openrouter.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
interface OpenRouterModel {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
context_length: number
|
||||||
|
architecture: {
|
||||||
|
modality: string
|
||||||
|
tokenizer: string
|
||||||
|
instruct_type?: string
|
||||||
|
}
|
||||||
|
pricing: {
|
||||||
|
prompt: string
|
||||||
|
completion: string
|
||||||
|
request?: string
|
||||||
|
image?: string
|
||||||
|
}
|
||||||
|
top_provider: {
|
||||||
|
context_length: number
|
||||||
|
max_completion_tokens?: number
|
||||||
|
is_moderated: boolean
|
||||||
|
}
|
||||||
|
per_request_limits?: {
|
||||||
|
prompt_tokens: string
|
||||||
|
completion_tokens: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenRouterResponse {
|
||||||
|
data: OpenRouterModel[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OpenRouterService {
|
||||||
|
private apiKey: string
|
||||||
|
private baseUrl = 'https://openrouter.ai/api/v1'
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.apiKey = process.env.OPENROUTER_API_KEY || ''
|
||||||
|
if (!this.apiKey) {
|
||||||
|
throw new Error('OPENROUTER_API_KEY environment variable is required')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAvailableModels(): Promise<OpenRouterModel[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/models`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${this.apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`OpenRouter API error: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: OpenRouterResponse = await response.json()
|
||||||
|
return data.data || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching OpenRouter models:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 OpenRouter 模型转换为我们数据库的格式
|
||||||
|
transformModelForDB(model: OpenRouterModel) {
|
||||||
|
return {
|
||||||
|
modelId: model.id,
|
||||||
|
name: model.name,
|
||||||
|
provider: this.extractProvider(model.id),
|
||||||
|
description: model.description || null,
|
||||||
|
maxTokens: model.context_length || null,
|
||||||
|
inputCostPer1k: parseFloat(model.pricing.prompt) * 1000 || null,
|
||||||
|
outputCostPer1k: parseFloat(model.pricing.completion) * 1000 || null,
|
||||||
|
supportedFeatures: {
|
||||||
|
modality: model.architecture.modality,
|
||||||
|
tokenizer: model.architecture.tokenizer,
|
||||||
|
instruct_type: model.architecture.instruct_type,
|
||||||
|
is_moderated: model.top_provider.is_moderated,
|
||||||
|
max_completion_tokens: model.top_provider.max_completion_tokens,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
per_request_limits: model.per_request_limits,
|
||||||
|
architecture: model.architecture,
|
||||||
|
top_provider: model.top_provider,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractProvider(modelId: string): string {
|
||||||
|
// 从模型 ID 中提取提供商名称,如 "openai/gpt-4" -> "OpenAI"
|
||||||
|
const providerMap: Record<string, string> = {
|
||||||
|
'openai': 'OpenAI',
|
||||||
|
'anthropic': 'Anthropic',
|
||||||
|
'google': 'Google',
|
||||||
|
'meta-llama': 'Meta',
|
||||||
|
'microsoft': 'Microsoft',
|
||||||
|
'mistralai': 'Mistral AI',
|
||||||
|
'cohere': 'Cohere',
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = modelId.split('/')[0] || 'Unknown'
|
||||||
|
return providerMap[provider] || provider.charAt(0).toUpperCase() + provider.slice(1)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user