- {props.mode === "video" || props.imageProfile.size.parameter !== "none" ?
{ratios.map((value) => { const selected = props.mode === "image" && usesImageResolutionPicker ? value === activeImageRatio : value === props.ratio; return ; })}
{props.mode !== "video" && props.imageProfile.size.allowCustom && (customRatioOpen ?
:
)}
: null}
+ {props.mode === "video" || mergedProfile.size.parameter !== "none" ?
{ratios.map((value) => { const selected = props.mode === "image" && usesImageResolutionPicker ? value === activeImageRatio : value === props.ratio; return ; })}
{props.mode !== "video" && mergedProfile.size.allowCustom && (customRatioOpen ?
:
)}
: null}
{props.mode === "video" ?
{resolutions.map((option) => )}
: <>
{imageResolutionChoiceOptions.length ?
{imageResolutionChoiceOptions.map((choice) => )}
: null}
{props.imageProfile.quality.supported ?
item.value === "1k" || item.value === "2k") ? "分辨率" : "图片质量"} value={qualityLabel}>{activeQualityOptions.map((option) => )}
: null}
From ad0923a892fc49157a5e9a61cac7fe073dd927e7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E2=9C=A8await?= <1335360669@qq.com>
Date: Mon, 17 Aug 2026 11:27:53 +0800
Subject: [PATCH 02/67] =?UTF-8?q?fix(web):=20=E6=A8=A1=E5=9E=8B=E5=88=86?=
=?UTF-8?q?=E7=BB=84=20-=20=E7=94=9F=E6=88=90/=E9=87=8D=E8=AF=95/=E5=B7=A5?=
=?UTF-8?q?=E5=85=B7=E9=93=BE=E6=8C=89=E5=BD=93=E5=89=8D=E5=B0=BA=E5=AF=B8?=
=?UTF-8?q?=E8=B7=AF=E7=94=B1=E6=9C=80=E4=BD=8E=E4=BB=B7=E5=85=BC=E5=AE=B9?=
=?UTF-8?q?=E6=A8=A1=E5=9E=8B=EF=BC=8C=E5=B0=BA=E5=AF=B8=E6=A3=80=E6=9F=A5?=
=?UTF-8?q?=E4=B8=8D=E5=86=8D=E4=BE=9D=E8=B5=96=E8=BE=93=E5=85=A5=E6=91=98?=
=?UTF-8?q?=E8=A6=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
web/src/lib/canvas/canvas-project-generation.ts | 5 ++++-
web/src/lib/model-selection.ts | 15 ++++++++++-----
2 files changed, 14 insertions(+), 6 deletions(-)
diff --git a/web/src/lib/canvas/canvas-project-generation.ts b/web/src/lib/canvas/canvas-project-generation.ts
index 6d545f728..627b770b6 100644
--- a/web/src/lib/canvas/canvas-project-generation.ts
+++ b/web/src/lib/canvas/canvas-project-generation.ts
@@ -373,7 +373,10 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
const storedModel = resolveCanvasGenerationModel(config, node?.metadata?.model, mode);
const preferredModel = storedModel || resolveCanvasGenerationModel(config, defaultModel, mode) || fallbackModel;
const imageSize = mode === "image" ? node?.metadata?.size || config.size || defaultConfig.size : undefined;
- const model = resolveCompatibleModel(config, preferredModel, imageSize ? { ...requirements, imageSize } : requirements) || preferredModel;
+ // 无 requirements 的调用(重试、媒体工具等)也按当前能力与尺寸路由到组内最低价兼容模型,
+ // 避免旧 metadata.model 不支持当前尺寸导致生成时被 normalize 回退。
+ const baseRequirements = requirements?.capability ? requirements : { capability: mode };
+ const model = resolveCompatibleModel(config, preferredModel, imageSize ? { ...baseRequirements, imageSize } : baseRequirements) || preferredModel;
const imageProfile = mode === "image" ? modelCapabilityConfigFor(config, model).image! : undefined;
const normalizedImage = imageProfile
? normalizeImageValue(imageProfile, {
diff --git a/web/src/lib/model-selection.ts b/web/src/lib/model-selection.ts
index 2afc949a7..7621a0f7d 100644
--- a/web/src/lib/model-selection.ts
+++ b/web/src/lib/model-selection.ts
@@ -50,30 +50,35 @@ export function configuredModelDisplayName(config: AiConfig, value: string) {
export function modelCompatibilityError(config: AiConfig, model: string, requirements?: ModelRequirements) {
const capability = requirements?.capability;
+ if (!capability) return "";
const input = requirements?.input;
- if (!capability || !input) return "";
- const visualInputCount = input.imageCount + input.characterCount;
+ const visualInputCount = input ? input.imageCount + input.characterCount : 0;
if (capability === "image") {
+ const image = modelCapabilityConfigFor(config, model).image!;
+ // 尺寸兼容不依赖输入摘要:无输入时(如画布重试/工具链)也要按尺寸过滤组内模型。
+ if (requirements.imageSize && !image.size.allowCustom && !image.size.values.includes(requirements.imageSize)) return "不支持当前尺寸";
+ if (!input) return "";
if (input.videoCount > 0) return "图片模型不支持参考视频";
if (input.audioCount > 0) return "图片模型不支持参考音频";
- const image = modelCapabilityConfigFor(config, model).image!;
if (visualInputCount > image.references.maxImages) return `最多支持 ${image.references.maxImages} 张参考图`;
- if (requirements.imageSize && !image.size.allowCustom && !image.size.values.includes(requirements.imageSize)) return "不支持当前尺寸";
return "";
}
if (capability === "video") {
const profile = modelCapabilityConfigFor(config, model).video!;
+ if (requirements.videoSeconds && !videoDurationAllowed(profile, Number(requirements.videoSeconds))) return "不支持当前视频时长";
+ if (!input) return "";
if (visualInputCount > profile.references.maxImages) return `最多支持 ${profile.references.maxImages} 张参考图`;
if (input.videoCount > profile.references.maxVideos) return `最多支持 ${profile.references.maxVideos} 个参考视频`;
if (input.audioCount > profile.references.maxAudios) return `最多支持 ${profile.references.maxAudios} 个参考音频`;
- if (requirements.videoSeconds && !videoDurationAllowed(profile, Number(requirements.videoSeconds))) return "不支持当前视频时长";
const operation = resolveVideoOperation(input, requirements.videoOperation);
if (operation !== "concat" && !profile.operations.includes(operation)) return `不支持${videoOperationLabel(operation)}`;
return "";
}
+ if (!input) return "";
+
if (capability === "text") {
return input.audioCount > 0 ? "文本模型不支持参考音频" : "";
}
From cbd598e8c13f826fd3a160a8a453b9b0bb757d8f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E2=9C=A8await?= <1335360669@qq.com>
Date: Mon, 17 Aug 2026 11:34:02 +0800
Subject: [PATCH 03/67] =?UTF-8?q?fix(web):=20=E6=A8=A1=E5=9E=8B=E5=88=86?=
=?UTF-8?q?=E7=BB=84=20-=20=E7=94=BB=E5=B8=83=E5=9B=BE=E7=89=87=E5=8F=82?=
=?UTF-8?q?=E6=95=B0=E5=AF=B9=E9=BD=90=E5=88=9B=E4=BD=9C=E9=A1=B5=E8=A7=84?=
=?UTF-8?q?=E5=88=99=EF=BC=8C=E5=AE=BD=E9=AB=98=E6=AF=94=E7=9B=B4=E6=8E=A5?=
=?UTF-8?q?=E5=8F=96=E6=A8=A1=E5=9E=8B=E7=BB=84=E5=B9=B6=E9=9B=86=20values?=
=?UTF-8?q?=EF=BC=8C=E8=B4=A8=E9=87=8F=E7=AD=89=E8=83=BD=E5=8A=9B=E5=8F=96?=
=?UTF-8?q?=E8=B7=AF=E7=94=B1=E6=A8=A1=E5=9E=8B=E9=85=8D=E7=BD=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
web/src/components/image-settings-panel.tsx | 30 ++++++++++++++++++++-
web/src/lib/model-selection.ts | 7 +++--
2 files changed, 34 insertions(+), 3 deletions(-)
diff --git a/web/src/components/image-settings-panel.tsx b/web/src/components/image-settings-panel.tsx
index 72249081e..e6126d9eb 100644
--- a/web/src/components/image-settings-panel.tsx
+++ b/web/src/components/image-settings-panel.tsx
@@ -68,7 +68,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
? []
: usesResolutionPicker && activeResolution
? resolutionOptions.filter((item) => item.tier === activeResolution.tier).map((item) => ({ value: item.ratio, label: item.ratio, size: item.size, width: item.width, height: item.height, icon: item.width === item.height ? "square" : item.width > item.height ? "landscape" : "portrait" }))
- : aspectOptions.filter((item) => imageOptionAllowed(profile, item));
+ : imageAspectOptions(profile);
const selectedAspect = availableAspects.find((item) => imageOptionValue(profile, item) === activeSize || item.value === activeSize) || availableAspects.find((item) => item.label === activeRatio);
const dimensions = readSizeDimensions(activeSize, selectedAspect || aspectOptions[0]);
const activeQualityOptions = profile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value });
@@ -199,6 +199,34 @@ function imageOptionAllowed(profile: ImageCapabilityConfig, option: AspectOption
return [option.value, option.size, option.width && option.height ? `${option.width}x${option.height}` : ""].filter(Boolean).some((value) => profile.size.values.includes(String(value)));
}
+// 宽高比选项直接取模型配置 values(与创作页面一致),不在白名单里的比例(如 8:1)也能显示。
+function imageAspectOptions(profile: ImageCapabilityConfig): AspectOption[] {
+ if (profile.size.parameter === "none") return [];
+ const values = profile.size.values.filter((value) => value.trim().toLowerCase() !== "auto");
+ if (!values.length) return profile.size.allowCustom ? aspectOptions.filter((item) => item.value !== "auto") : [];
+ return values.map((value) => {
+ const known = aspectOptions.find((item) => (item.size || item.value) === value || item.value === value);
+ if (known) return known;
+ const parts = ratioParts(value);
+ return { value, label: value, size: value, width: parts?.width || 0, height: parts?.height || 0, icon: "custom" };
+ });
+}
+
+function ratioParts(value: string) {
+ const pixel = value.trim().match(/^(\d+)x(\d+)$/i);
+ if (pixel) {
+ const divisor = gcd(Number(pixel[1]), Number(pixel[2]));
+ return { width: Number(pixel[1]) / divisor, height: Number(pixel[2]) / divisor };
+ }
+ const ratio = value.trim().match(/^(\d+):(\d+)$/);
+ if (!ratio) return undefined;
+ return { width: Number(ratio[1]), height: Number(ratio[2]) };
+}
+
+function gcd(a: number, b: number): number {
+ return b ? gcd(b, a % b) : a;
+}
+
function imageOptionValue(profile: ImageCapabilityConfig, option: AspectOption) {
const candidates = [option.size, option.value, option.width && option.height ? `${option.width}x${option.height}` : ""].filter(Boolean).map(String);
return candidates.find((value) => profile.size.values.includes(value)) || option.size || option.value || "auto";
diff --git a/web/src/lib/model-selection.ts b/web/src/lib/model-selection.ts
index 7621a0f7d..0d6b513e8 100644
--- a/web/src/lib/model-selection.ts
+++ b/web/src/lib/model-selection.ts
@@ -110,14 +110,17 @@ export function resolveCompatibleModel(config: AiConfig, selected: string, requi
// 同显示名分组的模型族:尺寸/比例/分辨率选项取组内全部模型配置的并集,
// 让用户能看到并选择任意成员支持的能力,选中后由兼容路由落到具体模型。
+// 质量、透明背景等其余能力取当前选中(路由后)模型的配置,与创作页面一致。
export function mergedImageCapabilityConfig(config: AiConfig, selected: string): ImageCapabilityConfig {
const options = selectableModelsByCapability(config, "image");
const group = options.length ? groupModelsByDisplayName(config, options).find((item) => item.models.includes(selected)) : undefined;
const models = group?.models.length ? group.models : [selected];
+ const selectedProfile = modelCapabilityConfigFor(config, selected).image;
const profiles = models.map((model) => modelCapabilityConfigFor(config, model).image).filter((profile): profile is ImageCapabilityConfig => Boolean(profile));
- if (profiles.length <= 1) return profiles[0] || defaultImageCapabilityConfig();
+ if (profiles.length <= 1) return selectedProfile || defaultImageCapabilityConfig();
const values = Array.from(new Set(profiles.flatMap((profile) => profile.size.values)));
- return { ...profiles[0], size: { ...profiles[0].size, values } };
+ const base = selectedProfile || profiles[0];
+ return { ...base, size: { ...base.size, values } };
}
// 切换模型后初始化图片参数为该模型能力默认值,避免旧参数在目标模型族不兼容导致无法切换。
From ea236b5956c055368fcf97c152014d65246b01f0 Mon Sep 17 00:00:00 2001
From: dingxin150015-commits
<252581087+dingxin150015-commits@users.noreply.github.com>
Date: Mon, 17 Aug 2026 13:47:27 +0800
Subject: [PATCH 04/67] feat(provider): add plugin system for service-specific
model discovery
Model discovery currently assumes every provider exposes all models via
GET {baseURL}/models. Providers like Alibaba Cloud Bailian serve video,
3D and some image models through dedicated endpoints, so 21 models across
5 families (HappyHorse / Wan / Kling / PixVerse / Vidu / Tripo) are never
discoverable - a functional blocker for a video creation workbench.
This introduces a ModelDiscovery plugin interface with a registry that
matches vendor plugins by base URL and falls back to the standard OpenAI
implementation, keeping core discovery logic vendor-agnostic.
- add provider.ModelDiscovery interface and plugin registry
- add openai plugin (standard behaviour, default fallback)
- add bailian plugin (standard + 21 extended multimodal models)
- gate behind ENABLE_PROVIDER_PLUGINS, default false
- register plugins lazily from service layer to avoid import cycle
Verified locally: flag off -> 238 models (unchanged); flag on -> 257
models (238 + 19 net new after dedup). OpenAI and Gemini channels
unaffected in both modes.
---
.env.example | 3 +
.../internal/provider/bailian/discovery.go | 485 ++++++++++++++++++
backend/internal/provider/interface.go | 54 ++
backend/internal/provider/openai/discovery.go | 205 ++++++++
backend/internal/provider/registry.go | 71 +++
.../internal/service/channel_model_catalog.go | 6 +
.../service/channel_model_catalog_plugin.go | 138 +++++
docker-compose.local.yml | 1 +
8 files changed, 963 insertions(+)
create mode 100644 backend/internal/provider/bailian/discovery.go
create mode 100644 backend/internal/provider/interface.go
create mode 100644 backend/internal/provider/openai/discovery.go
create mode 100644 backend/internal/provider/registry.go
create mode 100644 backend/internal/service/channel_model_catalog_plugin.go
diff --git a/.env.example b/.env.example
index 20beda757..18b74ad2e 100644
--- a/.env.example
+++ b/.env.example
@@ -8,6 +8,9 @@ CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS=
CANVAS_DESKTOP_LOCAL_CHANNELS_ENABLED=false
# 桌面本机渠道启用时必须显式使用:CANVAS_BACKEND_ADDR=127.0.0.1:8080
CANVAS_CORS_ORIGINS=http://localhost:3000
+# 启用服务商插件系统以支持特定服务的模型发现(如阿里云百炼的视频生成模型)
+# 设置为 true 启用插件机制,false 使用传统方式(默认:false)
+ENABLE_PROVIDER_PLUGINS=false
CANVAS_DATA_PATH=./data
CANVAS_WORKER_CONCURRENCY=3
CANVAS_CHANNEL_CONCURRENCY=3
diff --git a/backend/internal/provider/bailian/discovery.go b/backend/internal/provider/bailian/discovery.go
new file mode 100644
index 000000000..bc8a46b48
--- /dev/null
+++ b/backend/internal/provider/bailian/discovery.go
@@ -0,0 +1,485 @@
+package bailian
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "sort"
+ "strings"
+
+ "infinite-canvas/backend/internal/provider"
+)
+
+// Discovery 阿里云百炼插件
+type Discovery struct{}
+
+// New 创建百炼插件实例
+func New() *Discovery {
+ return &Discovery{}
+}
+
+// GetProviderID 返回服务商标识
+func (d *Discovery) GetProviderID() string {
+ return "bailian"
+}
+
+// Match 判断是否匹配阿里云百炼
+func (d *Discovery) Match(baseURL string, headers map[string]string) bool {
+ // 匹配阿里云百炼域名
+ return strings.Contains(baseURL, "dashscope") ||
+ strings.Contains(baseURL, "maas.aliyuncs.com")
+}
+
+// DiscoverModels 发现模型列表
+func (d *Discovery) DiscoverModels(ctx context.Context, config provider.DiscoveryConfig) ([]provider.Model, error) {
+ // 1. 调用标准 /v1/models 获取文本模型
+ textModels, err := d.fetchStandardModels(ctx, config)
+ if err != nil {
+ return nil, err
+ }
+
+ // 2. 添加百炼特有的多模态模型
+ extendedModels := d.getExtendedModels(config.Region)
+
+ // 3. 合并并去重
+ allModels := append(textModels, extendedModels...)
+ return d.deduplicate(allModels), nil
+}
+
+// fetchStandardModels 调用标准 /v1/models 接口(自己实现,不依赖 openai 包)
+func (d *Discovery) fetchStandardModels(ctx context.Context, config provider.DiscoveryConfig) ([]provider.Model, error) {
+ // 构建 URL
+ target := config.BaseURL
+ if !strings.HasSuffix(target, "/models") {
+ if strings.HasSuffix(target, "/") {
+ target += "models"
+ } else {
+ target += "/models"
+ }
+ }
+
+ // 创建请求
+ req, err := http.NewRequestWithContext(ctx, "GET", target, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ // 设置认证头
+ req.Header.Set("Authorization", "Bearer "+config.APIKey)
+ for k, v := range config.Headers {
+ req.Header.Set(k, v)
+ }
+
+ // 执行请求
+ client := &http.Client{}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch models: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+ }
+
+ // 解析响应
+ var payload struct {
+ Data []struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ SupportedEndpointTypes []string `json:"supported_generation_methods"`
+ } `json:"data"`
+ }
+
+ if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ // 转换为统一格式
+ models := make([]provider.Model, 0, len(payload.Data))
+ seen := make(map[string]bool)
+
+ for _, item := range payload.Data {
+ name := strings.TrimSpace(item.ID)
+ if name == "" {
+ name = strings.TrimSpace(item.Name)
+ }
+ name = strings.TrimPrefix(name, "models/")
+
+ if name == "" || seen[name] {
+ continue
+ }
+ seen[name] = true
+
+ models = append(models, provider.Model{
+ ID: name,
+ DisplayName: name,
+ Provider: "bailian",
+ Capability: []string{"text"},
+ SupportedEndpointTypes: item.SupportedEndpointTypes,
+ Metadata: map[string]any{
+ "source": "standard",
+ },
+ })
+ }
+
+ sort.Slice(models, func(i, j int) bool {
+ return models[i].ID < models[j].ID
+ })
+
+ return models, nil
+}
+
+// GetMetadata 返回插件元数据
+func (d *Discovery) GetMetadata() provider.ProviderMetadata {
+ return provider.ProviderMetadata{
+ Name: "bailian",
+ Version: "1.0.0",
+ Description: "Alibaba Cloud Bailian (DashScope) extended model discovery",
+ Author: "infinite-canvas",
+ SupportedRegions: []string{"cn-beijing", "ap-southeast-1", "us-east-1"},
+ }
+}
+
+// getExtendedModels 获取百炼特有的扩展模型列表
+func (d *Discovery) getExtendedModels(region string) []provider.Model {
+ models := []provider.Model{
+ // === 视频生成模型 ===
+ {
+ ID: "happyhorse-1.1-t2v",
+ DisplayName: "HappyHorse 1.1 文生视频",
+ Provider: "bailian",
+ Capability: []string{"video"},
+ SupportedEndpointTypes: []string{"video"},
+ APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
+ Metadata: map[string]any{
+ "source": "extended",
+ "model_type": "video_generation",
+ "generation_mode": "text_to_video",
+ },
+ },
+ {
+ ID: "happyhorse-1.1-i2v",
+ DisplayName: "HappyHorse 1.1 图生视频",
+ Provider: "bailian",
+ Capability: []string{"video"},
+ SupportedEndpointTypes: []string{"video"},
+ APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
+ Metadata: map[string]any{
+ "source": "extended",
+ "model_type": "video_generation",
+ "generation_mode": "image_to_video",
+ },
+ },
+ {
+ ID: "happyhorse-1.1-r2v",
+ DisplayName: "HappyHorse 1.1 参考视频生成",
+ Provider: "bailian",
+ Capability: []string{"video"},
+ SupportedEndpointTypes: []string{"video"},
+ APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
+ Metadata: map[string]any{
+ "source": "extended",
+ "model_type": "video_generation",
+ "generation_mode": "reference_to_video",
+ },
+ },
+ {
+ ID: "happyhorse-1.0-video-edit",
+ DisplayName: "HappyHorse 1.0 视频编辑",
+ Provider: "bailian",
+ Capability: []string{"video"},
+ SupportedEndpointTypes: []string{"video"},
+ APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
+ Metadata: map[string]any{
+ "source": "extended",
+ "model_type": "video_generation",
+ "generation_mode": "video_edit",
+ },
+ },
+
+ // Wan 系列视频生成模型
+ {
+ ID: "wan2.7-t2v-2026-06-12",
+ DisplayName: "万相 2.7 文生视频",
+ Provider: "bailian",
+ Capability: []string{"video"},
+ SupportedEndpointTypes: []string{"video"},
+ APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
+ Metadata: map[string]any{
+ "source": "extended",
+ "model_type": "video_generation",
+ "generation_mode": "text_to_video",
+ },
+ },
+ {
+ ID: "wan2.7-i2v-2026-04-25",
+ DisplayName: "万相 2.7 图生视频",
+ Provider: "bailian",
+ Capability: []string{"video"},
+ SupportedEndpointTypes: []string{"video"},
+ APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
+ Metadata: map[string]any{
+ "source": "extended",
+ "model_type": "video_generation",
+ "generation_mode": "image_to_video",
+ },
+ },
+ {
+ ID: "wan2.7-r2v-2026-06-12",
+ DisplayName: "万相 2.7 参考视频生成",
+ Provider: "bailian",
+ Capability: []string{"video"},
+ SupportedEndpointTypes: []string{"video"},
+ APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
+ Metadata: map[string]any{
+ "source": "extended",
+ "model_type": "video_generation",
+ "generation_mode": "reference_to_video",
+ },
+ },
+
+ // Kling(可灵)系列视频生成模型
+ {
+ ID: "kling/kling-v3-omni-video-generation",
+ DisplayName: "可灵 V3 Omni 视频生成",
+ Provider: "bailian",
+ Capability: []string{"video"},
+ SupportedEndpointTypes: []string{"video"},
+ APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
+ Metadata: map[string]any{
+ "source": "extended",
+ "model_type": "video_generation",
+ "resolution": "up to 4K",
+ "duration": "3-15s",
+ "generation_modes": []string{"text_to_video", "image_to_video", "reference_to_video", "video_edit"},
+ },
+ },
+ {
+ ID: "kling/kling-v3-video-generation",
+ DisplayName: "可灵 V3 视频生成",
+ Provider: "bailian",
+ Capability: []string{"video"},
+ SupportedEndpointTypes: []string{"video"},
+ APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
+ Metadata: map[string]any{
+ "source": "extended",
+ "model_type": "video_generation",
+ "resolution": "720P-4K",
+ "duration": "3-15s",
+ "generation_modes": []string{"text_to_video", "image_to_video"},
+ },
+ },
+
+ // PixVerse(爱诗)系列 - 文生视频
+ {
+ ID: "pixverse/pixverse-c1-t2v",
+ DisplayName: "爱诗 C1 文生视频",
+ Provider: "bailian",
+ Capability: []string{"video"},
+ SupportedEndpointTypes: []string{"video"},
+ APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
+ Metadata: map[string]any{
+ "source": "extended",
+ "model_type": "video_generation",
+ "generation_mode": "text_to_video",
+ "recommended": "action scenes, effects",
+ "duration": "1-15s",
+ },
+ },
+ {
+ ID: "pixverse/pixverse-v6-t2v",
+ DisplayName: "爱诗 V6 文生视频",
+ Provider: "bailian",
+ Capability: []string{"video"},
+ SupportedEndpointTypes: []string{"video"},
+ APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
+ Metadata: map[string]any{
+ "source": "extended",
+ "model_type": "video_generation",
+ "generation_mode": "text_to_video",
+ "recommended": "general purpose",
+ "duration": "1-15s",
+ },
+ },
+ {
+ ID: "pixverse/pixverse-v5.6-t2v",
+ DisplayName: "爱诗 V5.6 文生视频",
+ Provider: "bailian",
+ Capability: []string{"video"},
+ SupportedEndpointTypes: []string{"video"},
+ APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
+ Metadata: map[string]any{
+ "source": "extended",
+ "model_type": "video_generation",
+ "generation_mode": "text_to_video",
+ "deprecated": "recommend upgrade to v6",
+ },
+ },
+
+ // PixVerse(爱诗)系列 - 图生视频
+ {
+ ID: "pixverse/pixverse-c1-it2v",
+ DisplayName: "爱诗 C1 图生视频",
+ Provider: "bailian",
+ Capability: []string{"video"},
+ SupportedEndpointTypes: []string{"video"},
+ APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
+ Metadata: map[string]any{
+ "source": "extended",
+ "model_type": "video_generation",
+ "generation_mode": "image_to_video",
+ "duration": "1-15s",
+ },
+ },
+ {
+ ID: "pixverse/pixverse-v6-it2v",
+ DisplayName: "爱诗 V6 图生视频",
+ Provider: "bailian",
+ Capability: []string{"video"},
+ SupportedEndpointTypes: []string{"video"},
+ APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
+ Metadata: map[string]any{
+ "source": "extended",
+ "model_type": "video_generation",
+ "generation_mode": "image_to_video",
+ "duration": "1-15s",
+ },
+ },
+ {
+ ID: "pixverse/pixverse-v5.6-it2v",
+ DisplayName: "爱诗 V5.6 图生视频",
+ Provider: "bailian",
+ Capability: []string{"video"},
+ SupportedEndpointTypes: []string{"video"},
+ APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
+ Metadata: map[string]any{
+ "source": "extended",
+ "model_type": "video_generation",
+ "generation_mode": "image_to_video",
+ },
+ },
+
+ // Vidu 系列视频生成模型
+ {
+ ID: "vidu/viduq3-pro_text2video",
+ DisplayName: "Vidu Q3 Pro 文生视频",
+ Provider: "bailian",
+ Capability: []string{"video"},
+ SupportedEndpointTypes: []string{"video"},
+ APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
+ Metadata: map[string]any{
+ "source": "extended",
+ "model_type": "video_generation",
+ "generation_mode": "text_to_video",
+ "resolution": "540P-1080P",
+ "duration": "1-16s",
+ },
+ },
+ {
+ ID: "vidu/viduq3-turbo_text2video",
+ DisplayName: "Vidu Q3 Turbo 文生视频",
+ Provider: "bailian",
+ Capability: []string{"video"},
+ SupportedEndpointTypes: []string{"video"},
+ APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
+ Metadata: map[string]any{
+ "source": "extended",
+ "model_type": "video_generation",
+ "generation_mode": "text_to_video",
+ "resolution": "540P-1080P",
+ "duration": "1-16s",
+ },
+ },
+ {
+ ID: "vidu/viduq2_text2video",
+ DisplayName: "Vidu Q2 文生视频",
+ Provider: "bailian",
+ Capability: []string{"video"},
+ SupportedEndpointTypes: []string{"video"},
+ APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
+ Metadata: map[string]any{
+ "source": "extended",
+ "model_type": "video_generation",
+ "generation_mode": "text_to_video",
+ "resolution": "540P-1080P",
+ "duration": "1-10s",
+ },
+ },
+
+ // === 3D 生成模型 ===
+ {
+ ID: "Tripo/Tripo-H3.1",
+ DisplayName: "Tripo H3.1 3D生成",
+ Provider: "bailian",
+ Capability: []string{"image"}, // 前端无 3d 分类,暂归 image
+ SupportedEndpointTypes: []string{"image"},
+ APIPath: "/api/v1/services/aigc/3d-generation",
+ RequiresPlan: true,
+ Metadata: map[string]any{
+ "source": "extended",
+ "model_type": "3d_generation",
+ "note": "frontend lacks 3d category, classified as image",
+ },
+ },
+
+ // === 图像生成模型(补充)===
+ {
+ ID: "qwen-image-3.0-pro",
+ DisplayName: "通义千问图像 3.0 Pro",
+ Provider: "bailian",
+ Capability: []string{"image"},
+ SupportedEndpointTypes: []string{"image"},
+ APIPath: "/api/v1/services/aigc/multimodal-generation/generation",
+ Metadata: map[string]any{
+ "source": "extended",
+ "model_type": "image_generation",
+ },
+ },
+ {
+ ID: "wan2.7-image-pro",
+ DisplayName: "万相图像 2.7 Pro",
+ Provider: "bailian",
+ Capability: []string{"image"},
+ SupportedEndpointTypes: []string{"image"},
+ APIPath: "/api/v1/services/aigc/multimodal-generation/generation",
+ Metadata: map[string]any{
+ "source": "extended",
+ "model_type": "image_generation",
+ },
+ },
+ }
+
+ // 根据地域过滤(如果指定了地域)
+ if region != "" {
+ models = d.filterByRegion(models, region)
+ }
+
+ return models
+}
+
+// filterByRegion 根据地域过滤模型
+func (d *Discovery) filterByRegion(models []provider.Model, region string) []provider.Model {
+ // 美国地域可能不支持某些国内模型
+ if region == "us-east-1" {
+ // 过滤逻辑:保留所有模型(示例)
+ // 实际可以根据需要过滤
+ }
+ return models
+}
+
+// deduplicate 去重模型列表
+func (d *Discovery) deduplicate(models []provider.Model) []provider.Model {
+ seen := make(map[string]bool)
+ result := make([]provider.Model, 0, len(models))
+
+ for _, model := range models {
+ if !seen[model.ID] {
+ seen[model.ID] = true
+ result = append(result, model)
+ }
+ }
+
+ return result
+}
diff --git a/backend/internal/provider/interface.go b/backend/internal/provider/interface.go
new file mode 100644
index 000000000..0e50b69f8
--- /dev/null
+++ b/backend/internal/provider/interface.go
@@ -0,0 +1,54 @@
+package provider
+
+import (
+ "context"
+)
+
+// ModelDiscovery 模型发现插件接口
+type ModelDiscovery interface {
+ // GetProviderID 返回服务商唯一标识
+ GetProviderID() string
+
+ // Match 判断是否匹配该服务商
+ // 基于 baseURL、headers 等信息判断
+ Match(baseURL string, headers map[string]string) bool
+
+ // DiscoverModels 发现模型列表
+ DiscoverModels(ctx context.Context, config DiscoveryConfig) ([]Model, error)
+
+ // GetMetadata 返回插件元数据
+ GetMetadata() ProviderMetadata
+}
+
+// DiscoveryConfig 模型发现配置
+type DiscoveryConfig struct {
+ BaseURL string // API 基础 URL
+ APIKey string // API 密钥
+ SecretKey string // 可选的密钥对
+ APIFormat string // API 格式:openai, gemini, anthropic
+ Headers map[string]string // 自定义请求头
+ AllowLocalChannel bool // 是否允许本地渠道
+ Region string // 地域(从 baseURL 解析或指定)
+}
+
+// Model 统一的模型定义
+type Model struct {
+ ID string // 模型 ID
+ DisplayName string // 显示名称
+ Provider string // 提供商标识
+ Capability []string // 能力列表:text, image, video, audio, 3d
+ SupportedEndpointTypes []string // 支持的端点类型
+ APIPath string // API 路径(如果非标准)
+ Metadata map[string]any // 额外元数据
+ Deprecated bool // 是否已废弃
+ RequiresPlan bool // 是否需要特殊权限
+}
+
+// ProviderMetadata 插件元数据
+type ProviderMetadata struct {
+ Name string // 插件名称
+ Version string // 插件版本
+ Description string // 描述
+ Author string // 作者
+ SupportedRegions []string // 支持的地域
+}
diff --git a/backend/internal/provider/openai/discovery.go b/backend/internal/provider/openai/discovery.go
new file mode 100644
index 000000000..82e34b35a
--- /dev/null
+++ b/backend/internal/provider/openai/discovery.go
@@ -0,0 +1,205 @@
+package openai
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "sort"
+ "strings"
+
+ "infinite-canvas/backend/internal/provider"
+)
+
+// Discovery OpenAI 标准插件
+type Discovery struct{}
+
+// New 创建 OpenAI 插件实例
+func New() *Discovery {
+ return &Discovery{}
+}
+
+// GetProviderID 返回服务商标识
+func (d *Discovery) GetProviderID() string {
+ return "openai"
+}
+
+// Match 判断是否匹配 OpenAI 兼容端点
+func (d *Discovery) Match(baseURL string, headers map[string]string) bool {
+ // 排除已知的其他服务商
+ if strings.Contains(baseURL, "dashscope") ||
+ strings.Contains(baseURL, "maas.aliyuncs.com") {
+ return false // 百炼有自己的插件
+ }
+
+ // 匹配标准的 OpenAI 兼容端点
+ return strings.Contains(baseURL, "/v1") ||
+ strings.Contains(baseURL, "openai.com") ||
+ strings.Contains(baseURL, "compatible-mode")
+}
+
+// DiscoverModels 发现模型列表
+func (d *Discovery) DiscoverModels(ctx context.Context, config provider.DiscoveryConfig) ([]provider.Model, error) {
+ // 构建 /v1/models 请求
+ target := config.BaseURL
+ if !strings.HasSuffix(target, "/models") {
+ if strings.HasSuffix(target, "/") {
+ target += "models"
+ } else {
+ target += "/models"
+ }
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "GET", target, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ // 设置认证头
+ if config.APIFormat == "gemini" {
+ req.Header.Set("x-goog-api-key", config.APIKey)
+ } else {
+ req.Header.Set("Authorization", "Bearer "+config.APIKey)
+ }
+
+ // 添加自定义请求头
+ for k, v := range config.Headers {
+ req.Header.Set(k, v)
+ }
+
+ // 执行请求
+ client := &http.Client{}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch models: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+ }
+
+ // 解析响应
+ var payload struct {
+ Data []modelItem `json:"data"`
+ Models []modelItem `json:"models"` // Gemini 使用这个字段
+ }
+
+ if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ // 提取模型列表
+ items := payload.Data
+ if config.APIFormat == "gemini" && len(payload.Models) > 0 {
+ items = payload.Models
+ }
+
+ // 转换为统一格式
+ models := make([]provider.Model, 0, len(items))
+ seen := make(map[string]bool)
+
+ for _, item := range items {
+ // 提取模型 ID
+ name := strings.TrimSpace(item.ID)
+ if name == "" {
+ name = strings.TrimSpace(item.Name)
+ }
+ name = strings.TrimPrefix(name, "models/") // 移除 Gemini 的前缀
+
+ if name == "" || seen[name] {
+ continue
+ }
+ seen[name] = true
+
+ models = append(models, provider.Model{
+ ID: name,
+ DisplayName: d.getDisplayName(item),
+ Provider: "openai",
+ Capability: d.inferCapability(name),
+ SupportedEndpointTypes: d.normalizeSupportedTypes(item.SupportedEndpointTypes),
+ Metadata: map[string]any{
+ "source": "standard",
+ },
+ })
+ }
+
+ // 排序
+ sort.Slice(models, func(i, j int) bool {
+ return models[i].ID < models[j].ID
+ })
+
+ return models, nil
+}
+
+// GetMetadata 返回插件元数据
+func (d *Discovery) GetMetadata() provider.ProviderMetadata {
+ return provider.ProviderMetadata{
+ Name: "openai",
+ Version: "1.0.0",
+ Description: "Standard OpenAI-compatible model discovery",
+ Author: "infinite-canvas",
+ }
+}
+
+// modelItem 内部使用的模型数据结构
+type modelItem struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ SupportedEndpointTypes []string `json:"supported_generation_methods"`
+}
+
+// getDisplayName 获取显示名称
+func (d *Discovery) getDisplayName(item modelItem) string {
+ if item.Name != "" {
+ return item.Name
+ }
+ return item.ID
+}
+
+// inferCapability 根据模型 ID 推断能力
+func (d *Discovery) inferCapability(modelID string) []string {
+ lower := strings.ToLower(modelID)
+
+ // 图像模型
+ if strings.Contains(lower, "dall-e") ||
+ strings.Contains(lower, "image") {
+ return []string{"image"}
+ }
+
+ // 音频模型
+ if strings.Contains(lower, "whisper") ||
+ strings.Contains(lower, "tts") ||
+ strings.Contains(lower, "audio") {
+ return []string{"audio"}
+ }
+
+ // 嵌入模型
+ if strings.Contains(lower, "embedding") {
+ return []string{"embedding"}
+ }
+
+ // 默认为文本模型
+ return []string{"text"}
+}
+
+// normalizeSupportedTypes 标准化支持的端点类型
+func (d *Discovery) normalizeSupportedTypes(types []string) []string {
+ if len(types) == 0 {
+ return nil
+ }
+
+ seen := make(map[string]bool)
+ result := make([]string, 0, len(types))
+
+ for _, t := range types {
+ normalized := strings.TrimSpace(t)
+ if normalized == "" || seen[normalized] {
+ continue
+ }
+ seen[normalized] = true
+ result = append(result, normalized)
+ }
+
+ return result
+}
diff --git a/backend/internal/provider/registry.go b/backend/internal/provider/registry.go
new file mode 100644
index 000000000..72bad5b47
--- /dev/null
+++ b/backend/internal/provider/registry.go
@@ -0,0 +1,71 @@
+package provider
+
+import (
+ "sync"
+)
+
+// Registry 插件注册中心
+type Registry struct {
+ mu sync.RWMutex
+ providers map[string]ModelDiscovery
+}
+
+var globalRegistry = &Registry{
+ providers: make(map[string]ModelDiscovery),
+}
+
+// Register 注册插件
+func Register(discovery ModelDiscovery) {
+ globalRegistry.mu.Lock()
+ defer globalRegistry.mu.Unlock()
+
+ id := discovery.GetProviderID()
+ globalRegistry.providers[id] = discovery
+}
+
+// GetProvider 根据 ID 获取插件
+func GetProvider(id string) (ModelDiscovery, bool) {
+ globalRegistry.mu.RLock()
+ defer globalRegistry.mu.RUnlock()
+
+ provider, ok := globalRegistry.providers[id]
+ return provider, ok
+}
+
+// MatchProvider 根据配置匹配插件
+func MatchProvider(baseURL string, headers map[string]string) ModelDiscovery {
+ globalRegistry.mu.RLock()
+ defer globalRegistry.mu.RUnlock()
+
+ // 按优先级匹配
+ // 1. 先匹配特定服务商(如百炼)
+ for _, provider := range globalRegistry.providers {
+ if provider.GetProviderID() == "openai" {
+ continue // OpenAI 插件最后匹配
+ }
+ if provider.Match(baseURL, headers) {
+ return provider
+ }
+ }
+
+ // 2. 最后使用 OpenAI 默认实现
+ if provider, ok := globalRegistry.providers["openai"]; ok {
+ if provider.Match(baseURL, headers) {
+ return provider
+ }
+ }
+
+ return nil
+}
+
+// ListProviders 列出所有已注册插件
+func ListProviders() []ProviderMetadata {
+ globalRegistry.mu.RLock()
+ defer globalRegistry.mu.RUnlock()
+
+ result := make([]ProviderMetadata, 0, len(globalRegistry.providers))
+ for _, provider := range globalRegistry.providers {
+ result = append(result, provider.GetMetadata())
+ }
+ return result
+}
diff --git a/backend/internal/service/channel_model_catalog.go b/backend/internal/service/channel_model_catalog.go
index 6ac9ca5ab..60cebb2d7 100644
--- a/backend/internal/service/channel_model_catalog.go
+++ b/backend/internal/service/channel_model_catalog.go
@@ -61,6 +61,12 @@ type ChannelModelCatalogItem struct {
}
func (s *Service) FetchChannelModelCatalog(ctx context.Context, actor *model.User, input ChannelModelsRequest) ([]ChannelModelCatalogItem, error) {
+ // 功能开关:如果启用插件机制,使用新实现
+ if s.isPluginEnabled() {
+ return s.FetchChannelModelCatalogWithPlugin(ctx, actor, input)
+ }
+
+ // 否则使用原有实现(向后兼容)
if actor == nil || strings.TrimSpace(actor.ID) == "" {
return nil, Unauthorized("请先登录")
}
diff --git a/backend/internal/service/channel_model_catalog_plugin.go b/backend/internal/service/channel_model_catalog_plugin.go
new file mode 100644
index 000000000..620fced76
--- /dev/null
+++ b/backend/internal/service/channel_model_catalog_plugin.go
@@ -0,0 +1,138 @@
+package service
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "sort"
+ "strings"
+ "sync"
+
+ "infinite-canvas/backend/internal/model"
+ "infinite-canvas/backend/internal/provider"
+ "infinite-canvas/backend/internal/provider/bailian"
+ "infinite-canvas/backend/internal/provider/openai"
+)
+
+var (
+ pluginsInitialized bool
+ pluginsInitMutex sync.Mutex
+)
+
+// initPlugins 初始化并注册所有插件(只执行一次)
+func initPlugins() {
+ pluginsInitMutex.Lock()
+ defer pluginsInitMutex.Unlock()
+
+ if pluginsInitialized {
+ return
+ }
+
+ // 注册 OpenAI 标准插件(默认)
+ provider.Register(openai.New())
+
+ // 注册阿里云百炼插件
+ provider.Register(bailian.New())
+
+ // 未来可以添加更多插件:
+ // provider.Register(gemini.New())
+ // provider.Register(anthropic.New())
+
+ pluginsInitialized = true
+}
+
+// FetchChannelModelCatalogWithPlugin 使用插件机制拉取模型(新实现)
+func (s *Service) FetchChannelModelCatalogWithPlugin(ctx context.Context, actor *model.User, input ChannelModelsRequest) ([]ChannelModelCatalogItem, error) {
+ // 确保插件已初始化
+ initPlugins()
+
+ // 验证基本参数
+ if actor == nil || strings.TrimSpace(actor.ID) == "" {
+ return nil, Unauthorized("请先登录")
+ }
+ baseURL := strings.TrimRight(strings.TrimSpace(input.BaseURL), "/")
+ apiKey := strings.TrimSpace(input.APIKey)
+ if baseURL == "" {
+ return nil, BadAuthRequest("请填写 Base URL")
+ }
+ if apiKey == "" {
+ return nil, BadAuthRequest("请填写 API Key")
+ }
+
+ // 1. 匹配插件
+ headers := s.headersToMap(input.Headers)
+ discovery := provider.MatchProvider(baseURL, headers)
+ if discovery == nil {
+ return nil, fmt.Errorf("未找到匹配的服务商插件,请检查 Base URL")
+ }
+
+ // 2. 构建配置
+ config := provider.DiscoveryConfig{
+ BaseURL: baseURL,
+ APIKey: apiKey,
+ APIFormat: strings.ToLower(strings.TrimSpace(input.APIFormat)),
+ Headers: headers,
+ AllowLocalChannel: input.AllowLocalChannel,
+ Region: s.extractRegion(baseURL),
+ }
+
+ if config.APIFormat == "" {
+ config.APIFormat = "openai"
+ }
+
+ // 3. 调用插件发现模型
+ models, err := discovery.DiscoverModels(ctx, config)
+ if err != nil {
+ return nil, fmt.Errorf("插件发现模型失败: %w", err)
+ }
+
+ // 4. 转换为内部格式
+ catalog := make([]ChannelModelCatalogItem, 0, len(models))
+ for _, m := range models {
+ catalog = append(catalog, ChannelModelCatalogItem{
+ ID: m.ID,
+ SupportedEndpointTypes: m.SupportedEndpointTypes,
+ })
+ }
+
+ // 5. 排序
+ sort.Slice(catalog, func(i, j int) bool {
+ return catalog[i].ID < catalog[j].ID
+ })
+
+ return catalog, nil
+}
+
+// headersToMap 将 OutboundHeader 数组转换为 map
+func (s *Service) headersToMap(headers []OutboundHeader) map[string]string {
+ result := make(map[string]string, len(headers))
+ for _, h := range headers {
+ if h.Name != "" && h.Value != "" {
+ result[h.Name] = h.Value
+ }
+ }
+ return result
+}
+
+// extractRegion 从 baseURL 提取地域信息
+func (s *Service) extractRegion(baseURL string) string {
+ // 美国地域
+ if strings.Contains(baseURL, "dashscope-us") {
+ return "us-east-1"
+ }
+
+ // 亚太地域(新加坡)
+ if strings.Contains(baseURL, "ap-southeast-1") {
+ return "ap-southeast-1"
+ }
+
+ // 默认:中国北京
+ return "cn-beijing"
+}
+
+// isPluginEnabled 检查插件机制是否启用
+func (s *Service) isPluginEnabled() bool {
+ // 通过环境变量控制
+ value := os.Getenv("ENABLE_PROVIDER_PLUGINS")
+ return strings.ToLower(value) == "true" || value == "1"
+}
diff --git a/docker-compose.local.yml b/docker-compose.local.yml
index 3adf639aa..3017c6589 100644
--- a/docker-compose.local.yml
+++ b/docker-compose.local.yml
@@ -18,6 +18,7 @@ services:
CANVAS_ALLOW_PRIVATE_UPSTREAMS: ${CANVAS_ALLOW_PRIVATE_UPSTREAMS:-false}
CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS: ${CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS:-}
CANVAS_CORS_ORIGINS: ${CANVAS_CORS_ORIGINS:-http://localhost:3000}
+ ENABLE_PROVIDER_PLUGINS: ${ENABLE_PROVIDER_PLUGINS:-false}
GIN_MODE: release
volumes:
- backend-data:/data
From 0002cdd2cbcaf9819052c157e7b6ca5dcd7a40e3 Mon Sep 17 00:00:00 2001
From: ddCat <2695092045@qq.com>
Date: Mon, 17 Aug 2026 17:20:00 +0800
Subject: [PATCH 05/67] =?UTF-8?q?fix(security):=20=E6=A8=A1=E5=9E=8B?=
=?UTF-8?q?=E7=9B=AE=E5=BD=95=20-=20=E6=94=B6=E6=95=9B=E6=8F=92=E4=BB=B6?=
=?UTF-8?q?=E5=87=BA=E7=AB=99=E8=BE=B9=E7=95=8C=E5=B9=B6=E4=BF=9D=E7=95=99?=
=?UTF-8?q?=E8=A1=A5=E5=85=85=E8=83=BD=E5=8A=9B=E5=85=83=E6=95=B0=E6=8D=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../internal/provider/bailian/discovery.go | 266 +++++-------------
backend/internal/provider/interface.go | 24 +-
backend/internal/provider/openai/discovery.go | 205 --------------
backend/internal/provider/registry.go | 52 ++--
.../internal/service/channel_model_catalog.go | 9 +-
.../service/channel_model_catalog_plugin.go | 195 ++++++-------
6 files changed, 193 insertions(+), 558 deletions(-)
delete mode 100644 backend/internal/provider/openai/discovery.go
diff --git a/backend/internal/provider/bailian/discovery.go b/backend/internal/provider/bailian/discovery.go
index bc8a46b48..b7704a9ee 100644
--- a/backend/internal/provider/bailian/discovery.go
+++ b/backend/internal/provider/bailian/discovery.go
@@ -1,11 +1,7 @@
package bailian
import (
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "sort"
+ "net/url"
"strings"
"infinite-canvas/backend/internal/provider"
@@ -26,109 +22,18 @@ func (d *Discovery) GetProviderID() string {
// Match 判断是否匹配阿里云百炼
func (d *Discovery) Match(baseURL string, headers map[string]string) bool {
- // 匹配阿里云百炼域名
- return strings.Contains(baseURL, "dashscope") ||
- strings.Contains(baseURL, "maas.aliyuncs.com")
+ parsed, err := url.Parse(strings.TrimSpace(baseURL))
+ if err != nil {
+ return false
+ }
+ host := strings.ToLower(parsed.Hostname())
+ dashscopeHost := host == "dashscope.aliyuncs.com" || strings.HasSuffix(host, ".dashscope.aliyuncs.com") || (strings.HasPrefix(host, "dashscope-") && strings.HasSuffix(host, ".aliyuncs.com"))
+ return dashscopeHost || host == "maas.aliyuncs.com" || strings.HasSuffix(host, ".maas.aliyuncs.com")
}
-// DiscoverModels 发现模型列表
-func (d *Discovery) DiscoverModels(ctx context.Context, config provider.DiscoveryConfig) ([]provider.Model, error) {
- // 1. 调用标准 /v1/models 获取文本模型
- textModels, err := d.fetchStandardModels(ctx, config)
- if err != nil {
- return nil, err
- }
-
- // 2. 添加百炼特有的多模态模型
- extendedModels := d.getExtendedModels(config.Region)
-
- // 3. 合并并去重
- allModels := append(textModels, extendedModels...)
- return d.deduplicate(allModels), nil
-}
-
-// fetchStandardModels 调用标准 /v1/models 接口(自己实现,不依赖 openai 包)
-func (d *Discovery) fetchStandardModels(ctx context.Context, config provider.DiscoveryConfig) ([]provider.Model, error) {
- // 构建 URL
- target := config.BaseURL
- if !strings.HasSuffix(target, "/models") {
- if strings.HasSuffix(target, "/") {
- target += "models"
- } else {
- target += "/models"
- }
- }
-
- // 创建请求
- req, err := http.NewRequestWithContext(ctx, "GET", target, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- // 设置认证头
- req.Header.Set("Authorization", "Bearer "+config.APIKey)
- for k, v := range config.Headers {
- req.Header.Set(k, v)
- }
-
- // 执行请求
- client := &http.Client{}
- resp, err := client.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to fetch models: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
- }
-
- // 解析响应
- var payload struct {
- Data []struct {
- ID string `json:"id"`
- Name string `json:"name"`
- SupportedEndpointTypes []string `json:"supported_generation_methods"`
- } `json:"data"`
- }
-
- if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
- return nil, fmt.Errorf("failed to decode response: %w", err)
- }
-
- // 转换为统一格式
- models := make([]provider.Model, 0, len(payload.Data))
- seen := make(map[string]bool)
-
- for _, item := range payload.Data {
- name := strings.TrimSpace(item.ID)
- if name == "" {
- name = strings.TrimSpace(item.Name)
- }
- name = strings.TrimPrefix(name, "models/")
-
- if name == "" || seen[name] {
- continue
- }
- seen[name] = true
-
- models = append(models, provider.Model{
- ID: name,
- DisplayName: name,
- Provider: "bailian",
- Capability: []string{"text"},
- SupportedEndpointTypes: item.SupportedEndpointTypes,
- Metadata: map[string]any{
- "source": "standard",
- },
- })
- }
-
- sort.Slice(models, func(i, j int) bool {
- return models[i].ID < models[j].ID
- })
-
- return models, nil
+// AdditionalModels 只返回百炼标准 /models 未暴露的补充目录。
+func (d *Discovery) AdditionalModels(_ provider.DiscoveryConfig) []provider.Model {
+ return d.deduplicate(d.getExtendedModels())
}
// GetMetadata 返回插件元数据
@@ -143,7 +48,7 @@ func (d *Discovery) GetMetadata() provider.ProviderMetadata {
}
// getExtendedModels 获取百炼特有的扩展模型列表
-func (d *Discovery) getExtendedModels(region string) []provider.Model {
+func (d *Discovery) getExtendedModels() []provider.Model {
models := []provider.Model{
// === 视频生成模型 ===
{
@@ -154,8 +59,8 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model {
SupportedEndpointTypes: []string{"video"},
APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
Metadata: map[string]any{
- "source": "extended",
- "model_type": "video_generation",
+ "source": "extended",
+ "model_type": "video_generation",
"generation_mode": "text_to_video",
},
},
@@ -167,8 +72,8 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model {
SupportedEndpointTypes: []string{"video"},
APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
Metadata: map[string]any{
- "source": "extended",
- "model_type": "video_generation",
+ "source": "extended",
+ "model_type": "video_generation",
"generation_mode": "image_to_video",
},
},
@@ -180,8 +85,8 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model {
SupportedEndpointTypes: []string{"video"},
APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
Metadata: map[string]any{
- "source": "extended",
- "model_type": "video_generation",
+ "source": "extended",
+ "model_type": "video_generation",
"generation_mode": "reference_to_video",
},
},
@@ -193,12 +98,12 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model {
SupportedEndpointTypes: []string{"video"},
APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
Metadata: map[string]any{
- "source": "extended",
- "model_type": "video_generation",
+ "source": "extended",
+ "model_type": "video_generation",
"generation_mode": "video_edit",
},
},
-
+
// Wan 系列视频生成模型
{
ID: "wan2.7-t2v-2026-06-12",
@@ -208,8 +113,8 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model {
SupportedEndpointTypes: []string{"video"},
APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
Metadata: map[string]any{
- "source": "extended",
- "model_type": "video_generation",
+ "source": "extended",
+ "model_type": "video_generation",
"generation_mode": "text_to_video",
},
},
@@ -221,8 +126,8 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model {
SupportedEndpointTypes: []string{"video"},
APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
Metadata: map[string]any{
- "source": "extended",
- "model_type": "video_generation",
+ "source": "extended",
+ "model_type": "video_generation",
"generation_mode": "image_to_video",
},
},
@@ -234,12 +139,12 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model {
SupportedEndpointTypes: []string{"video"},
APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
Metadata: map[string]any{
- "source": "extended",
- "model_type": "video_generation",
+ "source": "extended",
+ "model_type": "video_generation",
"generation_mode": "reference_to_video",
},
},
-
+
// Kling(可灵)系列视频生成模型
{
ID: "kling/kling-v3-omni-video-generation",
@@ -249,10 +154,10 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model {
SupportedEndpointTypes: []string{"video"},
APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
Metadata: map[string]any{
- "source": "extended",
- "model_type": "video_generation",
- "resolution": "up to 4K",
- "duration": "3-15s",
+ "source": "extended",
+ "model_type": "video_generation",
+ "resolution": "up to 4K",
+ "duration": "3-15s",
"generation_modes": []string{"text_to_video", "image_to_video", "reference_to_video", "video_edit"},
},
},
@@ -264,14 +169,14 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model {
SupportedEndpointTypes: []string{"video"},
APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
Metadata: map[string]any{
- "source": "extended",
- "model_type": "video_generation",
- "resolution": "720P-4K",
- "duration": "3-15s",
+ "source": "extended",
+ "model_type": "video_generation",
+ "resolution": "720P-4K",
+ "duration": "3-15s",
"generation_modes": []string{"text_to_video", "image_to_video"},
},
},
-
+
// PixVerse(爱诗)系列 - 文生视频
{
ID: "pixverse/pixverse-c1-t2v",
@@ -281,11 +186,11 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model {
SupportedEndpointTypes: []string{"video"},
APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
Metadata: map[string]any{
- "source": "extended",
- "model_type": "video_generation",
+ "source": "extended",
+ "model_type": "video_generation",
"generation_mode": "text_to_video",
- "recommended": "action scenes, effects",
- "duration": "1-15s",
+ "recommended": "action scenes, effects",
+ "duration": "1-15s",
},
},
{
@@ -296,11 +201,11 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model {
SupportedEndpointTypes: []string{"video"},
APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
Metadata: map[string]any{
- "source": "extended",
- "model_type": "video_generation",
+ "source": "extended",
+ "model_type": "video_generation",
"generation_mode": "text_to_video",
- "recommended": "general purpose",
- "duration": "1-15s",
+ "recommended": "general purpose",
+ "duration": "1-15s",
},
},
{
@@ -311,13 +216,13 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model {
SupportedEndpointTypes: []string{"video"},
APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
Metadata: map[string]any{
- "source": "extended",
- "model_type": "video_generation",
+ "source": "extended",
+ "model_type": "video_generation",
"generation_mode": "text_to_video",
- "deprecated": "recommend upgrade to v6",
+ "deprecated": "recommend upgrade to v6",
},
},
-
+
// PixVerse(爱诗)系列 - 图生视频
{
ID: "pixverse/pixverse-c1-it2v",
@@ -327,10 +232,10 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model {
SupportedEndpointTypes: []string{"video"},
APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
Metadata: map[string]any{
- "source": "extended",
- "model_type": "video_generation",
+ "source": "extended",
+ "model_type": "video_generation",
"generation_mode": "image_to_video",
- "duration": "1-15s",
+ "duration": "1-15s",
},
},
{
@@ -341,10 +246,10 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model {
SupportedEndpointTypes: []string{"video"},
APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
Metadata: map[string]any{
- "source": "extended",
- "model_type": "video_generation",
+ "source": "extended",
+ "model_type": "video_generation",
"generation_mode": "image_to_video",
- "duration": "1-15s",
+ "duration": "1-15s",
},
},
{
@@ -355,12 +260,12 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model {
SupportedEndpointTypes: []string{"video"},
APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
Metadata: map[string]any{
- "source": "extended",
- "model_type": "video_generation",
+ "source": "extended",
+ "model_type": "video_generation",
"generation_mode": "image_to_video",
},
},
-
+
// Vidu 系列视频生成模型
{
ID: "vidu/viduq3-pro_text2video",
@@ -370,11 +275,11 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model {
SupportedEndpointTypes: []string{"video"},
APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
Metadata: map[string]any{
- "source": "extended",
- "model_type": "video_generation",
+ "source": "extended",
+ "model_type": "video_generation",
"generation_mode": "text_to_video",
- "resolution": "540P-1080P",
- "duration": "1-16s",
+ "resolution": "540P-1080P",
+ "duration": "1-16s",
},
},
{
@@ -385,11 +290,11 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model {
SupportedEndpointTypes: []string{"video"},
APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
Metadata: map[string]any{
- "source": "extended",
- "model_type": "video_generation",
+ "source": "extended",
+ "model_type": "video_generation",
"generation_mode": "text_to_video",
- "resolution": "540P-1080P",
- "duration": "1-16s",
+ "resolution": "540P-1080P",
+ "duration": "1-16s",
},
},
{
@@ -400,27 +305,11 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model {
SupportedEndpointTypes: []string{"video"},
APIPath: "/api/v1/services/aigc/video-generation/video-synthesis",
Metadata: map[string]any{
- "source": "extended",
- "model_type": "video_generation",
+ "source": "extended",
+ "model_type": "video_generation",
"generation_mode": "text_to_video",
- "resolution": "540P-1080P",
- "duration": "1-10s",
- },
- },
-
- // === 3D 生成模型 ===
- {
- ID: "Tripo/Tripo-H3.1",
- DisplayName: "Tripo H3.1 3D生成",
- Provider: "bailian",
- Capability: []string{"image"}, // 前端无 3d 分类,暂归 image
- SupportedEndpointTypes: []string{"image"},
- APIPath: "/api/v1/services/aigc/3d-generation",
- RequiresPlan: true,
- Metadata: map[string]any{
- "source": "extended",
- "model_type": "3d_generation",
- "note": "frontend lacks 3d category, classified as image",
+ "resolution": "540P-1080P",
+ "duration": "1-10s",
},
},
@@ -451,21 +340,6 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model {
},
}
- // 根据地域过滤(如果指定了地域)
- if region != "" {
- models = d.filterByRegion(models, region)
- }
-
- return models
-}
-
-// filterByRegion 根据地域过滤模型
-func (d *Discovery) filterByRegion(models []provider.Model, region string) []provider.Model {
- // 美国地域可能不支持某些国内模型
- if region == "us-east-1" {
- // 过滤逻辑:保留所有模型(示例)
- // 实际可以根据需要过滤
- }
return models
}
diff --git a/backend/internal/provider/interface.go b/backend/internal/provider/interface.go
index 0e50b69f8..22ec5f9cf 100644
--- a/backend/internal/provider/interface.go
+++ b/backend/internal/provider/interface.go
@@ -1,11 +1,8 @@
package provider
-import (
- "context"
-)
-
-// ModelDiscovery 模型发现插件接口
-type ModelDiscovery interface {
+// ModelCatalogPlugin 只补充标准 /models 没有暴露的厂商目录项。
+// 标准目录请求、鉴权和出站安全边界仍由 service 统一负责。
+type ModelCatalogPlugin interface {
// GetProviderID 返回服务商唯一标识
GetProviderID() string
@@ -13,8 +10,8 @@ type ModelDiscovery interface {
// 基于 baseURL、headers 等信息判断
Match(baseURL string, headers map[string]string) bool
- // DiscoverModels 发现模型列表
- DiscoverModels(ctx context.Context, config DiscoveryConfig) ([]Model, error)
+ // AdditionalModels 返回标准目录之外的补充模型。
+ AdditionalModels(config DiscoveryConfig) []Model
// GetMetadata 返回插件元数据
GetMetadata() ProviderMetadata
@@ -22,13 +19,10 @@ type ModelDiscovery interface {
// DiscoveryConfig 模型发现配置
type DiscoveryConfig struct {
- BaseURL string // API 基础 URL
- APIKey string // API 密钥
- SecretKey string // 可选的密钥对
- APIFormat string // API 格式:openai, gemini, anthropic
- Headers map[string]string // 自定义请求头
- AllowLocalChannel bool // 是否允许本地渠道
- Region string // 地域(从 baseURL 解析或指定)
+ BaseURL string
+ APIFormat string
+ Headers map[string]string
+ Region string
}
// Model 统一的模型定义
diff --git a/backend/internal/provider/openai/discovery.go b/backend/internal/provider/openai/discovery.go
deleted file mode 100644
index 82e34b35a..000000000
--- a/backend/internal/provider/openai/discovery.go
+++ /dev/null
@@ -1,205 +0,0 @@
-package openai
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "sort"
- "strings"
-
- "infinite-canvas/backend/internal/provider"
-)
-
-// Discovery OpenAI 标准插件
-type Discovery struct{}
-
-// New 创建 OpenAI 插件实例
-func New() *Discovery {
- return &Discovery{}
-}
-
-// GetProviderID 返回服务商标识
-func (d *Discovery) GetProviderID() string {
- return "openai"
-}
-
-// Match 判断是否匹配 OpenAI 兼容端点
-func (d *Discovery) Match(baseURL string, headers map[string]string) bool {
- // 排除已知的其他服务商
- if strings.Contains(baseURL, "dashscope") ||
- strings.Contains(baseURL, "maas.aliyuncs.com") {
- return false // 百炼有自己的插件
- }
-
- // 匹配标准的 OpenAI 兼容端点
- return strings.Contains(baseURL, "/v1") ||
- strings.Contains(baseURL, "openai.com") ||
- strings.Contains(baseURL, "compatible-mode")
-}
-
-// DiscoverModels 发现模型列表
-func (d *Discovery) DiscoverModels(ctx context.Context, config provider.DiscoveryConfig) ([]provider.Model, error) {
- // 构建 /v1/models 请求
- target := config.BaseURL
- if !strings.HasSuffix(target, "/models") {
- if strings.HasSuffix(target, "/") {
- target += "models"
- } else {
- target += "/models"
- }
- }
-
- req, err := http.NewRequestWithContext(ctx, "GET", target, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- // 设置认证头
- if config.APIFormat == "gemini" {
- req.Header.Set("x-goog-api-key", config.APIKey)
- } else {
- req.Header.Set("Authorization", "Bearer "+config.APIKey)
- }
-
- // 添加自定义请求头
- for k, v := range config.Headers {
- req.Header.Set(k, v)
- }
-
- // 执行请求
- client := &http.Client{}
- resp, err := client.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to fetch models: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
- }
-
- // 解析响应
- var payload struct {
- Data []modelItem `json:"data"`
- Models []modelItem `json:"models"` // Gemini 使用这个字段
- }
-
- if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
- return nil, fmt.Errorf("failed to decode response: %w", err)
- }
-
- // 提取模型列表
- items := payload.Data
- if config.APIFormat == "gemini" && len(payload.Models) > 0 {
- items = payload.Models
- }
-
- // 转换为统一格式
- models := make([]provider.Model, 0, len(items))
- seen := make(map[string]bool)
-
- for _, item := range items {
- // 提取模型 ID
- name := strings.TrimSpace(item.ID)
- if name == "" {
- name = strings.TrimSpace(item.Name)
- }
- name = strings.TrimPrefix(name, "models/") // 移除 Gemini 的前缀
-
- if name == "" || seen[name] {
- continue
- }
- seen[name] = true
-
- models = append(models, provider.Model{
- ID: name,
- DisplayName: d.getDisplayName(item),
- Provider: "openai",
- Capability: d.inferCapability(name),
- SupportedEndpointTypes: d.normalizeSupportedTypes(item.SupportedEndpointTypes),
- Metadata: map[string]any{
- "source": "standard",
- },
- })
- }
-
- // 排序
- sort.Slice(models, func(i, j int) bool {
- return models[i].ID < models[j].ID
- })
-
- return models, nil
-}
-
-// GetMetadata 返回插件元数据
-func (d *Discovery) GetMetadata() provider.ProviderMetadata {
- return provider.ProviderMetadata{
- Name: "openai",
- Version: "1.0.0",
- Description: "Standard OpenAI-compatible model discovery",
- Author: "infinite-canvas",
- }
-}
-
-// modelItem 内部使用的模型数据结构
-type modelItem struct {
- ID string `json:"id"`
- Name string `json:"name"`
- SupportedEndpointTypes []string `json:"supported_generation_methods"`
-}
-
-// getDisplayName 获取显示名称
-func (d *Discovery) getDisplayName(item modelItem) string {
- if item.Name != "" {
- return item.Name
- }
- return item.ID
-}
-
-// inferCapability 根据模型 ID 推断能力
-func (d *Discovery) inferCapability(modelID string) []string {
- lower := strings.ToLower(modelID)
-
- // 图像模型
- if strings.Contains(lower, "dall-e") ||
- strings.Contains(lower, "image") {
- return []string{"image"}
- }
-
- // 音频模型
- if strings.Contains(lower, "whisper") ||
- strings.Contains(lower, "tts") ||
- strings.Contains(lower, "audio") {
- return []string{"audio"}
- }
-
- // 嵌入模型
- if strings.Contains(lower, "embedding") {
- return []string{"embedding"}
- }
-
- // 默认为文本模型
- return []string{"text"}
-}
-
-// normalizeSupportedTypes 标准化支持的端点类型
-func (d *Discovery) normalizeSupportedTypes(types []string) []string {
- if len(types) == 0 {
- return nil
- }
-
- seen := make(map[string]bool)
- result := make([]string, 0, len(types))
-
- for _, t := range types {
- normalized := strings.TrimSpace(t)
- if normalized == "" || seen[normalized] {
- continue
- }
- seen[normalized] = true
- result = append(result, normalized)
- }
-
- return result
-}
diff --git a/backend/internal/provider/registry.go b/backend/internal/provider/registry.go
index 72bad5b47..e12d9af50 100644
--- a/backend/internal/provider/registry.go
+++ b/backend/internal/provider/registry.go
@@ -7,54 +7,50 @@ import (
// Registry 插件注册中心
type Registry struct {
mu sync.RWMutex
- providers map[string]ModelDiscovery
+ providers []ModelCatalogPlugin
}
-var globalRegistry = &Registry{
- providers: make(map[string]ModelDiscovery),
-}
+var globalRegistry = &Registry{}
// Register 注册插件
-func Register(discovery ModelDiscovery) {
+func Register(discovery ModelCatalogPlugin) {
globalRegistry.mu.Lock()
defer globalRegistry.mu.Unlock()
id := discovery.GetProviderID()
- globalRegistry.providers[id] = discovery
+ for index, registered := range globalRegistry.providers {
+ if registered.GetProviderID() == id {
+ globalRegistry.providers[index] = discovery
+ return
+ }
+ }
+ globalRegistry.providers = append(globalRegistry.providers, discovery)
}
// GetProvider 根据 ID 获取插件
-func GetProvider(id string) (ModelDiscovery, bool) {
+func GetProvider(id string) (ModelCatalogPlugin, bool) {
globalRegistry.mu.RLock()
defer globalRegistry.mu.RUnlock()
- provider, ok := globalRegistry.providers[id]
- return provider, ok
+ for _, registered := range globalRegistry.providers {
+ if registered.GetProviderID() == id {
+ return registered, true
+ }
+ }
+ return nil, false
}
// MatchProvider 根据配置匹配插件
-func MatchProvider(baseURL string, headers map[string]string) ModelDiscovery {
+func MatchProvider(baseURL string, headers map[string]string) ModelCatalogPlugin {
globalRegistry.mu.RLock()
defer globalRegistry.mu.RUnlock()
- // 按优先级匹配
- // 1. 先匹配特定服务商(如百炼)
- for _, provider := range globalRegistry.providers {
- if provider.GetProviderID() == "openai" {
- continue // OpenAI 插件最后匹配
- }
- if provider.Match(baseURL, headers) {
- return provider
+ // 按注册顺序匹配,避免 map 遍历导致多个厂商规则命中时结果不稳定。
+ for _, registered := range globalRegistry.providers {
+ if registered.Match(baseURL, headers) {
+ return registered
}
}
-
- // 2. 最后使用 OpenAI 默认实现
- if provider, ok := globalRegistry.providers["openai"]; ok {
- if provider.Match(baseURL, headers) {
- return provider
- }
- }
-
return nil
}
@@ -64,8 +60,8 @@ func ListProviders() []ProviderMetadata {
defer globalRegistry.mu.RUnlock()
result := make([]ProviderMetadata, 0, len(globalRegistry.providers))
- for _, provider := range globalRegistry.providers {
- result = append(result, provider.GetMetadata())
+ for _, registered := range globalRegistry.providers {
+ result = append(result, registered.GetMetadata())
}
return result
}
diff --git a/backend/internal/service/channel_model_catalog.go b/backend/internal/service/channel_model_catalog.go
index 681493b7e..0a030bc2e 100644
--- a/backend/internal/service/channel_model_catalog.go
+++ b/backend/internal/service/channel_model_catalog.go
@@ -104,12 +104,6 @@ type ChannelModelCatalogOption struct {
}
func (s *Service) FetchChannelModelCatalog(ctx context.Context, actor *model.User, input ChannelModelsRequest) ([]ChannelModelCatalogItem, error) {
- // 功能开关:如果启用插件机制,使用新实现
- if s.isPluginEnabled() {
- return s.FetchChannelModelCatalogWithPlugin(ctx, actor, input)
- }
-
- // 否则使用原有实现(向后兼容)
if actor == nil || strings.TrimSpace(actor.ID) == "" {
return nil, Unauthorized("请先登录")
}
@@ -206,6 +200,9 @@ func (s *Service) FetchChannelModelCatalog(ctx context.Context, actor *model.Use
sort.Slice(catalog, func(left int, right int) bool {
return catalog[left].ID < catalog[right].ID
})
+ if s.isPluginEnabled() {
+ catalog = extendChannelModelCatalog(baseURL, apiFormat, headers, catalog)
+ }
return catalog, nil
}
diff --git a/backend/internal/service/channel_model_catalog_plugin.go b/backend/internal/service/channel_model_catalog_plugin.go
index 620fced76..e687919fa 100644
--- a/backend/internal/service/channel_model_catalog_plugin.go
+++ b/backend/internal/service/channel_model_catalog_plugin.go
@@ -1,138 +1,117 @@
package service
import (
- "context"
- "fmt"
+ "net/url"
"os"
"sort"
"strings"
"sync"
- "infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/provider"
"infinite-canvas/backend/internal/provider/bailian"
- "infinite-canvas/backend/internal/provider/openai"
)
-var (
- pluginsInitialized bool
- pluginsInitMutex sync.Mutex
-)
+var pluginsInitOnce sync.Once
-// initPlugins 初始化并注册所有插件(只执行一次)
-func initPlugins() {
- pluginsInitMutex.Lock()
- defer pluginsInitMutex.Unlock()
-
- if pluginsInitialized {
- return
- }
-
- // 注册 OpenAI 标准插件(默认)
- provider.Register(openai.New())
-
- // 注册阿里云百炼插件
- provider.Register(bailian.New())
-
- // 未来可以添加更多插件:
- // provider.Register(gemini.New())
- // provider.Register(anthropic.New())
-
- pluginsInitialized = true
-}
-
-// FetchChannelModelCatalogWithPlugin 使用插件机制拉取模型(新实现)
-func (s *Service) FetchChannelModelCatalogWithPlugin(ctx context.Context, actor *model.User, input ChannelModelsRequest) ([]ChannelModelCatalogItem, error) {
- // 确保插件已初始化
- initPlugins()
-
- // 验证基本参数
- if actor == nil || strings.TrimSpace(actor.ID) == "" {
- return nil, Unauthorized("请先登录")
- }
- baseURL := strings.TrimRight(strings.TrimSpace(input.BaseURL), "/")
- apiKey := strings.TrimSpace(input.APIKey)
- if baseURL == "" {
- return nil, BadAuthRequest("请填写 Base URL")
- }
- if apiKey == "" {
- return nil, BadAuthRequest("请填写 API Key")
- }
-
- // 1. 匹配插件
- headers := s.headersToMap(input.Headers)
- discovery := provider.MatchProvider(baseURL, headers)
- if discovery == nil {
- return nil, fmt.Errorf("未找到匹配的服务商插件,请检查 Base URL")
- }
-
- // 2. 构建配置
- config := provider.DiscoveryConfig{
- BaseURL: baseURL,
- APIKey: apiKey,
- APIFormat: strings.ToLower(strings.TrimSpace(input.APIFormat)),
- Headers: headers,
- AllowLocalChannel: input.AllowLocalChannel,
- Region: s.extractRegion(baseURL),
- }
-
- if config.APIFormat == "" {
- config.APIFormat = "openai"
- }
-
- // 3. 调用插件发现模型
- models, err := discovery.DiscoverModels(ctx, config)
- if err != nil {
- return nil, fmt.Errorf("插件发现模型失败: %w", err)
- }
-
- // 4. 转换为内部格式
- catalog := make([]ChannelModelCatalogItem, 0, len(models))
- for _, m := range models {
- catalog = append(catalog, ChannelModelCatalogItem{
- ID: m.ID,
- SupportedEndpointTypes: m.SupportedEndpointTypes,
- })
- }
-
- // 5. 排序
- sort.Slice(catalog, func(i, j int) bool {
- return catalog[i].ID < catalog[j].ID
+func initModelCatalogPlugins() {
+ pluginsInitOnce.Do(func() {
+ provider.Register(bailian.New())
})
-
- return catalog, nil
}
-// headersToMap 将 OutboundHeader 数组转换为 map
-func (s *Service) headersToMap(headers []OutboundHeader) map[string]string {
- result := make(map[string]string, len(headers))
- for _, h := range headers {
- if h.Name != "" && h.Value != "" {
- result[h.Name] = h.Value
+// extendChannelModelCatalog 在已通过统一安全出站链路获取的标准目录上补充厂商条目。
+// 插件不持有密钥也不自行发 HTTP,避免绕过 SSRF、请求头和响应大小边界。
+func extendChannelModelCatalog(baseURL string, apiFormat string, headers []OutboundHeader, catalog []ChannelModelCatalogItem) []ChannelModelCatalogItem {
+ initModelCatalogPlugins()
+ discovery := provider.MatchProvider(baseURL, outboundHeadersMap(headers))
+ if discovery == nil {
+ return catalog
+ }
+ extra := discovery.AdditionalModels(provider.DiscoveryConfig{
+ BaseURL: baseURL,
+ APIFormat: apiFormat,
+ Headers: outboundHeadersMap(headers),
+ Region: modelCatalogRegion(baseURL),
+ })
+ if len(extra) == 0 {
+ return catalog
+ }
+
+ indexByID := make(map[string]int, len(catalog)+len(extra))
+ for index := range catalog {
+ indexByID[catalog[index].ID] = index
+ }
+ for _, model := range extra {
+ item := providerCatalogItem(model)
+ if item.ID == "" {
+ continue
}
+ if index, exists := indexByID[item.ID]; exists {
+ catalog[index] = enrichCatalogItem(catalog[index], item)
+ continue
+ }
+ indexByID[item.ID] = len(catalog)
+ catalog = append(catalog, item)
+ }
+ sort.Slice(catalog, func(left int, right int) bool {
+ return catalog[left].ID < catalog[right].ID
+ })
+ return catalog
+}
+
+func providerCatalogItem(item provider.Model) ChannelModelCatalogItem {
+ modelType := ""
+ for _, capability := range item.Capability {
+ if normalized := normalizeCatalogModelType(capability); normalized != "" {
+ modelType = normalized
+ break
+ }
+ }
+ return ChannelModelCatalogItem{
+ ID: strings.TrimPrefix(strings.TrimSpace(item.ID), "models/"),
+ DisplayName: strings.TrimSpace(item.DisplayName),
+ ModelType: modelType,
+ SupportedEndpointTypes: normalizeCatalogEndpointTypes(item.SupportedEndpointTypes),
+ }
+}
+
+func enrichCatalogItem(current ChannelModelCatalogItem, fallback ChannelModelCatalogItem) ChannelModelCatalogItem {
+ if current.DisplayName == "" {
+ current.DisplayName = fallback.DisplayName
+ }
+ if current.ModelType == "" {
+ current.ModelType = fallback.ModelType
+ }
+ if len(current.SupportedEndpointTypes) == 0 {
+ current.SupportedEndpointTypes = fallback.SupportedEndpointTypes
+ }
+ return current
+}
+
+func outboundHeadersMap(headers []OutboundHeader) map[string]string {
+ result := make(map[string]string, len(headers))
+ for _, header := range headers {
+ result[header.Name] = header.Value
}
return result
}
-// extractRegion 从 baseURL 提取地域信息
-func (s *Service) extractRegion(baseURL string) string {
- // 美国地域
- if strings.Contains(baseURL, "dashscope-us") {
+func modelCatalogRegion(baseURL string) string {
+ parsed, err := url.Parse(strings.TrimSpace(baseURL))
+ if err != nil {
+ return ""
+ }
+ host := strings.ToLower(parsed.Hostname())
+ if strings.Contains(host, "dashscope-us") {
return "us-east-1"
}
-
- // 亚太地域(新加坡)
- if strings.Contains(baseURL, "ap-southeast-1") {
+ if strings.Contains(host, "ap-southeast-1") {
return "ap-southeast-1"
}
-
- // 默认:中国北京
return "cn-beijing"
}
-// isPluginEnabled 检查插件机制是否启用
func (s *Service) isPluginEnabled() bool {
- // 通过环境变量控制
- value := os.Getenv("ENABLE_PROVIDER_PLUGINS")
- return strings.ToLower(value) == "true" || value == "1"
+ value := strings.ToLower(strings.TrimSpace(os.Getenv("ENABLE_PROVIDER_PLUGINS")))
+ return value == "true" || value == "1"
}
From d3a029732f6aa6be07957237b331ebcd112034ac Mon Sep 17 00:00:00 2001
From: QAyong
Date: Mon, 17 Aug 2026 19:46:50 +0800
Subject: [PATCH 06/67] =?UTF-8?q?feat(canvas):=20=E5=AF=BC=E6=BC=94?=
=?UTF-8?q?=E5=8F=B0=20-=20=E5=AE=8C=E5=96=84=E9=AA=A8=E9=AA=BC=E6=8E=A7?=
=?UTF-8?q?=E5=88=B6=E3=80=81=E5=AF=B9=E8=B1=A1=E7=AE=A1=E7=90=86=E4=B8=8E?=
=?UTF-8?q?=E9=9D=A2=E6=9D=BF=E4=BA=A4=E4=BA=92?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../director/canvas-director-workbench.tsx | 132 ++++++++++++++++--
.../canvas/director/director-sequencer.tsx | 15 +-
.../canvas/director/director-viewport.tsx | 93 ++++++++++--
web/src/lib/canvas/director/director-scene.ts | 18 ++-
web/src/pages/canvas/project.tsx | 1 +
.../canvas/use-director-workbench-store.ts | 5 +-
web/src/types/director.ts | 6 +-
7 files changed, 240 insertions(+), 30 deletions(-)
diff --git a/web/src/components/canvas/director/canvas-director-workbench.tsx b/web/src/components/canvas/director/canvas-director-workbench.tsx
index 6c90d570c..4aa627374 100644
--- a/web/src/components/canvas/director/canvas-director-workbench.tsx
+++ b/web/src/components/canvas/director/canvas-director-workbench.tsx
@@ -1,7 +1,9 @@
-import { App, Button, ColorPicker, Input, InputNumber, Select, Switch } from "antd";
+import { App, Button, ColorPicker, Dropdown, Input, InputNumber, Select, Slider, Switch } from "antd";
+import type { MenuProps } from "antd";
import { Box, BoxSelect, Camera, Circle, Cuboid, FileUp, Focus, Image as ImageIcon, LampDesk, Lightbulb, Plus, Redo2, Save, Trash2, Undo2, UserRound, Video, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from "react";
import { nanoid } from "nanoid";
+import { Euler, Quaternion } from "three";
import type { AnimationClip } from "three";
import { DirectorViewport, type DirectorViewportHandle } from "@/components/canvas/director/director-viewport";
@@ -17,7 +19,7 @@ import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasNodeData } from "@/types/canvas";
import type { DirectorCamera, DirectorCameraMove, DirectorHumanoidBone, DirectorLight, DirectorObject, DirectorPose, DirectorQuat, DirectorRig, DirectorScene, DirectorSceneOutput, DirectorShot, DirectorShotSize, DirectorTransform, DirectorVec3 } from "@/types/director";
-export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onChange, onApply }: { open: boolean; scene: DirectorScene | null; imageNodes: CanvasNodeData[]; onClose: () => void; onChange: (scene: DirectorScene) => void; onApply: (output: DirectorSceneOutput) => Promise }) {
+export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onChange, onApply, onDeleteImageNode }: { open: boolean; scene: DirectorScene | null; imageNodes: CanvasNodeData[]; onClose: () => void; onChange: (scene: DirectorScene) => void; onApply: (output: DirectorSceneOutput) => Promise; onDeleteImageNode: (nodeId: string) => void }) {
const { message } = App.useApp();
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const viewportRef = useRef(null);
@@ -36,6 +38,7 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
const selectedBone = useDirectorWorkbenchStore((state) => state.selectedBone);
const autoKey = useDirectorWorkbenchStore((state) => state.autoKey);
const sequencerHeight = useDirectorWorkbenchStore((state) => state.sequencerHeight);
+ const sequencerVisible = useDirectorWorkbenchStore((state) => state.sequencerVisible);
const setSelectedObjectId = useDirectorWorkbenchStore((state) => state.setSelectedObjectId);
const setSelectedLightId = useDirectorWorkbenchStore((state) => state.setSelectedLightId);
const setTransformMode = useDirectorWorkbenchStore((state) => state.setTransformMode);
@@ -45,6 +48,7 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
const setSelectedBone = useDirectorWorkbenchStore((state) => state.setSelectedBone);
const setAutoKey = useDirectorWorkbenchStore((state) => state.setAutoKey);
const setSequencerHeight = useDirectorWorkbenchStore((state) => state.setSequencerHeight);
+ const setSequencerVisible = useDirectorWorkbenchStore((state) => state.setSequencerVisible);
const resetWorkbench = useDirectorWorkbenchStore((state) => state.reset);
const assets = useAssetStore((state) => state.assets);
const addAsset = useAssetStore((state) => state.addAsset);
@@ -110,6 +114,30 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
const updateObject = (id: string, patch: Partial) => commit((current) => ({ ...current, objects: current.objects.map((item) => (item.id === id ? { ...item, ...patch } : item)) }));
const updateLight = (id: string, patch: Partial) => commit((current) => ({ ...current, lights: current.lights.map((item) => (item.id === id ? { ...item, ...patch } : item)) }));
const updateShot = (id: string, patch: Partial) => commit((current) => ({ ...current, shots: current.shots.map((item) => (item.id === id ? { ...item, ...patch } : item)) }));
+ const removeObject = (id: string) => {
+ commit((current) => ({ ...current, objects: current.objects.filter((item) => item.id !== id) }));
+ if (selectedObjectId === id) {
+ setSelectedObjectId(null);
+ setSelectedBone(null);
+ }
+ };
+ const removeLight = (id: string) => {
+ commit((current) => ({ ...current, lights: current.lights.filter((item) => item.id !== id) }));
+ if (selectedLightId === id) setSelectedLightId(null);
+ };
+ const removeCamera = (id: string) => {
+ if (!draft || draft.cameras.length <= 1) {
+ message.warning("至少保留一台摄影机");
+ return;
+ }
+ const fallback = draft.cameras.find((item) => item.id !== id);
+ if (!fallback) return;
+ commit((current) => ({
+ ...current,
+ cameras: current.cameras.filter((item) => item.id !== id),
+ shots: current.shots.map((shot) => shot.cameraId === id ? { ...shot, cameraId: fallback.id } : shot),
+ }));
+ };
const addPrimitive = (primitive: DirectorObject["primitive"], name: string) => {
const object = createDirectorObject(primitive, name);
@@ -152,12 +180,29 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
if (activeShot) updateShot(activeShot.id, { cameraId: camera.id });
};
- const addLight = () => {
- const light = createDirectorLight("point", `灯光 ${draft?.lights.length ? draft.lights.length + 1 : 1}`, [2, 3, 2], 1.5);
+ const addLight = (type: DirectorLight["type"] = "point", label = "灯光", position: DirectorVec3 = [2, 3, 2], intensity = 1.5) => {
+ const light = createDirectorLight(type, `${label} ${draft?.lights.length ? draft.lights.length + 1 : 1}`, position, intensity);
commit((current) => ({ ...current, lights: [...current.lights, light] }));
setSelectedLightId(light.id);
};
+ const addCameraMenuItems: MenuProps["items"] = [
+ { key: "camera", icon: , label: "添加摄影机", onClick: addCamera },
+ ];
+ const addLightMenuItems: MenuProps["items"] = [
+ { key: "directional", icon: , label: "方向光", onClick: () => addLight("directional", "方向光", [4, 6, 4], 2.4) },
+ { key: "point", icon: , label: "点光源", onClick: () => addLight("point", "点光源") },
+ { key: "spot", icon: , label: "聚光灯", onClick: () => addLight("spot", "聚光灯", [2, 4, 2], 2) },
+ { key: "ambient", icon: , label: "环境光", onClick: () => addLight("ambient", "环境光", [0, 0, 0], 0.65) },
+ ];
+ const addObjectMenuItems: MenuProps["items"] = [
+ { key: "actor", icon: , label: "演员", onClick: addActor },
+ { key: "box", icon: , label: "立方体", onClick: () => addPrimitive("box", "立方体") },
+ { key: "sphere", icon: , label: "球体", onClick: () => addPrimitive("sphere", "球体") },
+ { key: "cylinder", icon: , label: "圆柱", onClick: () => addPrimitive("cylinder", "圆柱") },
+ { key: "model", icon: , label: "上传模型", onClick: () => modelInputRef.current?.click() },
+ ];
+
const addShot = () => {
if (!activeCamera) return;
const shot: DirectorShot = { id: nanoid(), name: `镜头 ${(draft?.shots.length || 0) + 1}`, cameraId: activeCamera.id, duration: 5, fps: 24, shotSize: "medium", cameraMove: "static", prompt: "" };
@@ -291,14 +336,14 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
@@ -320,20 +365,28 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
- setPlaying(!playing)} onPlayheadChange={setPlayhead} onAutoKeyChange={setAutoKey} onHeightChange={setSequencerHeight} onSelectObject={setSelectedObjectId} onSelectBone={setSelectedBone} onRecordKeyframe={recordSelectedKeyframe} onAddShot={addShot} onSelectShot={(id) => { commit((current) => ({ ...current, activeShotId: id })); setPlayhead(0); }} />
+ setPlaying(!playing)} onPlayheadChange={setPlayhead} onAutoKeyChange={setAutoKey} onHeightChange={setSequencerHeight} onVisibilityChange={setSequencerVisible} onSelectObject={setSelectedObjectId} onSelectBone={setSelectedBone} onRecordKeyframe={recordSelectedKeyframe} onAddShot={addShot} onSelectShot={(id) => { commit((current) => ({ ...current, activeShotId: id })); setPlayhead(0); }} />
);
}
-function ObjectInspector({ object, playhead, selectedBone, onSelectBone, onUpdate, onAddKeyframe, onDelete }: { object: DirectorObject; playhead: number; selectedBone: string | null; onSelectBone: (bone: string | null) => void; onUpdate: (patch: Partial