From 4ee5b630edfbd6da1e41b98ef1d2f3b1184c345a Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Thu, 27 Aug 2026 23:43:54 +0800 Subject: [PATCH] =?UTF-8?q?fix(*):=20=E6=A8=A1=E5=9E=8B=E9=80=89=E6=8B=A9?= =?UTF-8?q?=20-=20=E4=BF=AE=E5=A4=8D=E7=B3=BB=E7=BB=9F=E6=B8=A0=E9=81=93?= =?UTF-8?q?=20Logo=20=E6=90=9C=E7=B4=A2=E4=B8=8E=E5=89=8D=E5=8F=B0?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=A8=A1=E5=BC=8F=E8=AF=AF=E5=88=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/task_creation.go | 63 ++++++++++++------- .../internal/service/task_creation_test.go | 57 ++++++++++++++++- web/src/components/model-logo.tsx | 4 +- web/test/model-logo.test.ts | 19 ++++++ 4 files changed, 117 insertions(+), 26 deletions(-) create mode 100644 web/test/model-logo.test.ts diff --git a/backend/internal/service/task_creation.go b/backend/internal/service/task_creation.go index 1a95f7d23..61273ad74 100644 --- a/backend/internal/service/task_creation.go +++ b/backend/internal/service/task_creation.go @@ -44,29 +44,9 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task, } if !workflowProviderTask { - if frontendEnabled { - // 前台模型模式:必须有 logicalModelId - if logicalModelID == "" { - return nil, InvalidModelSelection("前台模型模式下必须指定 logicalModelId") - } - intent := ModelRequestIntentFromTaskInput(normalizedInput, taskType, req.Operation) - routed, err = s.ResolveLogicalModel(logicalModelID, intent) - if err != nil { - return nil, err - } - normalizedInput = applyRoutedProviderSelection(normalizedInput, routed) - } else { - // 系统渠道模型模式:禁止 logicalModelId - if logicalModelID != "" { - return nil, ModelCatalogMismatch("模型目录已更新,请重新选择") - } - // 自定义渠道没有系统 channelId;它会在后续由自定义渠道功能开关、 - // 能力校验和 provider 配置校验共同处理,不能误报为“缺少系统渠道”。 - if !taskInputUsesCustomChannel(normalizedInput) { - if err := s.validateSystemChannelModelSelection(normalizedInput); err != nil { - return nil, err - } - } + routed, normalizedInput, err = s.resolveTaskModelSelection(normalizedInput, logicalModelID, taskType, req.Operation, frontendEnabled) + if err != nil { + return nil, err } } @@ -146,6 +126,34 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task, return taskForOutput(task), nil } +// resolveTaskModelSelection 根据请求实际携带的模型选择决定路由方式。 +// 显式系统渠道请求不能被全局前台模型开关误判,但仍必须通过渠道、模型和价格校验。 +func (s *Service) resolveTaskModelSelection(input map[string]any, logicalModelID string, taskType string, operation string, frontendEnabled bool) (*RoutedModel, map[string]any, error) { + if frontendEnabled && !taskInputUsesSystemChannel(input) { + if logicalModelID == "" { + return nil, input, InvalidModelSelection("前台模型模式下必须指定 logicalModelId") + } + intent := ModelRequestIntentFromTaskInput(input, taskType, operation) + routed, err := s.ResolveLogicalModel(logicalModelID, intent) + if err != nil { + return nil, input, err + } + return routed, applyRoutedProviderSelection(input, routed), nil + } + + if logicalModelID != "" { + return nil, input, ModelCatalogMismatch("模型目录已更新,请重新选择") + } + // 自定义渠道没有系统 channelId;它会在后续由自定义渠道功能开关、 + // 能力校验和 provider 配置校验共同处理,不能误报为“缺少系统渠道”。 + if !taskInputUsesCustomChannel(input) { + if err := s.validateSystemChannelModelSelection(input); err != nil { + return nil, input, err + } + } + return nil, input, nil +} + func applyRoutedProviderSelection(input map[string]any, routed *RoutedModel) map[string]any { config, _ := input["config"].(map[string]any) nextConfig := make(map[string]any, len(config)+2) @@ -331,6 +339,15 @@ func taskInputUsesCustomChannel(input map[string]any) bool { return strings.TrimSpace(baseURL) != "" && strings.TrimSpace(apiKey) != "" } +func taskInputUsesSystemChannel(input map[string]any) bool { + config, ok := input["config"].(map[string]any) + if !ok { + return false + } + channelID, _ := config["channelId"].(string) + return strings.TrimSpace(channelID) != "" +} + func taskInputUsesWorkflowProvider(input map[string]any) bool { config, ok := input["config"].(map[string]any) if !ok { diff --git a/backend/internal/service/task_creation_test.go b/backend/internal/service/task_creation_test.go index acd426c7d..1fdead543 100644 --- a/backend/internal/service/task_creation_test.go +++ b/backend/internal/service/task_creation_test.go @@ -1,6 +1,15 @@ package service -import "testing" +import ( + "strings" + "testing" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) func TestTaskInputUsesWorkflowProvider(t *testing.T) { tests := []struct { @@ -26,3 +35,49 @@ func TestTaskInputUsesWorkflowProvider(t *testing.T) { }) } } + +func TestResolveTaskModelSelectionAllowsExplicitSystemChannelWhenFrontendModelsEnabled(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.ModelChannel{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}); err != nil { + t.Fatal(err) + } + channel := model.ModelChannel{ID: "channel-1", Scope: model.ChannelScopeSystem, Enabled: true, Name: "Agnes"} + channelModel := model.ChannelModel{ + ID: "channel-model-1", ChannelID: channel.ID, ModelKey: "agnes-video-2.5", Capability: "video", + Protocol: model.ChannelInterfaceNewAPIVideo, BillingMode: "per_second", PriceConfigured: true, Enabled: true, + } + if err := db.Create(&channel).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&channelModel).Error; err != nil { + t.Fatal(err) + } + input, err := normalizeTaskInput(map[string]any{ + "mode": "video", + "config": providerConfig{ChannelID: channel.ID, Model: channelModel.ModelKey, InterfaceType: string(channelModel.Protocol)}, + }) + if err != nil { + t.Fatal(err) + } + + routed, resolvedInput, err := (&Service{repo: repository.New(db)}).resolveTaskModelSelection(input, "", "canvas_video", "", true) + if err != nil { + t.Fatalf("resolveTaskModelSelection() error = %v", err) + } + if routed != nil || resolvedInput["config"] == nil { + t.Fatalf("resolveTaskModelSelection() routed = %#v, input = %#v", routed, resolvedInput) + } +} + +func TestResolveTaskModelSelectionStillRequiresLogicalModelWithoutExplicitSystemChannel(t *testing.T) { + _, _, err := (&Service{}).resolveTaskModelSelection(map[string]any{ + "mode": "video", + "config": map[string]any{"model": "agnes-video-2.5"}, + }, "", "canvas_video", "", true) + if err == nil || !strings.Contains(err.Error(), "logicalModelId") { + t.Fatalf("resolveTaskModelSelection() error = %v, want logicalModelId validation", err) + } +} diff --git a/web/src/components/model-logo.tsx b/web/src/components/model-logo.tsx index 9e13c3f3c..a9a1126c7 100644 --- a/web/src/components/model-logo.tsx +++ b/web/src/components/model-logo.tsx @@ -10,7 +10,7 @@ type LobeIconComponent = ComponentType & { size?: number // 只允许按需加载 Mono 组件。这里不能使用 eager glob:模型 Logo 目录有数百个 provider 模块, // eager 会让每次进入工作区都发起数百个开发模块请求,即使用户从未打开 Logo 选择器。 -const iconModules = import.meta.glob?.("../../node_modules/@lobehub/icons/es/*/components/Mono.js", { import: "default" }) || {}; +const iconModules = import.meta.glob("../../node_modules/@lobehub/icons/es/*/components/Mono.js", { import: "default" }); const iconLoaders = Object.fromEntries( Object.entries(iconModules) .map(([path, loader]) => [path.match(/\/([^/]+)\/components\/Mono\.js$/)?.[1], loader]) @@ -19,7 +19,7 @@ const iconLoaders = Object.fromEntries( const iconRegistry = new Map(); const iconLoadPromises = new Map>(); const iconOptions = toc - .filter((item) => item.group === "model" || item.group === "provider") + .filter((item) => item.group === "model" || item.group === "provider" || item.group === "application") .map((item) => ({ id: item.id, title: item.fullTitle || item.title })) .filter((item) => Boolean(iconLoaders[item.id])); diff --git a/web/test/model-logo.test.ts b/web/test/model-logo.test.ts new file mode 100644 index 000000000..876b30302 --- /dev/null +++ b/web/test/model-logo.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +describe("模型 Logo 选择器", () => { + test("使用 Vite 可识别的直接 glob 并包含前台模型 Logo 数据源", () => { + const source = readFileSync(resolve(import.meta.dir, "../src/components/model-logo.tsx"), "utf8"); + const toc = JSON.parse(readFileSync(resolve(import.meta.dir, "../node_modules/@lobehub/icons/es/toc.json"), "utf8")) as Array<{ group: string; id: string }>; + const options = toc.filter((item) => item.group === "model" || item.group === "provider" || item.group === "application"); + + expect(source).toContain('import.meta.glob("../../node_modules/@lobehub/icons/es/*/components/Mono.js"'); + expect(source).not.toContain("import.meta.glob?.("); + expect(options.length).toBeGreaterThan(300); + expect(options.some((item) => item.id === "OpenAI")).toBe(true); + expect(options.some((item) => item.id === "AgnesAI")).toBe(true); + expect(existsSync(resolve(import.meta.dir, "../node_modules/@lobehub/icons/es/OpenAI/components/Mono.js"))).toBe(true); + expect(existsSync(resolve(import.meta.dir, "../node_modules/@lobehub/icons/es/AgnesAI/components/Mono.js"))).toBe(true); + }); +});