diff --git a/backend/internal/http/handler/admin_read.go b/backend/internal/http/handler/admin_read.go index 2e09ab9..6f9b50c 100644 --- a/backend/internal/http/handler/admin_read.go +++ b/backend/internal/http/handler/admin_read.go @@ -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 != "", } } diff --git a/backend/internal/http/handler/admin_write.go b/backend/internal/http/handler/admin_write.go index 3c1744f..a1a2ec8 100644 --- a/backend/internal/http/handler/admin_write.go +++ b/backend/internal/http/handler/admin_write.go @@ -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 } diff --git a/backend/internal/http/handler/model_name.go b/backend/internal/http/handler/model_name.go new file mode 100644 index 0000000..8d2f448 --- /dev/null +++ b/backend/internal/http/handler/model_name.go @@ -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 +} diff --git a/backend/internal/http/handler/user_generation.go b/backend/internal/http/handler/user_generation.go index 320de66..9218419 100644 --- a/backend/internal/http/handler/user_generation.go +++ b/backend/internal/http/handler/user_generation.go @@ -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, diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index a34a5d5..f2ec7ea 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -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 diff --git a/backend/internal/repo/model_repo.go b/backend/internal/repo/model_repo.go index ce99fcd..367cf8b 100644 --- a/backend/internal/repo/model_repo.go +++ b/backend/internal/repo/model_repo.go @@ -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{} diff --git a/backend/internal/service/admin_read.go b/backend/internal/service/admin_read.go index d99e2c9..d04eda8 100644 --- a/backend/internal/service/admin_read.go +++ b/backend/internal/service/admin_read.go @@ -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, diff --git a/backend/internal/service/admin_write.go b/backend/internal/service/admin_write.go index b81f9c5..0d7e31a 100644 --- a/backend/internal/service/admin_write.go +++ b/backend/internal/service/admin_write.go @@ -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 { diff --git a/backend/internal/service/user_generation.go b/backend/internal/service/user_generation.go index 0def13e..ef65163 100644 --- a/backend/internal/service/user_generation.go +++ b/backend/internal/service/user_generation.go @@ -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 { diff --git a/backend/internal/service/v1.go b/backend/internal/service/v1.go index 92aea29..efdd0b2 100644 --- a/backend/internal/service/v1.go +++ b/backend/internal/service/v1.go @@ -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 diff --git a/frontend/src/components/CustomModelModal.vue b/frontend/src/components/CustomModelModal.vue index def3c6b..07ac90d 100644 --- a/frontend/src/components/CustomModelModal.vue +++ b/frontend/src/components/CustomModelModal.vue @@ -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() {
- id 要与上游模型名一致 —— 生成时按 id 自动路由到「支持该 id 的上游账号」。价格按本地价计费。 + id 要与上游模型名一致 —— 生成时按 id 自动路由到「支持该 id 的上游账号」。价格按本地价计费。设了别名后,对外只用别名调用(原 id 调不到),但内部仍按 id 路由到上游,不影响。
设置后原模型名将不可调用,画图台 / API / 文档都改用别名
+