feat: 完善模型管理功能

- 新增模型 API 路由,支持 CRUD 和测试连接
- 支持 MiniMax、GLM、OpenAI Compatible 三种供应商
- 添加连接状态持久化 (untested/connected/disconnected)
- 修复 CORS 和数据库模型兼容性问题
- 前端 UI 优化:供应商默认 API 地址自动填充

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Developer
2026-03-17 23:02:43 +08:00
parent 15846a0f7a
commit 7514e7e763
13 changed files with 699 additions and 299 deletions

View File

@@ -2,7 +2,7 @@
API Dependencies API Dependencies
API 依赖项 API 依赖项
""" """
from typing import Annotated from typing import Annotated, Optional
from fastapi import Depends from fastapi import Depends
from app.core.auth import verify_api_key from app.core.auth import verify_api_key

View File

@@ -4,7 +4,7 @@ API v1 Router
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1 import files, projects, chunks, questions, datasets, eval from app.api.v1 import files, projects, chunks, questions, datasets, eval, models
api_router = APIRouter() api_router = APIRouter()
@@ -15,3 +15,4 @@ api_router.include_router(chunks.router, prefix="/chunks", tags=["chunks"])
api_router.include_router(questions.router, prefix="/questions", tags=["questions"]) api_router.include_router(questions.router, prefix="/questions", tags=["questions"])
api_router.include_router(datasets.router, prefix="/datasets", tags=["datasets"]) api_router.include_router(datasets.router, prefix="/datasets", tags=["datasets"])
api_router.include_router(eval.router, prefix="/eval", tags=["eval"]) api_router.include_router(eval.router, prefix="/eval", tags=["eval"])
api_router.include_router(models.router, prefix="/models", tags=["models"])

View File

@@ -0,0 +1,257 @@
"""
Model API Router
"""
import uuid
import httpx
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from app.core.database import get_db
from app.api.response import ApiResponse
from app.models.models import ModelConfig
from app.schemas.model import ModelCreate, ModelUpdate, ModelResponse
router = APIRouter()
async def test_model_connection(model: ModelConfig) -> dict:
"""Test model connection by calling the API"""
if not model.api_key:
return {"success": False, "message": "API Key is missing"}
api_base = model.api_base or ""
provider = model.provider
model_name = model.model_name
api_key = model.api_key
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
if provider == "openai":
# OpenAI compatible API test
response = await client.post(
f"{api_base.rstrip('/')}/chat/completions",
headers=headers,
json={
"model": model_name,
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 5
}
)
elif provider == "minimax":
# MiniMax API test
response = await client.post(
f"{api_base.rstrip('/')}/chat/completions_v2",
headers={
**headers,
"Authorization": f"Bearer {api_key}"
},
json={
"model": model_name,
"messages": [{"role": "user", "content": "Hi"}]
}
)
elif provider == "glm":
# GLM API test
response = await client.post(
f"{api_base.rstrip('/')}/chat/completions",
headers=headers,
json={
"model": model_name,
"messages": [{"role": "user", "content": "Hi"}]
}
)
else:
return {"success": False, "message": f"Unsupported provider: {provider}"}
if response.status_code == 200:
return {"success": True, "message": "Connection successful"}
else:
return {"success": False, "message": f"API error: {response.status_code} - {response.text[:100]}"}
except httpx.TimeoutException:
return {"success": False, "message": "Connection timeout"}
except Exception as e:
return {"success": False, "message": f"Connection failed: {str(e)}"}
# Helper to convert string to UUID
def parse_uuid(id_str: str) -> uuid.UUID:
"""Parse string to UUID"""
try:
return uuid.UUID(id_str)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid UUID format")
@router.get("", response_model=ApiResponse)
async def list_models(db: AsyncSession = Depends(get_db)):
"""Get all models"""
result = await db.execute(
select(ModelConfig).where(ModelConfig.project_id == None) # noqa: E711
)
models = result.scalars().all()
# Convert to Pydantic schema
model_responses = [ModelResponse.model_validate(m) for m in models]
return ApiResponse(data=model_responses)
@router.post("", response_model=ApiResponse)
async def create_model(model: ModelCreate, db: AsyncSession = Depends(get_db)):
"""Create a new model"""
# If setting as default, unset other defaults first
if model.is_default == "true":
await db.execute(
update(ModelConfig)
.where(ModelConfig.project_id == None) # noqa: E711
.values(is_default="false")
)
db_model = ModelConfig(
provider=model.provider,
model_name=model.model_name,
api_key=model.api_key,
api_base=model.api_base,
is_default=model.is_default,
project_id=None # Global model config
)
db.add(db_model)
await db.commit()
await db.refresh(db_model)
# Convert to Pydantic schema
response = ModelResponse.model_validate(db_model)
return ApiResponse(data=response)
@router.get("/{model_id}", response_model=ApiResponse)
async def get_model(model_id: str, db: AsyncSession = Depends(get_db)):
"""Get a model by ID"""
model_uuid = parse_uuid(model_id)
result = await db.execute(
select(ModelConfig).where(
ModelConfig.id == model_uuid,
ModelConfig.project_id == None # noqa: E711
)
)
model = result.scalar_one_or_none()
if not model:
raise HTTPException(status_code=404, detail="Model not found")
response = ModelResponse.model_validate(model)
return ApiResponse(data=response)
@router.put("/{model_id}", response_model=ApiResponse)
async def update_model(model_id: str, model_update: ModelUpdate, db: AsyncSession = Depends(get_db)):
"""Update a model"""
model_uuid = parse_uuid(model_id)
result = await db.execute(
select(ModelConfig).where(
ModelConfig.id == model_uuid,
ModelConfig.project_id == None # noqa: E711
)
)
model = result.scalar_one_or_none()
if not model:
raise HTTPException(status_code=404, detail="Model not found")
# If setting as default, unset other defaults first
if model_update.is_default == "true":
await db.execute(
update(ModelConfig)
.where(
ModelConfig.project_id == None, # noqa: E711
ModelConfig.id != model_uuid
)
.values(is_default="false")
)
update_data = model_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(model, key, value)
await db.commit()
await db.refresh(model)
response = ModelResponse.model_validate(model)
return ApiResponse(data=response)
@router.delete("/{model_id}", response_model=ApiResponse)
async def delete_model(model_id: str, db: AsyncSession = Depends(get_db)):
"""Delete a model"""
model_uuid = parse_uuid(model_id)
result = await db.execute(
select(ModelConfig).where(
ModelConfig.id == model_uuid,
ModelConfig.project_id == None # noqa: E711
)
)
model = result.scalar_one_or_none()
if not model:
raise HTTPException(status_code=404, detail="Model not found")
await db.delete(model)
await db.commit()
return ApiResponse(message="Model deleted successfully")
@router.post("/{model_id}/set-default", response_model=ApiResponse)
async def set_default_model(model_id: str, db: AsyncSession = Depends(get_db)):
"""Set a model as default"""
model_uuid = parse_uuid(model_id)
result = await db.execute(
select(ModelConfig).where(
ModelConfig.id == model_uuid,
ModelConfig.project_id == None # noqa: E711
)
)
model = result.scalar_one_or_none()
if not model:
raise HTTPException(status_code=404, detail="Model not found")
# Unset all other defaults
await db.execute(
update(ModelConfig)
.where(
ModelConfig.project_id == None, # noqa: E711
ModelConfig.id != model_uuid
)
.values(is_default="false")
)
model.is_default = "true"
await db.commit()
await db.refresh(model)
response = ModelResponse.model_validate(model)
return ApiResponse(data=response)
@router.post("/{model_id}/test", response_model=ApiResponse)
async def test_model(model_id: str, db: AsyncSession = Depends(get_db)):
"""Test model connection"""
model_uuid = parse_uuid(model_id)
result = await db.execute(
select(ModelConfig).where(
ModelConfig.id == model_uuid,
ModelConfig.project_id == None # noqa: E711
)
)
model = result.scalar_one_or_none()
if not model:
raise HTTPException(status_code=404, detail="Model not found")
# Test the connection
test_result = await test_model_connection(model)
# Save connection status to database
model.connection_status = "connected" if test_result["success"] else "disconnected"
await db.commit()
await db.refresh(model)
# Return updated model
response = ModelResponse.model_validate(model)
return ApiResponse(data={"test_result": test_result, "model": response})

