diff --git a/messages/en.json b/messages/en.json index d48b136..4d193b3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -278,7 +278,41 @@ "allPromptsDesc": "Review all shared prompts", "loadingAdmin": "Loading admin panel...", "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": { "title": "Prompt Plaza", diff --git a/messages/zh.json b/messages/zh.json index fcd880f..0ea965f 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -278,7 +278,41 @@ "allPromptsDesc": "审核所有共享提示词", "loadingAdmin": "加载管理员后台中...", "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": { "title": "提示词广场", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cd01652..9d3af1e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -68,6 +68,7 @@ model SubscriptionPlan { // 关联关系 users User[] subscriptions Subscription[] + models Model[] @@map("subscription_plans") } @@ -204,3 +205,29 @@ model Subscription { @@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") +} diff --git a/src/app/admin/models/page.tsx b/src/app/admin/models/page.tsx new file mode 100644 index 0000000..ae48835 --- /dev/null +++ b/src/app/admin/models/page.tsx @@ -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 + customLimits?: Record + 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 + metadata?: Record +} + +export default function AdminModelsPage() { + const t = useTranslations('admin') + const [models, setModels] = useState([]) + const [plans, setPlans] = useState([]) + const [availableModels, setAvailableModels] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [isFetching, setIsFetching] = useState(false) + const [isAdding, setIsAdding] = useState(false) + const [selectedPlan, setSelectedPlan] = useState('') + const [selectedModels, setSelectedModels] = useState([]) + 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 ( +
+
+ +

{t('loadingModels')}

+
+
+ ) + } + + return ( +
+ {/* Header */} +
+

{t('modelsConfig')}

+

+ {t('modelsConfigDesc')} +

+
+ + {/* Add Models Section */} + +
+
+

+ + {t('addModelsTo')} {selectedPlan && plans.find(p => p.id === selectedPlan)?.displayName} +

+

+ {t('syncFromOpenRouterDesc')} +

+
+
+ +
+ {/* Plan Selection */} +
+ + +
+ + {/* Fetch Models Button */} + {selectedPlan && ( +
+ + + {showAvailableModels && ( +
+ {availableModels.length} models available • {selectedModels.length} selected +
+ )} +
+ )} + + {/* Available Models Selection */} + {showAvailableModels && availableModels.length > 0 && ( +
+
+

{t('availableModels')} ({availableModels.length})

+ +
+ +
+ {availableModels.map(model => { + const isSelected = selectedModels.includes(model.modelId) + const isAlreadyAdded = models.some(m => + m.modelId === model.modelId && m.subscriptionPlanId === selectedPlan + ) + + return ( +
!isAlreadyAdded && toggleModelSelection(model.modelId)} + > +
+
+
+

{model.name}

+ + {model.provider} + +
+
+ {model.maxTokens && ( + + + {model.maxTokens.toLocaleString()} + + )} + {model.inputCostPer1k && ( + + + ${model.inputCostPer1k.toFixed(4)} + + )} +
+ {isAlreadyAdded && ( +
+ + Already added +
+ )} +
+ + {!isAlreadyAdded && ( + e.stopPropagation()} + className="h-4 w-4 text-primary focus:ring-primary border-input rounded" + /> + )} +
+
+ ) + })} +
+
+ )} +
+
+ + {/* Current Models by Plan */} +
+ {plans.map(plan => { + const planModels = getModelsByPlan(plan.id) + + return ( + +
+
+

+ {plan.displayName} +

+

+ {planModels.length} {t('planModelsCount')} +

+
+
+ + {planModels.length === 0 ? ( +
+ +

{t('noModelsConfigured')}

+

+ Select this plan above and fetch models to get started +

+
+ ) : ( +
+ {planModels.map(model => ( +
+
+
+
+

{model.name}

+ + {model.provider} + + + {model.isActive ? t('modelEnabled') : t('modelDisabled')} + +
+ +
+ + + {model.modelId} + + {model.maxTokens && ( + + + {model.maxTokens.toLocaleString()} tokens + + )} + {model.inputCostPer1k && ( + + + ${model.inputCostPer1k.toFixed(4)}/1K in + + )} + {model.outputCostPer1k && ( + + + ${model.outputCostPer1k.toFixed(4)}/1K out + + )} +
+ + {model.description && ( +

+ {model.description} +

+ )} +
+ +
+ + +
+
+
+ ))} +
+ )} +
+ ) + })} +
+
+ ) +} \ No newline at end of file diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 255be25..79f4deb 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react' import { useTranslations } from 'next-intl' 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' interface AdminStats { @@ -154,6 +154,25 @@ export default function AdminDashboard() { + +
+
+ +
+
+
+ AI Models Config +
+
+ Manage AI models and plan configurations +
+
+
+ + + 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 } + ) + } +} \ No newline at end of file diff --git a/src/lib/openrouter.ts b/src/lib/openrouter.ts new file mode 100644 index 0000000..47165af --- /dev/null +++ b/src/lib/openrouter.ts @@ -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 { + 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 = { + '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) + } +} \ No newline at end of file