From 8b04b000a2a066fa70394a7d7d0cbcd570932ec6 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:22:47 +0800 Subject: [PATCH] feat: add model vendor and display group management --- backend/docs/docs.go | 757 +++++++++++++++++- backend/docs/swagger.json | 757 +++++++++++++++++- backend/docs/swagger.yaml | 493 ++++++++++++ backend/internal/app/app.go | 2 +- backend/internal/application/channel/dto.go | 27 + backend/internal/application/channel/errs.go | 12 + backend/internal/application/channel/input.go | 29 + .../internal/application/channel/service.go | 16 +- .../application/channel/service_model.go | 46 +- .../channel/service_model_presentation.go | 324 ++++++++ .../channel/service_model_update_test.go | 148 +++- .../channel/service_routing_test.go | 1 + .../channel/service_view_normalization.go | 5 + backend/internal/domain/channel/types.go | 57 ++ .../infra/persistence/models/channel.go | 31 +- .../persistence/models/table_names_test.go | 2 + .../postgres/channel/model_presentation.go | 291 +++++++ .../postgres/channel/repository.go | 47 +- .../channel/repository_sqlite_test.go | 202 +++++ .../infra/persistence/postgres/postgres.go | 5 + .../infra/persistence/schema/schema.go | 41 + .../infra/persistence/schema/schema_test.go | 52 ++ .../infra/persistence/sqlite/sqlite.go | 3 + backend/internal/repository/channel.go | 52 +- .../http/channel/dto_model_presentation.go | 80 ++ .../transport/http/channel/dto_request.go | 35 + .../transport/http/channel/dto_response.go | 20 + .../transport/http/channel/handler.go | 14 + .../channel/handler_model_presentation.go | 257 ++++++ .../internal/transport/http/channel/router.go | 10 + frontend/components/ui/alert-dialog.tsx | 4 +- frontend/features/admin/api/llm.ts | 111 +++ frontend/features/admin/api/llm.types.ts | 30 +- .../admin/components/bulk-confirm-dialog.tsx | 4 +- .../sections/billing/billing-prices.tsx | 18 +- .../sections/groups/admin-groups.tsx | 12 +- .../groups/model-access-rules-panel.tsx | 7 +- .../sections/models/admin-models.tsx | 79 +- .../sections/models/models-order-sheet.tsx | 65 +- .../models/models-presentation-dialog.tsx | 473 +++++++++++ .../sections/models/models-sheet.tsx | 82 +- .../sections/models/models-table.tsx | 23 +- .../hooks/use-admin-model-presentation.ts | 56 ++ .../features/admin/hooks/use-admin-models.ts | 213 +++-- .../hooks/use-model-presentation-editor.ts | 233 ++++++ frontend/features/admin/types/llm.ts | 3 - .../components/sections/chat-model-picker.tsx | 212 ++--- .../components/shared/chat-mention-menu.tsx | 8 +- .../chat/hooks/use-chat-model-options.ts | 5 + frontend/features/chat/types/chat-runtime.ts | 5 + .../components/navigation/project-dialog.tsx | 66 +- .../sections/chat/settings-chat.tsx | 6 +- .../settings/hooks/use-settings-chat.ts | 8 +- frontend/features/settings/types/settings.ts | 2 +- .../features/settings/utils/chat-settings.ts | 11 +- .../i18n/messages/en-US/admin-models.json | 58 +- frontend/i18n/messages/en-US/chat.json | 2 +- .../i18n/messages/zh-CN/admin-models.json | 58 +- frontend/i18n/messages/zh-CN/chat.json | 2 +- .../{lobehub-icon.tsx => model-icon.tsx} | 23 +- .../shared/components/model-option-icon.tsx | 4 +- .../shared/lib/identity-provider-icons.ts | 4 +- frontend/shared/lib/model-identity.ts | 19 +- frontend/shared/lib/model-option-display.ts | 4 +- frontend/shared/lib/model-presentation.ts | 37 + packages/api-contract/src/types.generated.ts | 260 ++++++ 66 files changed, 5536 insertions(+), 487 deletions(-) create mode 100644 backend/internal/application/channel/service_model_presentation.go create mode 100644 backend/internal/infra/persistence/postgres/channel/model_presentation.go create mode 100644 backend/internal/transport/http/channel/dto_model_presentation.go create mode 100644 backend/internal/transport/http/channel/handler_model_presentation.go create mode 100644 frontend/features/admin/components/sections/models/models-presentation-dialog.tsx create mode 100644 frontend/features/admin/hooks/use-admin-model-presentation.ts create mode 100644 frontend/features/admin/hooks/use-model-presentation-editor.ts rename frontend/shared/components/{lobehub-icon.tsx => model-icon.tsx} (85%) create mode 100644 frontend/shared/lib/model-presentation.ts diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 97582cc25..d0d79d642 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -1605,6 +1605,371 @@ const docTemplate = `{ } } }, + "/admin/llm/model-display-groups": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "分页查询自定义展示分组;未绑定分组的模型继续按技术厂商展示", + "produces": [ + "application/json" + ], + "tags": [ + "llm" + ], + "summary": "管理员查询模型展示分组", + "parameters": [ + { + "type": "integer", + "description": "页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "搜索名称", + "name": "q", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ModelDisplayGroupListResponseDoc" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "创建仅影响用户界面归类的自定义模型分组", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "llm" + ], + "summary": "管理员创建模型展示分组", + "parameters": [ + { + "description": "展示分组参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateModelDisplayGroupRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ModelDisplayGroupDataResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + } + } + } + }, + "/admin/llm/model-display-groups/{id}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "删除展示分组后,关联模型恢复按技术厂商展示", + "produces": [ + "application/json" + ], + "tags": [ + "llm" + ], + "summary": "管理员删除模型展示分组", + "parameters": [ + { + "type": "integer", + "description": "展示分组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/SuccessDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + } + } + }, + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "llm" + ], + "summary": "管理员更新模型展示分组", + "parameters": [ + { + "type": "integer", + "description": "展示分组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "展示分组参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/UpdateModelDisplayGroupRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ModelDisplayGroupDataResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + } + } + } + }, + "/admin/llm/model-vendors": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "分页查询模型技术厂商目录;技术厂商是路由、权限和计费使用的稳定身份", + "produces": [ + "application/json" + ], + "tags": [ + "llm" + ], + "summary": "管理员查询模型技术厂商", + "parameters": [ + { + "type": "integer", + "description": "页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "搜索 key 或名称", + "name": "q", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ModelVendorListResponseDoc" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "创建新的稳定技术厂商身份;创建后可供平台模型选择", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "llm" + ], + "summary": "管理员创建模型技术厂商", + "parameters": [ + { + "description": "技术厂商参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateModelVendorRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ModelVendorDataResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + } + } + } + }, + "/admin/llm/model-vendors/{key}": { + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "更新厂商展示名称和图标;稳定技术 key 不可修改", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "llm" + ], + "summary": "管理员更新模型技术厂商", + "parameters": [ + { + "type": "string", + "description": "技术厂商 key", + "name": "key", + "in": "path", + "required": true + }, + { + "description": "技术厂商参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/UpdateModelVendorRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ModelVendorDataResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + } + } + } + }, "/admin/llm/models": { "get": { "security": [ @@ -1801,6 +2166,57 @@ const docTemplate = `{ } } }, + "/admin/llm/models/display-group": { + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "在单个事务中将指定模型归入展示分组;displayGroupID 为 0 时恢复按技术厂商展示", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "llm" + ], + "summary": "管理员批量设置模型展示分组", + "parameters": [ + { + "description": "批量归组参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/SetModelsDisplayGroupRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/SuccessDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + } + } + } + }, "/admin/llm/models/order": { "post": { "security": [ @@ -14517,6 +14933,29 @@ const docTemplate = `{ } } }, + "CreateModelDisplayGroupRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "icon": { + "type": "string", + "maxLength": 2048 + }, + "modelIDs": { + "type": "array", + "maxItems": 10000, + "items": { + "type": "integer" + } + }, + "name": { + "type": "string", + "maxLength": 64 + } + } + }, "CreateModelRequest": { "type": "object", "required": [ @@ -14557,6 +14996,9 @@ const docTemplate = `{ "type": "string", "maxLength": 10000 }, + "displayGroupID": { + "type": "integer" + }, "icon": { "type": "string", "maxLength": 128 @@ -14594,11 +15036,32 @@ const docTemplate = `{ "errorMsg" ], "properties": { - "data": { - "$ref": "#/definitions/ModelDataResponse" + "data": { + "$ref": "#/definitions/ModelDataResponse" + }, + "errorMsg": { + "type": "string" + } + } + }, + "CreateModelVendorRequest": { + "type": "object", + "required": [ + "key", + "name" + ], + "properties": { + "icon": { + "type": "string", + "maxLength": 2048 }, - "errorMsg": { - "type": "string" + "key": { + "type": "string", + "maxLength": 64 + }, + "name": { + "type": "string", + "maxLength": 64 } } }, @@ -16572,6 +17035,93 @@ const docTemplate = `{ } } }, + "ModelDisplayGroupDataResponse": { + "type": "object", + "required": [ + "group" + ], + "properties": { + "group": { + "$ref": "#/definitions/ModelDisplayGroupResponse" + } + } + }, + "ModelDisplayGroupDataResponseDoc": { + "type": "object", + "required": [ + "data", + "errorMsg" + ], + "properties": { + "data": { + "$ref": "#/definitions/ModelDisplayGroupDataResponse" + }, + "errorMsg": { + "type": "string" + } + } + }, + "ModelDisplayGroupListResponseDoc": { + "type": "object", + "required": [ + "data", + "errorMsg" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "results", + "total" + ], + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/definitions/ModelDisplayGroupResponse" + } + }, + "total": { + "type": "integer" + } + } + }, + "errorMsg": { + "type": "string" + } + } + }, + "ModelDisplayGroupResponse": { + "type": "object", + "required": [ + "createdAt", + "icon", + "id", + "name", + "sortOrder", + "updatedAt" + ], + "properties": { + "createdAt": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "sortOrder": { + "type": "integer" + }, + "updatedAt": { + "type": "string" + } + } + }, "ModelListResponseDoc": { "type": "object", "required": [ @@ -17003,6 +17553,9 @@ const docTemplate = `{ "cbWindowMin", "createdAt", "description", + "displayGroupID", + "displayGroupIcon", + "displayGroupName", "icon", "id", "kindsJSON", @@ -17014,7 +17567,9 @@ const docTemplate = `{ "systemPrompt", "updatedAt", "upstreamNamesJSON", - "vendor" + "vendor", + "vendorIcon", + "vendorName" ], "properties": { "accessScope": { @@ -17044,6 +17599,17 @@ const docTemplate = `{ "description": { "type": "string" }, + "displayGroupID": { + "type": "integer", + "x-nullable": true, + "x-omitempty": false + }, + "displayGroupIcon": { + "type": "string" + }, + "displayGroupName": { + "type": "string" + }, "icon": { "type": "string" }, @@ -17079,6 +17645,12 @@ const docTemplate = `{ }, "vendor": { "type": "string" + }, + "vendorIcon": { + "type": "string" + }, + "vendorName": { + "type": "string" } } }, @@ -17234,6 +17806,101 @@ const docTemplate = `{ } } }, + "ModelVendorDataResponse": { + "type": "object", + "required": [ + "vendor" + ], + "properties": { + "vendor": { + "$ref": "#/definitions/ModelVendorResponse" + } + } + }, + "ModelVendorDataResponseDoc": { + "type": "object", + "required": [ + "data", + "errorMsg" + ], + "properties": { + "data": { + "$ref": "#/definitions/ModelVendorDataResponse" + }, + "errorMsg": { + "type": "string" + } + } + }, + "ModelVendorListResponseDoc": { + "type": "object", + "required": [ + "data", + "errorMsg" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "results", + "total" + ], + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/definitions/ModelVendorResponse" + } + }, + "total": { + "type": "integer" + } + } + }, + "errorMsg": { + "type": "string" + } + } + }, + "ModelVendorResponse": { + "type": "object", + "required": [ + "builtIn", + "createdAt", + "icon", + "id", + "key", + "name", + "sortOrder", + "updatedAt" + ], + "properties": { + "builtIn": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "sortOrder": { + "type": "integer" + }, + "updatedAt": { + "type": "string" + } + } + }, "NativeToolPricingRequest": { "type": "object", "properties": { @@ -18337,13 +19004,18 @@ const docTemplate = `{ "required": [ "capabilitiesJSON", "description", + "displayGroupID", + "displayGroupIcon", + "displayGroupName", "icon", "kindsJSON", "platformModelName", "pricing", "protocolsJSON", "sortOrder", - "vendor" + "vendor", + "vendorIcon", + "vendorName" ], "properties": { "capabilitiesJSON": { @@ -18352,6 +19024,17 @@ const docTemplate = `{ "description": { "type": "string" }, + "displayGroupID": { + "type": "integer", + "x-nullable": true, + "x-omitempty": false + }, + "displayGroupIcon": { + "type": "string" + }, + "displayGroupName": { + "type": "string" + }, "icon": { "type": "string" }, @@ -18378,6 +19061,12 @@ const docTemplate = `{ }, "vendor": { "type": "string" + }, + "vendorIcon": { + "type": "string" + }, + "vendorName": { + "type": "string" } } }, @@ -19583,6 +20272,26 @@ const docTemplate = `{ } } }, + "SetModelsDisplayGroupRequest": { + "type": "object", + "required": [ + "displayGroupID", + "modelIDs" + ], + "properties": { + "displayGroupID": { + "type": "integer" + }, + "modelIDs": { + "type": "array", + "maxItems": 1000, + "minItems": 1, + "items": { + "type": "integer" + } + } + } + }, "SettingsPatchSettingsRequest": { "type": "object", "required": [ @@ -20477,6 +21186,26 @@ const docTemplate = `{ } } }, + "UpdateModelDisplayGroupRequest": { + "type": "object", + "properties": { + "icon": { + "type": "string", + "maxLength": 2048 + }, + "modelIDs": { + "type": "array", + "maxItems": 10000, + "items": { + "type": "integer" + } + }, + "name": { + "type": "string", + "maxLength": 64 + } + } + }, "UpdateModelRequest": { "type": "object", "properties": { @@ -20514,6 +21243,9 @@ const docTemplate = `{ "type": "string", "maxLength": 10000 }, + "displayGroupID": { + "type": "integer" + }, "icon": { "type": "string", "maxLength": 128 @@ -20608,6 +21340,19 @@ const docTemplate = `{ } } }, + "UpdateModelVendorRequest": { + "type": "object", + "properties": { + "icon": { + "type": "string", + "maxLength": 2048 + }, + "name": { + "type": "string", + "maxLength": 64 + } + } + }, "UpdatePermissionGroupRequest": { "type": "object", "required": [ diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index d8ba7b100..65b3a4c21 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -1598,6 +1598,371 @@ } } }, + "/admin/llm/model-display-groups": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "分页查询自定义展示分组;未绑定分组的模型继续按技术厂商展示", + "produces": [ + "application/json" + ], + "tags": [ + "llm" + ], + "summary": "管理员查询模型展示分组", + "parameters": [ + { + "type": "integer", + "description": "页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "搜索名称", + "name": "q", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ModelDisplayGroupListResponseDoc" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "创建仅影响用户界面归类的自定义模型分组", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "llm" + ], + "summary": "管理员创建模型展示分组", + "parameters": [ + { + "description": "展示分组参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateModelDisplayGroupRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ModelDisplayGroupDataResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + } + } + } + }, + "/admin/llm/model-display-groups/{id}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "删除展示分组后,关联模型恢复按技术厂商展示", + "produces": [ + "application/json" + ], + "tags": [ + "llm" + ], + "summary": "管理员删除模型展示分组", + "parameters": [ + { + "type": "integer", + "description": "展示分组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/SuccessDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + } + } + }, + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "llm" + ], + "summary": "管理员更新模型展示分组", + "parameters": [ + { + "type": "integer", + "description": "展示分组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "展示分组参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/UpdateModelDisplayGroupRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ModelDisplayGroupDataResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + } + } + } + }, + "/admin/llm/model-vendors": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "分页查询模型技术厂商目录;技术厂商是路由、权限和计费使用的稳定身份", + "produces": [ + "application/json" + ], + "tags": [ + "llm" + ], + "summary": "管理员查询模型技术厂商", + "parameters": [ + { + "type": "integer", + "description": "页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "搜索 key 或名称", + "name": "q", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ModelVendorListResponseDoc" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "创建新的稳定技术厂商身份;创建后可供平台模型选择", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "llm" + ], + "summary": "管理员创建模型技术厂商", + "parameters": [ + { + "description": "技术厂商参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateModelVendorRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ModelVendorDataResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + } + } + } + }, + "/admin/llm/model-vendors/{key}": { + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "更新厂商展示名称和图标;稳定技术 key 不可修改", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "llm" + ], + "summary": "管理员更新模型技术厂商", + "parameters": [ + { + "type": "string", + "description": "技术厂商 key", + "name": "key", + "in": "path", + "required": true + }, + { + "description": "技术厂商参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/UpdateModelVendorRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ModelVendorDataResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + } + } + } + }, "/admin/llm/models": { "get": { "security": [ @@ -1794,6 +2159,57 @@ } } }, + "/admin/llm/models/display-group": { + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "在单个事务中将指定模型归入展示分组;displayGroupID 为 0 时恢复按技术厂商展示", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "llm" + ], + "summary": "管理员批量设置模型展示分组", + "parameters": [ + { + "description": "批量归组参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/SetModelsDisplayGroupRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/SuccessDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + } + } + } + }, "/admin/llm/models/order": { "post": { "security": [ @@ -14510,6 +14926,29 @@ } } }, + "CreateModelDisplayGroupRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "icon": { + "type": "string", + "maxLength": 2048 + }, + "modelIDs": { + "type": "array", + "maxItems": 10000, + "items": { + "type": "integer" + } + }, + "name": { + "type": "string", + "maxLength": 64 + } + } + }, "CreateModelRequest": { "type": "object", "required": [ @@ -14550,6 +14989,9 @@ "type": "string", "maxLength": 10000 }, + "displayGroupID": { + "type": "integer" + }, "icon": { "type": "string", "maxLength": 128 @@ -14587,11 +15029,32 @@ "errorMsg" ], "properties": { - "data": { - "$ref": "#/definitions/ModelDataResponse" + "data": { + "$ref": "#/definitions/ModelDataResponse" + }, + "errorMsg": { + "type": "string" + } + } + }, + "CreateModelVendorRequest": { + "type": "object", + "required": [ + "key", + "name" + ], + "properties": { + "icon": { + "type": "string", + "maxLength": 2048 }, - "errorMsg": { - "type": "string" + "key": { + "type": "string", + "maxLength": 64 + }, + "name": { + "type": "string", + "maxLength": 64 } } }, @@ -16565,6 +17028,93 @@ } } }, + "ModelDisplayGroupDataResponse": { + "type": "object", + "required": [ + "group" + ], + "properties": { + "group": { + "$ref": "#/definitions/ModelDisplayGroupResponse" + } + } + }, + "ModelDisplayGroupDataResponseDoc": { + "type": "object", + "required": [ + "data", + "errorMsg" + ], + "properties": { + "data": { + "$ref": "#/definitions/ModelDisplayGroupDataResponse" + }, + "errorMsg": { + "type": "string" + } + } + }, + "ModelDisplayGroupListResponseDoc": { + "type": "object", + "required": [ + "data", + "errorMsg" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "results", + "total" + ], + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/definitions/ModelDisplayGroupResponse" + } + }, + "total": { + "type": "integer" + } + } + }, + "errorMsg": { + "type": "string" + } + } + }, + "ModelDisplayGroupResponse": { + "type": "object", + "required": [ + "createdAt", + "icon", + "id", + "name", + "sortOrder", + "updatedAt" + ], + "properties": { + "createdAt": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "sortOrder": { + "type": "integer" + }, + "updatedAt": { + "type": "string" + } + } + }, "ModelListResponseDoc": { "type": "object", "required": [ @@ -16996,6 +17546,9 @@ "cbWindowMin", "createdAt", "description", + "displayGroupID", + "displayGroupIcon", + "displayGroupName", "icon", "id", "kindsJSON", @@ -17007,7 +17560,9 @@ "systemPrompt", "updatedAt", "upstreamNamesJSON", - "vendor" + "vendor", + "vendorIcon", + "vendorName" ], "properties": { "accessScope": { @@ -17037,6 +17592,17 @@ "description": { "type": "string" }, + "displayGroupID": { + "type": "integer", + "x-nullable": true, + "x-omitempty": false + }, + "displayGroupIcon": { + "type": "string" + }, + "displayGroupName": { + "type": "string" + }, "icon": { "type": "string" }, @@ -17072,6 +17638,12 @@ }, "vendor": { "type": "string" + }, + "vendorIcon": { + "type": "string" + }, + "vendorName": { + "type": "string" } } }, @@ -17227,6 +17799,101 @@ } } }, + "ModelVendorDataResponse": { + "type": "object", + "required": [ + "vendor" + ], + "properties": { + "vendor": { + "$ref": "#/definitions/ModelVendorResponse" + } + } + }, + "ModelVendorDataResponseDoc": { + "type": "object", + "required": [ + "data", + "errorMsg" + ], + "properties": { + "data": { + "$ref": "#/definitions/ModelVendorDataResponse" + }, + "errorMsg": { + "type": "string" + } + } + }, + "ModelVendorListResponseDoc": { + "type": "object", + "required": [ + "data", + "errorMsg" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "results", + "total" + ], + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/definitions/ModelVendorResponse" + } + }, + "total": { + "type": "integer" + } + } + }, + "errorMsg": { + "type": "string" + } + } + }, + "ModelVendorResponse": { + "type": "object", + "required": [ + "builtIn", + "createdAt", + "icon", + "id", + "key", + "name", + "sortOrder", + "updatedAt" + ], + "properties": { + "builtIn": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "sortOrder": { + "type": "integer" + }, + "updatedAt": { + "type": "string" + } + } + }, "NativeToolPricingRequest": { "type": "object", "properties": { @@ -18330,13 +18997,18 @@ "required": [ "capabilitiesJSON", "description", + "displayGroupID", + "displayGroupIcon", + "displayGroupName", "icon", "kindsJSON", "platformModelName", "pricing", "protocolsJSON", "sortOrder", - "vendor" + "vendor", + "vendorIcon", + "vendorName" ], "properties": { "capabilitiesJSON": { @@ -18345,6 +19017,17 @@ "description": { "type": "string" }, + "displayGroupID": { + "type": "integer", + "x-nullable": true, + "x-omitempty": false + }, + "displayGroupIcon": { + "type": "string" + }, + "displayGroupName": { + "type": "string" + }, "icon": { "type": "string" }, @@ -18371,6 +19054,12 @@ }, "vendor": { "type": "string" + }, + "vendorIcon": { + "type": "string" + }, + "vendorName": { + "type": "string" } } }, @@ -19576,6 +20265,26 @@ } } }, + "SetModelsDisplayGroupRequest": { + "type": "object", + "required": [ + "displayGroupID", + "modelIDs" + ], + "properties": { + "displayGroupID": { + "type": "integer" + }, + "modelIDs": { + "type": "array", + "maxItems": 1000, + "minItems": 1, + "items": { + "type": "integer" + } + } + } + }, "SettingsPatchSettingsRequest": { "type": "object", "required": [ @@ -20470,6 +21179,26 @@ } } }, + "UpdateModelDisplayGroupRequest": { + "type": "object", + "properties": { + "icon": { + "type": "string", + "maxLength": 2048 + }, + "modelIDs": { + "type": "array", + "maxItems": 10000, + "items": { + "type": "integer" + } + }, + "name": { + "type": "string", + "maxLength": 64 + } + } + }, "UpdateModelRequest": { "type": "object", "properties": { @@ -20507,6 +21236,9 @@ "type": "string", "maxLength": 10000 }, + "displayGroupID": { + "type": "integer" + }, "icon": { "type": "string", "maxLength": 128 @@ -20601,6 +21333,19 @@ } } }, + "UpdateModelVendorRequest": { + "type": "object", + "properties": { + "icon": { + "type": "string", + "maxLength": 2048 + }, + "name": { + "type": "string", + "maxLength": 64 + } + } + }, "UpdatePermissionGroupRequest": { "type": "object", "required": [ diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 80b2c868c..d2ffd48a1 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -2218,6 +2218,22 @@ definitions: maxItems: 1000 type: array type: object + CreateModelDisplayGroupRequest: + properties: + icon: + maxLength: 2048 + type: string + modelIDs: + items: + type: integer + maxItems: 10000 + type: array + name: + maxLength: 64 + type: string + required: + - name + type: object CreateModelRequest: properties: accessScope: @@ -2245,6 +2261,8 @@ definitions: description: maxLength: 10000 type: string + displayGroupID: + type: integer icon: maxLength: 128 type: string @@ -2279,6 +2297,21 @@ definitions: - data - errorMsg type: object + CreateModelVendorRequest: + properties: + icon: + maxLength: 2048 + type: string + key: + maxLength: 64 + type: string + name: + maxLength: 64 + type: string + required: + - key + - name + type: object CreatePermissionGroupRequest: properties: description: @@ -3659,6 +3692,65 @@ definitions: required: - model type: object + ModelDisplayGroupDataResponse: + properties: + group: + $ref: '#/definitions/ModelDisplayGroupResponse' + required: + - group + type: object + ModelDisplayGroupDataResponseDoc: + properties: + data: + $ref: '#/definitions/ModelDisplayGroupDataResponse' + errorMsg: + type: string + required: + - data + - errorMsg + type: object + ModelDisplayGroupListResponseDoc: + properties: + data: + properties: + results: + items: + $ref: '#/definitions/ModelDisplayGroupResponse' + type: array + total: + type: integer + required: + - results + - total + type: object + errorMsg: + type: string + required: + - data + - errorMsg + type: object + ModelDisplayGroupResponse: + properties: + createdAt: + type: string + icon: + type: string + id: + type: integer + name: + type: string + sortOrder: + type: integer + updatedAt: + type: string + required: + - createdAt + - icon + - id + - name + - sortOrder + - updatedAt + type: object ModelListResponseDoc: properties: data: @@ -3971,6 +4063,14 @@ definitions: type: string description: type: string + displayGroupID: + type: integer + x-nullable: true + x-omitempty: false + displayGroupIcon: + type: string + displayGroupName: + type: string icon: type: string id: @@ -3995,6 +4095,10 @@ definitions: type: string vendor: type: string + vendorIcon: + type: string + vendorName: + type: string required: - accessScope - activeSourceCount @@ -4005,6 +4109,9 @@ definitions: - cbWindowMin - createdAt - description + - displayGroupID + - displayGroupIcon + - displayGroupName - icon - id - kindsJSON @@ -4017,6 +4124,8 @@ definitions: - updatedAt - upstreamNamesJSON - vendor + - vendorIcon + - vendorName type: object ModelUpstreamSourceDataResponse: properties: @@ -4127,6 +4236,71 @@ definitions: - upstreamStatus - weight type: object + ModelVendorDataResponse: + properties: + vendor: + $ref: '#/definitions/ModelVendorResponse' + required: + - vendor + type: object + ModelVendorDataResponseDoc: + properties: + data: + $ref: '#/definitions/ModelVendorDataResponse' + errorMsg: + type: string + required: + - data + - errorMsg + type: object + ModelVendorListResponseDoc: + properties: + data: + properties: + results: + items: + $ref: '#/definitions/ModelVendorResponse' + type: array + total: + type: integer + required: + - results + - total + type: object + errorMsg: + type: string + required: + - data + - errorMsg + type: object + ModelVendorResponse: + properties: + builtIn: + type: boolean + createdAt: + type: string + icon: + type: string + id: + type: integer + key: + type: string + name: + type: string + sortOrder: + type: integer + updatedAt: + type: string + required: + - builtIn + - createdAt + - icon + - id + - key + - name + - sortOrder + - updatedAt + type: object NativeToolPricingRequest: properties: billable: @@ -4903,6 +5077,14 @@ definitions: type: string description: type: string + displayGroupID: + type: integer + x-nullable: true + x-omitempty: false + displayGroupIcon: + type: string + displayGroupName: + type: string icon: type: string kindsJSON: @@ -4920,9 +5102,16 @@ definitions: type: integer vendor: type: string + vendorIcon: + type: string + vendorName: + type: string required: - capabilitiesJSON - description + - displayGroupID + - displayGroupIcon + - displayGroupName - icon - kindsJSON - platformModelName @@ -4930,6 +5119,8 @@ definitions: - protocolsJSON - sortOrder - vendor + - vendorIcon + - vendorName type: object PublicSharedConversationResponse: properties: @@ -5769,6 +5960,20 @@ definitions: type: integer type: array type: object + SetModelsDisplayGroupRequest: + properties: + displayGroupID: + type: integer + modelIDs: + items: + type: integer + maxItems: 1000 + minItems: 1 + type: array + required: + - displayGroupID + - modelIDs + type: object SettingsPatchSettingsRequest: properties: items: @@ -6399,6 +6604,20 @@ definitions: required: - content type: object + UpdateModelDisplayGroupRequest: + properties: + icon: + maxLength: 2048 + type: string + modelIDs: + items: + type: integer + maxItems: 10000 + type: array + name: + maxLength: 64 + type: string + type: object UpdateModelRequest: properties: accessScope: @@ -6426,6 +6645,8 @@ definitions: description: maxLength: 10000 type: string + displayGroupID: + type: integer icon: maxLength: 128 type: string @@ -6492,6 +6713,15 @@ definitions: - data - errorMsg type: object + UpdateModelVendorRequest: + properties: + icon: + maxLength: 2048 + type: string + name: + maxLength: 64 + type: string + type: object UpdatePermissionGroupRequest: properties: description: @@ -8988,6 +9218,237 @@ paths: summary: 管理员导出全量对话数据 tags: - admin + /admin/llm/model-display-groups: + get: + description: 分页查询自定义展示分组;未绑定分组的模型继续按技术厂商展示 + parameters: + - description: 页码 + in: query + name: page + type: integer + - description: 每页数量 + in: query + name: page_size + type: integer + - description: 搜索名称 + in: query + name: q + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/ModelDisplayGroupListResponseDoc' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/ChannelErrorDoc' + security: + - BearerAuth: [] + summary: 管理员查询模型展示分组 + tags: + - llm + post: + consumes: + - application/json + description: 创建仅影响用户界面归类的自定义模型分组 + parameters: + - description: 展示分组参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/CreateModelDisplayGroupRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/ModelDisplayGroupDataResponseDoc' + "400": + description: Bad Request + schema: + $ref: '#/definitions/ChannelErrorDoc' + "409": + description: Conflict + schema: + $ref: '#/definitions/ChannelErrorDoc' + security: + - BearerAuth: [] + summary: 管理员创建模型展示分组 + tags: + - llm + /admin/llm/model-display-groups/{id}: + delete: + description: 删除展示分组后,关联模型恢复按技术厂商展示 + parameters: + - description: 展示分组 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/SuccessDoc' + "400": + description: Bad Request + schema: + $ref: '#/definitions/ChannelErrorDoc' + "404": + description: Not Found + schema: + $ref: '#/definitions/ChannelErrorDoc' + security: + - BearerAuth: [] + summary: 管理员删除模型展示分组 + tags: + - llm + patch: + consumes: + - application/json + parameters: + - description: 展示分组 ID + in: path + name: id + required: true + type: integer + - description: 展示分组参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/UpdateModelDisplayGroupRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/ModelDisplayGroupDataResponseDoc' + "400": + description: Bad Request + schema: + $ref: '#/definitions/ChannelErrorDoc' + "404": + description: Not Found + schema: + $ref: '#/definitions/ChannelErrorDoc' + "409": + description: Conflict + schema: + $ref: '#/definitions/ChannelErrorDoc' + security: + - BearerAuth: [] + summary: 管理员更新模型展示分组 + tags: + - llm + /admin/llm/model-vendors: + get: + description: 分页查询模型技术厂商目录;技术厂商是路由、权限和计费使用的稳定身份 + parameters: + - description: 页码 + in: query + name: page + type: integer + - description: 每页数量 + in: query + name: page_size + type: integer + - description: 搜索 key 或名称 + in: query + name: q + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/ModelVendorListResponseDoc' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/ChannelErrorDoc' + security: + - BearerAuth: [] + summary: 管理员查询模型技术厂商 + tags: + - llm + post: + consumes: + - application/json + description: 创建新的稳定技术厂商身份;创建后可供平台模型选择 + parameters: + - description: 技术厂商参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/CreateModelVendorRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/ModelVendorDataResponseDoc' + "400": + description: Bad Request + schema: + $ref: '#/definitions/ChannelErrorDoc' + "409": + description: Conflict + schema: + $ref: '#/definitions/ChannelErrorDoc' + security: + - BearerAuth: [] + summary: 管理员创建模型技术厂商 + tags: + - llm + /admin/llm/model-vendors/{key}: + patch: + consumes: + - application/json + description: 更新厂商展示名称和图标;稳定技术 key 不可修改 + parameters: + - description: 技术厂商 key + in: path + name: key + required: true + type: string + - description: 技术厂商参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/UpdateModelVendorRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/ModelVendorDataResponseDoc' + "400": + description: Bad Request + schema: + $ref: '#/definitions/ChannelErrorDoc' + "404": + description: Not Found + schema: + $ref: '#/definitions/ChannelErrorDoc' + security: + - BearerAuth: [] + summary: 管理员更新模型技术厂商 + tags: + - llm /admin/llm/models: get: consumes: @@ -9401,6 +9862,38 @@ paths: summary: 管理员批量删除模型 tags: - llm + /admin/llm/models/display-group: + patch: + consumes: + - application/json + description: 在单个事务中将指定模型归入展示分组;displayGroupID 为 0 时恢复按技术厂商展示 + parameters: + - description: 批量归组参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/SetModelsDisplayGroupRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/SuccessDoc' + "400": + description: Bad Request + schema: + $ref: '#/definitions/ChannelErrorDoc' + "404": + description: Not Found + schema: + $ref: '#/definitions/ChannelErrorDoc' + security: + - BearerAuth: [] + summary: 管理员批量设置模型展示分组 + tags: + - llm /admin/llm/models/order: post: consumes: diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index b53c3ff12..bff064ff2 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -243,7 +243,7 @@ func NewApp() (*App, error) { llmClient := llm.NewClient(trustedOutboundPolicy) mcpClient := mcp.NewClient(trustedOutboundPolicy) mediaArtifactClient := mediaartifact.New(strictOutboundPolicy) - channelService := channel.NewServiceWithRuntime(runtimeCfg, channelRepo, channelCache, llmClient) + channelService := channel.NewServiceWithRuntime(runtimeCfg, channelRepo, channelRepo, channelCache, llmClient) channelService.SetLogger(log) channelService.SetBillingModelPricingFilter(billingService) channelService.SetPermissionGroupRepo(channelRepo) diff --git a/backend/internal/application/channel/dto.go b/backend/internal/application/channel/dto.go index 2da9cdff9..84231cf29 100644 --- a/backend/internal/application/channel/dto.go +++ b/backend/internal/application/channel/dto.go @@ -135,6 +135,11 @@ type ModelView struct { ID uint PlatformModelName string Vendor string + VendorName string + VendorIcon string + DisplayGroupID *uint + DisplayGroupName string + DisplayGroupIcon string KindsJSON string Icon string CapabilitiesJSON string @@ -156,6 +161,28 @@ type ModelView struct { UpdatedAt string } +// ModelVendorView 表示技术厂商目录展示数据。 +type ModelVendorView struct { + ID uint + Key string + Name string + Icon string + BuiltIn bool + SortOrder int + CreatedAt string + UpdatedAt string +} + +// ModelDisplayGroupView 表示自定义模型展示分组数据。 +type ModelDisplayGroupView struct { + ID uint + Name string + Icon string + SortOrder int + CreatedAt string + UpdatedAt string +} + // UpstreamModelView 上游模型路由绑定展示数据(内部传输,不携带序列化标记)。 type UpstreamModelView struct { ID uint diff --git a/backend/internal/application/channel/errs.go b/backend/internal/application/channel/errs.go index a7ec343a4..21891c82a 100644 --- a/backend/internal/application/channel/errs.go +++ b/backend/internal/application/channel/errs.go @@ -43,6 +43,18 @@ var ( ErrSystemPromptTooLong = errors.New("system prompt too long") // ErrInvalidModelOrder 模型排序参数无效。 ErrInvalidModelOrder = errors.New("invalid model order") + // ErrModelVendorNotFound 技术厂商不存在。 + ErrModelVendorNotFound = errors.New("model vendor not found") + // ErrModelVendorConflict 技术厂商 key 重复。 + ErrModelVendorConflict = errors.New("model vendor conflict") + // ErrInvalidModelVendor 技术厂商参数无效。 + ErrInvalidModelVendor = errors.New("invalid model vendor") + // ErrModelDisplayGroupNotFound 展示分组不存在。 + ErrModelDisplayGroupNotFound = errors.New("model display group not found") + // ErrModelDisplayGroupConflict 展示分组名称重复。 + ErrModelDisplayGroupConflict = errors.New("model display group conflict") + // ErrInvalidModelDisplayGroup 展示分组参数无效。 + ErrInvalidModelDisplayGroup = errors.New("invalid model display group") // ErrInvalidPermissionGroupModels 模型权限组参数无效。 ErrInvalidPermissionGroupModels = errors.New("invalid permission group models") // ErrPermissionGroupRepoUnavailable 权限组仓储未注入。 diff --git a/backend/internal/application/channel/input.go b/backend/internal/application/channel/input.go index 2d659526d..4ab35f6fd 100644 --- a/backend/internal/application/channel/input.go +++ b/backend/internal/application/channel/input.go @@ -44,6 +44,7 @@ type UpdateUpstreamInput struct { type CreateModelInput struct { PlatformModelName string Vendor string + DisplayGroupID uint KindsJSON string Icon string CapabilitiesJSON string @@ -61,6 +62,7 @@ type CreateModelInput struct { type UpdateModelInput struct { PlatformModelName *string Vendor *string + DisplayGroupID *uint KindsJSON *string Icon *string CapabilitiesJSON *string @@ -74,6 +76,33 @@ type UpdateModelInput struct { CbWindowMin *int } +// CreateModelVendorInput 定义创建技术厂商入参。 +type CreateModelVendorInput struct { + Key string + Name string + Icon string +} + +// UpdateModelVendorInput 定义更新技术厂商展示信息入参。 +type UpdateModelVendorInput struct { + Name *string + Icon *string +} + +// CreateModelDisplayGroupInput 定义创建模型展示分组入参。 +type CreateModelDisplayGroupInput struct { + Name string + Icon string + ModelIDs []uint +} + +// UpdateModelDisplayGroupInput 定义更新模型展示分组入参。 +type UpdateModelDisplayGroupInput struct { + Name *string + Icon *string + ModelIDs *[]uint +} + // UpsertUpstreamModelInput 定义上游真实模型与平台路由保存入参。 type UpsertUpstreamModelInput struct { RouteID uint diff --git a/backend/internal/application/channel/service.go b/backend/internal/application/channel/service.go index 913bd6519..9dc723b77 100644 --- a/backend/internal/application/channel/service.go +++ b/backend/internal/application/channel/service.go @@ -95,6 +95,7 @@ func (s *Service) isModelAccessible(ctx context.Context, platformModelID uint, u type Service struct { cfg *config.Runtime repo repository.ChannelRepository + presentationRepo repository.ModelPresentationRepository cache repository.ChannelCacheRepository llmClient *llm.Client modelPricingFilter billingModelPricingFilter @@ -192,17 +193,18 @@ const ( var localAPIKeyCounters sync.Map // NewService 创建服务。 -func NewService(cfg config.Config, repo repository.ChannelRepository, cache repository.ChannelCacheRepository, llmClient *llm.Client) *Service { - return NewServiceWithRuntime(config.NewRuntime(cfg), repo, cache, llmClient) +func NewService(cfg config.Config, repo repository.ChannelRepository, presentationRepo repository.ModelPresentationRepository, cache repository.ChannelCacheRepository, llmClient *llm.Client) *Service { + return NewServiceWithRuntime(config.NewRuntime(cfg), repo, presentationRepo, cache, llmClient) } // NewServiceWithRuntime 创建使用运行时配置容器的服务。 -func NewServiceWithRuntime(cfg *config.Runtime, repo repository.ChannelRepository, cache repository.ChannelCacheRepository, llmClient *llm.Client) *Service { +func NewServiceWithRuntime(cfg *config.Runtime, repo repository.ChannelRepository, presentationRepo repository.ModelPresentationRepository, cache repository.ChannelCacheRepository, llmClient *llm.Client) *Service { return &Service{ - cfg: cfg, - repo: repo, - cache: cache, - llmClient: llmClient, + cfg: cfg, + repo: repo, + presentationRepo: presentationRepo, + cache: cache, + llmClient: llmClient, } } diff --git a/backend/internal/application/channel/service_model.go b/backend/internal/application/channel/service_model.go index 10ede26b7..4ba9ff14d 100644 --- a/backend/internal/application/channel/service_model.go +++ b/backend/internal/application/channel/service_model.go @@ -208,6 +208,10 @@ func cloneModelViews(items []ModelView) []ModelView { } results := make([]ModelView, 0, len(items)) for _, item := range items { + if item.DisplayGroupID != nil { + displayGroupID := *item.DisplayGroupID + item.DisplayGroupID = &displayGroupID + } if item.Pricing != nil { pricing := *item.Pricing if len(pricing.Tiers) > 0 { @@ -347,12 +351,25 @@ func (s *Service) CreateModel(ctx context.Context, input CreateModelInput) (*Mod return nil, err } cbPolicyMode := normalizeModelCircuitPolicyMode(input.CbPolicyMode) + vendor, err := s.resolvePlatformModelVendor(ctx, input.Vendor, platformModelName) + if err != nil { + return nil, err + } + if err := s.validateModelDisplayGroup(ctx, input.DisplayGroupID); err != nil { + return nil, err + } + var displayGroupID *uint + if input.DisplayGroupID > 0 { + value := input.DisplayGroupID + displayGroupID = &value + } item := &domainchannel.PlatformModel{ PlatformModelName: platformModelName, - Vendor: normalizeModelVendor(input.Vendor, platformModelName), + Vendor: vendor, + DisplayGroupID: displayGroupID, KindsJSON: kindsJSON, - Icon: normalizeModelIcon(input.Icon, input.Vendor, platformModelName), + Icon: normalizeModelIcon(input.Icon, vendor, platformModelName), CapabilitiesJSON: strings.TrimSpace(input.CapabilitiesJSON), SystemPrompt: systemPrompt, AccessScope: accessScope, @@ -370,8 +387,7 @@ func (s *Service) CreateModel(ctx context.Context, input CreateModelInput) (*Mod return nil, err } s.InvalidateModelCatalog() - view := toModelView(repository.ChannelModelListRow{PlatformModel: *item}) - return &view, nil + return s.getModelViewByID(ctx, item.ID) } // UpdateModel 更新平台模型目录项。 @@ -381,7 +397,10 @@ func (s *Service) UpdateModel(ctx context.Context, modelID uint, input UpdateMod return nil, err } - nextVendor := normalizeModelVendor(current.Vendor, current.PlatformModelName) + nextVendor, err := normalizeModelVendorKey(current.Vendor) + if err != nil { + return nil, err + } nextPlatformModelName := current.PlatformModelName update := repository.UpdateChannelModelInput{} @@ -393,9 +412,19 @@ func (s *Service) UpdateModel(ctx context.Context, modelID uint, input UpdateMod update.PlatformModelName = &nextPlatformModelName } if input.Vendor != nil { - nextVendor = normalizeModelVendor(*input.Vendor, nextPlatformModelName) + nextVendor, err = s.resolvePlatformModelVendor(ctx, *input.Vendor, nextPlatformModelName) + if err != nil { + return nil, err + } update.Vendor = &nextVendor } + if input.DisplayGroupID != nil { + if err := s.validateModelDisplayGroup(ctx, *input.DisplayGroupID); err != nil { + return nil, err + } + value := *input.DisplayGroupID + update.DisplayGroupID = &value + } if input.KindsJSON != nil { kindsJSON, err := normalizeKindsJSON(*input.KindsJSON) if err != nil { @@ -453,7 +482,10 @@ func (s *Service) UpdateModel(ctx context.Context, modelID uint, input UpdateMod update.CbWindowMin = &value } if input.Vendor == nil && input.PlatformModelName != nil { - autoVendor := normalizeModelVendor("", nextPlatformModelName) + autoVendor, err := s.resolvePlatformModelVendor(ctx, "", nextPlatformModelName) + if err != nil { + return nil, err + } if autoVendor != nextVendor { update.Vendor = &autoVendor nextVendor = autoVendor diff --git a/backend/internal/application/channel/service_model_presentation.go b/backend/internal/application/channel/service_model_presentation.go new file mode 100644 index 000000000..2b17bd865 --- /dev/null +++ b/backend/internal/application/channel/service_model_presentation.go @@ -0,0 +1,324 @@ +package channel + +import ( + "context" + "errors" + "regexp" + "strings" + "time" + + domainchannel "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/channel" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" +) + +var modelVendorKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`) + +// ListModelVendors 分页查询技术厂商目录。 +func (s *Service) ListModelVendors(ctx context.Context, page int, pageSize int, query string) ([]ModelVendorView, int64, error) { + offset, limit := normalizePage(page, pageSize) + items, total, err := s.presentationRepo.ListModelVendors(ctx, repository.ListModelVendorsInput{ + Offset: offset, + Limit: limit, + Query: strings.TrimSpace(query), + }) + if err != nil { + return nil, 0, err + } + views := make([]ModelVendorView, 0, len(items)) + for _, item := range items { + views = append(views, toModelVendorView(item)) + } + return views, total, nil +} + +// CreateModelVendor 创建可供平台模型引用的技术厂商。 +func (s *Service) CreateModelVendor(ctx context.Context, input CreateModelVendorInput) (*ModelVendorView, error) { + key, err := normalizeModelVendorKey(input.Key) + if err != nil { + return nil, err + } + name, err := normalizeModelPresentationName(input.Name, ErrInvalidModelVendor) + if err != nil { + return nil, err + } + icon, err := normalizeModelPresentationIcon(input.Icon, ErrInvalidModelVendor) + if err != nil { + return nil, err + } + item := &domainchannel.ModelVendor{Key: key, Name: name, Icon: icon} + if err := s.presentationRepo.CreateModelVendor(ctx, item); err != nil { + if errors.Is(err, repository.ErrDuplicate) { + return nil, ErrModelVendorConflict + } + return nil, err + } + view := toModelVendorView(*item) + return &view, nil +} + +// UpdateModelVendor 更新技术厂商的展示名称和图标,稳定 key 保持不变。 +func (s *Service) UpdateModelVendor(ctx context.Context, key string, input UpdateModelVendorInput) (*ModelVendorView, error) { + normalizedKey, err := normalizeModelVendorKey(key) + if err != nil { + return nil, err + } + update := repository.UpdateModelVendorInput{} + if input.Name != nil { + name, err := normalizeModelPresentationName(*input.Name, ErrInvalidModelVendor) + if err != nil { + return nil, err + } + update.Name = &name + } + if input.Icon != nil { + icon, err := normalizeModelPresentationIcon(*input.Icon, ErrInvalidModelVendor) + if err != nil { + return nil, err + } + update.Icon = &icon + } + if err := s.presentationRepo.UpdateModelVendor(ctx, normalizedKey, update); err != nil { + switch { + case errors.Is(err, repository.ErrNotFound): + return nil, ErrModelVendorNotFound + default: + return nil, err + } + } + item, err := s.presentationRepo.GetModelVendorByKey(ctx, normalizedKey) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return nil, ErrModelVendorNotFound + } + return nil, err + } + s.InvalidateModelCatalog() + view := toModelVendorView(*item) + return &view, nil +} + +// ListModelDisplayGroups 分页查询管理员创建的模型展示分组。 +func (s *Service) ListModelDisplayGroups(ctx context.Context, page int, pageSize int, query string) ([]ModelDisplayGroupView, int64, error) { + offset, limit := normalizePage(page, pageSize) + items, total, err := s.presentationRepo.ListModelDisplayGroups(ctx, repository.ListModelDisplayGroupsInput{ + Offset: offset, + Limit: limit, + Query: strings.TrimSpace(query), + }) + if err != nil { + return nil, 0, err + } + views := make([]ModelDisplayGroupView, 0, len(items)) + for _, item := range items { + views = append(views, toModelDisplayGroupView(item)) + } + return views, total, nil +} + +// CreateModelDisplayGroup 创建可选展示分组;未绑定分组的模型仍按技术厂商展示。 +func (s *Service) CreateModelDisplayGroup(ctx context.Context, input CreateModelDisplayGroupInput) (*ModelDisplayGroupView, error) { + name, err := normalizeModelPresentationName(input.Name, ErrInvalidModelDisplayGroup) + if err != nil { + return nil, err + } + icon, err := normalizeModelPresentationIcon(input.Icon, ErrInvalidModelDisplayGroup) + if err != nil { + return nil, err + } + modelIDs, err := normalizeModelDisplayGroupMembers(input.ModelIDs) + if err != nil { + return nil, err + } + item := &domainchannel.ModelDisplayGroup{Name: name, Icon: icon} + if err := s.presentationRepo.CreateModelDisplayGroup(ctx, item, modelIDs); err != nil { + if errors.Is(err, repository.ErrDuplicate) { + return nil, ErrModelDisplayGroupConflict + } + if errors.Is(err, repository.ErrInvalidInput) { + return nil, ErrInvalidModelDisplayGroup + } + return nil, err + } + s.InvalidateModelCatalog() + view := toModelDisplayGroupView(*item) + return &view, nil +} + +// UpdateModelDisplayGroup 更新自定义模型展示分组。 +func (s *Service) UpdateModelDisplayGroup(ctx context.Context, groupID uint, input UpdateModelDisplayGroupInput) (*ModelDisplayGroupView, error) { + if groupID == 0 { + return nil, ErrInvalidModelDisplayGroup + } + update := repository.UpdateModelDisplayGroupInput{} + if input.Name != nil { + name, err := normalizeModelPresentationName(*input.Name, ErrInvalidModelDisplayGroup) + if err != nil { + return nil, err + } + update.Name = &name + } + if input.Icon != nil { + icon, err := normalizeModelPresentationIcon(*input.Icon, ErrInvalidModelDisplayGroup) + if err != nil { + return nil, err + } + update.Icon = &icon + } + if input.ModelIDs != nil { + modelIDs, err := normalizeModelDisplayGroupMembers(*input.ModelIDs) + if err != nil { + return nil, err + } + update.ModelIDs = &modelIDs + } + if err := s.presentationRepo.UpdateModelDisplayGroup(ctx, groupID, update); err != nil { + switch { + case errors.Is(err, repository.ErrNotFound): + return nil, ErrModelDisplayGroupNotFound + case errors.Is(err, repository.ErrDuplicate): + return nil, ErrModelDisplayGroupConflict + case errors.Is(err, repository.ErrInvalidInput): + return nil, ErrInvalidModelDisplayGroup + default: + return nil, err + } + } + item, err := s.presentationRepo.GetModelDisplayGroupByID(ctx, groupID) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return nil, ErrModelDisplayGroupNotFound + } + return nil, err + } + s.InvalidateModelCatalog() + view := toModelDisplayGroupView(*item) + return &view, nil +} + +func normalizeModelDisplayGroupMembers(modelIDs []uint) ([]uint, error) { + seen := make(map[uint]struct{}, len(modelIDs)) + normalized := make([]uint, 0, len(modelIDs)) + for _, modelID := range modelIDs { + if modelID == 0 { + return nil, ErrInvalidModelDisplayGroup + } + if _, exists := seen[modelID]; exists { + continue + } + seen[modelID] = struct{}{} + normalized = append(normalized, modelID) + } + return normalized, nil +} + +// SetModelsDisplayGroup 批量设置模型的自定义展示分组;groupID 为 0 时恢复按技术厂商展示。 +func (s *Service) SetModelsDisplayGroup(ctx context.Context, modelIDs []uint, groupID uint) error { + normalizedIDs, err := normalizeModelDisplayGroupMembers(modelIDs) + if err != nil || len(normalizedIDs) == 0 { + return ErrInvalidModelDisplayGroup + } + if err = s.presentationRepo.SetModelsDisplayGroup(ctx, normalizedIDs, groupID); err != nil { + switch { + case errors.Is(err, repository.ErrNotFound): + return ErrModelDisplayGroupNotFound + case errors.Is(err, repository.ErrInvalidInput): + return ErrInvalidModelDisplayGroup + default: + return err + } + } + s.InvalidateModelCatalog() + return nil +} + +// DeleteModelDisplayGroup 删除自定义展示分组,关联模型恢复按技术厂商展示。 +func (s *Service) DeleteModelDisplayGroup(ctx context.Context, groupID uint) error { + if groupID == 0 { + return ErrInvalidModelDisplayGroup + } + if err := s.presentationRepo.DeleteModelDisplayGroup(ctx, groupID); err != nil { + if errors.Is(err, repository.ErrNotFound) { + return ErrModelDisplayGroupNotFound + } + return err + } + s.InvalidateModelCatalog() + return nil +} + +func normalizeModelVendorKey(raw string) (string, error) { + value := strings.ToLower(strings.TrimSpace(raw)) + if canonical := canonicalVendorKey(value); canonical != "" { + return canonical, nil + } + if !modelVendorKeyPattern.MatchString(value) { + return "", ErrInvalidModelVendor + } + return value, nil +} + +func normalizeModelPresentationName(raw string, invalidErr error) (string, error) { + value := strings.TrimSpace(raw) + if value == "" || len([]rune(value)) > 64 { + return "", invalidErr + } + return value, nil +} + +func normalizeModelPresentationIcon(raw string, invalidErr error) (string, error) { + value := strings.TrimSpace(raw) + if len([]rune(value)) > 2048 { + return "", invalidErr + } + return value, nil +} + +func (s *Service) resolvePlatformModelVendor(ctx context.Context, explicit string, candidates ...string) (string, error) { + var key string + var err error + if strings.TrimSpace(explicit) == "" { + key = normalizeModelVendor("", candidates...) + } else { + key, err = normalizeModelVendorKey(explicit) + if err != nil { + return "", err + } + } + if _, err := s.presentationRepo.GetModelVendorByKey(ctx, key); err != nil { + if errors.Is(err, repository.ErrNotFound) { + return "", ErrModelVendorNotFound + } + return "", err + } + return key, nil +} + +func (s *Service) validateModelDisplayGroup(ctx context.Context, groupID uint) error { + if groupID == 0 { + return nil + } + if _, err := s.presentationRepo.GetModelDisplayGroupByID(ctx, groupID); err != nil { + if errors.Is(err, repository.ErrNotFound) { + return ErrModelDisplayGroupNotFound + } + return err + } + return nil +} + +func toModelVendorView(item domainchannel.ModelVendor) ModelVendorView { + return ModelVendorView{ + ID: item.ID, Key: item.Key, Name: item.Name, Icon: item.Icon, + BuiltIn: item.BuiltIn, SortOrder: item.SortOrder, + CreatedAt: item.CreatedAt.Format(time.RFC3339), + UpdatedAt: item.UpdatedAt.Format(time.RFC3339), + } +} + +func toModelDisplayGroupView(item domainchannel.ModelDisplayGroup) ModelDisplayGroupView { + return ModelDisplayGroupView{ + ID: item.ID, Name: item.Name, Icon: item.Icon, SortOrder: item.SortOrder, + CreatedAt: item.CreatedAt.Format(time.RFC3339), + UpdatedAt: item.UpdatedAt.Format(time.RFC3339), + } +} diff --git a/backend/internal/application/channel/service_model_update_test.go b/backend/internal/application/channel/service_model_update_test.go index 6611f22d3..190f11c7b 100644 --- a/backend/internal/application/channel/service_model_update_test.go +++ b/backend/internal/application/channel/service_model_update_test.go @@ -2,6 +2,8 @@ package channel import ( "context" + "errors" + "reflect" "testing" domainchannel "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/channel" @@ -22,7 +24,7 @@ func TestUpdateModelResetsIconToAutoWhenExplicitlyEmpty(t *testing.T) { Status: "active", }, } - service := NewService(config.Config{}, repo, nil, nil) + service := NewService(config.Config{}, repo, repo, nil, nil) emptyIcon := "" view, err := service.UpdateModel(context.Background(), 1, UpdateModelInput{Icon: &emptyIcon}) @@ -40,6 +42,51 @@ func TestUpdateModelResetsIconToAutoWhenExplicitlyEmpty(t *testing.T) { } } +func TestUpdateModelUsesCatalogVendorAndOptionalDisplayGroup(t *testing.T) { + repo := &modelUpdateRepo{ + model: domainchannel.PlatformModel{ + ID: 1, + PlatformModelName: "acme-chat", + Vendor: "unknown", + KindsJSON: `["chat"]`, + AccessScope: "public", + Status: "active", + }, + } + service := NewService(config.Config{}, repo, repo, nil, nil) + + vendor := "acme-ai" + displayGroupID := uint(7) + view, err := service.UpdateModel(context.Background(), 1, UpdateModelInput{ + Vendor: &vendor, + DisplayGroupID: &displayGroupID, + }) + if err != nil { + t.Fatalf("UpdateModel() error = %v", err) + } + if repo.lastUpdate.Vendor == nil || *repo.lastUpdate.Vendor != vendor { + t.Fatalf("expected vendor %q, got %#v", vendor, repo.lastUpdate.Vendor) + } + if repo.lastUpdate.DisplayGroupID == nil || *repo.lastUpdate.DisplayGroupID != displayGroupID { + t.Fatalf("expected display group %d, got %#v", displayGroupID, repo.lastUpdate.DisplayGroupID) + } + if view.DisplayGroupID == nil || *view.DisplayGroupID != displayGroupID { + t.Fatalf("expected returned display group %d, got %#v", displayGroupID, view.DisplayGroupID) + } + + clearGroupID := uint(0) + view, err = service.UpdateModel(context.Background(), 1, UpdateModelInput{DisplayGroupID: &clearGroupID}) + if err != nil { + t.Fatalf("UpdateModel(clear group) error = %v", err) + } + if repo.lastUpdate.DisplayGroupID == nil || *repo.lastUpdate.DisplayGroupID != 0 { + t.Fatalf("expected explicit group clear, got %#v", repo.lastUpdate.DisplayGroupID) + } + if view.DisplayGroupID != nil { + t.Fatalf("expected returned display group to be nil, got %#v", view.DisplayGroupID) + } +} + func TestUpdateModelUpstreamSourceUpdatesRouteCircuitSettings(t *testing.T) { repo := &modelUpdateRepo{ model: domainchannel.PlatformModel{ @@ -70,7 +117,7 @@ func TestUpdateModelUpstreamSourceUpdatesRouteCircuitSettings(t *testing.T) { UpstreamModelStatus: "active", }, } - service := NewService(config.Config{}, repo, nil, nil) + service := NewService(config.Config{}, repo, repo, nil, nil) threshold := 4 duration := 15 @@ -120,7 +167,7 @@ func TestListModelsNormalizesCircuitOpenSourceCount(t *testing.T) { {PlatformModelRoute: domainchannel.PlatformModelRoute{ID: 2, Status: "active"}, UpstreamID: 11, BindingCode: "upm_b", UpstreamStatus: "active", UpstreamModelStatus: "active"}, }, } - service := NewService(config.Config{}, repo, cache, nil) + service := NewService(config.Config{}, repo, repo, cache, nil) items, _, err := service.ListModels(ctx, 1, 20, ListModelsInput{}) if err != nil { @@ -154,7 +201,7 @@ func TestListUpstreamsNormalizesCircuitOpenModelCount(t *testing.T) { }, activeBindingCodes: []string{"upm_a", "upm_b"}, } - service := NewService(config.Config{}, repo, cache, nil) + service := NewService(config.Config{}, repo, repo, cache, nil) items, _, err := service.ListUpstreams(ctx, 1, 20, ListUpstreamsInput{}) if err != nil { @@ -168,15 +215,39 @@ func TestListUpstreamsNormalizesCircuitOpenModelCount(t *testing.T) { } } +func TestSetModelsDisplayGroupNormalizesIDsAndMapsRepositoryErrors(t *testing.T) { + repo := &modelUpdateRepo{} + service := NewService(config.Config{}, repo, repo, nil, nil) + + if err := service.SetModelsDisplayGroup(t.Context(), []uint{3, 3, 7}, 9); err != nil { + t.Fatalf("SetModelsDisplayGroup() error = %v", err) + } + if !reflect.DeepEqual(repo.lastDisplayGroupModelIDs, []uint{3, 7}) || repo.lastDisplayGroupID != 9 { + t.Fatalf("unexpected display group assignment: ids=%v group=%d", repo.lastDisplayGroupModelIDs, repo.lastDisplayGroupID) + } + + repo.setDisplayGroupErr = repository.ErrNotFound + if err := service.SetModelsDisplayGroup(t.Context(), []uint{3}, 99); !errors.Is(err, ErrModelDisplayGroupNotFound) { + t.Fatalf("expected display group not found, got %v", err) + } + repo.setDisplayGroupErr = repository.ErrInvalidInput + if err := service.SetModelsDisplayGroup(t.Context(), []uint{3}, 9); !errors.Is(err, ErrInvalidModelDisplayGroup) { + t.Fatalf("expected invalid display group assignment, got %v", err) + } +} + type modelUpdateRepo struct { - model domainchannel.PlatformModel - modelRows []repository.ChannelModelListRow - upstreamRows []repository.ChannelUpstreamListRow - activeBindingCodes []string - source repository.ChannelModelSourceRow - sources []repository.ChannelModelSourceRow - lastUpdate repository.UpdateChannelModelInput - lastRouteUpdate repository.UpdateChannelPlatformRouteInput + model domainchannel.PlatformModel + modelRows []repository.ChannelModelListRow + upstreamRows []repository.ChannelUpstreamListRow + activeBindingCodes []string + source repository.ChannelModelSourceRow + sources []repository.ChannelModelSourceRow + lastUpdate repository.UpdateChannelModelInput + lastRouteUpdate repository.UpdateChannelPlatformRouteInput + lastDisplayGroupModelIDs []uint + lastDisplayGroupID uint + setDisplayGroupErr error } func (r *modelUpdateRepo) CreateUpstream(context.Context, *domainchannel.Upstream) error { @@ -211,6 +282,14 @@ func (r *modelUpdateRepo) UpdateModel(_ context.Context, _ uint, input repositor if input.Vendor != nil { r.model.Vendor = *input.Vendor } + if input.DisplayGroupID != nil { + if *input.DisplayGroupID == 0 { + r.model.DisplayGroupID = nil + } else { + value := *input.DisplayGroupID + r.model.DisplayGroupID = &value + } + } if input.KindsJSON != nil { r.model.KindsJSON = *input.KindsJSON } @@ -407,4 +486,49 @@ func (r *modelUpdateRepo) DeleteModelCascade(context.Context, uint) error { return nil } +func (r *modelUpdateRepo) CreateModelVendor(_ context.Context, item *domainchannel.ModelVendor) error { + item.ID = 1 + return nil +} + +func (r *modelUpdateRepo) UpdateModelVendor(context.Context, string, repository.UpdateModelVendorInput) error { + return nil +} + +func (r *modelUpdateRepo) GetModelVendorByKey(_ context.Context, key string) (*domainchannel.ModelVendor, error) { + return &domainchannel.ModelVendor{ID: 1, Key: key, Name: key}, nil +} + +func (r *modelUpdateRepo) ListModelVendors(context.Context, repository.ListModelVendorsInput) ([]domainchannel.ModelVendor, int64, error) { + return nil, 0, nil +} + +func (r *modelUpdateRepo) CreateModelDisplayGroup(_ context.Context, item *domainchannel.ModelDisplayGroup, _ []uint) error { + item.ID = 1 + return nil +} + +func (r *modelUpdateRepo) UpdateModelDisplayGroup(context.Context, uint, repository.UpdateModelDisplayGroupInput) error { + return nil +} + +func (r *modelUpdateRepo) SetModelsDisplayGroup(_ context.Context, modelIDs []uint, groupID uint) error { + r.lastDisplayGroupModelIDs = append([]uint(nil), modelIDs...) + r.lastDisplayGroupID = groupID + return r.setDisplayGroupErr +} + +func (r *modelUpdateRepo) GetModelDisplayGroupByID(_ context.Context, groupID uint) (*domainchannel.ModelDisplayGroup, error) { + return &domainchannel.ModelDisplayGroup{ID: groupID, Name: "group"}, nil +} + +func (r *modelUpdateRepo) ListModelDisplayGroups(context.Context, repository.ListModelDisplayGroupsInput) ([]domainchannel.ModelDisplayGroup, int64, error) { + return nil, 0, nil +} + +func (r *modelUpdateRepo) DeleteModelDisplayGroup(context.Context, uint) error { + return nil +} + var _ repository.ChannelRepository = (*modelUpdateRepo)(nil) +var _ repository.ModelPresentationRepository = (*modelUpdateRepo)(nil) diff --git a/backend/internal/application/channel/service_routing_test.go b/backend/internal/application/channel/service_routing_test.go index de99c4532..7dda6c885 100644 --- a/backend/internal/application/channel/service_routing_test.go +++ b/backend/internal/application/channel/service_routing_test.go @@ -80,6 +80,7 @@ func TestResolveRouteExcludesPreviouslyAttemptedRoutes(t *testing.T) { service := NewService( config.Config{DataEncryptionKey: encryptionKey}, repo, + nil, memory.NewChannelCache(memory.New()), nil, ) diff --git a/backend/internal/application/channel/service_view_normalization.go b/backend/internal/application/channel/service_view_normalization.go index 11b8cea08..14557d955 100644 --- a/backend/internal/application/channel/service_view_normalization.go +++ b/backend/internal/application/channel/service_view_normalization.go @@ -73,6 +73,11 @@ func toModelView(item repository.ChannelModelListRow) ModelView { ID: item.ID, PlatformModelName: item.PlatformModelName, Vendor: item.Vendor, + VendorName: item.VendorName, + VendorIcon: item.VendorIcon, + DisplayGroupID: item.DisplayGroupID, + DisplayGroupName: item.DisplayGroupName, + DisplayGroupIcon: item.DisplayGroupIcon, KindsJSON: item.KindsJSON, Icon: item.Icon, CapabilitiesJSON: item.CapabilitiesJSON, diff --git a/backend/internal/domain/channel/types.go b/backend/internal/domain/channel/types.go index 0607b0366..a6420c4db 100644 --- a/backend/internal/domain/channel/types.go +++ b/backend/internal/domain/channel/types.go @@ -68,6 +68,7 @@ type PlatformModel struct { ID uint PlatformModelName string Vendor string + DisplayGroupID *uint KindsJSON string Icon string CapabilitiesJSON string @@ -84,6 +85,62 @@ type PlatformModel struct { UpdatedAt time.Time } +// ModelVendor 表示平台模型的技术厂商目录项。 +// Key 是路由、权限与计费使用的稳定标识;Name 和 Icon 仅用于展示。 +type ModelVendor struct { + ID uint + Key string + Name string + Icon string + BuiltIn bool + SortOrder int + CreatedAt time.Time + UpdatedAt time.Time +} + +// ModelDisplayGroup 表示管理员定义的可选模型展示分组。 +// 模型未绑定分组时继续按技术厂商展示。 +type ModelDisplayGroup struct { + ID uint + Name string + Icon string + SortOrder int + CreatedAt time.Time + UpdatedAt time.Time +} + +// BuiltInModelVendors 返回内置技术厂商目录。 +// 返回新切片,避免调用方修改全局共享状态。 +func BuiltInModelVendors() []ModelVendor { + return []ModelVendor{ + {Key: "openai", Name: "OpenAI", Icon: "openai", BuiltIn: true, SortOrder: 100}, + {Key: "anthropic", Name: "Anthropic", Icon: "anthropic", BuiltIn: true, SortOrder: 200}, + {Key: "google", Name: "Google", Icon: "google", BuiltIn: true, SortOrder: 300}, + {Key: "meta", Name: "Meta", Icon: "meta", BuiltIn: true, SortOrder: 400}, + {Key: "microsoft", Name: "Microsoft", Icon: "microsoft", BuiltIn: true, SortOrder: 500}, + {Key: "amazon", Name: "Amazon", Icon: "aws", BuiltIn: true, SortOrder: 600}, + {Key: "nvidia", Name: "NVIDIA", Icon: "nvidia", BuiltIn: true, SortOrder: 700}, + {Key: "deepseek", Name: "DeepSeek", Icon: "deepseek", BuiltIn: true, SortOrder: 800}, + {Key: "moonshot", Name: "MoonShot", Icon: "moonshot", BuiltIn: true, SortOrder: 900}, + {Key: "zhipu", Name: "ZhiPu", Icon: "zhipu", BuiltIn: true, SortOrder: 1000}, + {Key: "minimax", Name: "MiniMax", Icon: "minimax", BuiltIn: true, SortOrder: 1100}, + {Key: "bytedance", Name: "ByteDance", Icon: "bytedance", BuiltIn: true, SortOrder: 1200}, + {Key: "tencent", Name: "Tencent", Icon: "tencent", BuiltIn: true, SortOrder: 1300}, + {Key: "longcat", Name: "LongCat", Icon: "longcat", BuiltIn: true, SortOrder: 1400}, + {Key: "mistral", Name: "Mistral", Icon: "mistral", BuiltIn: true, SortOrder: 1500}, + {Key: "alibaba", Name: "Alibaba", Icon: "alibaba", BuiltIn: true, SortOrder: 1600}, + {Key: "xai", Name: "xAI", Icon: "xai", BuiltIn: true, SortOrder: 1700}, + {Key: "xiaomi", Name: "Xiaomi", Icon: "xiaomimimo", BuiltIn: true, SortOrder: 1800}, + {Key: "iflytek", Name: "iFlytek", Icon: "iflytekcloud", BuiltIn: true, SortOrder: 1900}, + {Key: "stepfun", Name: "StepFun", Icon: "stepfun", BuiltIn: true, SortOrder: 2000}, + {Key: "baichuan", Name: "Baichuan", Icon: "baichuan", BuiltIn: true, SortOrder: 2100}, + {Key: "baidu", Name: "Baidu", Icon: "baidu", BuiltIn: true, SortOrder: 2200}, + {Key: "openrouter", Name: "OpenRouter", Icon: "openrouter", BuiltIn: true, SortOrder: 2300}, + {Key: "copilot", Name: "GitHub Copilot", Icon: "copilot", BuiltIn: true, SortOrder: 2400}, + {Key: "unknown", Name: "Unknown", Icon: "", BuiltIn: true, SortOrder: 2500}, + } +} + // UpstreamModel 表示上游真实模型清单。 type UpstreamModel struct { ID uint diff --git a/backend/internal/infra/persistence/models/channel.go b/backend/internal/infra/persistence/models/channel.go index cf0d3ebf7..0e11b4e2a 100644 --- a/backend/internal/infra/persistence/models/channel.go +++ b/backend/internal/infra/persistence/models/channel.go @@ -33,7 +33,8 @@ func (LLMUpstream) TableName() string { type LLMPlatformModel struct { ControlPlaneModel Name string `gorm:"size:128;not null;default:'';uniqueIndex:idx_llm_platform_models_name;comment:平台模型名"` - Vendor string `gorm:"size:64;not null;default:'';index:idx_llm_platform_models_vendor;comment:平台展示厂商"` + Vendor string `gorm:"size:64;not null;default:'';index:idx_llm_platform_models_vendor;comment:平台模型技术厂商标识"` + DisplayGroupID *uint `gorm:"index:idx_llm_platform_models_display_group;comment:可选展示分组ID,为空时按技术厂商展示"` KindsJSON string `gorm:"type:text;not null;default:'[\"chat\"]';comment:模型类型JSON数组"` CapabilitiesJSON string `gorm:"type:text;not null;default:'{}';comment:平台能力配置JSON"` SystemPrompt string `gorm:"type:text;not null;default:'';comment:模型级系统提示词"` @@ -52,6 +53,34 @@ func (LLMPlatformModel) TableName() string { return "llm_platform_models" } +// LLMModelVendor 存储平台模型技术厂商目录。 +type LLMModelVendor struct { + ControlPlaneModel + Key string `gorm:"size:64;not null;uniqueIndex:idx_llm_model_vendors_key;comment:稳定技术厂商标识"` + Name string `gorm:"size:64;not null;index:idx_llm_model_vendors_name;comment:厂商展示名称"` + Icon string `gorm:"size:2048;not null;default:'';comment:厂商图标标识或图片 URL"` + BuiltIn bool `gorm:"not null;default:false;comment:是否内置厂商"` + SortOrder int `gorm:"not null;default:0;index:idx_llm_model_vendors_sort_order;comment:厂商展示顺序"` +} + +// TableName 指定技术厂商目录表名。 +func (LLMModelVendor) TableName() string { + return "llm_model_vendors" +} + +// LLMModelDisplayGroup 存储管理员定义的模型展示分组。 +type LLMModelDisplayGroup struct { + ControlPlaneModel + Name string `gorm:"size:64;not null;uniqueIndex:idx_llm_model_display_groups_name;comment:展示分组名称"` + Icon string `gorm:"size:2048;not null;default:'';comment:展示分组图标标识或图片 URL"` + SortOrder int `gorm:"not null;default:0;index:idx_llm_model_display_groups_sort_order;comment:展示分组顺序"` +} + +// TableName 指定模型展示分组表名。 +func (LLMModelDisplayGroup) TableName() string { + return "llm_model_display_groups" +} + // LLMUpstreamModel 存储上游真实模型清单。 // // BindingCode 是每个上游真实模型的内部链路编码;UpstreamModelName 是实际传给上游 API 的 model。 diff --git a/backend/internal/infra/persistence/models/table_names_test.go b/backend/internal/infra/persistence/models/table_names_test.go index 2289029e6..bdcad459b 100644 --- a/backend/internal/infra/persistence/models/table_names_test.go +++ b/backend/internal/infra/persistence/models/table_names_test.go @@ -24,6 +24,8 @@ func TestTableNamesUseRestructuredDomains(t *testing.T) { TrustedDevice{}, LLMUpstream{}, LLMUpstreamModel{}, + LLMModelVendor{}, + LLMModelDisplayGroup{}, LLMPlatformModel{}, LLMPlatformModelRoute{}, BillingPlan{}, diff --git a/backend/internal/infra/persistence/postgres/channel/model_presentation.go b/backend/internal/infra/persistence/postgres/channel/model_presentation.go new file mode 100644 index 000000000..b2b51dc0a --- /dev/null +++ b/backend/internal/infra/persistence/postgres/channel/model_presentation.go @@ -0,0 +1,291 @@ +package channel + +import ( + "context" + "errors" + "strings" + + domainchannel "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/channel" + models "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/models" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// CreateModelVendor 创建技术厂商目录项。 +func (r *Repo) CreateModelVendor(ctx context.Context, item *domainchannel.ModelVendor) error { + if item == nil { + return repository.ErrInvalidInput + } + entity := toModelVendorModel(item) + if err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if entity.SortOrder == 0 { + var maxSortOrder int + if err := tx.Model(&models.LLMModelVendor{}). + Select("COALESCE(MAX(sort_order), 0)"). + Scan(&maxSortOrder).Error; err != nil { + return translateError(err) + } + entity.SortOrder = maxSortOrder + 100 + } + return translateError(tx.Create(&entity).Error) + }); err != nil { + return err + } + *item = toModelVendorDomain(entity) + return nil +} + +// UpdateModelVendor 更新技术厂商的展示名称与图标,稳定 key 不参与修改。 +func (r *Repo) UpdateModelVendor(ctx context.Context, key string, input repository.UpdateModelVendorInput) error { + updates := make(map[string]interface{}) + if input.Name != nil { + updates["name"] = *input.Name + } + if input.Icon != nil { + updates["icon"] = *input.Icon + } + if len(updates) == 0 { + return nil + } + result := r.db.WithContext(ctx). + Model(&models.LLMModelVendor{}). + Where("key = ?", key). + Updates(updates) + if result.Error != nil { + return translateError(result.Error) + } + if result.RowsAffected == 0 { + return repository.ErrNotFound + } + return nil +} + +// GetModelVendorByKey 按稳定 key 获取技术厂商目录项。 +func (r *Repo) GetModelVendorByKey(ctx context.Context, key string) (*domainchannel.ModelVendor, error) { + var item models.LLMModelVendor + if err := r.db.WithContext(ctx).Where("key = ?", key).First(&item).Error; err != nil { + return nil, translateError(err) + } + result := toModelVendorDomain(item) + return &result, nil +} + +// ListModelVendors 分页查询技术厂商目录。 +func (r *Repo) ListModelVendors(ctx context.Context, input repository.ListModelVendorsInput) ([]domainchannel.ModelVendor, int64, error) { + query := r.db.WithContext(ctx).Model(&models.LLMModelVendor{}) + if keyword := strings.ToLower(strings.TrimSpace(input.Query)); keyword != "" { + like := "%" + keyword + "%" + query = query.Where("LOWER(key) LIKE ? OR LOWER(name) LIKE ?", like, like) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, translateError(err) + } + entities := make([]models.LLMModelVendor, 0) + if err := query.Order("sort_order ASC, id ASC").Offset(input.Offset).Limit(input.Limit).Find(&entities).Error; err != nil { + return nil, 0, translateError(err) + } + items := make([]domainchannel.ModelVendor, 0, len(entities)) + for _, entity := range entities { + items = append(items, toModelVendorDomain(entity)) + } + return items, total, nil +} + +// CreateModelDisplayGroup 创建自定义模型展示分组。 +func (r *Repo) CreateModelDisplayGroup(ctx context.Context, item *domainchannel.ModelDisplayGroup, modelIDs []uint) error { + if item == nil { + return repository.ErrInvalidInput + } + entity := toModelDisplayGroupModel(item) + if err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if entity.SortOrder == 0 { + var maxSortOrder int + if err := tx.Model(&models.LLMModelDisplayGroup{}). + Select("COALESCE(MAX(sort_order), 0)"). + Scan(&maxSortOrder).Error; err != nil { + return translateError(err) + } + entity.SortOrder = maxSortOrder + 100 + } + if err := tx.Create(&entity).Error; err != nil { + return translateError(err) + } + return replaceModelDisplayGroupMembers(tx, entity.ID, modelIDs) + }); err != nil { + return err + } + *item = toModelDisplayGroupDomain(entity) + return nil +} + +// UpdateModelDisplayGroup 更新自定义模型展示分组。 +func (r *Repo) UpdateModelDisplayGroup(ctx context.Context, groupID uint, input repository.UpdateModelDisplayGroupInput) error { + updates := make(map[string]interface{}) + if input.Name != nil { + updates["name"] = *input.Name + } + if input.Icon != nil { + updates["icon"] = *input.Icon + } + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var item models.LLMModelDisplayGroup + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").Where("id = ?", groupID).First(&item).Error; err != nil { + return translateError(err) + } + if len(updates) > 0 { + if err := tx.Model(&models.LLMModelDisplayGroup{}). + Where("id = ?", groupID). + Updates(updates).Error; err != nil { + return translateError(err) + } + } + if input.ModelIDs != nil { + return replaceModelDisplayGroupMembers(tx, groupID, *input.ModelIDs) + } + return nil + }) +} + +// replaceModelDisplayGroupMembers 在当前事务中完整替换分组成员;选中的模型会自动从原分组迁入。 +func replaceModelDisplayGroupMembers(tx *gorm.DB, groupID uint, modelIDs []uint) error { + if len(modelIDs) > 0 { + var count int64 + if err := tx.Model(&models.LLMPlatformModel{}).Where("id IN ?", modelIDs).Count(&count).Error; err != nil { + return translateError(err) + } + if count != int64(len(modelIDs)) { + return repository.ErrInvalidInput + } + } + + clearQuery := tx.Model(&models.LLMPlatformModel{}).Where("display_group_id = ?", groupID) + if len(modelIDs) > 0 { + clearQuery = clearQuery.Where("id NOT IN ?", modelIDs) + } + if err := clearQuery.Update("display_group_id", nil).Error; err != nil { + return translateError(err) + } + if len(modelIDs) == 0 { + return nil + } + return translateError(tx.Model(&models.LLMPlatformModel{}). + Where("id IN ?", modelIDs). + Update("display_group_id", groupID).Error) +} + +// SetModelsDisplayGroup 在单个事务中将指定模型归入展示分组;groupID 为 0 时清除自定义分组。 +func (r *Repo) SetModelsDisplayGroup(ctx context.Context, modelIDs []uint, groupID uint) error { + if len(modelIDs) == 0 { + return repository.ErrInvalidInput + } + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if groupID > 0 { + var group models.LLMModelDisplayGroup + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").Where("id = ?", groupID).First(&group).Error; err != nil { + return translateError(err) + } + } + + var count int64 + if err := tx.Model(&models.LLMPlatformModel{}).Where("id IN ?", modelIDs).Count(&count).Error; err != nil { + return translateError(err) + } + if count != int64(len(modelIDs)) { + return repository.ErrInvalidInput + } + + var value interface{} + if groupID > 0 { + value = groupID + } + return translateError(tx.Model(&models.LLMPlatformModel{}). + Where("id IN ?", modelIDs). + Update("display_group_id", value).Error) + }) +} + +// GetModelDisplayGroupByID 按 ID 获取自定义模型展示分组。 +func (r *Repo) GetModelDisplayGroupByID(ctx context.Context, groupID uint) (*domainchannel.ModelDisplayGroup, error) { + var item models.LLMModelDisplayGroup + if err := r.db.WithContext(ctx).Where("id = ?", groupID).First(&item).Error; err != nil { + return nil, translateError(err) + } + result := toModelDisplayGroupDomain(item) + return &result, nil +} + +// ListModelDisplayGroups 分页查询自定义模型展示分组。 +func (r *Repo) ListModelDisplayGroups(ctx context.Context, input repository.ListModelDisplayGroupsInput) ([]domainchannel.ModelDisplayGroup, int64, error) { + query := r.db.WithContext(ctx).Model(&models.LLMModelDisplayGroup{}) + if keyword := strings.ToLower(strings.TrimSpace(input.Query)); keyword != "" { + query = query.Where("LOWER(name) LIKE ?", "%"+keyword+"%") + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, translateError(err) + } + entities := make([]models.LLMModelDisplayGroup, 0) + if err := query.Order("sort_order ASC, id ASC").Offset(input.Offset).Limit(input.Limit).Find(&entities).Error; err != nil { + return nil, 0, translateError(err) + } + items := make([]domainchannel.ModelDisplayGroup, 0, len(entities)) + for _, entity := range entities { + items = append(items, toModelDisplayGroupDomain(entity)) + } + return items, total, nil +} + +// DeleteModelDisplayGroup 删除自定义展示分组,并让关联模型恢复按技术厂商展示。 +func (r *Repo) DeleteModelDisplayGroup(ctx context.Context, groupID uint) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var item models.LLMModelDisplayGroup + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", groupID).First(&item).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return repository.ErrNotFound + } + return translateError(err) + } + if err := tx.Model(&models.LLMPlatformModel{}). + Where("display_group_id = ?", groupID). + Update("display_group_id", nil).Error; err != nil { + return translateError(err) + } + return translateError(tx.Delete(&models.LLMModelDisplayGroup{}, groupID).Error) + }) +} + +func toModelVendorDomain(item models.LLMModelVendor) domainchannel.ModelVendor { + return domainchannel.ModelVendor{ + ID: item.ID, Key: item.Key, Name: item.Name, Icon: item.Icon, + BuiltIn: item.BuiltIn, SortOrder: item.SortOrder, + CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, + } +} + +func toModelVendorModel(item *domainchannel.ModelVendor) models.LLMModelVendor { + if item == nil { + return models.LLMModelVendor{} + } + return models.LLMModelVendor{ + Key: item.Key, Name: item.Name, Icon: item.Icon, + BuiltIn: item.BuiltIn, SortOrder: item.SortOrder, + } +} + +func toModelDisplayGroupDomain(item models.LLMModelDisplayGroup) domainchannel.ModelDisplayGroup { + return domainchannel.ModelDisplayGroup{ + ID: item.ID, Name: item.Name, Icon: item.Icon, SortOrder: item.SortOrder, + CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, + } +} + +func toModelDisplayGroupModel(item *domainchannel.ModelDisplayGroup) models.LLMModelDisplayGroup { + if item == nil { + return models.LLMModelDisplayGroup{} + } + return models.LLMModelDisplayGroup{Name: item.Name, Icon: item.Icon, SortOrder: item.SortOrder} +} + +var _ repository.ModelPresentationRepository = (*Repo)(nil) diff --git a/backend/internal/infra/persistence/postgres/channel/repository.go b/backend/internal/infra/persistence/postgres/channel/repository.go index b7dfadd35..6aa578040 100644 --- a/backend/internal/infra/persistence/postgres/channel/repository.go +++ b/backend/internal/infra/persistence/postgres/channel/repository.go @@ -272,6 +272,13 @@ func (r *Repo) UpdateModel(ctx context.Context, modelID uint, input repository.U if input.Vendor != nil { updates["vendor"] = *input.Vendor } + if input.DisplayGroupID != nil { + if *input.DisplayGroupID == 0 { + updates["display_group_id"] = nil + } else { + updates["display_group_id"] = *input.DisplayGroupID + } + } if input.KindsJSON != nil { updates["kinds_json"] = *input.KindsJSON } @@ -427,7 +434,7 @@ func (r *Repo) ListModels(ctx context.Context, input repository.ListChannelModel items := make([]ModelListRow, 0) var total int64 - query := applyModelListFilters(r.db.WithContext(ctx).Table("llm_platform_models AS m"), input) + query := applyModelListFilters(r.modelListBaseQuery(ctx), input) if err := query.Count(&total).Error; err != nil { return nil, 0, translateError(err) } @@ -448,10 +455,10 @@ func (r *Repo) ListModels(ctx context.Context, input repository.ListChannelModel } func (r *Repo) modelListQuery(ctx context.Context) *gorm.DB { - return r.db.WithContext(ctx). - Table("llm_platform_models AS m"). + return r.modelListBaseQuery(ctx). Select( - "m.id, m.name AS platform_model_name, m.vendor, m.kinds_json, m.icon, m.capabilities_json, m.system_prompt, m.access_scope, m.status, m.description, m.cb_policy_mode, m.cb_failure_threshold, m.cb_duration_min, m.cb_window_min, m.sort_order, m.created_at, m.updated_at, " + + "m.id, m.name AS platform_model_name, m.vendor, m.display_group_id, m.kinds_json, m.icon, m.capabilities_json, m.system_prompt, m.access_scope, m.status, m.description, m.cb_policy_mode, m.cb_failure_threshold, m.cb_duration_min, m.cb_window_min, m.sort_order, m.created_at, m.updated_at, " + + "COALESCE(v.name, m.vendor) AS vendor_name, COALESCE(v.icon, '') AS vendor_icon, COALESCE(g.name, '') AS display_group_name, COALESCE(g.icon, '') AS display_group_icon, " + "COALESCE(stats.source_count, 0) AS source_count, COALESCE(stats.active_source_count, 0) AS active_source_count, '[]' AS protocols_json, '[]' AS upstream_names_json", ). Joins( @@ -467,6 +474,13 @@ func (r *Repo) modelListQuery(ctx context.Context) *gorm.DB { ) } +func (r *Repo) modelListBaseQuery(ctx context.Context) *gorm.DB { + return r.db.WithContext(ctx). + Table("llm_platform_models AS m"). + Joins("LEFT JOIN llm_model_vendors AS v ON v.key = m.vendor"). + Joins("LEFT JOIN llm_model_display_groups AS g ON g.id = m.display_group_id") +} + func (r *Repo) applyModelListRouteMetadata(ctx context.Context, items []ModelListRow) error { if len(items) == 0 { return nil @@ -577,7 +591,14 @@ func applyModelListFilters(query *gorm.DB, input repository.ListChannelModelsInp } if keyword := strings.TrimSpace(input.Query); keyword != "" { like := "%" + strings.ToLower(keyword) + "%" - query = query.Where("LOWER(m.name) LIKE ? OR LOWER(m.vendor) LIKE ? OR LOWER(m.description) LIKE ?", like, like, like) + query = query.Where( + `LOWER(m.name) LIKE ? + OR LOWER(m.vendor) LIKE ? + OR LOWER(m.description) LIKE ? + OR LOWER(v.name) LIKE ? + OR LOWER(g.name) LIKE ?`, + like, like, like, like, like, + ) } if vendor := strings.TrimSpace(input.Vendor); vendor != "" { query = query.Where("m.vendor = ?", vendor) @@ -636,11 +657,11 @@ func modelListOrder(sort string) string { func modelDefaultDisplayOrder() string { availabilityRank := modelAvailabilityRankExpression() - vendorKey := modelVendorOrderKey("m.") - vendorGroupOrder := "MIN(m.sort_order) OVER (PARTITION BY " + availabilityRank + ", " + vendorKey + ")" + presentationKey := modelPresentationOrderKey("m.") + presentationGroupOrder := "MIN(m.sort_order) OVER (PARTITION BY " + availabilityRank + ", " + presentationKey + ")" return availabilityRank + " ASC, " + - vendorGroupOrder + " ASC, " + - vendorKey + " ASC, " + + presentationGroupOrder + " ASC, " + + presentationKey + " ASC, " + "m.sort_order ASC, m.id ASC" } @@ -652,6 +673,12 @@ func modelVendorOrderKey(prefix string) string { return "COALESCE(NULLIF(TRIM(LOWER(" + prefix + "vendor)), ''), LOWER(" + prefix + "name))" } +func modelPresentationOrderKey(prefix string) string { + return "CASE WHEN " + prefix + "display_group_id IS NOT NULL " + + "THEN 'group:' || CAST(" + prefix + "display_group_id AS TEXT) " + + "ELSE 'vendor:' || " + modelVendorOrderKey(prefix) + " END" +} + // --------------------------------------------------------------------------- // 上游真实模型与平台路由 // --------------------------------------------------------------------------- @@ -1525,6 +1552,7 @@ func toPlatformModelDomain(item model.LLMPlatformModel) domainchannel.PlatformMo ID: item.ID, PlatformModelName: item.Name, Vendor: item.Vendor, + DisplayGroupID: item.DisplayGroupID, KindsJSON: item.KindsJSON, Icon: item.Icon, CapabilitiesJSON: item.CapabilitiesJSON, @@ -1549,6 +1577,7 @@ func toPlatformModelModel(item *domainchannel.PlatformModel) model.LLMPlatformMo return model.LLMPlatformModel{ Name: item.PlatformModelName, Vendor: item.Vendor, + DisplayGroupID: item.DisplayGroupID, KindsJSON: item.KindsJSON, Icon: item.Icon, CapabilitiesJSON: item.CapabilitiesJSON, diff --git a/backend/internal/infra/persistence/postgres/channel/repository_sqlite_test.go b/backend/internal/infra/persistence/postgres/channel/repository_sqlite_test.go index e93021b8c..0b3f4c424 100644 --- a/backend/internal/infra/persistence/postgres/channel/repository_sqlite_test.go +++ b/backend/internal/infra/persistence/postgres/channel/repository_sqlite_test.go @@ -3,6 +3,7 @@ package channel import ( "context" "encoding/json" + "errors" "reflect" "strconv" "testing" @@ -96,6 +97,172 @@ func TestListModelsSQLiteUsesPortableRouteStats(t *testing.T) { } } +func TestModelPresentationSQLiteJoinsMetadataAndClearsDeletedGroup(t *testing.T) { + db := openChannelSQLiteTestDB(t) + ctx := context.Background() + + vendor := model.LLMModelVendor{Key: "acme-ai", Name: "Acme AI", Icon: "acme", SortOrder: 100} + group := model.LLMModelDisplayGroup{Name: "Paid models", Icon: "wallet", SortOrder: 100} + if err := db.Create(&vendor).Error; err != nil { + t.Fatalf("create vendor: %v", err) + } + if err := db.Create(&group).Error; err != nil { + t.Fatalf("create display group: %v", err) + } + platformModel := model.LLMPlatformModel{ + Name: "acme-pro", Vendor: vendor.Key, DisplayGroupID: &group.ID, Status: "active", + } + if err := db.Create(&platformModel).Error; err != nil { + t.Fatalf("create platform model: %v", err) + } + + repo := NewRepo(db) + items, total, err := repo.ListModels(ctx, repository.ListChannelModelsInput{Limit: 10}) + if err != nil { + t.Fatalf("ListModels() error = %v", err) + } + if total != 1 || len(items) != 1 { + t.Fatalf("expected one model, total=%d len=%d", total, len(items)) + } + if items[0].VendorName != vendor.Name || items[0].VendorIcon != vendor.Icon { + t.Fatalf("unexpected vendor metadata: %#v", items[0]) + } + if items[0].DisplayGroupName != group.Name || items[0].DisplayGroupIcon != group.Icon { + t.Fatalf("unexpected display group metadata: %#v", items[0]) + } + for _, query := range []string{"Acme AI", "Paid models"} { + matched, matchedTotal, queryErr := repo.ListModels(ctx, repository.ListChannelModelsInput{Limit: 10, Query: query}) + if queryErr != nil { + t.Fatalf("ListModels(query=%q) error = %v", query, queryErr) + } + if matchedTotal != 1 || len(matched) != 1 || matched[0].ID != platformModel.ID { + t.Fatalf("expected query %q to match presentation metadata, total=%d items=%#v", query, matchedTotal, matched) + } + } + + if err := repo.DeleteModelDisplayGroup(ctx, group.ID); err != nil { + t.Fatalf("DeleteModelDisplayGroup() error = %v", err) + } + var stored model.LLMPlatformModel + if err := db.First(&stored, platformModel.ID).Error; err != nil { + t.Fatalf("reload platform model: %v", err) + } + if stored.DisplayGroupID != nil { + t.Fatalf("expected display group to be cleared, got %#v", stored.DisplayGroupID) + } + if _, err := repo.GetModelDisplayGroupByID(ctx, group.ID); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("expected deleted group to be missing, got %v", err) + } +} + +func TestModelDisplayGroupSQLiteReplacesMembersAtomically(t *testing.T) { + db := openChannelSQLiteTestDB(t) + ctx := context.Background() + oldGroup := model.LLMModelDisplayGroup{Name: "Old group", SortOrder: 100} + if err := db.Create(&oldGroup).Error; err != nil { + t.Fatalf("create old group: %v", err) + } + modelsToGroup := []model.LLMPlatformModel{ + {Name: "model-a", Vendor: "openai", DisplayGroupID: &oldGroup.ID, Status: "active"}, + {Name: "model-b", Vendor: "openai", Status: "active"}, + {Name: "model-c", Vendor: "openai", Status: "active"}, + } + if err := db.Create(&modelsToGroup).Error; err != nil { + t.Fatalf("create platform models: %v", err) + } + + repo := NewRepo(db) + group := domainchannel.ModelDisplayGroup{Name: "Featured"} + if err := repo.CreateModelDisplayGroup(ctx, &group, []uint{modelsToGroup[0].ID, modelsToGroup[1].ID}); err != nil { + t.Fatalf("CreateModelDisplayGroup() error = %v", err) + } + assertModelDisplayGroupID(t, db, modelsToGroup[0].ID, &group.ID) + assertModelDisplayGroupID(t, db, modelsToGroup[1].ID, &group.ID) + + nextMembers := []uint{modelsToGroup[2].ID} + if err := repo.UpdateModelDisplayGroup(ctx, group.ID, repository.UpdateModelDisplayGroupInput{ModelIDs: &nextMembers}); err != nil { + t.Fatalf("UpdateModelDisplayGroup() error = %v", err) + } + assertModelDisplayGroupID(t, db, modelsToGroup[0].ID, nil) + assertModelDisplayGroupID(t, db, modelsToGroup[1].ID, nil) + assertModelDisplayGroupID(t, db, modelsToGroup[2].ID, &group.ID) + + invalidMembers := []uint{modelsToGroup[0].ID, 999999} + if err := repo.UpdateModelDisplayGroup(ctx, group.ID, repository.UpdateModelDisplayGroupInput{ModelIDs: &invalidMembers}); !errors.Is(err, repository.ErrInvalidInput) { + t.Fatalf("expected invalid member update to fail, got %v", err) + } + assertModelDisplayGroupID(t, db, modelsToGroup[0].ID, nil) + assertModelDisplayGroupID(t, db, modelsToGroup[2].ID, &group.ID) +} + +func TestModelDisplayGroupSQLiteSetsSelectedModelsAtomically(t *testing.T) { + db := openChannelSQLiteTestDB(t) + ctx := context.Background() + group := model.LLMModelDisplayGroup{Name: "Featured", SortOrder: 100} + if err := db.Create(&group).Error; err != nil { + t.Fatalf("create display group: %v", err) + } + platformModels := []model.LLMPlatformModel{ + {Name: "model-a", Vendor: "openai", Status: "active"}, + {Name: "model-b", Vendor: "anthropic", Status: "active"}, + } + if err := db.Create(&platformModels).Error; err != nil { + t.Fatalf("create platform models: %v", err) + } + + repo := NewRepo(db) + modelIDs := []uint{platformModels[0].ID, platformModels[1].ID} + if err := repo.SetModelsDisplayGroup(ctx, modelIDs, group.ID); err != nil { + t.Fatalf("SetModelsDisplayGroup() error = %v", err) + } + assertModelDisplayGroupID(t, db, platformModels[0].ID, &group.ID) + assertModelDisplayGroupID(t, db, platformModels[1].ID, &group.ID) + + if err := repo.SetModelsDisplayGroup(ctx, []uint{platformModels[0].ID}, 0); err != nil { + t.Fatalf("clear SetModelsDisplayGroup() error = %v", err) + } + assertModelDisplayGroupID(t, db, platformModels[0].ID, nil) + assertModelDisplayGroupID(t, db, platformModels[1].ID, &group.ID) + + if err := repo.SetModelsDisplayGroup(ctx, []uint{platformModels[0].ID, 999999}, group.ID); !errors.Is(err, repository.ErrInvalidInput) { + t.Fatalf("expected invalid batch assignment to fail, got %v", err) + } + assertModelDisplayGroupID(t, db, platformModels[0].ID, nil) + assertModelDisplayGroupID(t, db, platformModels[1].ID, &group.ID) +} + +func TestModelVendorSQLiteAllowsDuplicateDisplayNames(t *testing.T) { + db := openChannelSQLiteTestDB(t) + repo := NewRepo(db) + ctx := context.Background() + + first := domainchannel.ModelVendor{Key: "acme-primary", Name: "Acme"} + second := domainchannel.ModelVendor{Key: "acme-secondary", Name: "Acme"} + if err := repo.CreateModelVendor(ctx, &first); err != nil { + t.Fatalf("create first vendor: %v", err) + } + if err := repo.CreateModelVendor(ctx, &second); err != nil { + t.Fatalf("create second vendor with same display name: %v", err) + } +} + +func assertModelDisplayGroupID(t *testing.T, db *gorm.DB, modelID uint, want *uint) { + t.Helper() + var stored model.LLMPlatformModel + if err := db.First(&stored, modelID).Error; err != nil { + t.Fatalf("reload platform model %d: %v", modelID, err) + } + if want == nil { + if stored.DisplayGroupID != nil { + t.Fatalf("model %d display group = %v, want nil", modelID, *stored.DisplayGroupID) + } + return + } + if stored.DisplayGroupID == nil || *stored.DisplayGroupID != *want { + t.Fatalf("model %d display group = %v, want %d", modelID, stored.DisplayGroupID, *want) + } +} + func TestListUpstreamsSQLiteExcludesInactiveUpstreamFromActiveModelCount(t *testing.T) { db := openChannelSQLiteTestDB(t) ctx := context.Background() @@ -234,6 +401,39 @@ func TestListModelsSQLiteSortOrderKeepsVendorGroups(t *testing.T) { } } +func TestListModelsSQLiteSortOrderKeepsCrossVendorDisplayGroups(t *testing.T) { + db := openChannelSQLiteTestDB(t) + ctx := context.Background() + upstreamModel := createActiveRouteTarget(t, db) + paidGroup := model.LLMModelDisplayGroup{Name: "Paid", SortOrder: 100} + if err := db.Create(&paidGroup).Error; err != nil { + t.Fatalf("create display group: %v", err) + } + + models := []model.LLMPlatformModel{ + {Name: "claude-paid", Vendor: "anthropic", DisplayGroupID: &paidGroup.ID, Status: "active", SortOrder: 100}, + {Name: "gpt-free", Vendor: "openai", Status: "active", SortOrder: 200}, + {Name: "gpt-paid", Vendor: "openai", DisplayGroupID: &paidGroup.ID, Status: "active", SortOrder: 300}, + {Name: "claude-free", Vendor: "anthropic", Status: "active", SortOrder: 400}, + } + if err := db.Create(&models).Error; err != nil { + t.Fatalf("create platform models: %v", err) + } + createActiveRoutes(t, db, upstreamModel.ID, models...) + + items, _, err := NewRepo(db).ListModels(ctx, repository.ListChannelModelsInput{ + Limit: 10, + Sort: "sortOrder_asc", + }) + if err != nil { + t.Fatalf("ListModels() error = %v", err) + } + want := []string{"claude-paid", "gpt-paid", "gpt-free", "claude-free"} + if got := modelNames(items); !reflect.DeepEqual(got, want) { + t.Fatalf("expected effective display groups %v, got %v", want, got) + } +} + func TestListModelsSQLiteSortOrderIgnoresHiddenDisabledVendorAnchors(t *testing.T) { db := openChannelSQLiteTestDB(t) ctx := context.Background() @@ -983,6 +1183,8 @@ func openChannelSQLiteTestDB(t *testing.T) *gorm.DB { if err := db.AutoMigrate( &model.LLMUpstream{}, &model.LLMUpstreamModel{}, + &model.LLMModelVendor{}, + &model.LLMModelDisplayGroup{}, &model.LLMPlatformModel{}, &model.LLMPlatformModelRoute{}, &model.PermissionGroup{}, diff --git a/backend/internal/infra/persistence/postgres/postgres.go b/backend/internal/infra/persistence/postgres/postgres.go index 651d6a07d..5d14b0a17 100644 --- a/backend/internal/infra/persistence/postgres/postgres.go +++ b/backend/internal/infra/persistence/postgres/postgres.go @@ -30,6 +30,9 @@ func New(cfg config.Config) (*gorm.DB, error) { if err = migrate(db, cfg); err != nil { return nil, err } + if err = schema.SeedModelVendors(db); err != nil { + return nil, err + } if err = schema.SeedPermissionGroups(db); err != nil { return nil, err @@ -117,6 +120,8 @@ func migrate(db *gorm.DB, cfg config.Config) error { "identity_trusted_devices": "双因素认证可信设备表", "llm_upstreams": "上游配置表", "llm_upstream_models": "上游真实模型清单表", + "llm_model_vendors": "平台模型技术厂商目录表", + "llm_model_display_groups": "平台模型自定义展示分组表", "llm_platform_models": "平台模型表", "llm_model_routes": "平台模型路由绑定表", "mcp_servers": "MCP服务配置表", diff --git a/backend/internal/infra/persistence/schema/schema.go b/backend/internal/infra/persistence/schema/schema.go index 6a3156edb..f3c586173 100644 --- a/backend/internal/infra/persistence/schema/schema.go +++ b/backend/internal/infra/persistence/schema/schema.go @@ -22,6 +22,8 @@ func Models() []interface{} { &model.TrustedDevice{}, &model.LLMUpstream{}, &model.LLMUpstreamModel{}, + &model.LLMModelVendor{}, + &model.LLMModelDisplayGroup{}, &model.LLMPlatformModel{}, &model.LLMPlatformModelRoute{}, &model.MCPServer{}, @@ -68,6 +70,45 @@ func Models() []interface{} { } } +// SeedModelVendors 初始化内置厂商,并为存量模型中的技术厂商补齐目录项。 +// 同 key 的现有目录项会晋升为内置,但管理员修改的展示名称、图标和排序不会被覆盖。 +func SeedModelVendors(db *gorm.DB) error { + return db.Transaction(func(tx *gorm.DB) error { + for _, item := range domainchannel.BuiltInModelVendors() { + entity := model.LLMModelVendor{ + Key: item.Key, + Name: item.Name, + Icon: item.Icon, + BuiltIn: true, + SortOrder: item.SortOrder, + } + if err := tx.Where("key = ?", entity.Key).Attrs(entity).FirstOrCreate(&entity).Error; err != nil { + return err + } + if !entity.BuiltIn { + if err := tx.Model(&entity).Update("built_in", true).Error; err != nil { + return err + } + } + } + + var vendorKeys []string + if err := tx.Model(&model.LLMPlatformModel{}). + Distinct("vendor"). + Where("vendor <> ?", ""). + Pluck("vendor", &vendorKeys).Error; err != nil { + return err + } + for _, key := range vendorKeys { + entity := model.LLMModelVendor{Key: key, Name: key} + if err := tx.Where("key = ?", key).Attrs(entity).FirstOrCreate(&entity).Error; err != nil { + return err + } + } + return nil + }) +} + // Migrate creates or updates the baseline schema with Gorm's portable migrator. func Migrate(db *gorm.DB) error { for _, item := range Models() { diff --git a/backend/internal/infra/persistence/schema/schema_test.go b/backend/internal/infra/persistence/schema/schema_test.go index 2ae3775a3..c90ec78a8 100644 --- a/backend/internal/infra/persistence/schema/schema_test.go +++ b/backend/internal/infra/persistence/schema/schema_test.go @@ -371,6 +371,58 @@ func TestSeedPermissionGroupsDoesNotRecreateDefaultAllRuleAfterAccessConfigured( } } +func TestSeedModelVendorsPromotesBuiltInsPreservesEditsAndBackfillsExistingKeys(t *testing.T) { + dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err = db.AutoMigrate(&model.LLMModelVendor{}, &model.LLMPlatformModel{}); err != nil { + t.Fatalf("migrate: %v", err) + } + customized := model.LLMModelVendor{ + Key: "openai", Name: "OpenAI Custom", Icon: "custom-icon", BuiltIn: false, SortOrder: 99, + } + if err = db.Create(&customized).Error; err != nil { + t.Fatalf("create customized vendor: %v", err) + } + if err = db.Create(&model.LLMPlatformModel{Name: "acme-chat", Vendor: "acme-ai"}).Error; err != nil { + t.Fatalf("create existing model: %v", err) + } + if err = db.Create(&model.LLMPlatformModel{Name: "legacy-openai", Vendor: "OpenAI"}).Error; err != nil { + t.Fatalf("create legacy model: %v", err) + } + + if err = SeedModelVendors(db); err != nil { + t.Fatalf("SeedModelVendors() error = %v", err) + } + + var openAI model.LLMModelVendor + if err = db.Where("key = ?", "openai").First(&openAI).Error; err != nil { + t.Fatalf("load OpenAI vendor: %v", err) + } + if openAI.Name != customized.Name || openAI.Icon != customized.Icon || openAI.SortOrder != customized.SortOrder { + t.Fatalf("expected customized vendor to remain unchanged, got %#v", openAI) + } + if !openAI.BuiltIn { + t.Fatalf("expected matching custom vendor to be promoted to built-in, got %#v", openAI) + } + var custom model.LLMModelVendor + if err = db.Where("key = ?", "acme-ai").First(&custom).Error; err != nil { + t.Fatalf("load backfilled vendor: %v", err) + } + if custom.Name != "acme-ai" || custom.BuiltIn { + t.Fatalf("unexpected backfilled vendor: %#v", custom) + } + var legacy model.LLMModelVendor + if err = db.Where("key = ?", "OpenAI").First(&legacy).Error; err != nil { + t.Fatalf("load legacy vendor with duplicate display name: %v", err) + } + if legacy.Name != "OpenAI" || legacy.BuiltIn { + t.Fatalf("unexpected legacy vendor: %#v", legacy) + } +} + func openSchemaTestDB(t *testing.T) *gorm.DB { t.Helper() dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) diff --git a/backend/internal/infra/persistence/sqlite/sqlite.go b/backend/internal/infra/persistence/sqlite/sqlite.go index d4a3461b4..8d6f9e485 100644 --- a/backend/internal/infra/persistence/sqlite/sqlite.go +++ b/backend/internal/infra/persistence/sqlite/sqlite.go @@ -34,6 +34,9 @@ func New(cfg config.Config) (*gorm.DB, error) { if err = schema.Migrate(db); err != nil { return nil, err } + if err = schema.SeedModelVendors(db); err != nil { + return nil, err + } if err = schema.CleanupRemovedColumns(db); err != nil { return nil, err } diff --git a/backend/internal/repository/channel.go b/backend/internal/repository/channel.go index 2bd34b70b..9083135d8 100644 --- a/backend/internal/repository/channel.go +++ b/backend/internal/repository/channel.go @@ -137,12 +137,43 @@ type ChannelUpstreamListRow struct { // ChannelModelListRow 定义模型列表查询结果。 type ChannelModelListRow struct { domainchannel.PlatformModel + VendorName string + VendorIcon string + DisplayGroupName string + DisplayGroupIcon string SourceCount int64 ActiveSourceCount int64 ProtocolsJSON string UpstreamNamesJSON string } +// ListModelVendorsInput 定义技术厂商目录查询条件。 +type ListModelVendorsInput struct { + Offset int + Limit int + Query string +} + +// ListModelDisplayGroupsInput 定义模型展示分组查询条件。 +type ListModelDisplayGroupsInput struct { + Offset int + Limit int + Query string +} + +// UpdateModelVendorInput 定义技术厂商可修改的展示字段。 +type UpdateModelVendorInput struct { + Name *string + Icon *string +} + +// UpdateModelDisplayGroupInput 定义模型展示分组可修改字段。 +type UpdateModelDisplayGroupInput struct { + Name *string + Icon *string + ModelIDs *[]uint +} + // ChannelUpstreamModelListRow 定义上游模型路由绑定列表查询结果。 type ChannelUpstreamModelListRow struct { domainchannel.UpstreamModel @@ -216,8 +247,10 @@ type ListChannelModelsInput struct { // UpdateChannelModelInput 定义平台模型更新字段。 type UpdateChannelModelInput struct { - PlatformModelName *string - Vendor *string + PlatformModelName *string + Vendor *string + // DisplayGroupID 为 nil 时不更新;值为 0 时清空分组并恢复按技术厂商展示。 + DisplayGroupID *uint KindsJSON *string Icon *string CapabilitiesJSON *string @@ -324,6 +357,7 @@ func (input UpdateChannelPlatformRouteInput) IsZero() bool { func (input UpdateChannelModelInput) IsZero() bool { return input.PlatformModelName == nil && input.Vendor == nil && + input.DisplayGroupID == nil && input.KindsJSON == nil && input.Icon == nil && input.CapabilitiesJSON == nil && @@ -337,6 +371,20 @@ func (input UpdateChannelModelInput) IsZero() bool { input.CbWindowMin == nil } +// ModelPresentationRepository 定义技术厂商和可选展示分组的持久化能力。 +type ModelPresentationRepository interface { + CreateModelVendor(ctx context.Context, item *domainchannel.ModelVendor) error + UpdateModelVendor(ctx context.Context, key string, input UpdateModelVendorInput) error + GetModelVendorByKey(ctx context.Context, key string) (*domainchannel.ModelVendor, error) + ListModelVendors(ctx context.Context, input ListModelVendorsInput) ([]domainchannel.ModelVendor, int64, error) + CreateModelDisplayGroup(ctx context.Context, item *domainchannel.ModelDisplayGroup, modelIDs []uint) error + UpdateModelDisplayGroup(ctx context.Context, groupID uint, input UpdateModelDisplayGroupInput) error + SetModelsDisplayGroup(ctx context.Context, modelIDs []uint, groupID uint) error + GetModelDisplayGroupByID(ctx context.Context, groupID uint) (*domainchannel.ModelDisplayGroup, error) + ListModelDisplayGroups(ctx context.Context, input ListModelDisplayGroupsInput) ([]domainchannel.ModelDisplayGroup, int64, error) + DeleteModelDisplayGroup(ctx context.Context, groupID uint) error +} + // ChannelRepository 定义渠道管理依赖的仓储能力。 type ChannelRepository interface { CreateUpstream(ctx context.Context, item *domainchannel.Upstream) error diff --git a/backend/internal/transport/http/channel/dto_model_presentation.go b/backend/internal/transport/http/channel/dto_model_presentation.go new file mode 100644 index 000000000..876353923 --- /dev/null +++ b/backend/internal/transport/http/channel/dto_model_presentation.go @@ -0,0 +1,80 @@ +package channel + +import appchannel "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/channel" + +// ModelVendorResponse 技术厂商响应 DTO。 +type ModelVendorResponse struct { + ID uint `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + Icon string `json:"icon"` + BuiltIn bool `json:"builtIn"` + SortOrder int `json:"sortOrder"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +// ModelDisplayGroupResponse 模型展示分组响应 DTO。 +type ModelDisplayGroupResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + SortOrder int `json:"sortOrder"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +// ModelVendorDataResponse 技术厂商数据响应。 +type ModelVendorDataResponse struct { + Vendor ModelVendorResponse `json:"vendor"` +} + +// ModelDisplayGroupDataResponse 模型展示分组数据响应。 +type ModelDisplayGroupDataResponse struct { + Group ModelDisplayGroupResponse `json:"group"` +} + +// ModelVendorListResponseDoc 技术厂商分页响应文档。 +type ModelVendorListResponseDoc struct { + ErrorMsg string `json:"errorMsg"` + Data struct { + Total int64 `json:"total"` + Results []ModelVendorResponse `json:"results"` + } `json:"data"` +} + +// ModelDisplayGroupListResponseDoc 模型展示分组分页响应文档。 +type ModelDisplayGroupListResponseDoc struct { + ErrorMsg string `json:"errorMsg"` + Data struct { + Total int64 `json:"total"` + Results []ModelDisplayGroupResponse `json:"results"` + } `json:"data"` +} + +// ModelVendorDataResponseDoc 技术厂商单项响应文档。 +type ModelVendorDataResponseDoc struct { + ErrorMsg string `json:"errorMsg"` + Data ModelVendorDataResponse `json:"data"` +} + +// ModelDisplayGroupDataResponseDoc 模型展示分组单项响应文档。 +type ModelDisplayGroupDataResponseDoc struct { + ErrorMsg string `json:"errorMsg"` + Data ModelDisplayGroupDataResponse `json:"data"` +} + +func toModelVendorResponse(item appchannel.ModelVendorView) ModelVendorResponse { + return ModelVendorResponse{ + ID: item.ID, Key: item.Key, Name: item.Name, Icon: item.Icon, + BuiltIn: item.BuiltIn, SortOrder: item.SortOrder, + CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, + } +} + +func toModelDisplayGroupResponse(item appchannel.ModelDisplayGroupView) ModelDisplayGroupResponse { + return ModelDisplayGroupResponse{ + ID: item.ID, Name: item.Name, Icon: item.Icon, SortOrder: item.SortOrder, + CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, + } +} diff --git a/backend/internal/transport/http/channel/dto_request.go b/backend/internal/transport/http/channel/dto_request.go index 49825b6bc..0103a4cdc 100644 --- a/backend/internal/transport/http/channel/dto_request.go +++ b/backend/internal/transport/http/channel/dto_request.go @@ -49,6 +49,7 @@ type UpdateUpstreamRequest struct { type CreateModelRequest struct { PlatformModelName string `json:"platformModelName" binding:"required,min=2,max=128"` Vendor string `json:"vendor,omitempty" binding:"omitempty,max=64"` + DisplayGroupID uint `json:"displayGroupID,omitempty"` KindsJSON string `json:"kindsJSON,omitempty" binding:"omitempty,max=1000"` Icon string `json:"icon,omitempty" binding:"max=128"` CapabilitiesJSON string `json:"capabilitiesJSON,omitempty" binding:"max=10000"` @@ -66,6 +67,7 @@ type CreateModelRequest struct { type UpdateModelRequest struct { PlatformModelName *string `json:"platformModelName,omitempty" binding:"omitempty,min=2,max=128"` Vendor *string `json:"vendor,omitempty" binding:"omitempty,max=64"` + DisplayGroupID *uint `json:"displayGroupID,omitempty"` KindsJSON *string `json:"kindsJSON,omitempty" binding:"omitempty,max=1000"` Icon *string `json:"icon,omitempty" binding:"omitempty,max=128"` CapabilitiesJSON *string `json:"capabilitiesJSON,omitempty" binding:"omitempty,max=10000"` @@ -79,6 +81,39 @@ type UpdateModelRequest struct { CbWindowMin *int `json:"cbWindowMin,omitempty" binding:"omitempty,gte=0"` } +// CreateModelVendorRequest 创建技术厂商请求。 +type CreateModelVendorRequest struct { + Key string `json:"key" binding:"required,max=64"` + Name string `json:"name" binding:"required,max=64"` + Icon string `json:"icon,omitempty" binding:"max=2048"` +} + +// UpdateModelVendorRequest 更新技术厂商展示信息请求。 +type UpdateModelVendorRequest struct { + Name *string `json:"name,omitempty" binding:"omitempty,max=64"` + Icon *string `json:"icon,omitempty" binding:"omitempty,max=2048"` +} + +// CreateModelDisplayGroupRequest 创建模型展示分组请求。 +type CreateModelDisplayGroupRequest struct { + Name string `json:"name" binding:"required,max=64"` + Icon string `json:"icon,omitempty" binding:"max=2048"` + ModelIDs []uint `json:"modelIDs,omitempty" binding:"max=10000,dive,gt=0"` +} + +// UpdateModelDisplayGroupRequest 更新模型展示分组请求。 +type UpdateModelDisplayGroupRequest struct { + Name *string `json:"name,omitempty" binding:"omitempty,max=64"` + Icon *string `json:"icon,omitempty" binding:"omitempty,max=2048"` + ModelIDs *[]uint `json:"modelIDs,omitempty" binding:"omitempty,max=10000,dive,gt=0"` +} + +// SetModelsDisplayGroupRequest 批量设置模型展示分组请求;displayGroupID 为 0 时清除自定义分组。 +type SetModelsDisplayGroupRequest struct { + ModelIDs []uint `json:"modelIDs" binding:"required,min=1,max=1000,dive,gt=0"` + DisplayGroupID *uint `json:"displayGroupID" binding:"required"` +} + // ReorderModelsRequest 调整模型展示顺序请求。 type ReorderModelsRequest struct { ModelIDs []uint `json:"modelIDs" binding:"required,min=1,dive,gt=0"` diff --git a/backend/internal/transport/http/channel/dto_response.go b/backend/internal/transport/http/channel/dto_response.go index 2dbf78c78..15f945c24 100644 --- a/backend/internal/transport/http/channel/dto_response.go +++ b/backend/internal/transport/http/channel/dto_response.go @@ -92,6 +92,11 @@ type ModelResponse struct { ID uint `json:"id"` PlatformModelName string `json:"platformModelName"` Vendor string `json:"vendor"` + VendorName string `json:"vendorName"` + VendorIcon string `json:"vendorIcon"` + DisplayGroupID *uint `json:"displayGroupID" extensions:"x-nullable,!x-omitempty"` + DisplayGroupName string `json:"displayGroupName"` + DisplayGroupIcon string `json:"displayGroupIcon"` KindsJSON string `json:"kindsJSON"` Icon string `json:"icon"` CapabilitiesJSON string `json:"capabilitiesJSON"` @@ -117,6 +122,11 @@ func toModelResponse(v appchannel.ModelView) ModelResponse { ID: v.ID, PlatformModelName: v.PlatformModelName, Vendor: v.Vendor, + VendorName: v.VendorName, + VendorIcon: v.VendorIcon, + DisplayGroupID: v.DisplayGroupID, + DisplayGroupName: v.DisplayGroupName, + DisplayGroupIcon: v.DisplayGroupIcon, KindsJSON: v.KindsJSON, Icon: v.Icon, CapabilitiesJSON: v.CapabilitiesJSON, @@ -599,6 +609,11 @@ type CircuitResetResponse struct { type PublicModelResponse struct { PlatformModelName string `json:"platformModelName"` Vendor string `json:"vendor"` + VendorName string `json:"vendorName"` + VendorIcon string `json:"vendorIcon"` + DisplayGroupID *uint `json:"displayGroupID" extensions:"x-nullable,!x-omitempty"` + DisplayGroupName string `json:"displayGroupName"` + DisplayGroupIcon string `json:"displayGroupIcon"` KindsJSON string `json:"kindsJSON"` Icon string `json:"icon"` ProtocolsJSON string `json:"protocolsJSON"` @@ -780,6 +795,11 @@ func toPublicModelResponse(v appchannel.ModelView) PublicModelResponse { return PublicModelResponse{ PlatformModelName: v.PlatformModelName, Vendor: v.Vendor, + VendorName: v.VendorName, + VendorIcon: v.VendorIcon, + DisplayGroupID: v.DisplayGroupID, + DisplayGroupName: v.DisplayGroupName, + DisplayGroupIcon: v.DisplayGroupIcon, KindsJSON: v.KindsJSON, Icon: v.Icon, ProtocolsJSON: v.ProtocolsJSON, diff --git a/backend/internal/transport/http/channel/handler.go b/backend/internal/transport/http/channel/handler.go index bc928c281..3e673cc53 100644 --- a/backend/internal/transport/http/channel/handler.go +++ b/backend/internal/transport/http/channel/handler.go @@ -969,6 +969,7 @@ func (h *Handler) CreateModel(c *gin.Context) { item, err := h.service.CreateModel(c.Request.Context(), appchannel.CreateModelInput{ PlatformModelName: req.PlatformModelName, Vendor: req.Vendor, + DisplayGroupID: req.DisplayGroupID, KindsJSON: req.KindsJSON, Icon: req.Icon, CapabilitiesJSON: req.CapabilitiesJSON, @@ -995,6 +996,12 @@ func (h *Handler) CreateModel(c *gin.Context) { response.Error(c, http.StatusBadRequest, "system prompt too long") case errors.Is(err, appchannel.ErrInvalidPlatformModelName): response.Error(c, http.StatusBadRequest, "invalid platform model name") + case errors.Is(err, appchannel.ErrInvalidModelVendor): + response.Error(c, http.StatusBadRequest, "invalid model vendor") + case errors.Is(err, appchannel.ErrModelVendorNotFound): + response.Error(c, http.StatusBadRequest, "model vendor not found") + case errors.Is(err, appchannel.ErrModelDisplayGroupNotFound): + response.Error(c, http.StatusBadRequest, "model display group not found") default: response.Error(c, http.StatusInternalServerError, "create model failed") } @@ -1033,6 +1040,7 @@ func (h *Handler) UpdateModel(c *gin.Context) { item, err := h.service.UpdateModel(c.Request.Context(), modelID, appchannel.UpdateModelInput{ PlatformModelName: req.PlatformModelName, Vendor: req.Vendor, + DisplayGroupID: req.DisplayGroupID, KindsJSON: req.KindsJSON, Icon: req.Icon, CapabilitiesJSON: req.CapabilitiesJSON, @@ -1059,6 +1067,12 @@ func (h *Handler) UpdateModel(c *gin.Context) { response.Error(c, http.StatusBadRequest, "system prompt too long") case errors.Is(err, appchannel.ErrInvalidPlatformModelName): response.Error(c, http.StatusBadRequest, "invalid platform model name") + case errors.Is(err, appchannel.ErrInvalidModelVendor): + response.Error(c, http.StatusBadRequest, "invalid model vendor") + case errors.Is(err, appchannel.ErrModelVendorNotFound): + response.Error(c, http.StatusBadRequest, "model vendor not found") + case errors.Is(err, appchannel.ErrModelDisplayGroupNotFound): + response.Error(c, http.StatusBadRequest, "model display group not found") default: response.Error(c, http.StatusInternalServerError, "update model failed") } diff --git a/backend/internal/transport/http/channel/handler_model_presentation.go b/backend/internal/transport/http/channel/handler_model_presentation.go new file mode 100644 index 000000000..b659da87e --- /dev/null +++ b/backend/internal/transport/http/channel/handler_model_presentation.go @@ -0,0 +1,257 @@ +package channel + +import ( + "errors" + "net/http" + "strings" + + appchannel "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/channel" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/shared/response" + "github.com/gin-gonic/gin" +) + +// ListModelVendors godoc +// @Summary 管理员查询模型技术厂商 +// @Description 分页查询模型技术厂商目录;技术厂商是路由、权限和计费使用的稳定身份 +// @Tags llm +// @Produce json +// @Security BearerAuth +// @Param page query int false "页码" +// @Param page_size query int false "每页数量" +// @Param q query string false "搜索 key 或名称" +// @Success 200 {object} ModelVendorListResponseDoc +// @Failure 500 {object} ErrorDoc +// @Router /admin/llm/model-vendors [get] +func (h *Handler) ListModelVendors(c *gin.Context) { + page, pageSize := pageParams(c) + items, total, err := h.service.ListModelVendors(c.Request.Context(), page, pageSize, c.Query("q")) + if err != nil { + response.Error(c, http.StatusInternalServerError, "list model vendors failed") + return + } + results := make([]ModelVendorResponse, 0, len(items)) + for _, item := range items { + results = append(results, toModelVendorResponse(item)) + } + response.SuccessPage(c, total, results) +} + +// CreateModelVendor godoc +// @Summary 管理员创建模型技术厂商 +// @Description 创建新的稳定技术厂商身份;创建后可供平台模型选择 +// @Tags llm +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body CreateModelVendorRequest true "技术厂商参数" +// @Success 200 {object} ModelVendorDataResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 409 {object} ErrorDoc +// @Router /admin/llm/model-vendors [post] +func (h *Handler) CreateModelVendor(c *gin.Context) { + var request CreateModelVendorRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.InvalidRequestBody(c, err) + return + } + item, err := h.service.CreateModelVendor(c.Request.Context(), appchannel.CreateModelVendorInput{ + Key: request.Key, Name: request.Name, Icon: request.Icon, + }) + if err != nil { + writeModelVendorError(c, err, "create model vendor failed") + return + } + response.Success(c, ModelVendorDataResponse{Vendor: toModelVendorResponse(*item)}) +} + +// UpdateModelVendor godoc +// @Summary 管理员更新模型技术厂商 +// @Description 更新厂商展示名称和图标;稳定技术 key 不可修改 +// @Tags llm +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param key path string true "技术厂商 key" +// @Param body body UpdateModelVendorRequest true "技术厂商参数" +// @Success 200 {object} ModelVendorDataResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 404 {object} ErrorDoc +// @Router /admin/llm/model-vendors/{key} [patch] +func (h *Handler) UpdateModelVendor(c *gin.Context) { + var request UpdateModelVendorRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.InvalidRequestBody(c, err) + return + } + item, err := h.service.UpdateModelVendor(c.Request.Context(), strings.TrimSpace(c.Param("key")), appchannel.UpdateModelVendorInput{ + Name: request.Name, Icon: request.Icon, + }) + if err != nil { + writeModelVendorError(c, err, "update model vendor failed") + return + } + response.Success(c, ModelVendorDataResponse{Vendor: toModelVendorResponse(*item)}) +} + +// ListModelDisplayGroups godoc +// @Summary 管理员查询模型展示分组 +// @Description 分页查询自定义展示分组;未绑定分组的模型继续按技术厂商展示 +// @Tags llm +// @Produce json +// @Security BearerAuth +// @Param page query int false "页码" +// @Param page_size query int false "每页数量" +// @Param q query string false "搜索名称" +// @Success 200 {object} ModelDisplayGroupListResponseDoc +// @Failure 500 {object} ErrorDoc +// @Router /admin/llm/model-display-groups [get] +func (h *Handler) ListModelDisplayGroups(c *gin.Context) { + page, pageSize := pageParams(c) + items, total, err := h.service.ListModelDisplayGroups(c.Request.Context(), page, pageSize, c.Query("q")) + if err != nil { + response.Error(c, http.StatusInternalServerError, "list model display groups failed") + return + } + results := make([]ModelDisplayGroupResponse, 0, len(items)) + for _, item := range items { + results = append(results, toModelDisplayGroupResponse(item)) + } + response.SuccessPage(c, total, results) +} + +// CreateModelDisplayGroup godoc +// @Summary 管理员创建模型展示分组 +// @Description 创建仅影响用户界面归类的自定义模型分组 +// @Tags llm +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body CreateModelDisplayGroupRequest true "展示分组参数" +// @Success 200 {object} ModelDisplayGroupDataResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 409 {object} ErrorDoc +// @Router /admin/llm/model-display-groups [post] +func (h *Handler) CreateModelDisplayGroup(c *gin.Context) { + var request CreateModelDisplayGroupRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.InvalidRequestBody(c, err) + return + } + item, err := h.service.CreateModelDisplayGroup(c.Request.Context(), appchannel.CreateModelDisplayGroupInput{ + Name: request.Name, Icon: request.Icon, ModelIDs: request.ModelIDs, + }) + if err != nil { + writeModelDisplayGroupError(c, err, "create model display group failed") + return + } + response.Success(c, ModelDisplayGroupDataResponse{Group: toModelDisplayGroupResponse(*item)}) +} + +// UpdateModelDisplayGroup godoc +// @Summary 管理员更新模型展示分组 +// @Tags llm +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "展示分组 ID" +// @Param body body UpdateModelDisplayGroupRequest true "展示分组参数" +// @Success 200 {object} ModelDisplayGroupDataResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 404 {object} ErrorDoc +// @Failure 409 {object} ErrorDoc +// @Router /admin/llm/model-display-groups/{id} [patch] +func (h *Handler) UpdateModelDisplayGroup(c *gin.Context) { + groupID, err := uintParam(c, "id") + if err != nil { + response.Error(c, http.StatusBadRequest, "invalid model display group id") + return + } + var request UpdateModelDisplayGroupRequest + if err = c.ShouldBindJSON(&request); err != nil { + response.InvalidRequestBody(c, err) + return + } + item, err := h.service.UpdateModelDisplayGroup(c.Request.Context(), groupID, appchannel.UpdateModelDisplayGroupInput{ + Name: request.Name, Icon: request.Icon, ModelIDs: request.ModelIDs, + }) + if err != nil { + writeModelDisplayGroupError(c, err, "update model display group failed") + return + } + response.Success(c, ModelDisplayGroupDataResponse{Group: toModelDisplayGroupResponse(*item)}) +} + +// SetModelsDisplayGroup godoc +// @Summary 管理员批量设置模型展示分组 +// @Description 在单个事务中将指定模型归入展示分组;displayGroupID 为 0 时恢复按技术厂商展示 +// @Tags llm +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body SetModelsDisplayGroupRequest true "批量归组参数" +// @Success 200 {object} response.SuccessDoc +// @Failure 400 {object} ErrorDoc +// @Failure 404 {object} ErrorDoc +// @Router /admin/llm/models/display-group [patch] +func (h *Handler) SetModelsDisplayGroup(c *gin.Context) { + var request SetModelsDisplayGroupRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.InvalidRequestBody(c, err) + return + } + if err := h.service.SetModelsDisplayGroup(c.Request.Context(), request.ModelIDs, *request.DisplayGroupID); err != nil { + writeModelDisplayGroupError(c, err, "set model display group failed") + return + } + response.Success(c, nil) +} + +// DeleteModelDisplayGroup godoc +// @Summary 管理员删除模型展示分组 +// @Description 删除展示分组后,关联模型恢复按技术厂商展示 +// @Tags llm +// @Produce json +// @Security BearerAuth +// @Param id path int true "展示分组 ID" +// @Success 200 {object} response.SuccessDoc +// @Failure 400 {object} ErrorDoc +// @Failure 404 {object} ErrorDoc +// @Router /admin/llm/model-display-groups/{id} [delete] +func (h *Handler) DeleteModelDisplayGroup(c *gin.Context) { + groupID, err := uintParam(c, "id") + if err != nil { + response.Error(c, http.StatusBadRequest, "invalid model display group id") + return + } + if err = h.service.DeleteModelDisplayGroup(c.Request.Context(), groupID); err != nil { + writeModelDisplayGroupError(c, err, "delete model display group failed") + return + } + response.Success(c, nil) +} + +func writeModelVendorError(c *gin.Context, err error, fallback string) { + switch { + case errors.Is(err, appchannel.ErrInvalidModelVendor): + response.Error(c, http.StatusBadRequest, "invalid model vendor") + case errors.Is(err, appchannel.ErrModelVendorNotFound): + response.Error(c, http.StatusNotFound, "model vendor not found") + case errors.Is(err, appchannel.ErrModelVendorConflict): + response.Error(c, http.StatusConflict, "model vendor already exists") + default: + response.Error(c, http.StatusInternalServerError, fallback) + } +} + +func writeModelDisplayGroupError(c *gin.Context, err error, fallback string) { + switch { + case errors.Is(err, appchannel.ErrInvalidModelDisplayGroup): + response.Error(c, http.StatusBadRequest, "invalid model display group") + case errors.Is(err, appchannel.ErrModelDisplayGroupNotFound): + response.Error(c, http.StatusNotFound, "model display group not found") + case errors.Is(err, appchannel.ErrModelDisplayGroupConflict): + response.Error(c, http.StatusConflict, "model display group already exists") + default: + response.Error(c, http.StatusInternalServerError, fallback) + } +} diff --git a/backend/internal/transport/http/channel/router.go b/backend/internal/transport/http/channel/router.go index 4c51c424f..dec84c649 100644 --- a/backend/internal/transport/http/channel/router.go +++ b/backend/internal/transport/http/channel/router.go @@ -39,6 +39,7 @@ func (m *Module) RegisterAdminRoutes(adminGroup *gin.RouterGroup) { adminGroup.POST("/llm/models", m.Handler.CreateModel) adminGroup.POST("/llm/models/order", m.Handler.ReorderModels) adminGroup.POST("/llm/models/batch-delete", m.Handler.BatchDeleteModels) + adminGroup.PATCH("/llm/models/display-group", m.Handler.SetModelsDisplayGroup) adminGroup.PATCH("/llm/models/:id", m.Handler.UpdateModel) adminGroup.DELETE("/llm/models/:id", m.Handler.DeleteModel) adminGroup.POST("/llm/models/:id/test", m.Handler.TestModel) @@ -47,6 +48,15 @@ func (m *Module) RegisterAdminRoutes(adminGroup *gin.RouterGroup) { adminGroup.POST("/llm/models/:id/sources", m.Handler.BindModelUpstreamSource) adminGroup.PATCH("/llm/models/:id/sources/:route_id", m.Handler.UpdateModelUpstreamSource) + // 技术厂商与自定义展示分组 + adminGroup.GET("/llm/model-vendors", m.Handler.ListModelVendors) + adminGroup.POST("/llm/model-vendors", m.Handler.CreateModelVendor) + adminGroup.PATCH("/llm/model-vendors/:key", m.Handler.UpdateModelVendor) + adminGroup.GET("/llm/model-display-groups", m.Handler.ListModelDisplayGroups) + adminGroup.POST("/llm/model-display-groups", m.Handler.CreateModelDisplayGroup) + adminGroup.PATCH("/llm/model-display-groups/:id", m.Handler.UpdateModelDisplayGroup) + adminGroup.DELETE("/llm/model-display-groups/:id", m.Handler.DeleteModelDisplayGroup) + // 全局设置 adminGroup.GET("/llm/settings", m.Handler.ListLLMSettings) adminGroup.PATCH("/llm/settings/:key", m.Handler.UpdateLLMSetting) diff --git a/frontend/components/ui/alert-dialog.tsx b/frontend/components/ui/alert-dialog.tsx index ab7901b06..a7b8121fd 100644 --- a/frontend/components/ui/alert-dialog.tsx +++ b/frontend/components/ui/alert-dialog.tsx @@ -89,7 +89,7 @@ function AlertDialogContent({ size = "default", ...props }: React.ComponentProps & { - size?: "default" | "sm" + size?: "default" | "compact" | "sm" }) { return ( @@ -98,7 +98,7 @@ function AlertDialogContent({ data-slot="alert-dialog-content" data-size={size} className={cn( - "group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100svh-2rem)] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-lg border border-border/60 bg-background/96 p-5 shadow-xl outline-none backdrop-blur duration-200 data-[size=sm]:sm:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-[560px]", + "group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100svh-2rem)] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-lg border border-border/60 bg-background/96 p-5 shadow-xl outline-none backdrop-blur duration-200 data-[size=compact]:sm:max-w-[440px] data-[size=sm]:sm:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-[560px]", className )} {...props} diff --git a/frontend/features/admin/api/llm.ts b/frontend/features/admin/api/llm.ts index 4972ca87e..c44b7fd61 100644 --- a/frontend/features/admin/api/llm.ts +++ b/frontend/features/admin/api/llm.ts @@ -7,6 +7,10 @@ import type { AdminLLMSetting, AdminLLMModelData, AdminLLMModelDTO, + AdminLLMModelDisplayGroupData, + AdminLLMModelDisplayGroupDTO, + AdminLLMModelVendorData, + AdminLLMModelVendorDTO, AdminLLMModelProbeBatchData, AdminLLMModelProbeData, AdminLLMModelUpstreamSourceData, @@ -17,13 +21,18 @@ import type { AdminLLMUpstreamView, BindAdminLLMModelUpstreamSourceRequest, CreateAdminLLMModelRequest, + CreateAdminLLMModelDisplayGroupRequest, + CreateAdminLLMModelVendorRequest, CreateAdminLLMUpstreamRequest, ImportAdminLLMUpstreamModelsData, ImportAdminLLMUpstreamModelsRequest, ListAdminLLMRemoteModelsData, ReorderAdminLLMModelsRequest, + SetAdminLLMModelsDisplayGroupRequest, ResetAdminLLMCircuitData, UpdateAdminLLMModelRequest, + UpdateAdminLLMModelDisplayGroupRequest, + UpdateAdminLLMModelVendorRequest, UpdateAdminLLMModelUpstreamSourceRequest, UpdateAdminLLMUpstreamRequest, UpsertAdminLLMUpstreamModelRequest, @@ -376,6 +385,108 @@ export async function reorderAdminLLMModels( ); } +export async function setAdminLLMModelsDisplayGroup( + accessToken: string, + payload: SetAdminLLMModelsDisplayGroupRequest, +): Promise { + return authedRequest( + "/api/v1/admin/llm/models/display-group", + { method: "PATCH", accessToken, body: payload }, + true, + ); +} + +export async function listAdminLLMModelVendors( + accessToken: string, + options: AdminListQueryOptions = {}, +): Promise> { + const { page, pageSize } = resolveAdminPage(options); + const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) }); + if (options.query?.trim()) { + params.set("q", options.query.trim()); + } + const data = await authedRequest>( + `/api/v1/admin/llm/model-vendors?${params.toString()}`, + { accessToken }, + true, + ); + return normalizeAdminPagePayload(data); +} + +export async function createAdminLLMModelVendor( + accessToken: string, + payload: CreateAdminLLMModelVendorRequest, +): Promise { + return authedRequest( + "/api/v1/admin/llm/model-vendors", + { method: "POST", accessToken, body: payload }, + true, + ); +} + +export async function updateAdminLLMModelVendor( + accessToken: string, + vendorKey: string, + payload: UpdateAdminLLMModelVendorRequest, +): Promise { + return authedRequest( + `/api/v1/admin/llm/model-vendors/${pathParam(vendorKey)}`, + { method: "PATCH", accessToken, body: payload }, + true, + ); +} + +export async function listAdminLLMModelDisplayGroups( + accessToken: string, + options: AdminListQueryOptions = {}, +): Promise> { + const { page, pageSize } = resolveAdminPage(options); + const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) }); + if (options.query?.trim()) { + params.set("q", options.query.trim()); + } + const data = await authedRequest>( + `/api/v1/admin/llm/model-display-groups?${params.toString()}`, + { accessToken }, + true, + ); + return normalizeAdminPagePayload(data); +} + +export async function createAdminLLMModelDisplayGroup( + accessToken: string, + payload: CreateAdminLLMModelDisplayGroupRequest, +): Promise { + return authedRequest( + "/api/v1/admin/llm/model-display-groups", + { method: "POST", accessToken, body: payload }, + true, + ); +} + +export async function updateAdminLLMModelDisplayGroup( + accessToken: string, + groupID: number, + payload: UpdateAdminLLMModelDisplayGroupRequest, +): Promise { + return authedRequest( + `/api/v1/admin/llm/model-display-groups/${groupID}`, + { method: "PATCH", accessToken, body: payload }, + true, + ); +} + +export async function deleteAdminLLMModelDisplayGroup( + accessToken: string, + groupID: number, +): Promise { + return authedRequest( + `/api/v1/admin/llm/model-display-groups/${groupID}`, + { method: "DELETE", accessToken }, + true, + ); +} + export async function deleteAdminLLMModel( accessToken: string, modelID: number, diff --git a/frontend/features/admin/api/llm.types.ts b/frontend/features/admin/api/llm.types.ts index 5eac77b4b..709e96fd6 100644 --- a/frontend/features/admin/api/llm.types.ts +++ b/frontend/features/admin/api/llm.types.ts @@ -4,11 +4,15 @@ import type { BatchDeleteResultResponse, BindModelUpstreamSourceRequest, CircuitResetResponse, + CreateModelDisplayGroupRequest, CreateModelRequest, + CreateModelVendorRequest, CreateUpstreamRequest, ImportUpstreamModelsRequest, ImportUpstreamModelsResponse, ModelDataResponse, + ModelDisplayGroupDataResponse, + ModelDisplayGroupResponse, ModelProbeBatchResponse, ModelProbeDebugRequestResponse, ModelProbeDebugResponse, @@ -18,7 +22,10 @@ import type { ModelUpstreamSourceDataResponse, ModelUpstreamSourceResponse, ReorderModelsRequest, + SetModelsDisplayGroupRequest, + UpdateModelDisplayGroupRequest, UpdateModelRequest, + UpdateModelVendorRequest, UpdateModelUpstreamSourceRequest, UpdateUpstreamRequest, UpsertUpstreamModelRequest, @@ -29,8 +36,9 @@ import type { UpstreamRemoteModelResponse, UpstreamRemoteModelsResponse, UpstreamResponse, + ModelVendorDataResponse, + ModelVendorResponse, } from "@deeix/api-contract"; -import type { PagePayload } from "@/shared/api/common.types"; export type AdminLLMStatus = "active" | "inactive"; export type AdminLLMModelAccessScope = "public" | "internal"; @@ -167,6 +175,9 @@ export type AdminLLMSetting = { updatedAt: string; }; +export type AdminLLMModelVendorDTO = ModelVendorResponse; +export type AdminLLMModelDisplayGroupDTO = ModelDisplayGroupResponse; + // --------------------------------------------------------------------------- // Request types // --------------------------------------------------------------------------- @@ -210,6 +221,11 @@ export type UpdateAdminLLMModelRequest = Omit< }; export type ReorderAdminLLMModelsRequest = ReorderModelsRequest; +export type CreateAdminLLMModelVendorRequest = CreateModelVendorRequest; +export type UpdateAdminLLMModelVendorRequest = UpdateModelVendorRequest; +export type CreateAdminLLMModelDisplayGroupRequest = CreateModelDisplayGroupRequest; +export type UpdateAdminLLMModelDisplayGroupRequest = UpdateModelDisplayGroupRequest; +export type SetAdminLLMModelsDisplayGroupRequest = SetModelsDisplayGroupRequest; export type UpsertAdminLLMUpstreamModelRequest = Omit & { protocol?: AdminLLMAdapter; @@ -258,6 +274,9 @@ export type AdminLLMModelData = Omit & { model: AdminLLMModelDTO; }; +export type AdminLLMModelVendorData = ModelVendorDataResponse; +export type AdminLLMModelDisplayGroupData = ModelDisplayGroupDataResponse; + export type AdminLLMUpstreamModelData = Omit & { binding: AdminLLMUpstreamModelDTO; }; @@ -293,12 +312,3 @@ export type AdminBatchDeleteResult = Omit & { results: AdminBatchDeleteResult[]; }; - -// --------------------------------------------------------------------------- -// Page results -// --------------------------------------------------------------------------- - -export type ListAdminLLMUpstreamsResult = PagePayload; -export type ListAdminLLMModelsResult = PagePayload; -export type ListAdminLLMUpstreamModelsResult = PagePayload; -export type ListAdminLLMModelUpstreamSourcesResult = PagePayload; diff --git a/frontend/features/admin/components/bulk-confirm-dialog.tsx b/frontend/features/admin/components/bulk-confirm-dialog.tsx index c61a3a6fc..3ce0e778b 100644 --- a/frontend/features/admin/components/bulk-confirm-dialog.tsx +++ b/frontend/features/admin/components/bulk-confirm-dialog.tsx @@ -23,6 +23,7 @@ export function AdminBulkConfirmDialog({ confirmLabel, pendingLabel, onConfirm, + size = "default", }: { open: boolean; onOpenChange: (open: boolean) => void; @@ -32,12 +33,13 @@ export function AdminBulkConfirmDialog({ confirmLabel: string; pendingLabel: string; onConfirm: () => void; + size?: "default" | "compact" | "sm"; }) { const t = useTranslations("common.actions"); return ( - + {title} {description} diff --git a/frontend/features/admin/components/sections/billing/billing-prices.tsx b/frontend/features/admin/components/sections/billing/billing-prices.tsx index a36a9beee..632ad3058 100644 --- a/frontend/features/admin/components/sections/billing/billing-prices.tsx +++ b/frontend/features/admin/components/sections/billing/billing-prices.tsx @@ -46,11 +46,11 @@ import { type OfficialPricingCatalogItem, } from "@/features/admin/model/official-pricing"; import { resolveAdminErrorMessage } from "@/features/admin/utils/admin-error"; -import { LobeHubIcon } from "@/shared/components/lobehub-icon"; +import { ModelIcon } from "@/shared/components/model-icon"; import { resolveAccessToken } from "@/shared/auth/resolve-access-token"; import { useDialogSnapshot } from "@/shared/hooks/use-dialog-snapshot"; import { cn } from "@/lib/utils"; -import { KNOWN_VENDOR_OPTIONS, resolveLobeHubIconURL, resolveModelIdentity } from "@/shared/lib/model-identity"; +import { resolveModelIconURL, resolveModelIdentity } from "@/shared/lib/model-identity"; type BillingPricesSectionProps = { models: AdminLLMModelDTO[]; @@ -156,7 +156,9 @@ export function BillingPricesSection({ models, pricingItems, setPricingItems, lo const rows = React.useMemo(() => buildPricingRows(models, pricingItems), [models, pricingItems]); const vendorFilterOptions = React.useMemo(() => { - const options = new Map(KNOWN_VENDOR_OPTIONS.map((item) => [item.value, item.label])); + const options = new Map( + models.map((model) => [model.vendor, model.vendorName.trim() || model.vendor] as const), + ); for (const row of rows) { const value = row.vendor.trim(); if (!value || options.has(value)) { @@ -170,7 +172,7 @@ export function BillingPricesSection({ models, pricingItems, setPricingItems, lo options.set(value, identity.vendorLabel); } return Array.from(options.entries()).map(([value, label]) => ({ value, label })); - }, [rows]); + }, [models, rows]); const filteredRows = React.useMemo(() => { const keyword = query.trim().toLowerCase(); return rows.filter((row) => { @@ -619,13 +621,13 @@ export function BillingPricesSection({ models, pricingItems, setPricingItems, lo vendor: row.vendor, icon: row.icon, }); - const iconURL = resolveLobeHubIconURL(identity.modelIcon); + const iconURL = resolveModelIconURL(identity.modelIcon); return (
- +
{row.platformModelName} @@ -784,7 +786,7 @@ export function BillingPricesSection({ models, pricingItems, setPricingItems, lo {editOfficialPricingSuggestions.map((suggestion) => { const { vendor, modelID } = splitOfficialPricingID(suggestion.item.id); const identity = resolveModelIdentity({ code: suggestion.item.id, vendor }); - const iconURL = resolveLobeHubIconURL(identity.modelIcon || identity.vendorIcon); + const iconURL = resolveModelIconURL(identity.modelIcon || identity.vendorIcon); const displayName = officialPricingDisplayName(suggestion.item); const fullName = suggestion.item.name || suggestion.item.id; @@ -807,7 +809,7 @@ export function BillingPricesSection({ models, pricingItems, setPricingItems, lo
- modelPresentation.vendors.map((vendor) => ({ value: vendor.key, label: vendor.name })), + [modelPresentation.vendors], + ); + const userItems = React.useMemo( () => userRows.map((user) => ({ @@ -954,7 +960,7 @@ function GroupEditSheet({ onValueChange: handleModelVendorFilterChange, options: [ { label: t("allVendors"), value: "" }, - ...KNOWN_VENDOR_OPTIONS.map(({ label, value }) => ({ label, value })), + ...modelVendorOptions, ], }, { @@ -976,6 +982,7 @@ function GroupEditSheet({ modelUpstreamFilter, modelUpstreamOptions, modelVendorFilter, + modelVendorOptions, t, ], ); @@ -1128,6 +1135,7 @@ function GroupEditSheet({ rules={modelRules} onRulesChange={setModelRules} upstreamOptions={modelUpstreamOptions} + vendorOptions={modelVendorOptions} disabled={selectionLoading || modelLoading || modelBulkLoading} /> } diff --git a/frontend/features/admin/components/sections/groups/model-access-rules-panel.tsx b/frontend/features/admin/components/sections/groups/model-access-rules-panel.tsx index 7b37dd485..abd855cf8 100644 --- a/frontend/features/admin/components/sections/groups/model-access-rules-panel.tsx +++ b/frontend/features/admin/components/sections/groups/model-access-rules-panel.tsx @@ -15,7 +15,6 @@ import { } from "@/components/ui/select"; import type { AdminLLMUpstreamView } from "@/features/admin/api/llm.types"; import { ADAPTER_LABELS } from "@/features/admin/types/llm"; -import { KNOWN_VENDOR_OPTIONS } from "@/shared/lib/model-identity"; import type { PermissionGroupModelRule, PermissionGroupModelRuleType, @@ -25,6 +24,7 @@ type ModelAccessRulesPanelProps = { rules: PermissionGroupModelRule[]; onRulesChange: React.Dispatch>; upstreamOptions: AdminLLMUpstreamView[]; + vendorOptions: Array<{ label: string; value: string }>; disabled: boolean; }; @@ -32,16 +32,13 @@ export function ModelAccessRulesPanel({ rules, onRulesChange, upstreamOptions, + vendorOptions, disabled, }: ModelAccessRulesPanelProps) { const t = useTranslations("adminGroups"); const [draftType, setDraftType] = React.useState("all"); const [draftValue, setDraftValue] = React.useState(""); - const vendorOptions = React.useMemo( - () => KNOWN_VENDOR_OPTIONS.map(({ label, value }) => ({ label, value })), - [], - ); const protocolOptions = React.useMemo( () => Object.entries(ADAPTER_LABELS).map(([value, label]) => ({ label, value })), [], diff --git a/frontend/features/admin/components/sections/models/admin-models.tsx b/frontend/features/admin/components/sections/models/admin-models.tsx index 991d1a4b5..d0a0b8f48 100644 --- a/frontend/features/admin/components/sections/models/admin-models.tsx +++ b/frontend/features/admin/components/sections/models/admin-models.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import dynamic from "next/dynamic"; -import { Building2, Cable, Check, ChevronDownIcon, ListOrdered, Plus, Tags, ToggleLeft, Trash2 } from "lucide-react"; +import { Building2, Cable, Check, ChevronDownIcon, Layers3, ListOrdered, Plus, Tags, ToggleLeft, Trash2 } from "lucide-react"; import { useTranslations } from "next-intl"; import { toast } from "sonner"; @@ -28,6 +28,7 @@ import { testAdminLLMUpstreamModelRoute, } from "@/features/admin/api"; import { useAdminModels } from "@/features/admin/hooks/use-admin-models"; +import { useAdminModelPresentation } from "@/features/admin/hooks/use-admin-model-presentation"; import { BulkDeleteModelsDialog, DeleteModelDialog } from "./models-dialog"; import { ModelProbeDialog } from "./models-probe-dialog"; import { ModelsTable } from "./models-table"; @@ -45,7 +46,6 @@ import type { AdminLLMModelUpstreamSourceDTO, AdminLLMStatus, } from "@/features/admin/api/llm.types"; -import { KNOWN_VENDOR_OPTIONS } from "@/shared/lib/model-identity"; import { cn } from "@/lib/utils"; import { resolveAccessToken } from "@/shared/auth/resolve-access-token"; @@ -67,7 +67,12 @@ const ModelOrderSheet = dynamic( }, ); -type ModelBulkAction = "kinds" | "protocol" | "vendor" | "status"; +const ModelPresentationDialog = dynamic( + () => import("./models-presentation-dialog").then((module) => module.ModelPresentationDialog), + { ssr: false }, +); + +type ModelBulkAction = "kinds" | "protocol" | "vendor" | "displayGroup" | "status"; function BulkActionControlRow({ icon, @@ -173,8 +178,10 @@ function KindsDropdown({ export function AdminModelsPage() { const t = useTranslations("adminModels"); const models = useAdminModels(); + const presentation = useAdminModelPresentation(); const [createOpen, setCreateOpen] = React.useState(false); const [orderOpen, setOrderOpen] = React.useState(false); + const [presentationOpen, setPresentationOpen] = React.useState(false); const [bulkConfirmAction, setBulkConfirmAction] = React.useState(null); const [probeOpen, setProbeOpen] = React.useState(false); const [probeLoading, setProbeLoading] = React.useState(false); @@ -194,6 +201,9 @@ export function AdminModelsPage() { case "vendor": void models.handleBulkApplyVendor().then(() => setBulkConfirmAction(null)); break; + case "displayGroup": + void models.handleBulkApplyDisplayGroup().then(() => setBulkConfirmAction(null)); + break; case "status": void models.handleBulkApplyStatus().then(() => setBulkConfirmAction(null)); break; @@ -295,7 +305,7 @@ export function AdminModelsPage() { onValueChange: models.setVendorFilter, options: [ { label: t("table.allVendors"), value: "" }, - ...KNOWN_VENDOR_OPTIONS.map(({ label, value }) => ({ label, value })), + ...presentation.vendors.map((item) => ({ label: item.name, value: item.key })), ], }, ]} @@ -323,6 +333,33 @@ export function AdminModelsPage() { /> + } + label={t("actions.apply")} + onApply={() => setBulkConfirmAction("displayGroup")} + disabled={models.loading || models.batchApplying || models.selectedModels.length === 0 || !models.batchDisplayGroupID} + > + + + } label={t("actions.apply")} @@ -362,9 +399,9 @@ export function AdminModelsPage() { - {KNOWN_VENDOR_OPTIONS.map(({ label, value }) => ( - - {label} + {presentation.vendors.map((item) => ( + + {item.name} ))} @@ -407,6 +444,17 @@ export function AdminModelsPage() { loading={models.loading} onRefresh={() => void models.loadModels(models.page, models.pageSize)} > + @@ -333,7 +334,7 @@ export function ModelOrderSheet({ vendor: model.vendor, icon: model.icon, }); - const iconURL = resolveLobeHubIconURL(identity.modelIcon); + const iconURL = resolveModelIconURL(identity.modelIcon); return ( - + {model.platformModelName} diff --git a/frontend/features/admin/components/sections/models/models-presentation-dialog.tsx b/frontend/features/admin/components/sections/models/models-presentation-dialog.tsx new file mode 100644 index 000000000..343e3942f --- /dev/null +++ b/frontend/features/admin/components/sections/models/models-presentation-dialog.tsx @@ -0,0 +1,473 @@ +"use client"; + +import { CircleHelp, Pencil, Plus, Search, Trash2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import * as React from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "@/components/ui/input-group"; +import { Label } from "@/components/ui/label"; +import { Spinner } from "@/components/ui/spinner"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useVirtualTableRows } from "@/components/ui/virtual-table"; +import { cn } from "@/lib/utils"; +import type { + AdminLLMModelDisplayGroupDTO, + AdminLLMModelVendorDTO, +} from "@/features/admin/api/llm.types"; +import { AdminBulkConfirmDialog } from "@/features/admin/components/bulk-confirm-dialog"; +import { + type PresentationTab, + useModelPresentationEditor, +} from "@/features/admin/hooks/use-model-presentation-editor"; +import { ModelIcon } from "@/shared/components/model-icon"; +import { resolveModelIconURL } from "@/shared/lib/model-identity"; + +function InputHelp({ help }: { help: string }) { + return ( + + + + + + + {help} + + ); +} + +function PresentationIcon({ icon, label }: { icon: string; label: string }) { + return ( + + + + ); +} + +function DialogLayerTransition({ + editorOpen, + editorLayer, + listLayer, +}: { + editorOpen: boolean; + editorLayer: React.ReactNode; + listLayer: React.ReactNode; +}) { + const editorRef = React.useRef(null); + const listRef = React.useRef(null); + const [height, setHeight] = React.useState(null); + + const measureActiveLayer = React.useCallback(() => { + const activeLayer = editorOpen ? editorRef.current : listRef.current; + if (!activeLayer) { + return; + } + // Dialog 首次打开带缩放动画;offsetHeight 使用布局高度,避免把 zoom-in-95 + // 的视觉缩放误当成真实高度,导致初次打开时底部操作栏被裁掉。 + const nextHeight = activeLayer.offsetHeight; + setHeight((current) => current === nextHeight ? current : nextHeight); + }, [editorOpen]); + + // 两层保持挂载并分别测量,外框才能在列表层与编辑层之间执行真实的高度插值。 + React.useLayoutEffect(() => { + measureActiveLayer(); + if (typeof ResizeObserver === "undefined") { + return; + } + const observer = new ResizeObserver(measureActiveLayer); + if (editorRef.current) { + observer.observe(editorRef.current); + } + if (listRef.current) { + observer.observe(listRef.current); + } + return () => observer.disconnect(); + }, [measureActiveLayer]); + + return ( +
+
+ {editorLayer} +
+
+ {listLayer} +
+
+ ); +} + +export function ModelPresentationDialog({ + open, + vendors, + displayGroups, + onClose, + onChanged, +}: { + open: boolean; + vendors: AdminLLMModelVendorDTO[]; + displayGroups: AdminLLMModelDisplayGroupDTO[]; + onClose: () => void; + onChanged: () => Promise; +}) { + const t = useTranslations("adminModels.presentation"); + const commonT = useTranslations("common.actions"); + const [tab, setTab] = React.useState("vendors"); + const { + editor, + setEditor, + stableEditor, + pending, + deleteTarget, + setDeleteTarget, + catalogModels, + modelsLoading, + modelQuery, + setModelQuery, + loadCatalogModels, + closeDialog, + openCreate, + openVendorEdit, + openGroupEdit, + toggleEditorModel, + saveEditor, + confirmDelete, + } = useModelPresentationEditor({ onChanged, onClose }); + const keyInputID = React.useId(); + const nameInputID = React.useId(); + const iconInputID = React.useId(); + const modelQueryInputID = React.useId(); + + const items = tab === "vendors" ? vendors : displayGroups; + const editorOpen = editor !== null; + const editorTitle = stableEditor?.kind === "vendors" + ? stableEditor.creating + ? t("createVendorTitle") + : t("editVendorTitle") + : stableEditor?.creating + ? t("createGroupTitle") + : t("editGroupTitle"); + const editorDescription = stableEditor?.kind === "vendors" + ? t("vendorFormDescription") + : t("groupFormDescription"); + const normalizedModelQuery = modelQuery.trim().toLowerCase(); + const filteredCatalogModels = catalogModels?.filter((model) => { + if (!normalizedModelQuery) { + return true; + } + return [model.platformModelName, model.vendorName, model.displayGroupName] + .some((value) => value?.toLowerCase().includes(normalizedModelQuery)); + }) ?? []; + const selectedModelIDs = React.useMemo( + () => new Set(stableEditor?.modelIDs ?? []), + [stableEditor?.modelIDs], + ); + const memberRows = useVirtualTableRows(filteredCatalogModels, { + estimateSize: 48, + maxHeight: 224, + }); + + return ( + <> + !nextOpen && closeDialog()}> + + + + {editorTitle} + {editorDescription} + + +
{ + event.preventDefault(); + void saveEditor(); + }} + > +
+
+ {stableEditor.kind === "vendors" ? ( +
+ + + setEditor((current) => current ? { ...current, key: event.target.value } : current)} + /> + + + + +
+ ) : null} + +
+ + setEditor((current) => current ? { ...current, name: event.target.value } : current)} + /> +
+ +
+ + + setEditor((current) => current ? { ...current, icon: event.target.value } : current)} + /> + + + + +
+
+ + {stableEditor.kind === "groups" ? ( +
+
+ + + {t("membersSelected", { count: stableEditor.modelIDs.length })} + +
+
+ + setModelQuery(event.target.value)} + /> +
+
+ {modelsLoading ? ( +
+ +
+ ) : filteredCatalogModels.length === 0 ? ( +
+ {t("memberEmpty")} +
+ ) : ( +
+ {memberRows.rows.map(({ item: model }) => ( + + ))} +
+ )} +
+

{t("membersHelp")}

+
+ ) : null} +
+ + + + + +
+ + ) : null} + listLayer={( + <> + + {t("title")} + {t("description")} + + + { + const nextTab = value as PresentationTab; + setTab(nextTab); + if (nextTab === "groups" && catalogModels === null && !modelsLoading) { + void loadCatalogModels(); + } + }} + className="flex min-h-0 flex-1 flex-col gap-0" + > +
+ + {t("vendors")} + {t("groups")} + + +
+ + + {items.length === 0 ? ( +
+ {t("empty")} +
+ ) : ( +
+ {tab === "vendors" + ? vendors.map((vendor) => ( +
+ +
+
+ {vendor.name} + {vendor.builtIn ? {t("builtIn")} : null} +
+

{vendor.key}

+
+ +
+ )) + : displayGroups.map((group) => ( +
+ + {group.name} + + +
+ ))} +
+ )} +
+
+ + + + + + )} + /> +
+
+ + !nextOpen && !pending && setDeleteTarget(null)} + pending={pending} + title={t("deleteTitle")} + description={t("deleteDescription", { name: deleteTarget?.name ?? "" })} + confirmLabel={commonT("delete")} + pendingLabel={t("deleting")} + onConfirm={() => void confirmDelete()} + size="compact" + /> + + ); +} diff --git a/frontend/features/admin/components/sections/models/models-sheet.tsx b/frontend/features/admin/components/sections/models/models-sheet.tsx index e4191b56b..1084140e0 100644 --- a/frontend/features/admin/components/sections/models/models-sheet.tsx +++ b/frontend/features/admin/components/sections/models/models-sheet.tsx @@ -71,14 +71,16 @@ import { setModelPermissionGroups, type PermissionGroup, } from "@/features/admin/api/permission-groups"; -import { LobeHubIcon } from "@/shared/components/lobehub-icon"; -import { KNOWN_VENDOR_OPTIONS, resolveLobeHubIconURL, resolveModelIdentity, resolveVendorIdentity } from "@/shared/lib/model-identity"; +import { ModelIcon } from "@/shared/components/model-icon"; +import { resolveModelIconURL, resolveModelIdentity } from "@/shared/lib/model-identity"; import type { + AdminLLMModelDisplayGroupDTO, AdminLLMModelDTO, AdminLLMModelAccessScope, AdminLLMModelCbPolicyMode, AdminLLMModelUpstreamSourceDTO, AdminLLMModelVendor, + AdminLLMModelVendorDTO, AdminLLMStatus, AdminLLMUpstreamModelDTO, AdminLLMUpstreamView, @@ -126,6 +128,7 @@ import { PermissionGroupSelector } from "@/features/admin/components/sections/gr type FormState = { platformModelName: string; vendor: AdminLLMModelVendor | ""; + displayGroupID: string; kinds: string[]; icon: string; capabilitiesJSON: string; @@ -146,18 +149,7 @@ type VendorOption = { }; const UNKNOWN_VENDOR = "unknown"; - -const MODEL_SHEET_VENDOR_OPTIONS: VendorOption[] = [ - { value: UNKNOWN_VENDOR, label: "Unknown", iconUrl: null }, - ...KNOWN_VENDOR_OPTIONS.map(({ value, label }) => { - const identity = resolveVendorIdentity(value); - return { - value, - label, - iconUrl: resolveLobeHubIconURL(identity.vendorIcon), - }; - }), -]; +const FOLLOW_VENDOR_GROUP = "vendor"; const IMAGE_MEDIA_PROTOCOLS = new Set([ "openai_image_generations", @@ -189,7 +181,8 @@ function buildInitialState(target: AdminLLMModelDTO | null): FormState { if (!target) { return { platformModelName: "", - vendor: normalizeSupportedVendor(UNKNOWN_VENDOR), + vendor: UNKNOWN_VENDOR, + displayGroupID: FOLLOW_VENDOR_GROUP, kinds: [], icon: "", capabilitiesJSON: "", @@ -207,7 +200,8 @@ function buildInitialState(target: AdminLLMModelDTO | null): FormState { kinds = parseKindsJSON(target.kindsJSON); return { platformModelName: target.platformModelName, - vendor: normalizeSupportedVendor(target.vendor), + vendor: target.vendor, + displayGroupID: target.displayGroupID ? String(target.displayGroupID) : FOLLOW_VENDOR_GROUP, kinds, icon: target.icon ?? "", capabilitiesJSON: normalizeCapabilitiesText(target.capabilitiesJSON), @@ -226,17 +220,6 @@ function normalizeVendorValue(value: string): string { return value.trim().toLowerCase(); } -function normalizeSupportedVendor(value: string | null | undefined): AdminLLMModelVendor { - const normalized = normalizeVendorValue(value ?? ""); - if (MODEL_SHEET_VENDOR_OPTIONS.some((item) => item.value === normalized)) { - return normalized; - } - const identity = resolveVendorIdentity(normalized); - return MODEL_SHEET_VENDOR_OPTIONS.some((item) => item.value === identity.vendorKey) - ? identity.vendorKey - : UNKNOWN_VENDOR; -} - function normalizeCapabilitiesText(value: string | null | undefined): string { const trimmed = value?.trim() ?? ""; return trimmed === "{}" ? "" : trimmed; @@ -254,7 +237,7 @@ function VendorOptionIcon({ return ( {iconUrl ? ( - + ) : unknown ? ( ) : ( @@ -274,6 +257,8 @@ type ModelSheetProps = { mode: "create" | "edit"; target: AdminLLMModelDTO | null; models: AdminLLMModelDTO[]; + vendors: AdminLLMModelVendorDTO[]; + displayGroups: AdminLLMModelDisplayGroupDTO[]; onClose: () => void; onSuccess: () => void; }; @@ -282,7 +267,7 @@ type ModelSheetProps = { // Component // --------------------------------------------------------------------------- -export function ModelSheet({ open, mode, target, models, onClose, onSuccess }: ModelSheetProps) { +export function ModelSheet({ open, mode, target, models, vendors, displayGroups, onClose, onSuccess }: ModelSheetProps) { const t = useTranslations("adminModels"); const commonT = useTranslations("common"); const locale = useLocale(); @@ -450,9 +435,10 @@ export function ModelSheet({ open, mode, target, models, onClose, onSuccess }: M : kind, ) .join(", "); - const vendorOptions = MODEL_SHEET_VENDOR_OPTIONS.map((item) => ({ - ...item, - label: item.value === UNKNOWN_VENDOR ? t("sheet.unknownVendor") : item.label, + const vendorOptions = vendors.map((item) => ({ + value: item.key, + label: item.name, + iconUrl: resolveModelIconURL(item.icon), })); const routeProtocols = useMemo( () => Array.from(new Set([ @@ -691,6 +677,7 @@ export function ModelSheet({ open, mode, target, models, onClose, onSuccess }: M const data = await createAdminLLMModel(token, { platformModelName: form.platformModelName.trim(), vendor: form.vendor || undefined, + displayGroupID: form.displayGroupID === FOLLOW_VENDOR_GROUP ? undefined : Number(form.displayGroupID), kindsJSON: kindsJson, icon: form.icon.trim() || undefined, capabilitiesJSON: normalizeModelCapabilitiesJSON(form.capabilitiesJSON, nativeTools, routeProtocols) || undefined, @@ -739,6 +726,7 @@ export function ModelSheet({ open, mode, target, models, onClose, onSuccess }: M const payload: UpdateAdminLLMModelRequest = { platformModelName: form.platformModelName.trim() || undefined, vendor: form.vendor || undefined, + displayGroupID: form.displayGroupID === FOLLOW_VENDOR_GROUP ? 0 : Number(form.displayGroupID), kindsJSON: kindsJson, icon: form.icon.trim(), capabilitiesJSON: normalizeModelCapabilitiesJSON(form.capabilitiesJSON, nativeTools, routeProtocols), @@ -774,7 +762,7 @@ export function ModelSheet({ open, mode, target, models, onClose, onSuccess }: M vendor: form.vendor, icon: form.icon, }); - const iconPreviewUrl = resolveLobeHubIconURL(form.icon || resolvedIdentity.modelIcon); + const iconPreviewUrl = resolveModelIconURL(form.icon || resolvedIdentity.modelIcon); const selectedVendorOption = vendorOptions.find((item) => normalizeVendorValue(item.value) === normalizeVendorValue(form.vendor)) ?? vendorOptions[0]; @@ -863,6 +851,32 @@ export function ModelSheet({ open, mode, target, models, onClose, onSuccess }: M
+
+ + +

{t("sheet.displayGroupDescription")}

+
+