View File

@@ -100,3 +100,8 @@ async def get_db() -> AsyncSession:
raise raise
finally: finally:
await session.close() await session.close()
# Import all models to register them with Base.metadata
# This ensures all models are loaded before create_all is called
from app.models.models import * # noqa: F401, F403, E402

View File

@@ -21,6 +21,9 @@ from app.core.database import init_db, close_db
from app.core.exceptions import AppException from app.core.exceptions import AppException
from app.core.logging import logger from app.core.logging import logger
# Import all models to register them with Base.metadata
from app.models.models import * # noqa: F401, F403
class RequestIDMiddleware(BaseHTTPMiddleware): class RequestIDMiddleware(BaseHTTPMiddleware):
"""Middleware to add request ID to each request""" """Middleware to add request ID to each request"""
@@ -83,7 +86,7 @@ app.add_middleware(RequestIDMiddleware)
# CORS - Configure properly for production # CORS - Configure properly for production
# For development, you can use ["*"] but for production, specify exact origins # For development, you can use ["*"] but for production, specify exact origins
ALLOWED_ORIGINS = settings.ALLOWED_ORIGINS.split(",") if settings.ALLOWED_ORIGINS else ["*"] ALLOWED_ORIGINS = ["*"]
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,

View File

@@ -135,12 +135,13 @@ class ModelConfig(Base, UUIDMixin, TimestampMixin):
"""Model configuration for LLM providers""" """Model configuration for LLM providers"""
__tablename__ = "model_configs" __tablename__ = "model_configs"
project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False) project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True)
provider = Column(String(50), nullable=False) # openai, anthropic, ollama, custom provider = Column(String(50), nullable=False) # minimax, glm, openai
model_name = Column(String(100)) model_name = Column(String(100))
api_key = Column(String(500)) api_key = Column(String(500))
api_base = Column(String(500)) api_base = Column(String(500))
is_default = Column(String(10), default="false") is_default = Column(String(10), default="false")
connection_status = Column(String(20), default="untested") # untested, connected, disconnected
# Relationships # Relationships
project = relationship("Project", back_populates="model_configs") project = relationship("Project", back_populates="model_configs")

View File

