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