* 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 复现并验证通过。
164 lines
5.6 KiB
Go
164 lines
5.6 KiB
Go
// 渠道模型的上游目录拉取、导入预览与初始同步。
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
|
|
"yingce/backend/internal/model"
|
|
)
|
|
|
|
func (s *Service) FetchAdminChannelModels(ctx context.Context, actor *model.User, channelID string) (*AdminChannelModelFetchResult, error) {
|
|
if err := s.RequireAdmin(actor); err != nil {
|
|
return nil, err
|
|
}
|
|
channel, err := s.adminSystemChannel(channelID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
headers, err := ParseOutboundHeadersJSON(channel.HeadersJSON)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// 使用服务端保存的渠道密钥和请求头访问上游,避免敏感配置再次经过浏览器。
|
|
models, err := s.FetchChannelModels(ctx, actor, ChannelModelsRequest{BaseURL: channel.BaseURL, APIKey: channel.APIKey, APIFormat: channel.APIFormat, Headers: headers})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// 只按当前未删除记录去重;普通手动删除的模型仍可重新拉取,已合并进模型家族的 SKU 除外。
|
|
existing, err := s.repo.ChannelModels(channelID, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
known := make(map[string]struct{}, len(existing))
|
|
for _, item := range existing {
|
|
known[channelModelCatalogKey(item.ModelKey)] = struct{}{}
|
|
}
|
|
retired := retiredChannelModelKeys(channel.RetiredModelsJSON)
|
|
missing := make([]model.ChannelModel, 0, len(models))
|
|
for _, name := range models {
|
|
name = strings.TrimPrefix(strings.TrimSpace(name), "models/")
|
|
key := channelModelCatalogKey(name)
|
|
if _, ok := known[key]; ok || retired[key] {
|
|
continue
|
|
}
|
|
// 自动发现不能绕过定价边界;新模型由管理员定价后再手动启用。
|
|
modelID, idErr := s.repo.NextPrefixedID("MODEL")
|
|
if idErr != nil {
|
|
return nil, idErr
|
|
}
|
|
missing = append(missing, model.ChannelModel{ID: modelID, ChannelID: channelID, ModelKey: name, ProviderModelKey: name, DisplayName: "未分类", BillingMode: "fixed_request", Enabled: false, PriceVersion: 1})
|
|
}
|
|
added, err := s.repo.CreateMissingChannelModels(missing)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if added > 0 {
|
|
s.invalidateRouteCatalog()
|
|
}
|
|
return &AdminChannelModelFetchResult{Models: models, Added: added}, nil
|
|
}
|
|
|
|
// PreviewAdminChannelModels 只读取上游模型目录,不修改渠道模型配置。
|
|
func (s *Service) PreviewAdminChannelModels(ctx context.Context, actor *model.User, channelID string) ([]string, error) {
|
|
if err := s.RequireAdmin(actor); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.fetchAdminChannelModelCatalog(ctx, actor, channelID)
|
|
}
|
|
|
|
// ImportAdminChannelModels 只导入管理员明确选择、且仍存在于上游目录中的模型。
|
|
func (s *Service) ImportAdminChannelModels(ctx context.Context, actor *model.User, channelID string, selected []string) (*AdminChannelModelFetchResult, error) {
|
|
if err := s.RequireAdmin(actor); err != nil {
|
|
return nil, err
|
|
}
|
|
channel, err := s.adminSystemChannel(channelID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
models, err := s.fetchAdminChannelModelCatalog(ctx, actor, channelID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(selected) == 0 {
|
|
return nil, BadAuthRequest("请至少选择一个要导入的模型")
|
|
}
|
|
if len(selected) > 500 {
|
|
return nil, BadAuthRequest("单次最多导入 500 个模型")
|
|
}
|
|
available := make(map[string]string, len(models))
|
|
for _, name := range models {
|
|
available[channelModelCatalogKey(name)] = name
|
|
}
|
|
chosen := make([]string, 0, len(selected))
|
|
seen := make(map[string]struct{}, len(selected))
|
|
for _, rawName := range selected {
|
|
name := strings.TrimPrefix(strings.TrimSpace(rawName), "models/")
|
|
key := channelModelCatalogKey(name)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
canonical, ok := available[key]
|
|
if !ok {
|
|
return nil, BadAuthRequest("所选模型不在上游模型目录中:" + name)
|
|
}
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
chosen = append(chosen, canonical)
|
|
}
|
|
if len(chosen) == 0 {
|
|
return nil, BadAuthRequest("请至少选择一个有效的模型")
|
|
}
|
|
|
|
existing, err := s.repo.ChannelModels(channelID, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
known := make(map[string]struct{}, len(existing))
|
|
for _, item := range existing {
|
|
providerKey := firstNonEmpty(item.ProviderModelKey, item.ModelKey)
|
|
known[channelModelCatalogKey(providerKey)] = struct{}{}
|
|
}
|
|
retired := retiredChannelModelKeys(channel.RetiredModelsJSON)
|
|
missing := make([]model.ChannelModel, 0, len(chosen))
|
|
for _, name := range chosen {
|
|
key := channelModelCatalogKey(name)
|
|
if _, ok := known[key]; ok || retired[key] {
|
|
continue
|
|
}
|
|
modelID, idErr := s.repo.NextPrefixedID("MODEL")
|
|
if idErr != nil {
|
|
return nil, idErr
|
|
}
|
|
missing = append(missing, model.ChannelModel{ID: modelID, ChannelID: channelID, ModelKey: name, ProviderModelKey: name, DisplayName: "未分类", BillingMode: "fixed_request", Enabled: false, PriceConfigured: false, PriceVersion: 1})
|
|
known[key] = struct{}{}
|
|
}
|
|
added, err := s.repo.CreateMissingChannelModels(missing)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if added > 0 {
|
|
s.invalidateRouteCatalog()
|
|
}
|
|
return &AdminChannelModelFetchResult{Models: chosen, Added: added}, nil
|
|
}
|
|
|
|
func (s *Service) fetchAdminChannelModelCatalog(ctx context.Context, actor *model.User, channelID string) ([]string, error) {
|
|
channel, err := s.adminSystemChannel(channelID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
headers, err := ParseOutboundHeadersJSON(channel.HeadersJSON)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
models, err := s.FetchChannelModels(ctx, actor, ChannelModelsRequest{BaseURL: channel.BaseURL, APIKey: channel.APIKey, APIFormat: channel.APIFormat, Headers: headers})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return uniqueNonEmpty(models), nil
|
|
}
|