@@ -50,6 +50,13 @@ from app.schemas.eval import (
TaskResponse, TaskResponse,
) )
from app.schemas.model import (
ModelBase,
ModelCreate,
ModelUpdate,
ModelResponse,
)
__all__ = [ __all__ = [
# Base # Base
"TimestampMixin", "TimestampMixin",
@@ -86,4 +93,9 @@ __all__ = [
"EvalDatasetResponse", "EvalDatasetResponse",
"TaskBase", "TaskBase",
"TaskResponse", "TaskResponse",
# Model
"ModelBase",
"ModelCreate",
"ModelUpdate",
"ModelResponse",
] ]

View File

@@ -0,0 +1,41 @@
"""
Model Schema
"""
from pydantic import BaseModel, Field, ConfigDict
from typing import Optional
from datetime import datetime
from uuid import UUID
class ModelBase(BaseModel):
"""Base model schema"""
provider: str = Field(..., description="Model provider: minimax, glm, openai")
model_name: str = Field(..., description="Model name")
api_key: Optional[str] = Field(None, description="API key")
api_base: Optional[str] = Field(None, description="API base URL")
is_default: str = Field(default="false", description="Is default model: true/false")
class ModelCreate(ModelBase):
"""Model creation schema"""
pass
class ModelUpdate(BaseModel):
"""Model update schema"""
provider: Optional[str] = None
model_name: Optional[str] = None
api_key: Optional[str] = None
api_base: Optional[str] = None
is_default: Optional[str] = None
class ModelResponse(ModelBase):
"""Model response schema"""
id: UUID
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
project_id: Optional[UUID] = None
connection_status: Optional[str] = Field(default="untested")
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,6 +1,6 @@
import axios from 'axios' import axios from 'axios'
import type { AxiosInstance } from 'axios' import type { AxiosInstance } from 'axios'
import type { Project, ProjectCreate, ProjectUpdate } from '@/types' import type { Project, ProjectCreate, ProjectUpdate, Model, ModelCreate } from '@/types'
const request: AxiosInstance = axios.create({ const request: AxiosInstance = axios.create({
baseURL: import.meta.env.PROD baseURL: import.meta.env.PROD
@@ -91,4 +91,14 @@ export const evalApi = {
getResults: (projectId: string, taskId: string) => request.get(`/projects/${projectId}/eval-tasks/${taskId}`) getResults: (projectId: string, taskId: string) => request.get(`/projects/${projectId}/eval-tasks/${taskId}`)
} }
export const modelApi = {
list: () => request.get<Model[]>('/models/'),
get: (id: string) => request.get<Model>(`/models/${id}`),
create: (data: ModelCreate) => request.post<{ id: string }>('/models/', data),
update: (id: string, data: Partial<Model>) => request.put<Model>(`/models/${id}`, data),
delete: (id: string) => request.delete(`/models/${id}`),
setDefault: (id: string) => request.post(`/models/${id}/set-default`),
test: (id: string) => request.post<{ success: boolean; message: string }>(`/models/${id}/test`)
}
export default request export default request

View File

@@ -51,11 +51,6 @@ const routes = [
path: '/models', path: '/models',
name: 'ModelSettings', name: 'ModelSettings',
component: () => import('@/views/ModelSettingsView.vue') component: () => import('@/views/ModelSettingsView.vue')
},
{
path: '/data-square',
name: 'DataSquare',
component: () => import('@/views/DataSquareView.vue')
} }
] ]

View File

@@ -2,6 +2,18 @@
* Model Configuration Types * Model Configuration Types
*/ */
export interface Model {
id: string
provider: ModelProvider
model_name: string
api_key?: string
api_base?: string
is_default: 'true' | 'false'
connection_status?: 'untested' | 'connected' | 'disconnected'
created_at?: string
updated_at?: string
}
export interface ModelConfig { export interface ModelConfig {
id: string id: string
provider: ModelProvider provider: ModelProvider
@@ -9,11 +21,12 @@ export interface ModelConfig {
api_key?: string api_key?: string
api_base?: string api_base?: string
is_default: 'true' | 'false' is_default: 'true' | 'false'
connection_status?: 'untested' | 'connected' | 'disconnected'
created_at?: string created_at?: string
updated_at?: string updated_at?: string
} }
export type ModelProvider = 'openai' | 'anthropic' | 'google' | 'other' export type ModelProvider = 'minimax' | 'glm' | 'openai'
export interface ModelCreate { export interface ModelCreate {
provider: ModelProvider provider: ModelProvider

View File

@@ -3,6 +3,33 @@
<!-- Hero Section --> <!-- Hero Section -->
<section class="hero"> <section class="hero">
<div class="hero-content"> <div class="hero-content">
<!-- Logo -->
<div class="hero-logo">
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="logoGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#00d4ff"/>
<stop offset="100%" style="stop-color:#7c3aed"/>
</linearGradient>
</defs>
<!-- 外圈 - 数据集合 -->
<rect x="4" y="4" width="48" height="48" rx="12" stroke="url(#logoGradient)" stroke-width="2.5" fill="none" opacity="0.3"/>
<!-- Y 字母 - 数据流/分支 -->
<path d="M18 42V22L28 12V18" stroke="url(#logoGradient)" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<path d="M28 18L38 28" stroke="url(#logoGradient)" stroke-width="3.5" stroke-linecap="round" fill="none"/>
<!-- 数据节点 - 神经网络样式 -->
<circle cx="18" cy="42" r="3" fill="#00d4ff"/>
<circle cx="28" cy="12" r="3" fill="#7c3aed"/>
<circle cx="38" cy="28" r="3" fill="#00d4ff"/>
<circle cx="28" cy="18" r="2.5" fill="#00d4ff" opacity="0.7"/>
<!-- 连接线 - 数据流向 -->
<circle cx="28" cy="32" r="2" fill="#7c3aed" opacity="0.5"/>
<circle cx="20" cy="32" r="1.5" fill="#00d4ff" opacity="0.4"/>
<circle cx="36" cy="38" r="1.5" fill="#7c3aed" opacity="0.4"/>
</svg>
<span class="logo-text">YG<span class="logo-highlight">Datasets</span></span>
</div>
<div class="hero-badge"> <div class="hero-badge">
<span class="badge-dot"></span> <span class="badge-dot"></span>
<span>AI 驱动数据生成</span> <span>AI 驱动数据生成</span>
@@ -20,103 +47,85 @@
<el-icon><Plus /></el-icon> <el-icon><Plus /></el-icon>
创建项目 创建项目
</el-button> </el-button>
<el-button size="large" @click="goToDataSquare" class="btn-secondary"> <el-button size="large" @click="goToModels" class="btn-secondary">
<el-icon><Grid /></el-icon> <el-icon><Cpu /></el-icon>
数据集广场 模型管理
</el-button> </el-button>
</div> </div>
</div> </div>
<!-- Hero Visual - 全息粒子矩阵风格 --> <!-- Hero Visual - Modern Abstract Composition -->
<div class="hero-visual"> <div class="hero-visual">
<!-- Card 1: 多格式支持 --> <!-- Light rays -->
<div class="hologram-card card-1"> <div class="light-rays">
<div class="card-bg"></div> <div class="ray"></div>
<div class="scan-line"></div> <div class="ray"></div>
<div class="particles-container"> <div class="ray"></div>
<span class="particle" style="--x: 20%; --y: 30%"></span> <div class="ray"></div>
<span class="particle" style="--x: 80%; --y: 20%"></span> <div class="ray"></div>
<span class="particle" style="--x: 50%; --y: 70%"></span> </div>
<span class="particle" style="--x: 30%; --y: 60%"></span>
<span class="particle" style="--x: 70%; --y: 80%"></span> <!-- Ambient particles -->
<span class="particle" style="--x: 15%; --y: 85%"></span> <span class="ambient-particle"></span>
<span class="particle" style="--x: 85%; --y: 45%"></span> <span class="ambient-particle"></span>
<span class="particle" style="--x: 45%; --y: 15%"></span> <span class="ambient-particle"></span>
<span class="ambient-particle"></span>
<span class="ambient-particle"></span>
<!-- Abstract background orbs -->
<div class="orb orb-1"></div>
<div class="orb orb-2"></div>
<div class="orb orb-3"></div>
<!-- Central floating UI element -->
<div class="floating-ui">
<div class="ui-header">
<div class="ui-dot"></div>
<div class="ui-dot"></div>
<div class="ui-dot"></div>
</div> </div>
<div class="pulse-ring"></div> <div class="ui-content">
<div class="card-content"> <div class="ui-line"></div>
<div class="icon-wrapper cyan"> <div class="ui-line short"></div>
<div class="icon-glow"></div> <div class="ui-line"></div>
<el-icon size="28"><Document /></el-icon> </div>
</div> <div class="ui-badge">
<span class="card-label">多格式支持</span> <el-icon><Check /></el-icon>
<span class="card-sublabel">PDF DOCX EPUB Excel</span> <span>处理完成</span>
</div> </div>
</div> </div>
<!-- Card 2: AI 生成 --> <!-- Floating feature pills - main features -->
<div class="hologram-card card-2"> <div class="feature-pill pill-1">
<div class="card-bg"></div> <el-icon><Document /></el-icon>
<div class="scan-line"></div> <span>多格式支持</span>
<div class="particles-container"> </div>
<span class="particle" style="--x: 25%; --y: 35%"></span> <div class="feature-pill pill-2">
<span class="particle" style="--x: 75%; --y: 25%"></span> <el-icon><MagicStick /></el-icon>
<span class="particle" style="--x: 55%; --y: 65%"></span> <span>AI 生成</span>
<span class="particle" style="--x: 35%; --y: 55%"></span> </div>
<span class="particle" style="--x: 65%; --y: 85%"></span> <div class="feature-pill pill-3">
<span class="particle" style="--x: 20%; --y: 80%"></span> <el-icon><DataAnalysis /></el-icon>
<span class="particle" style="--x: 80%; --y: 50%"></span> <span>智能评估</span>
<span class="particle" style="--x: 50%; --y: 20%"></span>
</div>
<div class="pulse-ring"></div>
<div class="card-content">
<div class="icon-wrapper violet">
<div class="icon-glow"></div>
<el-icon size="28"><MagicStick /></el-icon>
</div>
<span class="card-label">AI 生成</span>
<span class="card-sublabel">智能问答 自动标注</span>
</div>
</div> </div>
<!-- Card 3: 智能评估 --> <!-- Additional floating labels -->
<div class="hologram-card card-3"> <div class="feature-pill pill-4">
<div class="card-bg"></div> <el-icon><Connection /></el-icon>
<div class="scan-line"></div> <span>API 集成</span>
<div class="particles-container">
<span class="particle" style="--x: 30%; --y: 25%"></span>
<span class="particle" style="--x: 70%; --y: 35%"></span>
<span class="particle" style="--x: 45%; --y: 75%"></span>
<span class="particle" style="--x: 25%; --y: 65%"></span>
<span class="particle" style="--x: 75%; --y: 85%"></span>
<span class="particle" style="--x: 10%; --y: 75%"></span>
<span class="particle" style="--x: 90%; --y: 40%"></span>
<span class="particle" style="--x: 40%; --y: 10%"></span>
</div>
<div class="pulse-ring"></div>
<div class="card-content">
<div class="icon-wrapper teal">
<div class="icon-glow"></div>
<el-icon size="28"><DataAnalysis /></el-icon>
</div>
<span class="card-label">智能评估</span>
<span class="card-sublabel">质量分析 模型对比</span>
</div>
</div> </div>
</div> <div class="feature-pill pill-5">
</section> <el-icon><Clock /></el-icon>
<span>批量处理</span>
<!-- Quick Actions -->
<section class="quick-actions">
<div class="action-card" @click="goToModels">
<div class="action-icon">
<el-icon><Setting /></el-icon>
</div> </div>
<div class="action-info"> <div class="feature-pill pill-6">
<h3>模型配置</h3> <el-icon><Lock /></el-icon>
<p>管理 AI 模型 API 配置</p> <span>数据安全</span>
</div>
<div class="feature-pill pill-7">
<el-icon><TrendCharts /></el-icon>
<span>可视化</span>
</div> </div>
<el-icon class="action-arrow"><ArrowRight /></el-icon>
</div> </div>
</section> </section>
@@ -179,7 +188,7 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { FolderAdd } from '@element-plus/icons-vue' import { FolderAdd, Check, Connection, Clock, Lock, TrendCharts } from '@element-plus/icons-vue'
import { projectApi } from '@/api' import { projectApi } from '@/api'
import type { Project, ProjectCreate } from '@/types' import type { Project, ProjectCreate } from '@/types'
@@ -275,5 +284,5 @@ onMounted(() => fetchProjects())
</script> </script>
<style scoped> <style scoped>
@import '@/styles/home.scss'; @import '@/styles/pages/home.scss';
</style> </style>

View File

@@ -17,9 +17,9 @@
<div class="header-content"> <div class="header-content">
<h1 class="page-title"> <h1 class="page-title">
<el-icon class="title-icon"><Cpu /></el-icon> <el-icon class="title-icon"><Cpu /></el-icon>
模型配置 模型管理
</h1> </h1>
<p class="page-subtitle">管理您的 AI 模型 API 配置</p> <p class="page-subtitle">管理您的 AI 模型 API</p>
</div> </div>
<div class="header-right"> <div class="header-right">
<el-button type="primary" class="add-btn" @click="openAddDialog"> <el-button type="primary" class="add-btn" @click="openAddDialog">
@@ -31,19 +31,6 @@
<!-- 主内容 --> <!-- 主内容 -->
<main class="page-main"> <main class="page-main">
<!-- 统计卡片 -->
<section class="stats-grid">
<div class="stat-card" v-for="stat in stats" :key="stat.label">
<div class="stat-icon" :class="stat.class">
{{ stat.icon }}
</div>
<div class="stat-info">
<span class="stat-value">{{ stat.value }}</span>
<span class="stat-label">{{ stat.label }}</span>
</div>
</div>
</section>
<!-- 模型列表 --> <!-- 模型列表 -->
<section class="models-section"> <section class="models-section">
<div class="section-header"> <div class="section-header">
@@ -98,9 +85,11 @@
<!-- 底部操作 --> <!-- 底部操作 -->
<div class="card-footer"> <div class="card-footer">
<div class="status-badge"> <div class="status-badge" :class="model.connection_status">
<span class="status-dot online"></span> <span class="status-dot" :class="model.connection_status"></span>
已配置 <template v-if="model.connection_status === 'connected'">已联通</template>
<template v-else-if="model.connection_status === 'disconnected'">未联通</template>
<template v-else>待测试</template>
</div> </div>
<div class="card-actions"> <div class="card-actions">
<el-tooltip content="测试连接" placement="top"> <el-tooltip content="测试连接" placement="top">
@@ -146,18 +135,24 @@
<el-form :model="modelForm" label-position="top" class="model-form"> <el-form :model="modelForm" label-position="top" class="model-form">
<!-- 提供商选择 --> <!-- 提供商选择 -->
<el-form-item label="选择提供商"> <el-form-item label="选择提供商">
<div class="provider-grid"> <el-select
<div v-model="modelForm.provider"
placeholder="选择 AI 服务提供商"
size="large"
class="provider-select"
>
<el-option
v-for="provider in providers" v-for="provider in providers"
:key="provider.value" :key="provider.value"
class="provider-option" :label="provider.label"
:class="{ active: modelForm.provider === provider.value }" :value="provider.value"
@click="modelForm.provider = provider.value"
> >
<span class="provider-abbr">{{ provider.abbr }}</span> <div class="provider-option-item">
<span class="provider-name">{{ provider.label }}</span> <span class="provider-icon">{{ provider.abbr }}</span>
</div> <span>{{ provider.label }}</span>
</div> </div>
</el-option>
</el-select>
</el-form-item> </el-form-item>
<!-- 模型名称 --> <!-- 模型名称 -->
@@ -246,6 +241,7 @@ import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import type { ModelConfig, ProviderOption, ModelCreate } from '@/types' import type { ModelConfig, ProviderOption, ModelCreate } from '@/types'
import { modelApi } from '@/api'
const router = useRouter() const router = useRouter()
@@ -260,35 +256,34 @@ const models = ref<ModelConfig[]>([])
// 表单 // 表单
const modelForm = reactive<ModelCreate>({ const modelForm = reactive<ModelCreate>({
provider: 'openai', provider: 'minimax',
model_name: '', model_name: '',
api_key: '', api_key: '',
api_base: '', api_base: 'https://api.minimax.chat/v1',
is_default: false is_default: false
}) })
// 供应商默认 API 地址
const providerDefaultUrls: Record<string, string> = {
minimax: 'https://api.minimax.chat/v1',
glm: 'https://open.bigmodel.cn/api/paas/v4',
openai: 'https://api.openai.com/v1'
}
// 提供商 // 提供商
const providers: ProviderOption[] = [ const providers: ProviderOption[] = [
{ value: 'openai', label: 'OpenAI', abbr: 'OP' }, { value: 'minimax', label: 'MiniMax', abbr: 'MM' },
{ value: 'anthropic', label: 'Anthropic', abbr: 'AN' }, { value: 'glm', label: 'GLM', abbr: 'GL' },
{ value: 'google', label: 'Google', abbr: 'GO' }, { value: 'openai', label: 'OpenAI Compatible', abbr: 'OP' }
{ value: 'other', label: '其他', abbr: 'OT' }
] ]
// Mock // 监听 provider 变化,自动设置默认 API 地址
const mockModels: ModelConfig[] = [ import { watch } from 'vue'
{ id: '1', provider: 'openai', model_name: 'gpt-4o', api_base: 'https://api.openai.com/v1', is_default: 'true' }, watch(() => modelForm.provider, (newProvider) => {
{ id: '2', provider: 'openai', model_name: 'gpt-4o-mini', api_base: 'https://api.openai.com/v1', is_default: 'false' }, if (providerDefaultUrls[newProvider]) {
{ id: '3', provider: 'anthropic', model_name: 'claude-3-5-sonnet', api_base: 'https://api.anthropic.com', is_default: 'false' } modelForm.api_base = providerDefaultUrls[newProvider]
] }
})
// 统计
const stats = computed(() => [
{ label: 'OpenAI', value: models.value.filter(m => m.provider === 'openai').length, icon: 'OP', class: 'openai' },
{ label: 'Anthropic', value: models.value.filter(m => m.provider === 'anthropic').length, icon: 'AN', class: 'anthropic' },
{ label: 'Google', value: models.value.filter(m => m.provider === 'google').length, icon: 'GO', class: 'google' },
{ label: '默认模型', value: models.value.find(m => m.is_default === 'true')?.model_name || '未设置', icon: '★', class: 'default' }
])
// 方法 // 方法
const goHome = () => router.push('/') const goHome = () => router.push('/')
@@ -301,20 +296,28 @@ const getProviderAbbr = (provider: string) => {
const fetchModels = async () => { const fetchModels = async () => {
loading.value = true loading.value = true
try { try {
await new Promise(r => setTimeout(r, 500)) const res = await modelApi.list()
models.value = mockModels // Handle different response formats
} catch { if (Array.isArray(res)) {
ElMessage.error('加载失败') models.value = res
} else if (res?.data && Array.isArray(res.data)) {
models.value = res.data
} else {
models.value = []
}
} catch (error: any) {
console.error('获取模型列表失败:', error)
ElMessage.error(error?.message || '加载失败')
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const openAddDialog = () => { const openAddDialog = () => {
modelForm.provider = 'openai' modelForm.provider = 'minimax'
modelForm.model_name = '' modelForm.model_name = ''
modelForm.api_key = '' modelForm.api_key = ''
modelForm.api_base = '' modelForm.api_base = providerDefaultUrls['minimax']
modelForm.is_default = false modelForm.is_default = false
showAddDialog.value = true showAddDialog.value = true
} }
@@ -327,12 +330,21 @@ const addModel = async () => {
submitting.value = true submitting.value = true
try { try {
await new Promise(r => setTimeout(r, 500)) // Convert is_default from boolean to string
const data = {
provider: modelForm.provider,
model_name: modelForm.model_name,
api_key: modelForm.api_key,
api_base: modelForm.api_base,
is_default: modelForm.is_default ? 'true' : 'false'
}
await modelApi.create(data)
ElMessage.success('添加成功') ElMessage.success('添加成功')
showAddDialog.value = false showAddDialog.value = false
fetchModels() fetchModels()
} catch { } catch (error: any) {
ElMessage.error('添加失败') console.error('添加模型失败:', error)
ElMessage.error(error?.message || '添加失败')
} finally { } finally {
submitting.value = false submitting.value = false
} }
@@ -344,22 +356,44 @@ const confirmDelete = (model: ModelConfig) => {
} }
const handleDelete = async () => { const handleDelete = async () => {
if (!modelToDelete.value?.id) return
deleting.value = true deleting.value = true
try { try {
await new Promise(r => setTimeout(r, 500)) await modelApi.delete(modelToDelete.value.id)
ElMessage.success('删除成功') ElMessage.success('删除成功')
deleteDialogVisible.value = false deleteDialogVisible.value = false
modelToDelete.value = null modelToDelete.value = null
fetchModels() fetchModels()
} catch { } catch (error: any) {
ElMessage.error('删除失败') console.error('删除模型失败:', error)
ElMessage.error(error?.message || '删除失败')
} finally { } finally {
deleting.value = false deleting.value = false
} }
} }
const testConnection = (model: ModelConfig) => { const testConnection = async (model: ModelConfig) => {
ElMessage.info(`测试 ${model.model_name}...`) ElMessage.info(`正在测试 ${model.model_name}...`)
try {
const res = await modelApi.test(model.id)
// Update model connection status from response
const modelItem = models.value.find(m => m.id === model.id)
if (modelItem && res?.model) {
modelItem.connection_status = res.model.connection_status
if (res.test_result?.success) {
ElMessage.success('连接成功!')
} else {
ElMessage.error(res.test_result?.message || '连接失败')
}
}
} catch (error: any) {
console.error('测试连接失败:', error)
const modelItem = models.value.find(m => m.id === model.id)
if (modelItem) {
modelItem.connection_status = 'disconnected'
}
ElMessage.error(error?.message || '连接失败')
}
} }
onMounted(() => fetchModels()) onMounted(() => fetchModels())
@@ -489,63 +523,6 @@ onMounted(() => fetchModels())
padding: 32px; padding: 32px;
} }
/* 统计卡片 */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
transition: all var(--transition-fast);
}
.stat-card:hover {
border-color: var(--border-default);
transform: translateY(-2px);
}
.stat-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 700;
color: white;
}
.stat-icon.openai { background: linear-gradient(135deg, #10a37f, #0d8c6d); }
.stat-icon.anthropic { background: linear-gradient(135deg, #d97757, #c45f3f); }
.stat-icon.google { background: linear-gradient(135deg, #4285f4, #3367d6); }
.stat-icon.default { background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); }
.stat-info {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
}
.stat-label {
font-size: 12px;
color: var(--text-tertiary);
}
/* 模型列表 */ /* 模型列表 */
.models-section { .models-section {
background: var(--bg-secondary); background: var(--bg-secondary);
@@ -637,36 +614,38 @@ onMounted(() => fetchModels())
.model-card { .model-card {
position: relative; position: relative;
padding: 24px; padding: 24px;
background: var(--bg-tertiary); background: var(--bg-secondary);
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg); border-radius: 16px;
transition: all var(--transition-base); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
animation: fadeInUp 0.4s ease backwards; animation: fadeInUp 0.4s ease backwards;
animation-delay: var(--delay); animation-delay: var(--delay);
overflow: hidden;
} }
@keyframes fadeInUp { @keyframes fadeInUp {
from { opacity: 0; transform: translateY(16px); } from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
.model-card:hover { .model-card:hover {
border-color: var(--accent-primary-muted); border-color: rgba(0, 212, 255, 0.4);
transform: translateY(-4px); transform: translateY(-6px);
box-shadow: var(--glow-primary); box-shadow: 0 12px 40px rgba(0, 212, 255, 0.15);
} }
.model-card.is-default { .model-card.is-default {
border-color: rgba(52, 211, 153, 0.3); border-color: rgba(52, 211, 153, 0.4);
} }
.card-glow { .card-glow {
position: absolute; position: absolute;
inset: 0; inset: 0;
border-radius: var(--radius-lg); border-radius: 16px;
background: radial-gradient(circle at top right, var(--accent-primary-muted), transparent 60%); background: radial-gradient(circle at top right, rgba(0, 212, 255, 0.15), transparent 60%);
opacity: 0; opacity: 0;
transition: opacity var(--transition-base); transition: opacity 0.3s ease;
pointer-events: none;
} }
.model-card:hover .card-glow { .model-card:hover .card-glow {
@@ -688,23 +667,20 @@ onMounted(() => fetchModels())
} }
.provider-logo { .provider-logo {
width: 44px; width: 48px;
height: 44px; height: 48px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: var(--radius-md); border-radius: 12px;
font-size: 13px; font-size: 14px;
font-weight: 700; font-weight: 700;
color: white; color: white;
margin-bottom: 16px; margin-bottom: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
} }
.provider-logo.openai { background: linear-gradient(135deg, #10a37f, #0d8c6d); }
.provider-logo.anthropic { background: linear-gradient(135deg, #d97757, #c45f3f); }
.provider-logo.google { background: linear-gradient(135deg, #4285f4, #3367d6); }
.provider-logo.other { background: linear-gradient(135deg, #6b7280, #4b5563); }
.model-info { .model-info {
margin-bottom: 20px; margin-bottom: 20px;
} }
@@ -741,6 +717,18 @@ onMounted(() => fetchModels())
color: var(--text-secondary); color: var(--text-secondary);
} }
.status-badge.connected {
color: var(--success);
}
.status-badge.disconnected {
color: var(--danger);
}
.status-badge.untested {
color: var(--warning);
}
.status-dot { .status-dot {
width: 6px; width: 6px;
height: 6px; height: 6px;
@@ -748,9 +736,29 @@ onMounted(() => fetchModels())
background: var(--text-muted); background: var(--text-muted);
} }
.status-dot.online { .status-dot.online,
.status-dot.connected {
background: var(--success); background: var(--success);
box-shadow: 0 0 8px var(--success); box-shadow: 0 0 8px var(--success);
animation: pulse 2s infinite;
}
.status-dot.disconnected {
background: var(--danger);
box-shadow: 0 0 8px var(--danger);
}
.status-dot.untested {
background: var(--warning);
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
} }
.card-actions { .card-actions {
@@ -780,30 +788,42 @@ onMounted(() => fetchModels())
:deep(.model-dialog .el-dialog) { :deep(.model-dialog .el-dialog) {
background: var(--bg-elevated); background: var(--bg-elevated);
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
border-radius: var(--radius-xl); border-radius: 20px;
overflow: hidden;
}
:deep(.model-dialog .el-dialog__header) {
padding: 0;
margin: 0;
}
:deep(.model-dialog .el-dialog__body) {
padding: 24px;
} }
.dialog-header { .dialog-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
padding: 20px 24px; padding: 24px;
position: relative; background: linear-gradient(135deg, var(--accent-primary-muted), rgba(124, 58, 237, 0.1));
border-bottom: 1px solid var(--border-subtle);
} }
.dialog-icon { .dialog-icon {
width: 44px; width: 48px;
height: 44px; height: 48px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: var(--accent-primary); background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
border-radius: var(--radius-md); border-radius: 12px;
color: #030407; color: #030407;
box-shadow: 0 4px 16px rgba(0, 212, 255, 0.3);
} }
.dialog-title h3 { .dialog-title h3 {
font-size: 18px; font-size: 20px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
margin: 0; margin: 0;
@@ -817,74 +837,104 @@ onMounted(() => fetchModels())
.dialog-close { .dialog-close {
position: absolute; position: absolute;
right: 16px; right: 20px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
width: 32px; width: 36px;
height: 32px; height: 36px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: transparent; background: var(--bg-tertiary);
border: none; border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm); border-radius: 10px;
color: var(--text-tertiary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease;
} }
.dialog-close:hover { .dialog-close:hover {
background: var(--bg-hover); background: var(--bg-hover);
color: var(--text-primary); color: var(--text-primary);
}
.provider-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.provider-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 8px;
background: var(--bg-tertiary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
}
.provider-option:hover {
border-color: var(--border-default); border-color: var(--border-default);
} }
.provider-option.active { .provider-select {
border-color: var(--accent-primary); width: 100%;
background: var(--accent-primary-muted);
} }
.provider-option .provider-abbr { .provider-select :deep(.el-input__wrapper) {
font-size: 14px; background: var(--bg-tertiary);
font-weight: 700; border: 1px solid var(--border-subtle);
border-radius: 12px;
padding: 4px 16px;
box-shadow: none !important;
}
.provider-select :deep(.el-input__wrapper:hover) {
border-color: var(--border-default);
}
.provider-select :deep(.el-input__wrapper.is-focus) {
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.15) !important;
}
.provider-select :deep(.el-select__placeholder) {
color: var(--text-secondary);
}
.provider-select :deep(.el-select__selected-item) {
color: var(--text-primary); color: var(--text-primary);
} }
.provider-option .provider-name { .provider-select :deep(.el-select-dropdown) {
font-size: 11px; background: var(--bg-elevated) !important;
color: var(--text-tertiary); border: 1px solid var(--border-subtle) !important;
border-radius: 12px;
padding: 6px;
} }
.provider-option.active .provider-name { .provider-select :deep(.el-select-dropdown__item) {
color: var(--text-primary) !important;
border-radius: 8px;
padding: 8px 12px;
}
.provider-select :deep(.el-select-dropdown__item:hover) {
background: var(--bg-hover);
}
.provider-select :deep(.el-select-dropdown__item.is-selected) {
background: var(--accent-primary-muted);
color: var(--accent-primary); color: var(--accent-primary);
} }
.provider-option-item {
display: flex;
align-items: center;
gap: 12px;
color: var(--text-primary);
}
.provider-icon {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
font-size: 10px;
font-weight: 700;
color: #030407;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
}
.dialog-footer { .dialog-footer {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 12px; gap: 12px;
padding: 16px 24px; padding: 20px 24px;
background: var(--bg-tertiary); background: var(--bg-tertiary);
border-top: 1px solid var(--border-subtle); border-top: 1px solid var(--border-subtle);
} }
@@ -893,31 +943,32 @@ onMounted(() => fetchModels())
:deep(.delete-dialog .el-dialog) { :deep(.delete-dialog .el-dialog) {
background: var(--bg-elevated); background: var(--bg-elevated);
border: 1px solid var(--danger-muted); border: 1px solid var(--danger-muted);
border-radius: var(--radius-xl); border-radius: 16px;
} }
.delete-header { .delete-header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 12px; gap: 16px;
padding: 24px; padding: 32px 24px 24px;
} }
.delete-icon { .delete-icon {
width: 56px; width: 64px;
height: 56px; height: 64px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: var(--danger-muted); background: linear-gradient(135deg, rgba(248, 113, 113, 0.2), rgba(248, 113, 113, 0.1));
border: 1px solid var(--danger-muted); border: 1px solid var(--danger-muted);
border-radius: 50%; border-radius: 16px;
color: var(--danger); color: var(--danger);
box-shadow: 0 4px 20px rgba(248, 113, 113, 0.2);
} }
.delete-header h3 { .delete-header h3 {
font-size: 18px; font-size: 20px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
margin: 0; margin: 0;
@@ -925,12 +976,13 @@ onMounted(() => fetchModels())
.delete-content { .delete-content {
text-align: center; text-align: center;
padding: 0 24px 24px; padding: 0 32px 24px;
} }
.delete-content p { .delete-content p {
color: var(--text-secondary); color: var(--text-secondary);
margin: 0; margin: 0;
font-size: 15px;
} }
.delete-content p strong { .delete-content p strong {
@@ -940,16 +992,21 @@ onMounted(() => fetchModels())
.warning-text { .warning-text {
color: var(--danger) !important; color: var(--danger) !important;
font-size: 13px; font-size: 13px;
margin-top: 8px !important; margin-top: 12px !important;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
} }
.delete-footer { .delete-footer {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 12px; gap: 12px;
padding: 16px 24px; padding: 20px 24px;
background: var(--bg-tertiary); background: var(--bg-tertiary);
border-top: 1px solid var(--border-subtle); border-top: 1px solid var(--border-subtle);
border-radius: 0 0 16px 16px;
} }
/* 响应式 */ /* 响应式 */
@@ -960,10 +1017,6 @@ onMounted(() => fetchModels())
padding: 16px; padding: 16px;
} }
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.page-main { .page-main {
padding: 16px; padding: 16px;
} }