From 33265b4b133b26ba699544649eef60e3a194bdbe Mon Sep 17 00:00:00 2001 From: fawney19 Date: Tue, 16 Dec 2025 12:21:21 +0800 Subject: [PATCH] refactor(global-model): migrate model metadata to flexible config structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将模型配置从多个固定字段(description, official_url, icon_url, default_supports_* 等) 统一为灵活的 config JSON 字段,提高扩展性。同时优化前端模型创建表单,支持从 models-dev 列表直接选择模型快速填充。 主要变更: - 后端:模型表迁移,支持 config JSON 存储模型能力和元信息 - 前端:GlobalModelFormDialog 支持两种创建方式(列表选择/手动填写) - API 类型更新,对齐新的数据结构 --- README.md | 2 +- ...6f_refactor_global_model_to_use_config_.py | 86 +++ frontend/src/api/endpoints/types.ts | 34 +- frontend/src/api/models-dev.ts | 244 +++++++ frontend/src/api/public-models.ts | 10 +- .../src/components/charts/ScatterChart.vue | 2 +- .../components/GlobalModelFormDialog.vue | 633 ++++++++++++------ .../models/components/ModelDetailDrawer.vue | 343 +++++----- .../providers/components/ModelAliasDialog.vue | 8 +- frontend/src/mocks/data.ts | 117 ++-- frontend/src/mocks/handler.ts | 12 +- frontend/src/views/admin/CacheMonitoring.vue | 10 +- frontend/src/views/admin/ModelManagement.vue | 43 +- frontend/src/views/user/Announcements.vue | 2 +- frontend/src/views/user/ModelCatalog.vue | 18 +- .../user/components/UserModelDetailDrawer.vue | 24 +- src/api/admin/models/__init__.py | 2 + src/api/admin/models/catalog.py | 10 +- src/api/admin/models/external.py | 79 +++ src/api/admin/models/global_models.py | 10 +- src/api/public/catalog.py | 22 +- src/models/api.py | 9 +- src/models/database.py | 56 +- src/models/pydantic_models.py | 48 +- src/services/cache/model_cache.py | 30 +- src/services/model/global_model.py | 21 +- 26 files changed, 1230 insertions(+), 645 deletions(-) create mode 100644 alembic/versions/20251216_0311_1cc6942cf06f_refactor_global_model_to_use_config_.py create mode 100644 frontend/src/api/models-dev.ts create mode 100644 src/api/admin/models/external.py diff --git a/README.md b/README.md index f44e1f7..7d675fa 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ docker-compose up -d # 4. 更新 docker-compose pull && docker-compose up -d -# 5. 数据库迁移(首次部署或更新后执行) +# 5. 数据库迁移 - 更新后执行 ./migrate.sh ``` diff --git a/alembic/versions/20251216_0311_1cc6942cf06f_refactor_global_model_to_use_config_.py b/alembic/versions/20251216_0311_1cc6942cf06f_refactor_global_model_to_use_config_.py new file mode 100644 index 0000000..fd60af8 --- /dev/null +++ b/alembic/versions/20251216_0311_1cc6942cf06f_refactor_global_model_to_use_config_.py @@ -0,0 +1,86 @@ +"""refactor global_model to use config json field + +Revision ID: 1cc6942cf06f +Revises: 180e63a9c83a +Create Date: 2025-12-16 03:11:32.480976+00:00 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '1cc6942cf06f' +down_revision = '180e63a9c83a' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """应用迁移:升级到新版本 + + 1. 添加 config 列 + 2. 把旧数据迁移到 config + 3. 删除旧列 + """ + # 1. 添加 config 列(使用 JSONB 类型,支持索引和更高效的查询) + op.add_column('global_models', sa.Column('config', postgresql.JSONB(), nullable=True)) + + # 2. 迁移数据:把旧字段合并到 config JSON + # 注意:使用 COALESCE 为布尔字段设置默认值,避免数据丢失 + # - streaming 默认 true(大多数模型支持) + # - 其他能力默认 false + # - jsonb_strip_nulls 只移除 null 字段,不影响 false 值 + op.execute(""" + UPDATE global_models + SET config = jsonb_strip_nulls(jsonb_build_object( + 'streaming', COALESCE(default_supports_streaming, true), + 'vision', CASE WHEN COALESCE(default_supports_vision, false) THEN true ELSE NULL END, + 'function_calling', CASE WHEN COALESCE(default_supports_function_calling, false) THEN true ELSE NULL END, + 'extended_thinking', CASE WHEN COALESCE(default_supports_extended_thinking, false) THEN true ELSE NULL END, + 'image_generation', CASE WHEN COALESCE(default_supports_image_generation, false) THEN true ELSE NULL END, + 'description', description, + 'icon_url', icon_url, + 'official_url', official_url + )) + """) + + # 3. 删除旧列 + op.drop_column('global_models', 'default_supports_streaming') + op.drop_column('global_models', 'default_supports_vision') + op.drop_column('global_models', 'default_supports_function_calling') + op.drop_column('global_models', 'default_supports_extended_thinking') + op.drop_column('global_models', 'default_supports_image_generation') + op.drop_column('global_models', 'description') + op.drop_column('global_models', 'icon_url') + op.drop_column('global_models', 'official_url') + + +def downgrade() -> None: + """回滚迁移:降级到旧版本""" + # 1. 添加旧列 + op.add_column('global_models', sa.Column('icon_url', sa.VARCHAR(length=500), nullable=True)) + op.add_column('global_models', sa.Column('official_url', sa.VARCHAR(length=500), nullable=True)) + op.add_column('global_models', sa.Column('description', sa.TEXT(), nullable=True)) + op.add_column('global_models', sa.Column('default_supports_streaming', sa.BOOLEAN(), nullable=True)) + op.add_column('global_models', sa.Column('default_supports_vision', sa.BOOLEAN(), nullable=True)) + op.add_column('global_models', sa.Column('default_supports_function_calling', sa.BOOLEAN(), nullable=True)) + op.add_column('global_models', sa.Column('default_supports_extended_thinking', sa.BOOLEAN(), nullable=True)) + op.add_column('global_models', sa.Column('default_supports_image_generation', sa.BOOLEAN(), nullable=True)) + + # 2. 从 config 恢复数据 + op.execute(""" + UPDATE global_models + SET + default_supports_streaming = COALESCE((config->>'streaming')::boolean, true), + default_supports_vision = COALESCE((config->>'vision')::boolean, false), + default_supports_function_calling = COALESCE((config->>'function_calling')::boolean, false), + default_supports_extended_thinking = COALESCE((config->>'extended_thinking')::boolean, false), + default_supports_image_generation = COALESCE((config->>'image_generation')::boolean, false), + description = config->>'description', + icon_url = config->>'icon_url', + official_url = config->>'official_url' + """) + + # 3. 删除 config 列 + op.drop_column('global_models', 'config') diff --git a/frontend/src/api/endpoints/types.ts b/frontend/src/api/endpoints/types.ts index c069b2d..4f666ad 100644 --- a/frontend/src/api/endpoints/types.ts +++ b/frontend/src/api/endpoints/types.ts @@ -407,67 +407,45 @@ export interface TieredPricingConfig { export interface GlobalModelCreate { name: string display_name: string - description?: string - official_url?: string - icon_url?: string // 按次计费配置(可选,与阶梯计费叠加) default_price_per_request?: number // 阶梯计费配置(必填,固定价格用单阶梯表示) default_tiered_pricing: TieredPricingConfig - // 默认能力配置 - default_supports_vision?: boolean - default_supports_function_calling?: boolean - default_supports_streaming?: boolean - default_supports_extended_thinking?: boolean - default_supports_image_generation?: boolean // Key 能力配置 - 模型支持的能力列表 supported_capabilities?: string[] + // 模型配置(JSON格式)- 包含能力、规格、元信息等 + config?: Record is_active?: boolean } export interface GlobalModelUpdate { display_name?: string - description?: string - official_url?: string - icon_url?: string is_active?: boolean // 按次计费配置 default_price_per_request?: number | null // null 表示清空 // 阶梯计费配置 default_tiered_pricing?: TieredPricingConfig - // 默认能力配置 - default_supports_vision?: boolean - default_supports_function_calling?: boolean - default_supports_streaming?: boolean - default_supports_extended_thinking?: boolean - default_supports_image_generation?: boolean // Key 能力配置 - 模型支持的能力列表 supported_capabilities?: string[] | null + // 模型配置(JSON格式)- 包含能力、规格、元信息等 + config?: Record | null } export interface GlobalModelResponse { id: string name: string display_name: string - description?: string - official_url?: string - icon_url?: string is_active: boolean // 按次计费配置 default_price_per_request?: number // 阶梯计费配置(必填) default_tiered_pricing: TieredPricingConfig - // 默认能力配置 - default_supports_vision?: boolean - default_supports_function_calling?: boolean - default_supports_streaming?: boolean - default_supports_extended_thinking?: boolean - default_supports_image_generation?: boolean // Key 能力配置 - 模型支持的能力列表 supported_capabilities?: string[] | null + // 模型配置(JSON格式) + config?: Record | null // 统计数据 provider_count?: number - alias_count?: number usage_count?: number created_at: string updated_at?: string diff --git a/frontend/src/api/models-dev.ts b/frontend/src/api/models-dev.ts new file mode 100644 index 0000000..594df17 --- /dev/null +++ b/frontend/src/api/models-dev.ts @@ -0,0 +1,244 @@ +/** + * Models.dev API 服务 + * 通过后端代理获取 models.dev 数据(解决跨域问题) + */ + +import api from './client' + +// 缓存配置 +const CACHE_KEY = 'models_dev_cache' +const CACHE_DURATION = 15 * 60 * 1000 // 15 分钟 + +// Models.dev API 数据结构 +export interface ModelsDevCost { + input?: number + output?: number + reasoning?: number + cache_read?: number +} + +export interface ModelsDevLimit { + context?: number + output?: number +} + +export interface ModelsDevModel { + id: string + name: string + family?: string + reasoning?: boolean + tool_call?: boolean + structured_output?: boolean + temperature?: boolean + attachment?: boolean + knowledge?: string + release_date?: string + last_updated?: string + input?: string[] // 输入模态: text, image, audio, video, pdf + output?: string[] // 输出模态: text, image, audio + open_weights?: boolean + cost?: ModelsDevCost + limit?: ModelsDevLimit + deprecated?: boolean +} + +export interface ModelsDevProvider { + id: string + env?: string[] + npm?: string + api?: string + name: string + doc?: string + models: Record +} + +export type ModelsDevData = Record + +// 扁平化的模型列表项(用于搜索和选择) +export interface ModelsDevModelItem { + providerId: string + providerName: string + modelId: string + modelName: string + family?: string + inputPrice?: number + outputPrice?: number + contextLimit?: number + outputLimit?: number + supportsVision?: boolean + supportsToolCall?: boolean + supportsReasoning?: boolean + deprecated?: boolean + // 用于 display_metadata 的额外字段 + knowledgeCutoff?: string + releaseDate?: string + inputModalities?: string[] + outputModalities?: string[] +} + +interface CacheData { + timestamp: number + data: ModelsDevData +} + +// 内存缓存 +let memoryCache: CacheData | null = null + +/** + * 获取 models.dev 数据(带缓存) + */ +export async function getModelsDevData(): Promise { + // 1. 检查内存缓存 + if (memoryCache && Date.now() - memoryCache.timestamp < CACHE_DURATION) { + return memoryCache.data + } + + // 2. 检查 localStorage 缓存 + try { + const cached = localStorage.getItem(CACHE_KEY) + if (cached) { + const cacheData: CacheData = JSON.parse(cached) + if (Date.now() - cacheData.timestamp < CACHE_DURATION) { + memoryCache = cacheData + return cacheData.data + } + } + } catch { + // 缓存解析失败,忽略 + } + + // 3. 从后端代理获取新数据 + const response = await api.get('/api/admin/models/external') + const data = response.data + + // 4. 更新缓存 + const cacheData: CacheData = { + timestamp: Date.now(), + data, + } + memoryCache = cacheData + try { + localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData)) + } catch { + // localStorage 写入失败,忽略 + } + + return data +} + +/** + * 获取扁平化的模型列表 + */ +export async function getModelsDevList(): Promise { + const data = await getModelsDevData() + const items: ModelsDevModelItem[] = [] + + for (const [providerId, provider] of Object.entries(data)) { + if (!provider.models) continue + + for (const [modelId, model] of Object.entries(provider.models)) { + items.push({ + providerId, + providerName: provider.name, + modelId, + modelName: model.name || modelId, + family: model.family, + inputPrice: model.cost?.input, + outputPrice: model.cost?.output, + contextLimit: model.limit?.context, + outputLimit: model.limit?.output, + supportsVision: model.input?.includes('image'), + supportsToolCall: model.tool_call, + supportsReasoning: model.reasoning, + deprecated: model.deprecated, + // display_metadata 相关字段 + knowledgeCutoff: model.knowledge, + releaseDate: model.release_date, + inputModalities: model.input, + outputModalities: model.output, + }) + } + } + + // 按 provider 名称和模型名称排序 + items.sort((a, b) => { + const providerCompare = a.providerName.localeCompare(b.providerName) + if (providerCompare !== 0) return providerCompare + return a.modelName.localeCompare(b.modelName) + }) + + return items +} + +/** + * 搜索模型 + */ +export async function searchModelsDevModels( + query: string, + options?: { + limit?: number + excludeDeprecated?: boolean + } +): Promise { + const allModels = await getModelsDevList() + const { limit = 50, excludeDeprecated = true } = options || {} + + const queryLower = query.toLowerCase() + + const filtered = allModels.filter((model) => { + if (excludeDeprecated && model.deprecated) return false + + // 搜索模型 ID、名称、provider 名称、family + return ( + model.modelId.toLowerCase().includes(queryLower) || + model.modelName.toLowerCase().includes(queryLower) || + model.providerName.toLowerCase().includes(queryLower) || + model.family?.toLowerCase().includes(queryLower) + ) + }) + + // 排序:精确匹配优先 + filtered.sort((a, b) => { + const aExact = + a.modelId.toLowerCase() === queryLower || + a.modelName.toLowerCase() === queryLower + const bExact = + b.modelId.toLowerCase() === queryLower || + b.modelName.toLowerCase() === queryLower + if (aExact && !bExact) return -1 + if (!aExact && bExact) return 1 + return 0 + }) + + return filtered.slice(0, limit) +} + +/** + * 获取特定模型详情 + */ +export async function getModelsDevModel( + providerId: string, + modelId: string +): Promise { + const data = await getModelsDevData() + return data[providerId]?.models?.[modelId] || null +} + +/** + * 获取 provider logo URL + */ +export function getProviderLogoUrl(providerId: string): string { + return `https://models.dev/logos/${providerId}.svg` +} + +/** + * 清除缓存 + */ +export function clearModelsDevCache(): void { + memoryCache = null + try { + localStorage.removeItem(CACHE_KEY) + } catch { + // 忽略错误 + } +} diff --git a/frontend/src/api/public-models.ts b/frontend/src/api/public-models.ts index 60bb1e8..212deaa 100644 --- a/frontend/src/api/public-models.ts +++ b/frontend/src/api/public-models.ts @@ -9,20 +9,14 @@ export interface PublicGlobalModel { id: string name: string display_name: string | null - description: string | null - icon_url: string | null is_active: boolean // 阶梯计费配置 default_tiered_pricing: TieredPricingConfig default_price_per_request: number | null // 按次计费价格 - // 能力 - default_supports_vision: boolean - default_supports_function_calling: boolean - default_supports_streaming: boolean - default_supports_extended_thinking: boolean - default_supports_image_generation: boolean // Key 能力支持 supported_capabilities: string[] | null + // 模型配置(JSON) + config: Record | null } export interface PublicGlobalModelListResponse { diff --git a/frontend/src/components/charts/ScatterChart.vue b/frontend/src/components/charts/ScatterChart.vue index 6d634f1..2b35122 100644 --- a/frontend/src/components/charts/ScatterChart.vue +++ b/frontend/src/components/charts/ScatterChart.vue @@ -299,7 +299,7 @@ function formatDuration(ms: number): string { const hours = Math.floor(ms / (1000 * 60 * 60)) const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60)) if (hours > 0) { - return `${hours}h${minutes > 0 ? minutes + 'm' : ''}` + return `${hours}h${minutes > 0 ? `${minutes}m` : ''}` } return `${minutes}m` } diff --git a/frontend/src/features/models/components/GlobalModelFormDialog.vue b/frontend/src/features/models/components/GlobalModelFormDialog.vue index 11083e3..55c19df 100644 --- a/frontend/src/features/models/components/GlobalModelFormDialog.vue +++ b/frontend/src/features/models/components/GlobalModelFormDialog.vue @@ -2,174 +2,258 @@ -
- -
-

- 基本信息 -

- -
-
- - -

- 创建后不可修改 -

-
-
- - -
-
- -
- - -
-
- - -
-

- 默认能力 -

-
- - - - - -
-
- - -
+
-

- Key 能力支持 -

-
- -
-
- - -
-

- 价格配置 -

- - - -
- + +
+ - 每次请求固定费用,可与 Token 计费叠加
-
-
+ + +
+
+ +
+ +
+ + + +
+
+ +
+

+ 基本信息 +

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+

+ 默认能力 +

+
+ + + + + +
+
+ + +
+

+ Key 能力支持 +

+
+ +
+
+ + +
+

+ 价格配置 +

+ +
+ + + 可与 Token 计费叠加 +
+
+
+
+