Files
ttstd-infinite-canvas/backend/internal/app/channel_models.go
Rou 054dab28cc fix(workbench): 模型目录与助手形象 - 修正 Live2D 首帧闪现和模型默认分组 (#773)
* fix(workbench): 模型目录与助手形象 - 修正 Live2D 首帧闪现和模型默认分组

进入画布时形象配置与 Live2D 首帧未就绪不再显示默认球,改为加载提示,失败才回退。

新导入模型一级目录默认未分类、二级渠道展示名默认跟随上游 ID;历史非空分组(含与模型 ID 同名)保留。

拉取模型弹窗默认只勾选已存在模型并移除逐项已存在字样;一级分组下拉点击即展开全部选项。

* style(web): 画布与模型导入 - 按 Prettier 规范格式化改动文件

修复 PR 中 Web checks 的格式化检查失败:Live2D 启动器 JSX、模型导入选择工具函数与 Live2D 加载测试按项目 Prettier 配置重新排版,不改变行为。

* test(web): Live2D 生命周期 - 修复 bun 1.3.9 下 text 导入与模块导入冲突

agent-live2d-lifecycle 曾用 with { type: text } 导入 live2d-avatar.tsx,bun 1.3.9 会按路径复用该缓存,导致新增的模块导入拿不到 Live2DAvatar 具名导出并报 SyntaxError。改为 readFileSync 读取源码文本,本地以 bun 1.3.9 复现并验证通过。
2026-10-14 09:36:01 +08:00

463 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"encoding/json"
"errors"
"strings"
"time"
"gorm.io/gorm"
"yingce/backend/internal/model"
"yingce/backend/internal/protocol"
"yingce/backend/internal/repository"
)
type ChannelModelRequest struct {
ModelKey string `json:"modelKey"`
ProviderModelKey string `json:"providerModelKey"`
DisplayName string `json:"displayName"`
ChannelLabel string `json:"channelLabel"`
Tags []model.ChannelModelTag `json:"tags"`
Description string `json:"description"`
Icon string `json:"icon"`
Capability string `json:"capability"`
Protocol string `json:"protocol"`
BillingMode string `json:"billingMode"`
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"`
OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"`
CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"`
PriceConfigured bool `json:"priceConfigured"`
Enabled *bool `json:"enabled"`
CapabilityConfig *ModelCapabilityConfig `json:"capabilityConfig"`
PriceTiers []ChannelModelPriceTierRequest `json:"priceTiers"`
}
const maxAdminChannelModelBatchDeleteCount = 100
// ChannelModelPriceTierRequest 是系统渠道内某个规格的上游 SKU 与结算价格。
// Resolution="*"、VideoSeconds=0 分别表示任意分辨率和任意时长。
type ChannelModelPriceTierRequest struct {
CostPricing model.CreditCostPricing `json:"costPricing"`
// Selector 是 SKU 的规范匹配条件。支持 operation、quality、size、vquality、videoSeconds、imageCount、videoGenerateAudio;
// operation 可区分文生/图生/视频生,避免同一分辨率下错误复用价格。
Selector map[string]string `json:"selector"`
Resolution string `json:"resolution"`
VideoSeconds int `json:"videoSeconds"`
ProviderModelKey string `json:"providerModelKey"`
BillingMode string `json:"billingMode"`
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"`
OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"`
CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"`
PriceConfigured bool `json:"priceConfigured"`
Enabled *bool `json:"enabled"`
}
// AdminChannelModelFetchResult 是管理员从上游拉目录后的汇总:models 为去重后的标识,added 为本次新建条数。
type AdminChannelModelFetchResult struct {
Models []string `json:"models"`
Added int64 `json:"added"`
}
type AdminChannelModelImportRequest struct {
Models []string `json:"models"`
}
type AdminChannelModelTestResult struct {
DurationMs int64 `json:"durationMs"`
}
func (s *Service) EnsureSystemChannelModels() error {
channels, err := s.repo.SystemChannels(true)
if err != nil {
return err
}
for index := range channels {
items, err := s.repo.ChannelModels(channels[index].ID, true)
if err != nil {
return err
}
if len(items) == 0 {
if err := s.syncInitialChannelModels(&channels[index], channelModelNames(channels[index])); err != nil {
return err
}
}
}
return nil
}
func (s *Service) AdminChannelModels(actor *model.User, channelID string) ([]model.ChannelModel, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
if _, err := s.adminSystemChannel(channelID); err != nil {
return nil, err
}
items, err := s.ensureChannelModels(channelID, true)
if err != nil {
return nil, err
}
for index := range items {
if strings.TrimSpace(items[index].CapabilityConfigJSON) == "" {
continue
}
config, decodeErr := DecodeModelCapabilityConfig(items[index].CapabilityConfigJSON)
if decodeErr != nil || config == nil {
continue
}
normalized, normalizeErr := NormalizeModelCapabilityConfigForModel(items[index].Capability, string(items[index].Protocol), firstNonEmpty(items[index].ProviderModelKey, items[index].ModelKey), config, s.protocolRegistry())
if normalizeErr != nil || normalized == nil {
continue
}
encoded, encodeErr := json.Marshal(normalized)
var value map[string]any
if encodeErr == nil && json.Unmarshal(encoded, &value) == nil {
items[index].CapabilityConfig = value
}
}
return items, nil
}
func (s *Service) SystemChannelModel(channelID string, modelKey string) (*model.ChannelModel, error) {
return s.repo.ChannelModelByKey(channelID, strings.TrimPrefix(strings.TrimSpace(modelKey), "models/"))
}
// SystemChannelHasProtocol 用于没有携带 model 字段的轮询请求:先确认渠道确实配置了该协议,
// 再由 handler 按协议限定请求路径,避免用空模型绕过系统渠道授权。
func (s *Service) SystemChannelHasProtocol(channelID string, protocol model.ChannelInterfaceType) (bool, error) {
items, err := s.repo.ChannelModels(channelID, false)
if err != nil {
return false, err
}
for _, item := range items {
if item.Protocol == protocol {
return true, nil
}
}
return false, nil
}
func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id string, req ChannelModelRequest) (*model.ChannelModel, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
channelLabel := strings.TrimSpace(req.ChannelLabel)
tags, err := normalizeChannelModelTags(req.Tags)
if err != nil {
return nil, err
}
description := strings.TrimSpace(req.Description)
if len([]rune(description)) > 500 {
return nil, BadAuthRequest("模型描述不能超过 500 字")
}
channel, err := s.repo.AdminSystemChannel(channelID)
if err != nil {
return nil, err
}
modelKey, providerModelKey, capability, protocol, err := s.normalizeChannelModelContract(channel, req)
if err != nil {
return nil, err
}
// 空值表示跟随上游 ID;不把默认名称固化,避免上游重命名后遗留旧 ID。
if channelLabel == providerModelKey {
channelLabel = ""
}
if len([]rune(channelLabel)) > 80 {
return nil, BadAuthRequest("渠道展示名不能超过 80 字")
}
// 先检查同渠道重复模型,避免无关能力校验或生成无用序列号掩盖真正的冲突。
conflict, conflictErr := s.repo.ChannelModelByKeyIncludingDisabled(channelID, modelKey)
if conflictErr != nil && !errors.Is(conflictErr, gorm.ErrRecordNotFound) {
return nil, conflictErr
}
if conflict != nil && conflict.ID != strings.TrimSpace(id) {
return nil, BadAuthRequest("该渠道已存在模型 " + modelKey + ",请直接编辑已有模型")
}
if capability == "text" || capability == "image" || capability == "video" {
validated, validateErr := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig, s.protocolRegistry())
if validateErr != nil {
return nil, validateErr
}
req.CapabilityConfig = validated
}
tiers, err := s.normalizeChannelModelPriceTiers(req, capability, protocol, providerModelKey)
if err != nil {
return nil, err
}
modelID, err := s.repo.NextPrefixedID("MODEL")
if err != nil {
return nil, err
}
item := &model.ChannelModel{ID: modelID, ChannelID: channelID, Enabled: true, PriceVersion: 1}
previousProviderModelKey := ""
if id != "" {
item, err = s.repo.ChannelModelByID(channelID, id)
if err != nil {
return nil, err
}
previousProviderModelKey = strings.TrimPrefix(strings.TrimSpace(item.ProviderModelKey), "models/")
item.PriceVersion++
}
// 模型级上游键重命名时,与旧值相同的档位键属于“跟随模型默认”的隐式固化,必须级联跟随;
// 否则任务请求会继续把旧上游键发给供应商。管理员显式配置的其他上游 SKU 不受影响。
if previousProviderModelKey != "" && providerModelKey != previousProviderModelKey {
for index := range tiers {
if strings.TrimPrefix(strings.TrimSpace(tiers[index].ProviderModelKey), "models/") == previousProviderModelKey {
tiers[index].ProviderModelKey = providerModelKey
}
}
}
item.ModelKey = modelKey
item.ProviderModelKey = providerModelKey
item.DisplayName = strings.TrimSpace(req.DisplayName)
if item.DisplayName == "" {
item.DisplayName = "未分类"
}
item.ChannelLabel = channelLabel
item.Tags = tags
item.Description = description
item.Icon = strings.TrimSpace(req.Icon)
item.Capability = capability
item.Protocol = protocol
s.applyChannelModelPriceTierSummary(item, tiers)
if capability == "text" || capability == "image" || capability == "video" {
capabilityConfig, normalizeErr := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig, s.protocolRegistry())
if normalizeErr != nil {
return nil, normalizeErr
}
encoded, encodeErr := json.Marshal(capabilityConfig)
if encodeErr != nil {
return nil, encodeErr
}
if item.CapabilityConfigJSON != string(encoded) {
item.CapabilityVersion++
}
item.CapabilityConfigJSON = string(encoded)
} else {
item.CapabilityConfigJSON = ""
item.CapabilityVersion = 0
}
if req.Enabled != nil {
item.Enabled = *req.Enabled
}
if err := validateChannelModelTierCapabilities(tiers, item.CapabilityConfigJSON, capability, string(protocol)); err != nil {
return nil, err
}
// 渠道模型及所有价格档必须同时落库,不能出现“能力已开放但规格价格尚未更新”的窗口。
if err := s.repo.SaveChannelModelWithPriceTiers(item, tiers); err != nil {
return nil, err
}
item.PriceTiers = tiers
s.invalidateRouteCatalog()
if err := s.syncChannelModelNames(channel); err != nil {
return nil, err
}
if err := s.syncLogicalModelsFromChannelModel(actor, item); err != nil {
return nil, err
}
return item, nil
}
func normalizeChannelModelContract(channel *model.ModelChannel, req ChannelModelRequest) (string, string, string, model.ChannelInterfaceType, error) {
return normalizeChannelModelContractWithRegistry(protocol.Builtins(), channel, req)
}
func (s *Service) normalizeChannelModelContract(channel *model.ModelChannel, req ChannelModelRequest) (string, string, string, model.ChannelInterfaceType, error) {
return normalizeChannelModelContractWithRegistry(s.protocolRegistry(), channel, req)
}
func normalizeChannelModelContractWithRegistry(registry *protocol.Registry, channel *model.ModelChannel, req ChannelModelRequest) (string, string, string, model.ChannelInterfaceType, error) {
modelKey := strings.TrimPrefix(strings.TrimSpace(req.ModelKey), "models/")
if modelKey == "" {
return "", "", "", "", BadAuthRequest("请填写模型标识")
}
providerModelKey := strings.TrimPrefix(strings.TrimSpace(req.ProviderModelKey), "models/")
if providerModelKey == "" {
providerModelKey = modelKey
}
capability := normalizeCapability(req.Capability)
if capability == "" {
return "", "", "", "", BadAuthRequest("请选择模型能力")
}
adapter, ok := registry.Resolve(strings.TrimSpace(req.Protocol))
if !ok || !adapter.Metadata().Enabled || adapter.Metadata().UnavailableReason != "" {
return "", "", "", "", BadAuthRequest("请选择有效的模型请求协议")
}
protocol := model.ChannelInterfaceType(adapter.Metadata().ID)
if expected := protocolCapabilityFromMetadata(adapter.Metadata()); expected != "" && expected != capability {
return "", "", "", "", BadAuthRequest("模型能力与请求协议不匹配")
}
if (protocol == model.ChannelInterfaceVolcengineJiMengImage || protocol == model.ChannelInterfaceVolcengineJiMengVideo) && (strings.TrimSpace(channel.APIKey) == "" || strings.TrimSpace(channel.SecretKey) == "") {
return "", "", "", "", BadAuthRequest("即梦官方协议需要先在渠道中配置 Access Key 和 Secret Key")
}
return modelKey, providerModelKey, capability, protocol, nil
}
func (s *Service) DeleteAdminChannelModel(actor *model.User, channelID string, id string) error {
_, err := s.DeleteAdminChannelModels(actor, channelID, []string{id})
return err
}
// DeleteAdminChannelModels validates the complete selection before asking the
// repository to remove it atomically. This deliberately rejects partial success:
// administrators can safely correct an in-use model and retry the same selection.
func (s *Service) DeleteAdminChannelModels(actor *model.User, channelID string, ids []string) (int64, error) {
if err := s.RequireAdmin(actor); err != nil {
return 0, err
}
if _, err := s.repo.AdminSystemChannel(channelID); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, BadAuthRequest("系统渠道不存在或已删除")
}
return 0, err
}
modelIDs, err := normalizeAdminChannelModelDeleteIDs(ids)
if err != nil {
return 0, err
}
items, err := s.repo.ChannelModels(channelID, true)
if err != nil {
return 0, err
}
selected := make(map[string]bool, len(modelIDs))
for _, id := range modelIDs {
selected[id] = true
}
found := 0
for _, item := range items {
if selected[item.ID] {
found++
}
}
if found != len(modelIDs) {
return 0, BadAuthRequest("所选渠道模型中存在已删除或不属于当前渠道的记录,请刷新后重试")
}
// 删除模型与渠道的兼容模型清单必须同事务提交,避免接口报错但列表已部分变化。
deleted, err := s.repo.DeleteChannelModels(channelID, modelIDs, time.Now())
if errors.Is(err, repository.ErrChannelModelInUse) {
return 0, BadAuthRequest("所选渠道模型中有模型仍被前台模型供应线路或进行中任务使用,本次未删除任何模型")
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, BadAuthRequest("所选渠道模型中存在已删除或不属于当前渠道的记录,请刷新后重试")
}
if err == nil {
s.invalidateRouteCatalog()
}
return deleted, err
}
func normalizeAdminChannelModelDeleteIDs(values []string) ([]string, error) {
result := make([]string, 0, len(values))
seen := make(map[string]bool, len(values))
for _, value := range values {
id := strings.TrimSpace(value)
if id == "" || seen[id] {
continue
}
seen[id] = true
result = append(result, id)
}
if len(result) == 0 {
return nil, BadAuthRequest("请至少选择一个要删除的渠道模型")
}
if len(result) > maxAdminChannelModelBatchDeleteCount {
return nil, BadAuthRequest("单次最多删除 100 个渠道模型")
}
return result, nil
}
func (s *Service) syncInitialChannelModels(channel *model.ModelChannel, names []string) error {
existing, err := s.repo.ChannelModels(channel.ID, true)
if err != nil {
return err
}
byKey := make(map[string]*model.ChannelModel, len(existing))
for index := range existing {
byKey[existing[index].ModelKey] = &existing[index]
}
desired := make(map[string]bool, len(names))
retired := retiredChannelModelKeys(channel.RetiredModelsJSON)
for _, name := range uniqueNonEmpty(names) {
name = strings.TrimPrefix(name, "models/")
if retired[channelModelCatalogKey(name)] {
continue
}
desired[name] = true
if item := byKey[name]; item != nil {
continue
}
modelID, idErr := s.repo.NextPrefixedID("MODEL")
if idErr != nil {
return idErr
}
item := model.ChannelModel{ID: modelID, ChannelID: channel.ID, ModelKey: name, ProviderModelKey: name, DisplayName: "未分类", BillingMode: "fixed_request", Enabled: false, PriceConfigured: false, UnitPriceMicrocredits: 0, PriceVersion: 1}
if err := s.repo.SaveChannelModel(&item); err != nil {
return err
}
}
for index := range existing {
if !desired[existing[index].ModelKey] {
changed := existing[index].Enabled
existing[index].Enabled = false
if changed {
existing[index].PriceVersion++
if err := s.repo.SaveChannelModel(&existing[index]); err != nil {
return err
}
}
}
}
return nil
}
func retiredChannelModelKeys(raw string) map[string]bool {
var values []string
_ = json.Unmarshal([]byte(raw), &values)
result := make(map[string]bool, len(values))
for _, value := range values {
if key := channelModelCatalogKey(value); key != "" {
result[key] = true
}
}
return result
}
func channelModelCatalogKey(value string) string {
return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(value), "models/"))
}
func (s *Service) ensureChannelModels(channelID string, includeDisabled bool) ([]model.ChannelModel, error) {
items, err := s.repo.ChannelModels(channelID, includeDisabled)
if err != nil || len(items) > 0 {
return items, err
}
channel, err := s.repo.AdminSystemChannel(channelID)
if err != nil {
return nil, err
}
if err := s.syncInitialChannelModels(channel, channelModelNames(*channel)); err != nil {
return nil, err
}
return s.repo.ChannelModels(channelID, includeDisabled)
}
func (s *Service) syncChannelModelNames(channel *model.ModelChannel) error {
return s.repo.SyncChannelModelNames(channel.ID, time.Now())
}
func (s *Service) capabilityForProtocol(protocol model.ChannelInterfaceType) string {
metadata, ok := s.channelProtocolMetadata(string(protocol))
if !ok {
return ""
}
return protocolCapabilityFromMetadata(metadata)
}
func protocolCapabilityFromMetadata(metadata protocol.Metadata) string {
if len(metadata.Categories) == 0 {
return ""
}
return string(metadata.Categories[0])
}