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 依赖项
"""
from typing import Annotated
from typing import Annotated, Optional
from fastapi import Depends
from app.core.auth import verify_api_key

View File

@@ -4,7 +4,7 @@ API v1 Router
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()
@@ -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(datasets.router, prefix="/datasets", tags=["datasets"])
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
finally:
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.logging import logger
# Import all models to register them with Base.metadata
from app.models.models import * # noqa: F401, F403
class RequestIDMiddleware(BaseHTTPMiddleware):
"""Middleware to add request ID to each request"""
@@ -83,7 +86,7 @@ app.add_middleware(RequestIDMiddleware)
# CORS - Configure properly for production
# 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(
CORSMiddleware,

View File

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

View File

@@ -50,6 +50,13 @@ from app.schemas.eval import (
TaskResponse,
)
from app.schemas.model import (
ModelBase,
ModelCreate,
ModelUpdate,
ModelResponse,
)
__all__ = [
# Base
"TimestampMixin",
@@ -86,4 +93,9 @@ __all__ = [
"EvalDatasetResponse",
"TaskBase",
"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 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({
baseURL: import.meta.env.PROD
@@ -91,4 +91,14 @@ export const evalApi = {
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

View File

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

View File

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

View File

@@ -3,6 +3,33 @@
<!-- Hero Section -->
<section class="hero">
<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">
<span class="badge-dot"></span>
<span>AI 驱动数据生成</span>
@@ -20,103 +47,85 @@
<el-icon><Plus /></el-icon>
创建项目
</el-button>
<el-button size="large" @click="goToDataSquare" class="btn-secondary">
<el-icon><Grid /></el-icon>
数据集广场
<el-button size="large" @click="goToModels" class="btn-secondary">
<el-icon><Cpu /></el-icon>
模型管理
</el-button>
</div>
</div>
<!-- Hero Visual - 全息粒子矩阵风格 -->
<!-- Hero Visual - Modern Abstract Composition -->
<div class="hero-visual">
<!-- Card 1: 多格式支持 -->
<div class="hologram-card card-1">
<div class="card-bg"></div>
<div class="scan-line"></div>
<div class="particles-container">
<span class="particle" style="--x: 20%; --y: 30%"></span>
<span class="particle" style="--x: 80%; --y: 20%"></span>
<span class="particle" style="--x: 50%; --y: 70%"></span>
<span class="particle" style="--x: 30%; --y: 60%"></span>
<span class="particle" style="--x: 70%; --y: 80%"></span>
<span class="particle" style="--x: 15%; --y: 85%"></span>
<span class="particle" style="--x: 85%; --y: 45%"></span>
<span class="particle" style="--x: 45%; --y: 15%"></span>
<!-- Light rays -->
<div class="light-rays">
<div class="ray"></div>
<div class="ray"></div>
<div class="ray"></div>
<div class="ray"></div>
<div class="ray"></div>
</div>
<!-- Ambient particles -->
<span class="ambient-particle"></span>
<span class="ambient-particle"></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 class="pulse-ring"></div>
<div class="card-content">
<div class="icon-wrapper cyan">
<div class="icon-glow"></div>
<el-icon size="28"><Document /></el-icon>
</div>
<span class="card-label">多格式支持</span>
<span class="card-sublabel">PDF DOCX EPUB Excel</span>
<div class="ui-content">
<div class="ui-line"></div>
<div class="ui-line short"></div>
<div class="ui-line"></div>
</div>
<div class="ui-badge">
<el-icon><Check /></el-icon>
<span>处理完成</span>
</div>
</div>
<!-- Card 2: AI 生成 -->
<div class="hologram-card card-2">
<div class="card-bg"></div>
<div class="scan-line"></div>
<div class="particles-container">
<span class="particle" style="--x: 25%; --y: 35%"></span>
<span class="particle" style="--x: 75%; --y: 25%"></span>
<span class="particle" style="--x: 55%; --y: 65%"></span>
<span class="particle" style="--x: 35%; --y: 55%"></span>
<span class="particle" style="--x: 65%; --y: 85%"></span>
<span class="particle" style="--x: 20%; --y: 80%"></span>
<span class="particle" style="--x: 80%; --y: 50%"></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>
<!-- Floating feature pills - main features -->
<div class="feature-pill pill-1">
<el-icon><Document /></el-icon>
<span>多格式支持</span>
</div>
<div class="feature-pill pill-2">
<el-icon><MagicStick /></el-icon>
<span>AI 生成</span>
</div>
<div class="feature-pill pill-3">
<el-icon><DataAnalysis /></el-icon>
<span>智能评估</span>
</div>
<!-- Card 3: 智能评估 -->
<div class="hologram-card card-3">
<div class="card-bg"></div>
<div class="scan-line"></div>
<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>
<!-- Additional floating labels -->
<div class="feature-pill pill-4">
<el-icon><Connection /></el-icon>
<span>API 集成</span>
</div>
</div>
</section>
<!-- Quick Actions -->
<section class="quick-actions">
<div class="action-card" @click="goToModels">
<div class="action-icon">
<el-icon><Setting /></el-icon>
<div class="feature-pill pill-5">
<el-icon><Clock /></el-icon>
<span>批量处理</span>
</div>
<div class="action-info">
<h3>模型配置</h3>
<p>管理 AI 模型 API 配置</p>
<div class="feature-pill pill-6">
<el-icon><Lock /></el-icon>
<span>数据安全</span>
</div>
<div class="feature-pill pill-7">
<el-icon><TrendCharts /></el-icon>
<span>可视化</span>
</div>
<el-icon class="action-arrow"><ArrowRight /></el-icon>
</div>
</section>
@@ -179,7 +188,7 @@
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
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 type { Project, ProjectCreate } from '@/types'
@@ -275,5 +284,5 @@ onMounted(() => fetchProjects())
</script>
<style scoped>
@import '@/styles/home.scss';
@import '@/styles/pages/home.scss';
</style>

View File

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