* fix(backend): 分镜生成 - 自动修复携带原始剧情并支持有界重试 * fix(canvas): 视频模型 - 按端点协议拉取模型并适配分镜视频节点 * fix(canvas): 视频模型 - 保留已有协议并刷新分镜节点模型 * fix(canvas): 引用图预览 - 修正提示面板代码格式 --------- Co-authored-by: sikongyue <3155157671@qq.com> Co-authored-by: ddCat <2695092045@qq.com>
178 lines
5.7 KiB
Go
178 lines
5.7 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"sort"
|
||
"strings"
|
||
|
||
"infinite-canvas/backend/internal/model"
|
||
)
|
||
|
||
type ChannelModelsRequest struct {
|
||
BaseURL string `json:"baseUrl"`
|
||
APIKey string `json:"apiKey"`
|
||
APIFormat string `json:"apiFormat"`
|
||
Headers []OutboundHeader `json:"headers"`
|
||
}
|
||
|
||
type channelModelsPayload struct {
|
||
Data []channelModelItem `json:"data"`
|
||
Models []channelModelItem `json:"models"`
|
||
Error *providerError `json:"error"`
|
||
Code *int `json:"code"`
|
||
Msg string `json:"msg"`
|
||
}
|
||
|
||
type channelModelItem struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
SupportedEndpointTypes []string `json:"supported_endpoint_types"`
|
||
}
|
||
|
||
func (s *Service) FetchChannelModels(ctx context.Context, actor *model.User, input ChannelModelsRequest) ([]string, error) {
|
||
items, err := s.FetchChannelModelCatalog(ctx, actor, input)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
seen := make(map[string]bool, len(items))
|
||
models := make([]string, 0, len(items))
|
||
for _, item := range items {
|
||
name := strings.TrimSpace(item.ID)
|
||
if name == "" || seen[name] {
|
||
continue
|
||
}
|
||
seen[name] = true
|
||
models = append(models, name)
|
||
}
|
||
sort.Strings(models)
|
||
return models, nil
|
||
}
|
||
|
||
// ChannelModelCatalogItem 是前端自定义渠道拉取模型目录后的最小合同;
|
||
// supportedEndpointTypes 由上游 /models 返回,前端据此推导能力与协议,不依赖模型名猜测。
|
||
type ChannelModelCatalogItem struct {
|
||
ID string `json:"id"`
|
||
SupportedEndpointTypes []string `json:"supportedEndpointTypes,omitempty"`
|
||
}
|
||
|
||
func (s *Service) FetchChannelModelCatalog(ctx context.Context, actor *model.User, input ChannelModelsRequest) ([]ChannelModelCatalogItem, error) {
|
||
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")
|
||
}
|
||
apiFormat := strings.ToLower(strings.TrimSpace(input.APIFormat))
|
||
if apiFormat == "" {
|
||
apiFormat = "openai"
|
||
}
|
||
if apiFormat != "openai" && apiFormat != "gemini" {
|
||
return nil, BadAuthRequest("接口协议不支持拉取模型")
|
||
}
|
||
headers, err := NormalizeOutboundHeaders(input.Headers)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
target := apiURL(baseURL, "/models")
|
||
if apiFormat == "gemini" {
|
||
if !strings.HasSuffix(strings.ToLower(baseURL), "/v1beta") {
|
||
baseURL += "/v1beta"
|
||
}
|
||
target = baseURL + "/models"
|
||
}
|
||
if _, err := ValidateOutboundURL(target); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||
if err != nil {
|
||
return nil, BadAuthRequest("模型服务地址无效")
|
||
}
|
||
if apiFormat == "gemini" {
|
||
request.Header.Set("x-goog-api-key", apiKey)
|
||
} else {
|
||
request.Header.Set("Authorization", "Bearer "+apiKey)
|
||
}
|
||
ApplyOutboundHeaders(request, headers)
|
||
|
||
// 只代理固定的模型目录 GET;用户密钥仅用于本次请求,不写入数据库或日志。
|
||
data, _, err := doBinary(request)
|
||
if err != nil {
|
||
return nil, channelModelsUpstreamError(err)
|
||
}
|
||
var payload channelModelsPayload
|
||
if err := json.Unmarshal(data, &payload); err != nil {
|
||
return nil, &AuthError{Status: http.StatusBadGateway, Message: "模型服务返回的不是有效 JSON"}
|
||
}
|
||
if payload.Error != nil && strings.TrimSpace(payload.Error.Message) != "" {
|
||
return nil, &AuthError{Status: http.StatusBadGateway, Message: payload.Error.Message}
|
||
}
|
||
if payload.Code != nil && *payload.Code != 0 {
|
||
return nil, &AuthError{Status: http.StatusBadGateway, Message: firstNonEmpty(strings.TrimSpace(payload.Msg), "模型服务返回失败")}
|
||
}
|
||
|
||
items := payload.Data
|
||
if apiFormat == "gemini" {
|
||
items = payload.Models
|
||
}
|
||
seen := make(map[string]bool, len(items))
|
||
catalog := make([]ChannelModelCatalogItem, 0, len(items))
|
||
for _, item := range items {
|
||
name := strings.TrimPrefix(strings.TrimSpace(firstNonEmpty(item.ID, item.Name)), "models/")
|
||
if name == "" || seen[name] {
|
||
continue
|
||
}
|
||
seen[name] = true
|
||
catalog = append(catalog, ChannelModelCatalogItem{ID: name, SupportedEndpointTypes: normalizeCatalogEndpointTypes(item.SupportedEndpointTypes)})
|
||
}
|
||
sort.Slice(catalog, func(left int, right int) bool {
|
||
return catalog[left].ID < catalog[right].ID
|
||
})
|
||
return catalog, nil
|
||
}
|
||
|
||
func normalizeCatalogEndpointTypes(values []string) []string {
|
||
seen := make(map[string]bool, len(values))
|
||
normalized := make([]string, 0, len(values))
|
||
for _, value := range values {
|
||
item := strings.TrimSpace(value)
|
||
if item == "" || seen[item] {
|
||
continue
|
||
}
|
||
seen[item] = true
|
||
normalized = append(normalized, item)
|
||
}
|
||
return normalized
|
||
}
|
||
|
||
func channelModelsUpstreamError(err error) error {
|
||
var authErr *AuthError
|
||
if errors.As(err, &authErr) {
|
||
return authErr
|
||
}
|
||
var httpErr providerHTTPError
|
||
if !errors.As(err, &httpErr) {
|
||
return &AuthError{Status: http.StatusBadGateway, Message: "连接模型服务失败:" + err.Error()}
|
||
}
|
||
switch httpErr.StatusCode {
|
||
case http.StatusUnauthorized, http.StatusForbidden:
|
||
return &AuthError{Status: http.StatusBadGateway, Message: "模型服务鉴权失败,请检查 API Key"}
|
||
case http.StatusNotFound:
|
||
return &AuthError{Status: http.StatusBadGateway, Message: "模型服务未提供 /models 接口"}
|
||
case http.StatusTooManyRequests:
|
||
return &AuthError{Status: http.StatusBadGateway, Message: "模型服务请求过于频繁或额度不足"}
|
||
default:
|
||
return &AuthError{Status: http.StatusBadGateway, Message: fmt.Sprintf("模型服务请求失败:HTTP %d", httpErr.StatusCode)}
|
||
}
|
||
}
|