增加模型别名
This commit is contained in:
@@ -74,6 +74,11 @@ func (h *AdminReadHandler) Logs(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
|
||||
return
|
||||
}
|
||||
modelByID, err := h.admin.ModelNameMap(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(items))
|
||||
for _, item := range items {
|
||||
var userName any
|
||||
@@ -97,7 +102,7 @@ func (h *AdminReadHandler) Logs(c *gin.Context) {
|
||||
"ts": item.TS.Unix(),
|
||||
"kind": item.Kind,
|
||||
"status": item.Status,
|
||||
"model": item.Model,
|
||||
"model": displayModelName(modelByID, item.Model),
|
||||
"provider": item.Provider,
|
||||
"prompt": item.Prompt,
|
||||
"ratio": item.Ratio,
|
||||
@@ -192,24 +197,24 @@ func userPublic(user model.User) gin.H {
|
||||
})
|
||||
}
|
||||
return gin.H{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
"role": user.Role,
|
||||
"status": user.Status,
|
||||
"credits": user.Credits,
|
||||
"notes": user.Notes,
|
||||
"recharge_total": user.RechargeTotal,
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
"role": user.Role,
|
||||
"status": user.Status,
|
||||
"credits": user.Credits,
|
||||
"notes": user.Notes,
|
||||
"recharge_total": user.RechargeTotal,
|
||||
"concurrency_group_id": user.ConcurrencyGroupID,
|
||||
"created_at": unixSec(user.CreatedAt),
|
||||
"last_login_at": unixSecPtr(user.LastLoginAt),
|
||||
"last_login_ip": user.LastLoginIP,
|
||||
"invite_code": user.InviteCode,
|
||||
"invited_by": user.InvitedBy,
|
||||
"checkin_last": user.CheckinLast,
|
||||
"checkin_streak": user.CheckinStreak,
|
||||
"api_keys": keys,
|
||||
"has_password": user.PasswordHash != "",
|
||||
"created_at": unixSec(user.CreatedAt),
|
||||
"last_login_at": unixSecPtr(user.LastLoginAt),
|
||||
"last_login_ip": user.LastLoginIP,
|
||||
"invite_code": user.InviteCode,
|
||||
"invited_by": user.InvitedBy,
|
||||
"checkin_last": user.CheckinLast,
|
||||
"checkin_streak": user.CheckinStreak,
|
||||
"api_keys": keys,
|
||||
"has_password": user.PasswordHash != "",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -190,6 +190,10 @@ func (h *AdminWriteHandler) CreateModel(c *gin.Context) {
|
||||
}
|
||||
item, err := h.admin.CreateModel(c.Request.Context(), body)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrModelAliasCollision) {
|
||||
c.JSON(http.StatusConflict, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -204,6 +208,10 @@ func (h *AdminWriteHandler) UpdateModel(c *gin.Context) {
|
||||
}
|
||||
item, err := h.admin.UpdateModel(c.Request.Context(), c.Param("model_id"), body)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrModelAliasCollision) {
|
||||
c.JSON(http.StatusConflict, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package handler
|
||||
|
||||
import "strings"
|
||||
|
||||
func displayModelName(modelNames map[string]string, raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if modelNames != nil {
|
||||
if name, ok := modelNames[raw]; ok && strings.TrimSpace(name) != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
@@ -225,6 +225,11 @@ func (h *UserGenerationHandler) Logs(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
|
||||
return
|
||||
}
|
||||
modelByID, err := h.admin.ModelNameMap(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]gin.H, 0, len(items))
|
||||
for _, item := range items {
|
||||
@@ -249,7 +254,7 @@ func (h *UserGenerationHandler) Logs(c *gin.Context) {
|
||||
"ts": item.TS.Unix(),
|
||||
"kind": item.Kind,
|
||||
"status": item.Status,
|
||||
"model": item.Model,
|
||||
"model": displayModelName(modelByID, item.Model),
|
||||
"provider": item.Provider,
|
||||
"prompt": item.Prompt,
|
||||
"ratio": item.Ratio,
|
||||
|
||||
@@ -1,35 +1,36 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `gorm:"primaryKey;size:32"`
|
||||
Email string `gorm:"size:255;uniqueIndex;not null"`
|
||||
Name string `gorm:"size:255"`
|
||||
PasswordHash string `gorm:"size:255"`
|
||||
Role string `gorm:"size:32;index;not null"`
|
||||
Status string `gorm:"size:32;index;not null"`
|
||||
Credits float64 `gorm:"not null;default:0"`
|
||||
Notes string `gorm:"type:text"`
|
||||
ConcurrencyGroupID string `gorm:"size:32;index"`
|
||||
AnnouncementSeen string `gorm:"size:32"` // version hash of the last announcement this user dismissed
|
||||
ID string `gorm:"primaryKey;size:32"`
|
||||
Email string `gorm:"size:255;uniqueIndex;not null"`
|
||||
Name string `gorm:"size:255"`
|
||||
PasswordHash string `gorm:"size:255"`
|
||||
Role string `gorm:"size:32;index;not null"`
|
||||
Status string `gorm:"size:32;index;not null"`
|
||||
Credits float64 `gorm:"not null;default:0"`
|
||||
Notes string `gorm:"type:text"`
|
||||
ConcurrencyGroupID string `gorm:"size:32;index"`
|
||||
AnnouncementSeen string `gorm:"size:32"` // version hash of the last announcement this user dismissed
|
||||
RechargeTotal float64 `gorm:"not null;default:0"` // 累计充值金额(元)
|
||||
InviteCode string `gorm:"size:32;uniqueIndex"`
|
||||
InvitedBy *string `gorm:"size:32;index"`
|
||||
InviteRewardDone bool `gorm:"not null;default:false"`
|
||||
InviteRewardAt *time.Time
|
||||
CheckinLast string `gorm:"size:32"`
|
||||
CheckinStreak int `gorm:"not null;default:0"`
|
||||
GenerationCount int64 `gorm:"not null;default:0"`
|
||||
LastLoginAt *time.Time
|
||||
LastLoginIP string `gorm:"size:128"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
APIKeys []APIKey `gorm:"foreignKey:UserID"`
|
||||
InviteCode string `gorm:"size:32;uniqueIndex"`
|
||||
InvitedBy *string `gorm:"size:32;index"`
|
||||
InviteRewardDone bool `gorm:"not null;default:false"`
|
||||
InviteRewardAt *time.Time
|
||||
CheckinLast string `gorm:"size:32"`
|
||||
CheckinStreak int `gorm:"not null;default:0"`
|
||||
GenerationCount int64 `gorm:"not null;default:0"`
|
||||
LastLoginAt *time.Time
|
||||
LastLoginIP string `gorm:"size:128"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
APIKeys []APIKey `gorm:"foreignKey:UserID"`
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
@@ -57,16 +58,16 @@ type ShowcaseItem struct {
|
||||
}
|
||||
|
||||
type EventLog struct {
|
||||
ID string `gorm:"primaryKey;size:32"`
|
||||
TS time.Time `gorm:"index;not null"`
|
||||
Kind string `gorm:"size:32;index;not null"`
|
||||
Status string `gorm:"size:32;index;not null"`
|
||||
Model string `gorm:"size:255;index"`
|
||||
Provider string `gorm:"size:100;index"`
|
||||
Prompt string `gorm:"type:text"`
|
||||
Ratio string `gorm:"size:32"`
|
||||
Resolution string `gorm:"size:32"`
|
||||
Duration string `gorm:"size:32"`
|
||||
ID string `gorm:"primaryKey;size:32"`
|
||||
TS time.Time `gorm:"index;not null"`
|
||||
Kind string `gorm:"size:32;index;not null"`
|
||||
Status string `gorm:"size:32;index;not null"`
|
||||
Model string `gorm:"size:255;index"`
|
||||
Provider string `gorm:"size:100;index"`
|
||||
Prompt string `gorm:"type:text"`
|
||||
Ratio string `gorm:"size:32"`
|
||||
Resolution string `gorm:"size:32"`
|
||||
Duration string `gorm:"size:32"`
|
||||
Refs int `gorm:"not null;default:0"`
|
||||
RefFiles datatypes.JSON `gorm:"type:jsonb"` // relative paths of saved reference images, for回显 on reload
|
||||
Source string `gorm:"size:32;index"`
|
||||
@@ -74,39 +75,40 @@ type EventLog struct {
|
||||
// stamped when the upstream call begins. Drives the accounts view's live
|
||||
// in-flight count (pending events per account) and lets an abandoned-event
|
||||
// purge attribute the failure back to the account it was using.
|
||||
AccountID string `gorm:"size:64;index"`
|
||||
UserID string `gorm:"size:32;index"`
|
||||
Cost float64 `gorm:"not null;default:0"`
|
||||
AccountID string `gorm:"size:64;index"`
|
||||
UserID string `gorm:"size:32;index"`
|
||||
Cost float64 `gorm:"not null;default:0"`
|
||||
// Refunded marks that this event's up-front charge has already been credited
|
||||
// back, so the normal failure path and the abandoned-purge sweep can never
|
||||
// double-refund the same generation.
|
||||
Refunded bool `gorm:"not null;default:false"`
|
||||
ElapsedMS int `gorm:"not null;default:0"`
|
||||
File string `gorm:"size:500;index"`
|
||||
Error string `gorm:"type:text"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Refunded bool `gorm:"not null;default:false"`
|
||||
ElapsedMS int `gorm:"not null;default:0"`
|
||||
File string `gorm:"size:500;index"`
|
||||
Error string `gorm:"type:text"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type ModelConfig struct {
|
||||
ID string `gorm:"primaryKey;size:255"`
|
||||
Type string `gorm:"size:32;index;not null"`
|
||||
Name string `gorm:"size:255;not null"`
|
||||
Provider string `gorm:"size:100;index;not null"`
|
||||
Enabled bool `gorm:"not null;default:true"`
|
||||
Ratios datatypes.JSON `gorm:"type:jsonb"`
|
||||
Prices datatypes.JSONMap `gorm:"type:jsonb"`
|
||||
Resolutions datatypes.JSON `gorm:"type:jsonb"`
|
||||
ImageToImage bool `gorm:"not null;default:false"`
|
||||
DurationPrices datatypes.JSONMap `gorm:"type:jsonb"`
|
||||
ID string `gorm:"primaryKey;size:255"`
|
||||
Type string `gorm:"size:32;index;not null"`
|
||||
Name string `gorm:"size:255;not null"`
|
||||
Alias string `gorm:"column:alias;size:255;not null;default:''"`
|
||||
Provider string `gorm:"size:100;index;not null"`
|
||||
Enabled bool `gorm:"not null;default:true"`
|
||||
Ratios datatypes.JSON `gorm:"type:jsonb"`
|
||||
Prices datatypes.JSONMap `gorm:"type:jsonb"`
|
||||
Resolutions datatypes.JSON `gorm:"type:jsonb"`
|
||||
ImageToImage bool `gorm:"not null;default:false"`
|
||||
DurationPrices datatypes.JSONMap `gorm:"type:jsonb"`
|
||||
// Agent (代理) pricing — optional overlay over Prices/DurationPrices. A tier
|
||||
// left unset here means agent users pay the normal price for that tier; the
|
||||
// set of *supported* tiers is always driven by Prices, not these.
|
||||
PricesAgent datatypes.JSONMap `gorm:"type:jsonb;column:prices_agent"`
|
||||
DurationPricesAgent datatypes.JSONMap `gorm:"type:jsonb;column:duration_prices_agent"`
|
||||
Durations datatypes.JSON `gorm:"type:jsonb"`
|
||||
MaxReferenceImages int `gorm:"not null;default:0"`
|
||||
ReferenceMode string `gorm:"size:32;not null;default:'none'"`
|
||||
Durations datatypes.JSON `gorm:"type:jsonb"`
|
||||
MaxReferenceImages int `gorm:"not null;default:0"`
|
||||
ReferenceMode string `gorm:"size:32;not null;default:'none'"`
|
||||
// Custom-upstream models (provider="custom"): UpstreamModel is the model name
|
||||
// sent to the upstream OpenAI-compatible API; the base_url + key live on the
|
||||
// matching custom account (pool="custom", meta.base_url). Empty for built-ins.
|
||||
@@ -114,13 +116,20 @@ type ModelConfig struct {
|
||||
// Weight controls display order in the model dropdown / admin list: higher
|
||||
// weight floats to the top (matches ShowcaseItem.Weight semantics). Ties fall
|
||||
// back to created_at desc. Default 0.
|
||||
Weight int `gorm:"not null;default:0;index"`
|
||||
Weight int `gorm:"not null;default:0;index"`
|
||||
// GenerationCount is a persistent success counter, incremented once per
|
||||
// successful generation. Independent of the event_log (which is subject to
|
||||
// retention / manual clearing), so the admin "次数" is a true running total.
|
||||
GenerationCount int64 `gorm:"not null;default:0"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (m ModelConfig) EffectiveName() string {
|
||||
if strings.TrimSpace(m.Alias) != "" {
|
||||
return strings.TrimSpace(m.Alias)
|
||||
}
|
||||
return m.ID
|
||||
}
|
||||
|
||||
type CDKCode struct {
|
||||
@@ -154,19 +163,19 @@ type TokenAccount struct {
|
||||
// enters the shared "quota" waiting status when BOTH are limited; a single
|
||||
// limit leaves the account usable for the other kind. Recovery time is shared
|
||||
// (QuotaRecoverAt / CachedQuotaResetAfter) since Adobe resets both at once.
|
||||
ImageLimited bool `gorm:"not null;default:false"`
|
||||
VideoLimited bool `gorm:"not null;default:false"`
|
||||
AccountEmail string `gorm:"size:255"`
|
||||
AccountDisplayName string `gorm:"size:255"`
|
||||
ImageLimited bool `gorm:"not null;default:false"`
|
||||
VideoLimited bool `gorm:"not null;default:false"`
|
||||
AccountEmail string `gorm:"size:255"`
|
||||
AccountDisplayName string `gorm:"size:255"`
|
||||
// Weight biases scheduling order for ANY account — higher weight is picked
|
||||
// first within its pool (ties fall back to round-robin). Default 0.
|
||||
Weight int `gorm:"not null;default:0"`
|
||||
// Concurrency is the max simultaneous jobs for THIS account. Only custom
|
||||
// (upstream) accounts honor it; built-in pools use their system default
|
||||
// (1 per account, grok 10). 0 = use the system default.
|
||||
Concurrency int `gorm:"not null;default:0"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Concurrency int `gorm:"not null;default:0"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type RefreshProfile struct {
|
||||
@@ -215,15 +224,15 @@ func AutoMigrateModels() []any {
|
||||
// order number (out_trade_no). Status: pending | paid | cancelled. Unpaid orders
|
||||
// auto-cancel 30 min after creation (ExpiresAt).
|
||||
type Order struct {
|
||||
ID string `gorm:"primaryKey;size:40"`
|
||||
UserID string `gorm:"size:32;index;not null"`
|
||||
Amount float64 `gorm:"not null"` // 充值金额(元)
|
||||
Points int `gorm:"not null"` // 到账积分
|
||||
PayType string `gorm:"size:16"` // wxpay | alipay
|
||||
Status string `gorm:"size:16;index;not null"` // pending | paid | cancelled
|
||||
TradeNo string `gorm:"size:64;index"` // 易支付平台订单号
|
||||
PayInfo string `gorm:"type:text"` // 二维码 url / 跳转 url
|
||||
PayInfoType string `gorm:"size:16"` // qrcode | jump | html | ...
|
||||
ID string `gorm:"primaryKey;size:40"`
|
||||
UserID string `gorm:"size:32;index;not null"`
|
||||
Amount float64 `gorm:"not null"` // 充值金额(元)
|
||||
Points int `gorm:"not null"` // 到账积分
|
||||
PayType string `gorm:"size:16"` // wxpay | alipay
|
||||
Status string `gorm:"size:16;index;not null"` // pending | paid | cancelled
|
||||
TradeNo string `gorm:"size:64;index"` // 易支付平台订单号
|
||||
PayInfo string `gorm:"type:text"` // 二维码 url / 跳转 url
|
||||
PayInfoType string `gorm:"size:16"` // qrcode | jump | html | ...
|
||||
ExpiresAt time.Time `gorm:"index"`
|
||||
PaidAt *time.Time
|
||||
CreatedAt time.Time
|
||||
|
||||
@@ -38,12 +38,24 @@ func (r *ModelRepository) List(ctx context.Context) ([]model.ModelConfig, error)
|
||||
|
||||
func (r *ModelRepository) Get(ctx context.Context, modelID string) (*model.ModelConfig, error) {
|
||||
var item model.ModelConfig
|
||||
if err := r.db.WithContext(ctx).First(&item, "id = ?", modelID).Error; err != nil {
|
||||
if err := r.db.WithContext(ctx).First(&item, "(alias <> '' AND alias = ?) OR (alias = '' AND id = ?)", modelID, modelID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *ModelRepository) NameMap(ctx context.Context) (map[string]string, error) {
|
||||
items, err := r.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]string, len(items))
|
||||
for _, item := range items {
|
||||
out[item.ID] = item.EffectiveName()
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func JSONStrings(v datatypes.JSON) []string {
|
||||
if len(v) == 0 {
|
||||
return []string{}
|
||||
|
||||
@@ -54,6 +54,10 @@ func (s *AdminReadService) Models(ctx context.Context) ([]model.ModelConfig, err
|
||||
return s.models.List(ctx)
|
||||
}
|
||||
|
||||
func (s *AdminReadService) ModelNameMap(ctx context.Context) (map[string]string, error) {
|
||||
return s.models.NameMap(ctx)
|
||||
}
|
||||
|
||||
func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, error) {
|
||||
items, err := s.models.List(ctx)
|
||||
if err != nil {
|
||||
@@ -63,6 +67,7 @@ func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, er
|
||||
for _, item := range items {
|
||||
out = append(out, map[string]any{
|
||||
"id": item.ID,
|
||||
"alias": item.Alias,
|
||||
"type": item.Type,
|
||||
"name": item.Name,
|
||||
"provider": item.Provider,
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
// row does not exist, so handlers can translate it into a 404 (GORM's Delete
|
||||
// does not error on a zero-row delete).
|
||||
var ErrNotFound = errors.New("not found")
|
||||
var ErrModelAliasCollision = errors.New("model alias collision")
|
||||
|
||||
type AdminWriteService struct {
|
||||
users *repo.UserRepository
|
||||
@@ -94,18 +95,18 @@ func (s *AdminWriteService) CreateUser(ctx context.Context, body map[string]any)
|
||||
}
|
||||
|
||||
user := &model.User{
|
||||
ID: "u-" + uuid.NewString()[:10],
|
||||
Email: email,
|
||||
Name: name,
|
||||
PasswordHash: passwordHash,
|
||||
Role: role,
|
||||
Status: status,
|
||||
Credits: credits,
|
||||
Notes: notes,
|
||||
ID: "u-" + uuid.NewString()[:10],
|
||||
Email: email,
|
||||
Name: name,
|
||||
PasswordHash: passwordHash,
|
||||
Role: role,
|
||||
Status: status,
|
||||
Credits: credits,
|
||||
Notes: notes,
|
||||
ConcurrencyGroupID: cgroupID,
|
||||
InviteCode: randomInviteCode(),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
InviteCode: randomInviteCode(),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := s.users.Create(ctx, user); err != nil {
|
||||
return nil, err
|
||||
@@ -347,6 +348,7 @@ func (s *AdminWriteService) CreateModel(ctx context.Context, body map[string]any
|
||||
modelID := strings.TrimSpace(stringValue(body["id"]))
|
||||
modelType := normalizedModelType(stringValue(body["type"]))
|
||||
provider := strings.TrimSpace(stringValue(body["provider"]))
|
||||
alias := strings.TrimSpace(stringValue(body["alias"]))
|
||||
if modelID == "" {
|
||||
return nil, errors.New("id required")
|
||||
}
|
||||
@@ -356,6 +358,9 @@ func (s *AdminWriteService) CreateModel(ctx context.Context, body map[string]any
|
||||
if provider == "" {
|
||||
return nil, errors.New("provider required")
|
||||
}
|
||||
if err := s.validateModelNameSpace(ctx, "", modelID, alias); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prices := jsonMap(body["prices"])
|
||||
// image: tiers derive from the price keys (form omits resolutions);
|
||||
@@ -366,24 +371,25 @@ func (s *AdminWriteService) CreateModel(ctx context.Context, body map[string]any
|
||||
}
|
||||
|
||||
item := &model.ModelConfig{
|
||||
ID: modelID,
|
||||
Type: modelType,
|
||||
Name: defaultString(strings.TrimSpace(stringValue(body["name"])), modelID),
|
||||
Provider: provider,
|
||||
Enabled: boolValueWithDefault(body["enabled"], true),
|
||||
Ratios: jsonArray(body["ratios"]),
|
||||
Prices: prices,
|
||||
Resolutions: resolutions,
|
||||
ImageToImage: boolValueWithDefault(body["image_to_image"], false),
|
||||
DurationPrices: jsonMap(body["duration_prices"]),
|
||||
ID: modelID,
|
||||
Type: modelType,
|
||||
Name: defaultString(strings.TrimSpace(stringValue(body["name"])), modelID),
|
||||
Alias: alias,
|
||||
Provider: provider,
|
||||
Enabled: boolValueWithDefault(body["enabled"], true),
|
||||
Ratios: jsonArray(body["ratios"]),
|
||||
Prices: prices,
|
||||
Resolutions: resolutions,
|
||||
ImageToImage: boolValueWithDefault(body["image_to_image"], false),
|
||||
DurationPrices: jsonMap(body["duration_prices"]),
|
||||
PricesAgent: jsonMap(body["prices_agent"]),
|
||||
DurationPricesAgent: jsonMap(body["duration_prices_agent"]),
|
||||
Durations: jsonArray(body["durations"]),
|
||||
MaxReferenceImages: intValue(body["max_reference_images"]),
|
||||
ReferenceMode: defaultString(strings.TrimSpace(stringValue(body["reference_mode"])), "none"),
|
||||
Weight: intValue(body["weight"]),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
Durations: jsonArray(body["durations"]),
|
||||
MaxReferenceImages: intValue(body["max_reference_images"]),
|
||||
ReferenceMode: defaultString(strings.TrimSpace(stringValue(body["reference_mode"])), "none"),
|
||||
Weight: intValue(body["weight"]),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := s.models.Create(ctx, item); err != nil {
|
||||
return nil, err
|
||||
@@ -393,6 +399,7 @@ func (s *AdminWriteService) CreateModel(ctx context.Context, body map[string]any
|
||||
|
||||
func (s *AdminWriteService) UpdateModel(ctx context.Context, modelID string, body map[string]any) (*model.ModelConfig, error) {
|
||||
patch := map[string]any{}
|
||||
alias := ""
|
||||
if _, ok := body["type"]; ok {
|
||||
modelType := normalizedModelType(stringValue(body["type"]))
|
||||
if modelType == "" {
|
||||
@@ -403,6 +410,13 @@ func (s *AdminWriteService) UpdateModel(ctx context.Context, modelID string, bod
|
||||
if _, ok := body["name"]; ok {
|
||||
patch["name"] = strings.TrimSpace(stringValue(body["name"]))
|
||||
}
|
||||
if _, ok := body["alias"]; ok {
|
||||
alias = strings.TrimSpace(stringValue(body["alias"]))
|
||||
if err := s.validateModelNameSpace(ctx, modelID, modelID, alias); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
patch["alias"] = alias
|
||||
}
|
||||
if _, ok := body["provider"]; ok {
|
||||
provider := strings.TrimSpace(stringValue(body["provider"]))
|
||||
if provider == "" {
|
||||
@@ -457,6 +471,36 @@ func (s *AdminWriteService) UpdateModel(ctx context.Context, modelID string, bod
|
||||
return s.models.Update(ctx, modelID, patch)
|
||||
}
|
||||
|
||||
func (s *AdminWriteService) validateModelNameSpace(ctx context.Context, selfID, candidateID, candidateAlias string) error {
|
||||
candidateID = strings.TrimSpace(candidateID)
|
||||
candidateAlias = strings.TrimSpace(candidateAlias)
|
||||
if candidateID == "" && candidateAlias == "" {
|
||||
return nil
|
||||
}
|
||||
items, err := s.models.List(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.ID == selfID {
|
||||
continue
|
||||
}
|
||||
existingAlias := strings.TrimSpace(item.Alias)
|
||||
if candidateID != "" && existingAlias == candidateID {
|
||||
return fmt.Errorf("%w: model id %q collides with existing alias %q", ErrModelAliasCollision, candidateID, existingAlias)
|
||||
}
|
||||
if candidateAlias != "" {
|
||||
if item.ID == candidateAlias {
|
||||
return fmt.Errorf("%w: alias %q collides with existing model id %q", ErrModelAliasCollision, candidateAlias, item.ID)
|
||||
}
|
||||
if existingAlias != "" && existingAlias == candidateAlias {
|
||||
return fmt.Errorf("%w: alias %q collides with existing alias %q", ErrModelAliasCollision, candidateAlias, existingAlias)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AdminWriteService) DeleteModel(ctx context.Context, modelID string) error {
|
||||
rows, err := s.models.Delete(ctx, modelID)
|
||||
if err != nil {
|
||||
|
||||
@@ -124,6 +124,10 @@ func (s *UserGenerationService) MyJobs(ctx context.Context, user *model.User, so
|
||||
if source != "admin" {
|
||||
source = "user"
|
||||
}
|
||||
modelNames, err := s.ModelNameMap(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pending, err := s.events.PendingByUser(ctx, user.ID, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -133,12 +137,16 @@ func (s *UserGenerationService) MyJobs(ctx context.Context, user *model.User, so
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"pending": shapeJobEvent(pending),
|
||||
"latest": shapeJobEvent(latest),
|
||||
"pending": shapeJobEvent(pending, modelNames),
|
||||
"latest": shapeJobEvent(latest, modelNames),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func shapeJobEvent(item *model.EventLog) map[string]any {
|
||||
func (s *UserGenerationService) ModelNameMap(ctx context.Context) (map[string]string, error) {
|
||||
return s.models.NameMap(ctx)
|
||||
}
|
||||
|
||||
func shapeJobEvent(item *model.EventLog, modelNames map[string]string) map[string]any {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -150,7 +158,7 @@ func shapeJobEvent(item *model.EventLog) map[string]any {
|
||||
return map[string]any{
|
||||
"id": item.ID,
|
||||
"kind": item.Kind,
|
||||
"model": item.Model,
|
||||
"model": displayModelName(modelNames, item.Model),
|
||||
"prompt": item.Prompt,
|
||||
"ratio": item.Ratio,
|
||||
"resolution": item.Resolution,
|
||||
@@ -167,6 +175,19 @@ func shapeJobEvent(item *model.EventLog) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
func displayModelName(modelNames map[string]string, raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if modelNames != nil {
|
||||
if name, ok := modelNames[raw]; ok && strings.TrimSpace(name) != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// referenceURLs turns the stored relative reference paths into /images URLs so
|
||||
// the playground can re-display the uploaded reference image(s) after a reload.
|
||||
func referenceURLs(raw []byte) []string {
|
||||
|
||||
@@ -299,7 +299,7 @@ func (s *V1Service) ListModels(ctx context.Context) ([]map[string]any, error) {
|
||||
continue
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"id": item.ID,
|
||||
"id": item.EffectiveName(),
|
||||
"object": "model",
|
||||
"created": now,
|
||||
"owned_by": item.Provider,
|
||||
@@ -537,7 +537,7 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
|
||||
return map[string]any{
|
||||
"created": time.Now().Unix(),
|
||||
"data": []map[string]any{{"b64_json": b64}},
|
||||
"model": modelItem.ID,
|
||||
"model": modelItem.EffectiveName(),
|
||||
"provider": modelItem.Provider,
|
||||
"kind": "image",
|
||||
"b64_json": b64,
|
||||
@@ -549,7 +549,7 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
|
||||
return map[string]any{
|
||||
"created": time.Now().Unix(),
|
||||
"data": []map[string]any{{"url": fileURL, "b64_json": nil}},
|
||||
"model": modelItem.ID,
|
||||
"model": modelItem.EffectiveName(),
|
||||
"provider": modelItem.Provider,
|
||||
"kind": "image",
|
||||
"url": fileURL,
|
||||
@@ -681,7 +681,7 @@ func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPri
|
||||
return map[string]any{
|
||||
"created": time.Now().Unix(),
|
||||
"data": []map[string]any{{"b64_json": b64}},
|
||||
"model": modelItem.ID,
|
||||
"model": modelItem.EffectiveName(),
|
||||
"provider": modelItem.Provider,
|
||||
"kind": "video",
|
||||
"b64_json": b64,
|
||||
@@ -693,7 +693,7 @@ func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPri
|
||||
return map[string]any{
|
||||
"created": time.Now().Unix(),
|
||||
"data": []map[string]any{{"url": fileURL}},
|
||||
"model": modelItem.ID,
|
||||
"model": modelItem.EffectiveName(),
|
||||
"provider": modelItem.Provider,
|
||||
"kind": "video",
|
||||
"url": fileURL,
|
||||
@@ -725,7 +725,7 @@ func (s *V1Service) StartVideoJob(ctx context.Context, principal *APIPrincipal,
|
||||
return nil, err
|
||||
}
|
||||
go s.runVideoJob(ctx, principal, in, modelItem, eventID, aspectRatio, resolution, duration, price, refFiles)
|
||||
return videoJobObject(eventID, modelItem.ID, "queued", 0, duration, sizeFromRatioRes(aspectRatio, resolution), time.Now().Unix(), 0, ""), nil
|
||||
return videoJobObject(eventID, modelItem.EffectiveName(), "queued", 0, duration, sizeFromRatioRes(aspectRatio, resolution), time.Now().Unix(), 0, ""), nil
|
||||
}
|
||||
|
||||
// runVideoJob renders the clip in the background, capturing the upstream URL
|
||||
@@ -792,7 +792,13 @@ func (s *V1Service) VideoJob(ctx context.Context, principal *APIPrincipal, id st
|
||||
if ev.Status == "failed" {
|
||||
errMsg = ev.Error
|
||||
}
|
||||
return videoJobObject(ev.ID, ev.Model, status, progress, ev.Duration, sizeFromRatioRes(ev.Ratio, ev.Resolution), ev.TS.Unix(), completedAt, errMsg), nil
|
||||
modelName := ev.Model
|
||||
if nameByID, nerr := s.models.NameMap(ctx); nerr == nil {
|
||||
if name, ok := nameByID[ev.Model]; ok && strings.TrimSpace(name) != "" {
|
||||
modelName = name
|
||||
}
|
||||
}
|
||||
return videoJobObject(ev.ID, modelName, status, progress, ev.Duration, sizeFromRatioRes(ev.Ratio, ev.Resolution), ev.TS.Unix(), completedAt, errMsg), nil
|
||||
}
|
||||
|
||||
// OpenVideoContent streams a completed job's video by proxying the stored
|
||||
|
||||
@@ -15,6 +15,7 @@ const ALL_RES = ['1K', '2K', '4K', '720p', '1080p']
|
||||
const DUR_OPTS = ['5s', '6s', '8s', '10s', '15s']
|
||||
|
||||
const id = ref('')
|
||||
const alias = ref('')
|
||||
const type = ref('image')
|
||||
const ratios = ref(['1:1', '16:9', '9:16'])
|
||||
const maxRefs = ref(0)
|
||||
@@ -89,6 +90,7 @@ async function save() {
|
||||
const body = {
|
||||
id: mid,
|
||||
name: mid,
|
||||
alias: alias.value.trim(),
|
||||
type: type.value,
|
||||
provider: 'custom',
|
||||
prices: r.prices,
|
||||
@@ -127,7 +129,7 @@ async function save() {
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<p class="text-xs text-slate-500 leading-relaxed">
|
||||
id 要与上游模型名<strong class="text-slate-700">一致</strong> —— 生成时按 id 自动路由到「支持该 id 的上游账号」。价格按本地价计费。
|
||||
id 要与上游模型名<strong class="text-slate-700">一致</strong> —— 生成时按 id 自动路由到「支持该 id 的上游账号」。价格按本地价计费。设了别名后,对外只用别名调用(原 id 调不到),但内部仍按 id 路由到上游,不影响。
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
@@ -135,6 +137,10 @@ async function save() {
|
||||
<label class="text-xs text-slate-500 block mb-1">模型 id <span class="text-rose-500">*</span></label>
|
||||
<input v-model="id" class="field font-mono text-xs h-10" placeholder="gpt-image-2" />
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<label class="text-xs text-slate-500 block mb-1">别名(选填)</label>
|
||||
<input v-model="alias" class="field font-mono text-xs h-10" placeholder="对外调用名" />
|
||||
</div>
|
||||
<div class="w-28">
|
||||
<label class="text-xs text-slate-500 block mb-1">类型</label>
|
||||
<SelectMenu v-model="type" :options="[{value:'image',label:'图像'},{value:'video',label:'视频'}]" />
|
||||
|
||||
@@ -18,6 +18,7 @@ const REF_MODE_LABEL = { none: '无', frame: '首帧/首尾帧', asset: '参考
|
||||
const catalog = ref([])
|
||||
const loading = ref(true)
|
||||
const selectedId = ref(props.model?.id || '')
|
||||
const alias = ref(props.model?.alias || '')
|
||||
const imagePrices = ref({}) // 普通价 { '1K': '', '2K': '', ... } keyed by resolutions
|
||||
const videoPrices = ref({}) // 普通价 { '5s': '', '10s': '', ... } keyed by durations
|
||||
const imagePricesAgent = ref({}) // 代理价(留空 = 跟随普通价)
|
||||
@@ -138,6 +139,7 @@ async function save() {
|
||||
duration_prices,
|
||||
prices_agent,
|
||||
duration_prices_agent,
|
||||
alias: alias.value.trim(),
|
||||
max_reference_images: e.max_reference_images || 0,
|
||||
reference_mode: e.reference_mode || 'none',
|
||||
weight: Number(weight.value) || 0,
|
||||
@@ -153,6 +155,7 @@ async function save() {
|
||||
prices,
|
||||
prices_agent,
|
||||
image_to_image: !!e.image_to_image,
|
||||
alias: alias.value.trim(),
|
||||
// 多参考图:把目录定义的张数(gpt=3/seedream=6/flux=4 …)写进模型,
|
||||
// 否则后端仍按旧值(默认 1)限制。
|
||||
max_reference_images: e.max_reference_images || 0,
|
||||
@@ -197,6 +200,12 @@ async function save() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="lbl">别名</label>
|
||||
<input v-model="alias" class="field font-mono" placeholder="可选,对外名" />
|
||||
<p class="text-[11px] text-white/40 mt-1.5">设置后原模型名将不可调用,画图台 / API / 文档都改用别名</p>
|
||||
</div>
|
||||
|
||||
<!-- read-only param summary, straight from the loaded catalog -->
|
||||
<div v-if="entry" class="rounded-xl bg-white/[0.03] ring-1 ring-white/[0.06] p-3.5 space-y-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -8,6 +8,7 @@ const props = defineProps({
|
||||
model: { type: Object, required: true },
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
const publishName = computed(() => props.model.alias || props.model.id)
|
||||
|
||||
const isVideo = props.model.type === 'video'
|
||||
|
||||
@@ -124,7 +125,7 @@ async function run() {
|
||||
resultUrl.value = ''
|
||||
resultKind.value = ''
|
||||
const payload = {
|
||||
model: props.model.id,
|
||||
model: publishName.value,
|
||||
prompt: prompt.value,
|
||||
ratio: ratio.value,
|
||||
resolution: resolution.value,
|
||||
@@ -198,7 +199,7 @@ async function recover() {
|
||||
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-sm font-semibold">测试模型</h2>
|
||||
<div class="text-xs text-white/45 font-mono truncate">{{ model.id }}</div>
|
||||
<div class="text-xs text-white/45 font-mono truncate">{{ publishName }}</div>
|
||||
</div>
|
||||
<button @click="emit('close')" class="text-white/40 hover:text-white transition-colors">
|
||||
<Icon name="close" class="w-5 h-5" />
|
||||
|
||||
@@ -19,8 +19,11 @@ onMounted(async () => {
|
||||
|
||||
const imageModels = computed(() => models.value.filter((m) => m.type === 'image'))
|
||||
const videoModels = computed(() => models.value.filter((m) => m.type === 'video'))
|
||||
const sampleImage = computed(() => imageModels.value[0]?.id || 'firefly-image-4')
|
||||
const sampleVideo = computed(() => videoModels.value[0]?.id || 'firefly-kling3')
|
||||
function pubName(m) {
|
||||
return m?.alias || m?.id || ''
|
||||
}
|
||||
const sampleImage = computed(() => pubName(imageModels.value[0]) || 'firefly-image-4')
|
||||
const sampleVideo = computed(() => pubName(videoModels.value[0]) || 'firefly-kling3')
|
||||
const sampleSeconds = computed(() => String(videoModels.value[0]?.durations?.[0] || '8s').replace(/s$/, ''))
|
||||
|
||||
function priceOf(m) {
|
||||
@@ -41,18 +44,18 @@ function priceOf(m) {
|
||||
|
||||
// ---- request parameter tables ----
|
||||
const imageParams = [
|
||||
['model', 'string', '必填', '模型 id,见上表(图像)'],
|
||||
['model', 'string', '必填', '模型名(别名优先),见上表(图像)'],
|
||||
['prompt', 'string', '必填', '文字描述'],
|
||||
['size', 'string', '可选', '宽x高,如 "1024x1024"。同时决定「比例」+「分辨率档」(按长边)。具体怎么填见下方对照表;留空 = 1:1 · 2K'],
|
||||
]
|
||||
const editParams = [
|
||||
['image', 'file', '必填', '输入图;多张参考图重复 image[] 字段(multipart 文件上传)'],
|
||||
['prompt', 'string', '必填', '编辑/参考描述'],
|
||||
['model', 'string', '必填', '模型 id(需支持图生图)'],
|
||||
['model', 'string', '必填', '模型名(别名优先,需支持图生图)'],
|
||||
['size', 'string', '可选', '同图像:决定比例 + 分辨率档(见下方对照表)'],
|
||||
]
|
||||
const videoParams = [
|
||||
['model', 'string', '必填', '模型 id,见上表(视频)'],
|
||||
['model', 'string', '必填', '模型名(别名优先),见上表(视频)'],
|
||||
['prompt', 'string', '必填', '文字描述'],
|
||||
['seconds', 'string|int', '必填', '时长秒数,如 "5" "8"(取决于模型支持)'],
|
||||
['size', 'string', '可选', '如 "1280x720" / "720x1280" → 决定比例与分辨率'],
|
||||
@@ -273,7 +276,7 @@ async function copy(text) {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in models" :key="m.id" class="border-b border-white/[0.04] last:border-0">
|
||||
<td class="px-4 py-3 font-mono text-white/90">{{ m.id }}</td>
|
||||
<td class="px-4 py-3 font-mono text-white/90">{{ pubName(m) }}</td>
|
||||
<td class="px-4 py-3 text-white/60">{{ m.type === 'video' ? '视频' : '图像' }}</td>
|
||||
<td class="px-4 py-3 text-white/60">{{ (m.type === 'video' ? m.durations : m.resolutions || [])?.join(' · ') || '—' }}</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums text-white/80">{{ priceOf(m) }}</td>
|
||||
|
||||
@@ -66,7 +66,7 @@ const filtered = computed(() => {
|
||||
if (kindFilter.value && m.type !== kindFilter.value) return false
|
||||
if (statusFilter.value === 'enabled' && m.enabled === false) return false
|
||||
if (statusFilter.value === 'disabled' && m.enabled !== false) return false
|
||||
if (q && !(m.id.toLowerCase().includes(q) || (m.provider || '').toLowerCase().includes(q))) return false
|
||||
if (q && !(m.id.toLowerCase().includes(q) || (m.alias || '').toLowerCase().includes(q) || (m.provider || '').toLowerCase().includes(q))) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
@@ -114,7 +114,7 @@ onMounted(loadModels)
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 模型 ID / Provider…" />
|
||||
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 模型 ID / 别名 / Provider…" />
|
||||
</div>
|
||||
<button @click="loadModels" class="btn-soft">
|
||||
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
|
||||
@@ -163,7 +163,10 @@ onMounted(loadModels)
|
||||
class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors">
|
||||
<!-- Model id + provider underneath -->
|
||||
<td class="px-5 py-3.5 align-middle min-w-0">
|
||||
<div class="font-mono text-xs text-white/90 truncate" :title="m.id">{{ m.id }}</div>
|
||||
<div class="flex items-start gap-2 min-w-0">
|
||||
<div class="font-mono text-xs text-white/90 truncate" :title="m.id">{{ m.id }}</div>
|
||||
<span v-if="m.alias" class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] bg-sky-500/10 text-sky-300 ring-1 ring-sky-400/20 shrink-0">别名: {{ m.alias }}</span>
|
||||
</div>
|
||||
<div class="mt-1 text-[10px] text-white/45 capitalize truncate">{{ m.provider || '—' }}</div>
|
||||
</td>
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ const models = computed(() =>
|
||||
allModels.value.filter((m) => m.enabled !== false && m.type === mode.value),
|
||||
)
|
||||
const modelOptions = computed(() =>
|
||||
models.value.map((m) => ({ value: m.id, label: m.name || m.id })),
|
||||
models.value.map((m) => ({ value: m.id, label: m.alias || m.name || m.id })),
|
||||
)
|
||||
const model = computed(() => allModels.value.find((m) => m.id === modelId.value) || null)
|
||||
const familyPreset = computed(() => {
|
||||
@@ -305,7 +305,7 @@ async function fireOne() {
|
||||
// user edits the form (or fires another batch) while this one runs.
|
||||
const task = {
|
||||
id: Math.random().toString(36).slice(2, 10),
|
||||
model: modelId.value,
|
||||
model: model.value?.alias || modelId.value,
|
||||
kind: mode.value,
|
||||
prompt: prompt.value,
|
||||
ratio: ratio.value,
|
||||
|
||||
Reference in New Issue
Block a user