Compare commits
2
Commits
master
..
b76e6ab717
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b76e6ab717 | ||
|
|
abd7b5c23b |
@@ -68,6 +68,10 @@ func NewApp(ctx context.Context) (*App, error) {
|
|||||||
`ON cdk_codes (batch_id, redeemed_by) WHERE type = 'marketing' AND redeemed_by IS NOT NULL`).Error; err != nil {
|
`ON cdk_codes (batch_id, redeemed_by) WHERE type = 'marketing' AND redeemed_by IS NOT NULL`).Error; err != nil {
|
||||||
return nil, fmt.Errorf("cdk marketing index: %w", err)
|
return nil, fmt.Errorf("cdk marketing index: %w", err)
|
||||||
}
|
}
|
||||||
|
// 配价即上架(vivid 同款):models 不再有启停开关,删遗留列。
|
||||||
|
if err := db.WithContext(ctx).Exec(`ALTER TABLE model_configs DROP COLUMN IF EXISTS enabled`).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("drop model_configs.enabled: %w", err)
|
||||||
|
}
|
||||||
if err := seedDefaults(ctx, db); err != nil {
|
if err := seedDefaults(ctx, db); err != nil {
|
||||||
return nil, fmt.Errorf("seed defaults: %w", err)
|
return nil, fmt.Errorf("seed defaults: %w", err)
|
||||||
}
|
}
|
||||||
@@ -115,6 +119,13 @@ func NewApp(ctx context.Context) (*App, error) {
|
|||||||
channelSecret = cfg.PostgresDSN
|
channelSecret = cfg.PostgresDSN
|
||||||
}
|
}
|
||||||
channelSvc := service.NewChannelService(channelRepo, modelRepo, upClient, channelSecret)
|
channelSvc := service.NewChannelService(channelRepo, modelRepo, upClient, channelSecret)
|
||||||
|
// Sweep provider-era model rows: anything not bound to a channel can never
|
||||||
|
// be routed under the channel scheduler.
|
||||||
|
if n, err := channelSvc.PurgeOrphanModels(ctx); err != nil {
|
||||||
|
log.Printf("purge orphan models: %v", err)
|
||||||
|
} else if n > 0 {
|
||||||
|
log.Printf("purged %d unbound model(s) left over from the provider era", n)
|
||||||
|
}
|
||||||
v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, channelSvc, siteRepo, cgroupRepo, concSvc, rustfsClient)
|
v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, channelSvc, siteRepo, cgroupRepo, concSvc, rustfsClient)
|
||||||
siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle)
|
siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle)
|
||||||
showcaseSvc := service.NewShowcaseService(showcaseRepo)
|
showcaseSvc := service.NewShowcaseService(showcaseRepo)
|
||||||
@@ -152,7 +163,7 @@ func NewApp(ctx context.Context) (*App, error) {
|
|||||||
|
|
||||||
// Background self-healing sweep (order expiry, stale-pending cleanup+refund,
|
// Background self-healing sweep (order expiry, stale-pending cleanup+refund,
|
||||||
// log/media retention).
|
// log/media retention).
|
||||||
maintenanceSvc := service.NewMaintenanceService(eventRepo, userRepo, siteRepo, rustfsClient, v1Svc.Inflight(), showcaseRepo, orderRepo)
|
maintenanceSvc := service.NewMaintenanceService(eventRepo, userRepo, siteRepo, rustfsClient, v1Svc, showcaseRepo, orderRepo)
|
||||||
loopCtx, loopCancel := context.WithCancel(context.Background())
|
loopCtx, loopCancel := context.WithCancel(context.Background())
|
||||||
go maintenanceSvc.Run(loopCtx)
|
go maintenanceSvc.Run(loopCtx)
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func (h *ChannelAdminHandler) Save(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
body.BaseURL = strings.TrimSpace(body.BaseURL)
|
body.BaseURL = strings.TrimSpace(body.BaseURL)
|
||||||
ch, err := h.channels.Save(c.Request.Context(), body)
|
ch, warning, err := h.channels.Save(c.Request.Context(), body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"detail": "channel not found"})
|
c.JSON(http.StatusNotFound, gin.H{"detail": "channel not found"})
|
||||||
@@ -47,7 +47,7 @@ func (h *ChannelAdminHandler) Save(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": ch.ID})
|
c.JSON(http.StatusOK, gin.H{"ok": true, "id": ch.ID, "warning": warning})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ChannelAdminHandler) Delete(c *gin.Context) {
|
func (h *ChannelAdminHandler) Delete(c *gin.Context) {
|
||||||
@@ -106,6 +106,24 @@ func (h *ChannelAdminHandler) ImportModels(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnbindModel detaches one upstream model from a channel; the local model row
|
||||||
|
// is deleted too when no other channel still binds it.
|
||||||
|
func (h *ChannelAdminHandler) UnbindModel(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.ID) == "" || strings.TrimSpace(body.Model) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "id and model required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.channels.UnbindModel(c.Request.Context(), body.ID, body.Model); err != nil {
|
||||||
|
writeUpstreamErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
// writeUpstreamErr translates upstream-protocol errors into sensible admin
|
// writeUpstreamErr translates upstream-protocol errors into sensible admin
|
||||||
// responses (auth → 400 with the upstream's message, temporary → 502).
|
// responses (auth → 400 with the upstream's message, temporary → 502).
|
||||||
func writeUpstreamErr(c *gin.Context, err error) {
|
func writeUpstreamErr(c *gin.Context, err error) {
|
||||||
|
|||||||
@@ -461,7 +461,7 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
out := make([]gin.H, 0, len(models))
|
out := make([]gin.H, 0, len(models))
|
||||||
for _, m := range models {
|
for _, m := range models {
|
||||||
if m.Type != "video" || !m.Enabled {
|
if m.Type != "video" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, modelPreset(m))
|
out = append(out, modelPreset(m))
|
||||||
@@ -547,7 +547,7 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// publicModels — GET /api/models for the public site (docs/landing pickers).
|
// publicModels — GET /api/models for the public site (docs/landing pickers).
|
||||||
// Enabled models only, in the same shape as before minus the provider id.
|
// Fully-priced models only (配价即上架), same shape as before.
|
||||||
func (h *UserGenerationHandler) publicModels(c *gin.Context) ([]gin.H, error) {
|
func (h *UserGenerationHandler) publicModels(c *gin.Context) ([]gin.H, error) {
|
||||||
models, err := h.admin.Models(c.Request.Context())
|
models, err := h.admin.Models(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -555,7 +555,8 @@ func (h *UserGenerationHandler) publicModels(c *gin.Context) ([]gin.H, error) {
|
|||||||
}
|
}
|
||||||
items := make([]gin.H, 0, len(models))
|
items := make([]gin.H, 0, len(models))
|
||||||
for _, m := range models {
|
for _, m := range models {
|
||||||
if !m.Enabled {
|
// 配价即上架:未配齐价的模型对前台不可见(绑定/配价都满足才进 /v1)。
|
||||||
|
if !service.ModelFullyPriced(&m) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
items = append(items, gin.H{
|
items = append(items, gin.H{
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
|
|||||||
authed.POST("/channels/delete", handlers.ChannelAdmin.Delete)
|
authed.POST("/channels/delete", handlers.ChannelAdmin.Delete)
|
||||||
authed.POST("/channels/fetch-models", handlers.ChannelAdmin.FetchModels)
|
authed.POST("/channels/fetch-models", handlers.ChannelAdmin.FetchModels)
|
||||||
authed.POST("/channels/import-models", handlers.ChannelAdmin.ImportModels)
|
authed.POST("/channels/import-models", handlers.ChannelAdmin.ImportModels)
|
||||||
|
authed.POST("/channels/unbind-model", handlers.ChannelAdmin.UnbindModel)
|
||||||
authed.GET("/providers", handlers.AdminRead.Providers)
|
authed.GET("/providers", handlers.AdminRead.Providers)
|
||||||
authed.GET("/images", handlers.AdminRead.Images)
|
authed.GET("/images", handlers.AdminRead.Images)
|
||||||
authed.DELETE("/images", handlers.AdminRead.DeleteImage)
|
authed.DELETE("/images", handlers.AdminRead.DeleteImage)
|
||||||
|
|||||||
@@ -126,7 +126,6 @@ type ModelConfig struct {
|
|||||||
Name string `gorm:"size:255;not null"`
|
Name string `gorm:"size:255;not null"`
|
||||||
Alias string `gorm:"column:alias;size:255;not null;default:''"`
|
Alias string `gorm:"column:alias;size:255;not null;default:''"`
|
||||||
Provider string `gorm:"size:100;index;not null"`
|
Provider string `gorm:"size:100;index;not null"`
|
||||||
Enabled bool `gorm:"not null;default:true"`
|
|
||||||
FreeAllowed bool `gorm:"not null;default:false"` // 普号(free)能否调度此模型,默认不可用
|
FreeAllowed bool `gorm:"not null;default:false"` // 普号(free)能否调度此模型,默认不可用
|
||||||
Ratios datatypes.JSON `gorm:"type:jsonb"`
|
Ratios datatypes.JSON `gorm:"type:jsonb"`
|
||||||
Prices datatypes.JSONMap `gorm:"type:jsonb"`
|
Prices datatypes.JSONMap `gorm:"type:jsonb"`
|
||||||
|
|||||||
@@ -80,6 +80,15 @@ func (r *ChannelRepository) Unbind(ctx context.Context, channelID, modelID strin
|
|||||||
return r.db.WithContext(ctx).Delete(&model.ChannelModel{}, "channel_id = ? AND model_id = ?", channelID, modelID).Error
|
return r.db.WithContext(ctx).Delete(&model.ChannelModel{}, "channel_id = ? AND model_id = ?", channelID, modelID).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnbindExcept removes every binding of a channel whose upstream model name is
|
||||||
|
// NOT in the keep list (upstream_model, falling back to model_id for legacy
|
||||||
|
// rows) — the "true sync" counterpart used after saving a channel.
|
||||||
|
func (r *ChannelRepository) UnbindExcept(ctx context.Context, channelID string, upstreamIDs []string) error {
|
||||||
|
return r.db.WithContext(ctx).
|
||||||
|
Where("channel_id = ? AND NOT (COALESCE(NULLIF(upstream_model, ''), model_id) IN ?)", channelID, upstreamIDs).
|
||||||
|
Delete(&model.ChannelModel{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ChannelRepository) DeleteBindingsByChannel(ctx context.Context, channelID string) error {
|
func (r *ChannelRepository) DeleteBindingsByChannel(ctx context.Context, channelID string) error {
|
||||||
return r.db.WithContext(ctx).Delete(&model.ChannelModel{}, "channel_id = ?", channelID).Error
|
return r.db.WithContext(ctx).Delete(&model.ChannelModel{}, "channel_id = ?", channelID).Error
|
||||||
}
|
}
|
||||||
@@ -158,6 +167,22 @@ func (r *ChannelRepository) UpstreamModelMap(ctx context.Context, channelID stri
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RoutableModelIDs returns the set of local model ids bound to at least one
|
||||||
|
// ENABLED channel — the "can actually dispatch" set behind the sellable flag.
|
||||||
|
func (r *ChannelRepository) RoutableModelIDs(ctx context.Context) (map[string]struct{}, error) {
|
||||||
|
var ids []string
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Table("channel_models").
|
||||||
|
Select("DISTINCT channel_models.model_id").
|
||||||
|
Joins("JOIN channels ON channels.id = channel_models.channel_id AND channels.enabled = ?", true).
|
||||||
|
Scan(&ids).Error
|
||||||
|
out := make(map[string]struct{}, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
out[id] = struct{}{}
|
||||||
|
}
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
// ChannelIDsByModel lists channel ids bound to a model (any enabled state).
|
// ChannelIDsByModel lists channel ids bound to a model (any enabled state).
|
||||||
func (r *ChannelRepository) ChannelIDsByModel(ctx context.Context, modelID string) ([]string, error) {
|
func (r *ChannelRepository) ChannelIDsByModel(ctx context.Context, modelID string) ([]string, error) {
|
||||||
var ids []string
|
var ids []string
|
||||||
|
|||||||
@@ -430,12 +430,41 @@ const stalePendingWhere = "status = ? AND ((kind = ? AND ts < ?) OR (kind <> ? A
|
|||||||
|
|
||||||
// StaleEvent identifies a purged pending event so the caller can refund the
|
// StaleEvent identifies a purged pending event so the caller can refund the
|
||||||
// credits debited up-front AND attribute the failure to the account the
|
// credits debited up-front AND attribute the failure to the account the
|
||||||
// (now-abandoned) generation was using.
|
// (now-abandoned) generation was using. UpstreamJob/Kind/Model/File/Source are
|
||||||
|
// what the channel-resume path needs to pick the job back up.
|
||||||
type StaleEvent struct {
|
type StaleEvent struct {
|
||||||
ID string `gorm:"column:id"`
|
ID string `gorm:"column:id"`
|
||||||
UserID string `gorm:"column:user_id"`
|
UserID string `gorm:"column:user_id"`
|
||||||
AccountID string `gorm:"column:account_id"`
|
AccountID string `gorm:"column:account_id"`
|
||||||
Cost float64 `gorm:"column:cost"`
|
UpstreamJob string `gorm:"column:upstream_job"`
|
||||||
|
Kind string `gorm:"column:kind"`
|
||||||
|
Model string `gorm:"column:model"`
|
||||||
|
File string `gorm:"column:file"`
|
||||||
|
Source string `gorm:"column:source"`
|
||||||
|
Cost float64 `gorm:"column:cost"`
|
||||||
|
TS time.Time `gorm:"column:ts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResumableStale returns pending events past their per-kind deadline that still
|
||||||
|
// carry an upstream job id — a process restart orphaned the local goroutine but
|
||||||
|
// the upstream job may still be running, so these get resumed (not purged).
|
||||||
|
// Events older than maxResumeAge are excluded and fall through to PurgeStale.
|
||||||
|
func (r *EventRepository) ResumableStale(ctx context.Context, maxAge, videoMaxAge, maxResumeAge time.Duration) ([]StaleEvent, error) {
|
||||||
|
if maxAge <= 0 {
|
||||||
|
maxAge = 600 * time.Second
|
||||||
|
}
|
||||||
|
if videoMaxAge <= 0 {
|
||||||
|
videoMaxAge = maxAge
|
||||||
|
}
|
||||||
|
cutoff := time.Now().Add(-maxAge)
|
||||||
|
videoCutoff := time.Now().Add(-videoMaxAge)
|
||||||
|
resumeFloor := time.Now().Add(-maxResumeAge)
|
||||||
|
var stale []StaleEvent
|
||||||
|
err := r.db.WithContext(ctx).Model(&model.EventLog{}).
|
||||||
|
Where(stalePendingWhere+" AND upstream_job <> '' AND ts >= ?", "pending", "video", videoCutoff, "video", cutoff, resumeFloor).
|
||||||
|
Select("id", "user_id", "account_id", "upstream_job", "kind", "model", "file", "source", "cost", "ts").
|
||||||
|
Scan(&stale).Error
|
||||||
|
return stale, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// PurgeStale marks long-pending entries as failed/abandoned and RETURNS them so
|
// PurgeStale marks long-pending entries as failed/abandoned and RETURNS them so
|
||||||
@@ -446,7 +475,10 @@ type StaleEvent struct {
|
|||||||
//
|
//
|
||||||
// videoMaxAge 单独给视频用:出片本来就慢(上游长镜头能跑二十多分钟),按图片的
|
// videoMaxAge 单独给视频用:出片本来就慢(上游长镜头能跑二十多分钟),按图片的
|
||||||
// 期限扫会在它出片前就判死并退款。
|
// 期限扫会在它出片前就判死并退款。
|
||||||
func (r *EventRepository) PurgeStale(ctx context.Context, maxAge, videoMaxAge time.Duration) ([]StaleEvent, error) {
|
//
|
||||||
|
// Events carrying an upstream job id and younger than resumeFloor are skipped —
|
||||||
|
// ResumableStale resumes them against the channel instead of killing them.
|
||||||
|
func (r *EventRepository) PurgeStale(ctx context.Context, maxAge, videoMaxAge, maxResumeAge time.Duration) ([]StaleEvent, error) {
|
||||||
if maxAge <= 0 {
|
if maxAge <= 0 {
|
||||||
maxAge = 600 * time.Second
|
maxAge = 600 * time.Second
|
||||||
}
|
}
|
||||||
@@ -455,13 +487,14 @@ func (r *EventRepository) PurgeStale(ctx context.Context, maxAge, videoMaxAge ti
|
|||||||
}
|
}
|
||||||
cutoff := time.Now().Add(-maxAge)
|
cutoff := time.Now().Add(-maxAge)
|
||||||
videoCutoff := time.Now().Add(-videoMaxAge)
|
videoCutoff := time.Now().Add(-videoMaxAge)
|
||||||
|
resumeFloor := time.Now().Add(-maxResumeAge)
|
||||||
var stale []StaleEvent
|
var stale []StaleEvent
|
||||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
// Snapshot who/what to refund BEFORE flipping status, so a concurrent
|
// Snapshot who/what to refund BEFORE flipping status, so a concurrent
|
||||||
// sweep can't double-count (the UPDATE in the same tx removes them from
|
// sweep can't double-count (the UPDATE in the same tx removes them from
|
||||||
// the pending set).
|
// the pending set).
|
||||||
if err := tx.Model(&model.EventLog{}).
|
if err := tx.Model(&model.EventLog{}).
|
||||||
Where(stalePendingWhere, "pending", "video", videoCutoff, "video", cutoff).
|
Where(stalePendingWhere+" AND (upstream_job IS NULL OR upstream_job = '' OR ts < ?)", "pending", "video", videoCutoff, "video", cutoff, resumeFloor).
|
||||||
Select("id", "user_id", "account_id", "cost").
|
Select("id", "user_id", "account_id", "cost").
|
||||||
Scan(&stale).Error; err != nil {
|
Scan(&stale).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -470,7 +503,7 @@ func (r *EventRepository) PurgeStale(ctx context.Context, maxAge, videoMaxAge ti
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return tx.Model(&model.EventLog{}).
|
return tx.Model(&model.EventLog{}).
|
||||||
Where(stalePendingWhere, "pending", "video", videoCutoff, "video", cutoff).
|
Where(stalePendingWhere+" AND (upstream_job IS NULL OR upstream_job = '' OR ts < ?)", "pending", "video", videoCutoff, "video", cutoff, resumeFloor).
|
||||||
Updates(map[string]any{
|
Updates(map[string]any{
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
"error": gorm.Expr("COALESCE(NULLIF(error, ''), ?)", "abandoned (process restarted or request interrupted)"),
|
"error": gorm.Expr("COALESCE(NULLIF(error, ''), ?)", "abandoned (process restarted or request interrupted)"),
|
||||||
|
|||||||
@@ -84,15 +84,18 @@ func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, er
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
routable, _ := s.channels.RoutableModelIDs(ctx)
|
||||||
out := make([]map[string]any, 0, len(items))
|
out := make([]map[string]any, 0, len(items))
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
|
_, bound := routable[item.ID]
|
||||||
out = append(out, map[string]any{
|
out = append(out, map[string]any{
|
||||||
"id": item.ID,
|
"id": item.ID,
|
||||||
"alias": item.Alias,
|
"alias": item.Alias,
|
||||||
"type": item.Type,
|
"type": item.Type,
|
||||||
"name": item.Name,
|
"name": item.Name,
|
||||||
"provider": item.Provider,
|
"provider": item.Provider,
|
||||||
"enabled": item.Enabled,
|
// 配价即上架(无启停开关):全档位有价 + 绑定了启用渠道才算可售。
|
||||||
|
"sellable": bound && ModelFullyPriced(&item),
|
||||||
"ratios": repo.JSONStrings(item.Ratios),
|
"ratios": repo.JSONStrings(item.Ratios),
|
||||||
"prices": map[string]any(item.Prices),
|
"prices": map[string]any(item.Prices),
|
||||||
"resolutions": repo.JSONStrings(item.Resolutions),
|
"resolutions": repo.JSONStrings(item.Resolutions),
|
||||||
|
|||||||
@@ -391,7 +391,6 @@ func (s *AdminWriteService) CreateModel(ctx context.Context, body map[string]any
|
|||||||
Name: defaultString(strings.TrimSpace(stringValue(body["name"])), modelID),
|
Name: defaultString(strings.TrimSpace(stringValue(body["name"])), modelID),
|
||||||
Alias: alias,
|
Alias: alias,
|
||||||
Provider: provider,
|
Provider: provider,
|
||||||
Enabled: boolValueWithDefault(body["enabled"], true),
|
|
||||||
Ratios: jsonArray(body["ratios"]),
|
Ratios: jsonArray(body["ratios"]),
|
||||||
Prices: prices,
|
Prices: prices,
|
||||||
Resolutions: resolutions,
|
Resolutions: resolutions,
|
||||||
@@ -440,13 +439,6 @@ func (s *AdminWriteService) UpdateModel(ctx context.Context, modelID string, bod
|
|||||||
patch["provider"] = provider
|
patch["provider"] = provider
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Only touch `enabled` when the caller explicitly sends a non-null value;
|
|
||||||
// mirrors Python models_store.update ("enabled" in fields and is not None).
|
|
||||||
// Without this guard a PATCH that omits the field would default it to false
|
|
||||||
// and silently disable the model.
|
|
||||||
if raw, ok := body["enabled"]; ok && raw != nil {
|
|
||||||
patch["enabled"] = boolValueWithDefault(raw, true)
|
|
||||||
}
|
|
||||||
if _, ok := body["ratios"]; ok {
|
if _, ok := body["ratios"]; ok {
|
||||||
patch["ratios"] = jsonArray(body["ratios"])
|
patch["ratios"] = jsonArray(body["ratios"])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,14 +120,17 @@ type SaveChannelDto struct {
|
|||||||
Weight int `json:"weight"`
|
Weight int `json:"weight"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ChannelService) Save(ctx context.Context, d SaveChannelDto) (*model.Channel, error) {
|
// Save upserts a channel then auto-syncs its upstream models (same semantics
|
||||||
|
// as vivid: upstream /v1/models is the truth). The returned warning is a
|
||||||
|
// non-fatal sync message — "" means fully synced.
|
||||||
|
func (s *ChannelService) Save(ctx context.Context, d SaveChannelDto) (*model.Channel, string, error) {
|
||||||
name := strings.TrimSpace(d.Name)
|
name := strings.TrimSpace(d.Name)
|
||||||
base := strings.TrimRight(strings.TrimSpace(d.BaseURL), "/")
|
base := strings.TrimRight(strings.TrimSpace(d.BaseURL), "/")
|
||||||
if base == "" {
|
if base == "" {
|
||||||
return nil, errors.New("base_url required")
|
return nil, "", errors.New("base_url required")
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
|
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
|
||||||
return nil, errors.New("base_url must start with http:// or https://")
|
return nil, "", errors.New("base_url must start with http:// or https://")
|
||||||
}
|
}
|
||||||
weight := d.Weight
|
weight := d.Weight
|
||||||
if weight < 1 {
|
if weight < 1 {
|
||||||
@@ -135,11 +138,11 @@ func (s *ChannelService) Save(ctx context.Context, d SaveChannelDto) (*model.Cha
|
|||||||
}
|
}
|
||||||
if d.ID == "" {
|
if d.ID == "" {
|
||||||
if strings.TrimSpace(d.APIKey) == "" {
|
if strings.TrimSpace(d.APIKey) == "" {
|
||||||
return nil, errors.New("api_key required")
|
return nil, "", errors.New("api_key required")
|
||||||
}
|
}
|
||||||
enc, err := s.encrypt(d.APIKey)
|
enc, err := s.encrypt(d.APIKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
ch := &model.Channel{
|
ch := &model.Channel{
|
||||||
ID: "ch-" + randomUpper(10),
|
ID: "ch-" + randomUpper(10),
|
||||||
@@ -153,7 +156,10 @@ func (s *ChannelService) Save(ctx context.Context, d SaveChannelDto) (*model.Cha
|
|||||||
if ch.Name == "" {
|
if ch.Name == "" {
|
||||||
ch.Name = base
|
ch.Name = base
|
||||||
}
|
}
|
||||||
return ch, s.channels.Create(ctx, ch)
|
if err := s.channels.Create(ctx, ch); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return ch, s.syncChannelModels(ctx, ch.ID), nil
|
||||||
}
|
}
|
||||||
patch := map[string]any{
|
patch := map[string]any{
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -165,11 +171,95 @@ func (s *ChannelService) Save(ctx context.Context, d SaveChannelDto) (*model.Cha
|
|||||||
if key := strings.TrimSpace(d.APIKey); key != "" && !strings.Contains(key, "•") && !strings.Contains(key, "*") {
|
if key := strings.TrimSpace(d.APIKey); key != "" && !strings.Contains(key, "•") && !strings.Contains(key, "*") {
|
||||||
enc, err := s.encrypt(key)
|
enc, err := s.encrypt(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
patch["api_key_enc"] = enc
|
patch["api_key_enc"] = enc
|
||||||
}
|
}
|
||||||
return s.channels.Update(ctx, d.ID, patch)
|
ch, err := s.channels.Update(ctx, d.ID, patch)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return ch, s.syncChannelModels(ctx, d.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncChannelModels runs after Save: upstream /v1/models is the truth — every
|
||||||
|
// sellable upstream model is upserted locally (alias = upstream id) and bound;
|
||||||
|
// bindings on this channel not in the upstream list are removed, and orphan
|
||||||
|
// local models are deleted. An unreachable upstream or empty/anomalous list
|
||||||
|
// only produces a warning — the DB is left untouched.
|
||||||
|
func (s *ChannelService) syncChannelModels(ctx context.Context, channelID string) string {
|
||||||
|
ch, err := s.channels.Get(ctx, channelID)
|
||||||
|
if err != nil {
|
||||||
|
return "模型自动同步失败:" + err.Error()
|
||||||
|
}
|
||||||
|
key := s.decrypt(ch.APIKeyEnc)
|
||||||
|
if key == "" {
|
||||||
|
return "模型自动同步失败:无法解密上游密钥"
|
||||||
|
}
|
||||||
|
fctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
models, err := s.up.Models(fctx, ch.BaseURL, key)
|
||||||
|
if err != nil {
|
||||||
|
return "模型自动同步失败:" + err.Error()
|
||||||
|
}
|
||||||
|
if len(models) == 0 {
|
||||||
|
return "上游未返回任何模型,已跳过同步"
|
||||||
|
}
|
||||||
|
specs := make([]ChannelModelSpec, 0, len(models))
|
||||||
|
upstreamIDs := make([]string, 0, len(models))
|
||||||
|
var skipped []string
|
||||||
|
for _, m := range models {
|
||||||
|
spec, serr := normalizeRemoteModel(m)
|
||||||
|
if serr != nil {
|
||||||
|
skipped = append(skipped, m.ID+":"+serr.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
specs = append(specs, spec)
|
||||||
|
upstreamIDs = append(upstreamIDs, spec.ID)
|
||||||
|
}
|
||||||
|
if len(upstreamIDs) == 0 {
|
||||||
|
return fmt.Sprintf("上游模型均规格异常,已跳过同步:%s", strings.Join(skipped, ";"))
|
||||||
|
}
|
||||||
|
if err := s.ImportModels(ctx, channelID, specs); err != nil {
|
||||||
|
return "模型自动同步失败:" + err.Error()
|
||||||
|
}
|
||||||
|
if err := s.channels.UnbindExcept(ctx, channelID, upstreamIDs); err != nil {
|
||||||
|
return "模型自动同步失败:" + err.Error()
|
||||||
|
}
|
||||||
|
if orphans, oerr := s.channels.OrphanModelIDs(ctx); oerr == nil {
|
||||||
|
for _, mid := range orphans {
|
||||||
|
_, _ = s.models.Delete(ctx, mid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(skipped) > 0 {
|
||||||
|
return fmt.Sprintf("已同步上游模型,跳过 %d 个异常项:%s", len(skipped), strings.Join(skipped, ";"))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeRemoteModel validates an upstream /v1/models item into an import
|
||||||
|
// spec (alias = upstream id).
|
||||||
|
func normalizeRemoteModel(m upstream.RemoteModel) (ChannelModelSpec, error) {
|
||||||
|
spec := ChannelModelSpec{
|
||||||
|
ID: strings.TrimSpace(m.ID),
|
||||||
|
Kind: strings.TrimSpace(m.Kind),
|
||||||
|
Qualities: m.Qualities,
|
||||||
|
Duration: m.Duration,
|
||||||
|
RefImages: m.RefImages,
|
||||||
|
RefVideos: m.RefVideos,
|
||||||
|
RefAudios: m.RefAudios,
|
||||||
|
RefVideoSeconds: m.RefVideoSeconds,
|
||||||
|
RefAudioSeconds: m.RefAudioSeconds,
|
||||||
|
}
|
||||||
|
if spec.ID == "" {
|
||||||
|
return spec, errors.New("模型 id 为空")
|
||||||
|
}
|
||||||
|
switch spec.Kind {
|
||||||
|
case "image", "video", "text":
|
||||||
|
default:
|
||||||
|
return spec, fmt.Errorf("未知类型 %q", spec.Kind)
|
||||||
|
}
|
||||||
|
return spec, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete removes a channel, its bindings, and any local model that ends up
|
// Delete removes a channel, its bindings, and any local model that ends up
|
||||||
@@ -205,8 +295,9 @@ type FetchModelsDto struct {
|
|||||||
APIKey string `json:"api_key"`
|
APIKey string `json:"api_key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchModels lists the upstream's sellable models and flags which are already
|
// FetchModels lists the upstream's sellable models; when a saved channel id is
|
||||||
// imported locally.
|
// given, items already bound to that channel are flagged Bound so the admin UI
|
||||||
|
// can render them as a bound/unbound toggle.
|
||||||
func (s *ChannelService) FetchModels(ctx context.Context, d FetchModelsDto) ([]upstream.RemoteModel, error) {
|
func (s *ChannelService) FetchModels(ctx context.Context, d FetchModelsDto) ([]upstream.RemoteModel, error) {
|
||||||
base, key, err := s.resolveCreds(ctx, d.ID, d.BaseURL, d.APIKey)
|
base, key, err := s.resolveCreds(ctx, d.ID, d.BaseURL, d.APIKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -216,9 +307,75 @@ func (s *ChannelService) FetchModels(ctx context.Context, d FetchModelsDto) ([]u
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(d.ID) != "" {
|
||||||
|
if bound, berr := s.channels.UpstreamModelMap(ctx, d.ID); berr == nil && len(bound) > 0 {
|
||||||
|
set := make(map[string]struct{}, len(bound))
|
||||||
|
for _, upID := range bound {
|
||||||
|
set[upID] = struct{}{}
|
||||||
|
}
|
||||||
|
for i := range items {
|
||||||
|
if _, ok := set[items[i].ID]; ok {
|
||||||
|
items[i].Bound = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnbindModel detaches one upstream model from a channel; when the local model
|
||||||
|
// row ends up bound to no channel at all it is deleted too (same orphan
|
||||||
|
// semantics as deleting a whole channel).
|
||||||
|
func (s *ChannelService) UnbindModel(ctx context.Context, channelID, upstreamModelID string) error {
|
||||||
|
upID := strings.TrimSpace(upstreamModelID)
|
||||||
|
if upID == "" {
|
||||||
|
return errors.New("model id required")
|
||||||
|
}
|
||||||
|
m, err := s.channels.UpstreamModelMap(ctx, channelID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var localID string
|
||||||
|
for mid, up := range m {
|
||||||
|
if up == upID {
|
||||||
|
localID = mid
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if localID == "" {
|
||||||
|
return nil // not bound — idempotent
|
||||||
|
}
|
||||||
|
if err := s.channels.Unbind(ctx, channelID, localID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rest, _ := s.channels.ChannelIDsByModel(ctx, localID); len(rest) == 0 {
|
||||||
|
_, _ = s.models.Delete(ctx, localID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeOrphanModels sweeps model_configs rows left over from the provider era:
|
||||||
|
// an unbound model can never be routed, so any row that didn't come through a
|
||||||
|
// channel import (provider != "channel") is deleted at startup. Channel-typed
|
||||||
|
// orphans keep their pricing config for a later rebind.
|
||||||
|
func (s *ChannelService) PurgeOrphanModels(ctx context.Context) (int, error) {
|
||||||
|
ids, err := s.channels.OrphanModelIDs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, id := range ids {
|
||||||
|
m, gerr := s.models.Get(ctx, id)
|
||||||
|
if gerr != nil || m == nil || m.Provider == "channel" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, derr := s.models.Delete(ctx, id); derr == nil {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ChannelModelSpec is one model the admin ticks for import (same shape as the
|
// ChannelModelSpec is one model the admin ticks for import (same shape as the
|
||||||
// upstream /v1/models item plus an optional local alias).
|
// upstream /v1/models item plus an optional local alias).
|
||||||
type ChannelModelSpec struct {
|
type ChannelModelSpec struct {
|
||||||
@@ -281,7 +438,6 @@ func (s *ChannelService) ImportModels(ctx context.Context, channelID string, spe
|
|||||||
Name: localID,
|
Name: localID,
|
||||||
Type: spec.Kind,
|
Type: spec.Kind,
|
||||||
Provider: "channel",
|
Provider: "channel",
|
||||||
Enabled: true,
|
|
||||||
Ratios: jsonArray([]string{"16:9"}),
|
Ratios: jsonArray([]string{"16:9"}),
|
||||||
Prices: datatypes.JSONMap{},
|
Prices: datatypes.JSONMap{},
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
@@ -392,6 +548,11 @@ func (s *ChannelService) ChannelsForModelAnyState(ctx context.Context, modelID s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt exposes the upstream key for dispatch.
|
// Decrypt exposes the upstream key for dispatch.
|
||||||
|
// Get loads one channel row by id (resume path needs the owning channel).
|
||||||
|
func (s *ChannelService) Get(ctx context.Context, id string) (*model.Channel, error) {
|
||||||
|
return s.channels.Get(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ChannelService) Decrypt(ch model.Channel) string { return s.decrypt(ch.APIKeyEnc) }
|
func (s *ChannelService) Decrypt(ch model.Channel) string { return s.decrypt(ch.APIKeyEnc) }
|
||||||
|
|
||||||
// Upstream returns the shared protocol client (dispatch needs it for
|
// Upstream returns the shared protocol client (dispatch needs it for
|
||||||
|
|||||||
@@ -20,23 +20,24 @@ type MaintenanceService struct {
|
|||||||
users *repo.UserRepository
|
users *repo.UserRepository
|
||||||
settings *repo.SiteSettingRepository
|
settings *repo.SiteSettingRepository
|
||||||
store *storage.Client
|
store *storage.Client
|
||||||
inflight *InflightRegistry
|
v1 *V1Service
|
||||||
showcase *repo.ShowcaseRepository
|
showcase *repo.ShowcaseRepository
|
||||||
orders *repo.OrderRepository
|
orders *repo.OrderRepository
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
stalePending time.Duration
|
stalePending time.Duration
|
||||||
stalePendingVid time.Duration
|
stalePendingVid time.Duration
|
||||||
|
maxResumeAge time.Duration
|
||||||
mediaPruneEvery time.Duration
|
mediaPruneEvery time.Duration
|
||||||
lastMediaPrune time.Time
|
lastMediaPrune time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMaintenanceService(events *repo.EventRepository, users *repo.UserRepository, settings *repo.SiteSettingRepository, store *storage.Client, inflight *InflightRegistry, showcase *repo.ShowcaseRepository, orders *repo.OrderRepository) *MaintenanceService {
|
func NewMaintenanceService(events *repo.EventRepository, users *repo.UserRepository, settings *repo.SiteSettingRepository, store *storage.Client, v1 *V1Service, showcase *repo.ShowcaseRepository, orders *repo.OrderRepository) *MaintenanceService {
|
||||||
return &MaintenanceService{
|
return &MaintenanceService{
|
||||||
events: events,
|
events: events,
|
||||||
users: users,
|
users: users,
|
||||||
settings: settings,
|
settings: settings,
|
||||||
store: store,
|
store: store,
|
||||||
inflight: inflight,
|
v1: v1,
|
||||||
showcase: showcase,
|
showcase: showcase,
|
||||||
orders: orders,
|
orders: orders,
|
||||||
interval: 60 * time.Second,
|
interval: 60 * time.Second,
|
||||||
@@ -44,6 +45,9 @@ func NewMaintenanceService(events *repo.EventRepository, users *repo.UserReposit
|
|||||||
// 视频比图片慢得多,单条执行预算就是 30 分钟(videoGenBudget),用图片的
|
// 视频比图片慢得多,单条执行预算就是 30 分钟(videoGenBudget),用图片的
|
||||||
// 10 分钟去扫会在它出片前判死并退款。
|
// 10 分钟去扫会在它出片前判死并退款。
|
||||||
stalePendingVid: 30 * time.Minute,
|
stalePendingVid: 30 * time.Minute,
|
||||||
|
// 带上游 jobId 的逾期任务先尝试续等而不是判死;超过 3 小时总时限
|
||||||
|
// (vivid 同款)才放弃走判死退款。
|
||||||
|
maxResumeAge: 3 * time.Hour,
|
||||||
mediaPruneEvery: 60 * time.Second,
|
mediaPruneEvery: 60 * time.Second,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,10 +79,26 @@ func (m *MaintenanceService) tick(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Fail long-pending events so they stop blocking the per-user gate, and
|
// 1a. Pending events with an upstream job id that outlived their local
|
||||||
|
// goroutine (process restart) get resumed against the owning channel —
|
||||||
|
// the upstream job keeps running even while we were down.
|
||||||
|
if m.v1 != nil {
|
||||||
|
if resumable, err := m.events.ResumableStale(ctx, m.stalePending, m.stalePendingVid, m.maxResumeAge); err != nil {
|
||||||
|
log.Printf("maintenance: resumable_stale: %v", err)
|
||||||
|
} else {
|
||||||
|
for _, e := range resumable {
|
||||||
|
m.v1.ResumeStaleJob(ctx, e)
|
||||||
|
}
|
||||||
|
if len(resumable) > 0 {
|
||||||
|
log.Printf("maintenance: resuming %d stale job(s) on their channels", len(resumable))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1b. Fail long-pending events so they stop blocking the per-user gate, and
|
||||||
// refund the credits debited up-front for each abandoned generation (the
|
// refund the credits debited up-front for each abandoned generation (the
|
||||||
// normal failure-refund path never ran for a process-restart orphan).
|
// normal failure-refund path never ran for a process-restart orphan).
|
||||||
if purged, err := m.events.PurgeStale(ctx, m.stalePending, m.stalePendingVid); err != nil {
|
if purged, err := m.events.PurgeStale(ctx, m.stalePending, m.stalePendingVid, m.maxResumeAge); err != nil {
|
||||||
log.Printf("maintenance: purge_stale: %v", err)
|
log.Printf("maintenance: purge_stale: %v", err)
|
||||||
} else if len(purged) > 0 {
|
} else if len(purged) > 0 {
|
||||||
refunded := 0
|
refunded := 0
|
||||||
@@ -87,7 +107,7 @@ func (m *MaintenanceService) tick(ctx context.Context) {
|
|||||||
// Stop the generation goroutine if it's still running, so it doesn't
|
// Stop the generation goroutine if it's still running, so it doesn't
|
||||||
// keep grinding for minutes and surface a late "success" on this
|
// keep grinding for minutes and surface a late "success" on this
|
||||||
// just-abandoned event.
|
// just-abandoned event.
|
||||||
if m.inflight != nil && m.inflight.Cancel(e.ID) {
|
if m.v1 != nil && m.v1.Inflight().Cancel(e.ID) {
|
||||||
cancelled++
|
cancelled++
|
||||||
}
|
}
|
||||||
if e.UserID == "" || e.Cost <= 0 {
|
if e.UserID == "" || e.Cost <= 0 {
|
||||||
|
|||||||
+115
-11
@@ -159,6 +159,16 @@ func (r *InflightRegistry) Add(eventID string, cancel context.CancelFunc) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TryAdd registers an event only when nothing is tracked for it — the resume
|
||||||
|
// sweep uses it so two ticks can't double-attach to the same upstream job.
|
||||||
|
func (r *InflightRegistry) TryAdd(eventID string, cancel context.CancelFunc) bool {
|
||||||
|
if eventID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, loaded := r.m.LoadOrStore(eventID, cancel)
|
||||||
|
return !loaded
|
||||||
|
}
|
||||||
|
|
||||||
// Done deregisters an event (called on normal completion).
|
// Done deregisters an event (called on normal completion).
|
||||||
func (r *InflightRegistry) Done(eventID string) { r.m.Delete(eventID) }
|
func (r *InflightRegistry) Done(eventID string) { r.m.Delete(eventID) }
|
||||||
|
|
||||||
@@ -342,9 +352,9 @@ type V1ModelDur struct {
|
|||||||
Options []int64 `json:"options,omitempty"`
|
Options []int64 `json:"options,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SellableModels — GET /v1/models. Only enabled models bound to at least one
|
// SellableModels — GET /v1/models. 配价即上架(无启停开关):绑定到至少一个
|
||||||
// enabled channel AND fully priced are sellable (a model with an unpriced tier
|
// enabled 渠道 AND 全部档位有价才是可售模型(缺价的档位提交时会扣费拒绝,
|
||||||
// would charge-reject at submit time — hide it instead).
|
// 直接不下发)。kind 不限——image/video/text 同构透出,下游才能连导。
|
||||||
func (s *V1Service) SellableModels(ctx context.Context) ([]V1ModelItem, error) {
|
func (s *V1Service) SellableModels(ctx context.Context) ([]V1ModelItem, error) {
|
||||||
items, err := s.models.List(ctx)
|
items, err := s.models.List(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -352,9 +362,6 @@ func (s *V1Service) SellableModels(ctx context.Context) ([]V1ModelItem, error) {
|
|||||||
}
|
}
|
||||||
out := make([]V1ModelItem, 0, len(items))
|
out := make([]V1ModelItem, 0, len(items))
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if !item.Enabled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !s.modelSellable(ctx, &item) {
|
if !s.modelSellable(ctx, &item) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -393,6 +400,12 @@ func (s *V1Service) modelSellable(ctx context.Context, item *model.ModelConfig)
|
|||||||
if err != nil || len(channels) == 0 {
|
if err != nil || len(channels) == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
return ModelFullyPriced(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelFullyPriced — 上架门槛(无启停开关,配价即上架):每个清晰度档位在
|
||||||
|
// Prices 里有价;视频模型还要求时长有价(离散档逐档,或区间档按秒价)。
|
||||||
|
func ModelFullyPriced(item *model.ModelConfig) bool {
|
||||||
for _, res := range repo.JSONStrings(item.Resolutions) {
|
for _, res := range repo.JSONStrings(item.Resolutions) {
|
||||||
if _, ok := jsonMapFloat(item.Prices, res); !ok {
|
if _, ok := jsonMapFloat(item.Prices, res); !ok {
|
||||||
return false
|
return false
|
||||||
@@ -564,6 +577,10 @@ func (s *V1Service) generateOnChannel(ctx context.Context, eventID string, ch mo
|
|||||||
if upstreamJob == "" {
|
if upstreamJob == "" {
|
||||||
upstreamJob = job
|
upstreamJob = job
|
||||||
_ = s.events.SetUpstreamJob(ctx, eventID, job)
|
_ = s.events.SetUpstreamJob(ctx, eventID, job)
|
||||||
|
// Stamp the owning channel the moment the upstream accepts the job —
|
||||||
|
// a restart between submit and finish would otherwise leave a
|
||||||
|
// pending row that can't tell which channel to resume on.
|
||||||
|
_ = s.events.SetAccount(ctx, eventID, ch.ID, ch.Name)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -881,9 +898,6 @@ func (s *V1Service) CreateJob(ctx context.Context, principal *APIPrincipal, in V
|
|||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !modelItem.Enabled {
|
|
||||||
return nil, ErrUnknownModel
|
|
||||||
}
|
|
||||||
kind := modelItem.Type
|
kind := modelItem.Type
|
||||||
if kind != "image" && kind != "video" {
|
if kind != "image" && kind != "video" {
|
||||||
return nil, ErrUnknownModel
|
return nil, ErrUnknownModel
|
||||||
@@ -1052,6 +1066,96 @@ func (s *V1Service) runV1Job(ctx context.Context, principal *APIPrincipal, event
|
|||||||
_ = s.maybeGrantInviteReward(ctx, principal)
|
_ = s.maybeGrantInviteReward(ctx, principal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResumeStaleJob re-attaches to an upstream job whose local goroutine died with
|
||||||
|
// a process restart. Called by the maintenance sweep; TryAdd guards against
|
||||||
|
// double-attach across ticks. Terminal failure (upstream job lost/failed,
|
||||||
|
// channel gone or key broken) fails the event and refunds; transient failure
|
||||||
|
// leaves it pending for the next sweep (events older than maxResumeAge get
|
||||||
|
// purged instead of resumed).
|
||||||
|
func (s *V1Service) ResumeStaleJob(ctx context.Context, ev repo.StaleEvent) {
|
||||||
|
if ev.UpstreamJob == "" || ev.AccountID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), videoGenBudget)
|
||||||
|
if !s.inflight.TryAdd(ev.ID, cancel) {
|
||||||
|
cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go s.resumeUpstreamJob(rctx, ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *V1Service) resumeUpstreamJob(ctx context.Context, ev repo.StaleEvent) {
|
||||||
|
defer s.inflight.Done(ev.ID)
|
||||||
|
fail := func(msg string) {
|
||||||
|
s.refundStale(ctx, ev)
|
||||||
|
elapsed := int(time.Since(ev.TS).Milliseconds())
|
||||||
|
_ = s.events.UpdateStatus(ctx, ev.ID, "failed", msg, elapsed)
|
||||||
|
}
|
||||||
|
ch, err := s.channels.Get(ctx, ev.AccountID)
|
||||||
|
if err != nil || ch == nil {
|
||||||
|
fail("上游渠道已删除,任务无法续等")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiKey := s.channels.Decrypt(*ch)
|
||||||
|
if apiKey == "" {
|
||||||
|
fail("上游渠道密钥不可用,任务无法续等")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base := strings.TrimRight(ch.BaseURL, "/")
|
||||||
|
resultURL, credits, err := s.up.Generate(ctx, base, apiKey, upstream.GenParams{JobID: ev.UpstreamJob}, int64(videoGenBudget/time.Second), nil)
|
||||||
|
if err != nil {
|
||||||
|
switch upstream.KindOf(err) {
|
||||||
|
case upstream.KindJobFailed, upstream.KindInvalid, upstream.KindAuth:
|
||||||
|
fail(upstreamUserError(err).Error())
|
||||||
|
default:
|
||||||
|
// Retryable/transport/timeout — the upstream job may still run;
|
||||||
|
// leave the event pending for the next sweep.
|
||||||
|
log.Printf("[v1] resume upstream job %s (event %s) retry later: %v", ev.UpstreamJob, ev.ID, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, ct, err := s.up.Download(ctx, resultURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[v1] resume download %s (event %s) retry later: %v", ev.UpstreamJob, ev.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rel := strings.TrimSpace(ev.File)
|
||||||
|
if rel == "" {
|
||||||
|
rel = filepath.ToSlash(filepath.Join("tmp", ev.ID+"."+resultExt(ct, ev.Kind)))
|
||||||
|
}
|
||||||
|
if err := s.store.Put(ctx, rel, data, ct); err != nil {
|
||||||
|
fail("storage upload failed: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.events.SetFile(ctx, ev.ID, rel)
|
||||||
|
if err := s.events.UpdateStatus(ctx, ev.ID, "success", "", int(time.Since(ev.TS).Milliseconds())); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.channels.BumpSuccess(ctx, ch.ID, float64(credits))
|
||||||
|
if ev.Model != "" {
|
||||||
|
_ = s.models.IncrementGenerationCount(ctx, ev.Model)
|
||||||
|
}
|
||||||
|
if ev.UserID != "" {
|
||||||
|
_ = s.users.IncrementGenerationCount(ctx, ev.UserID)
|
||||||
|
}
|
||||||
|
log.Printf("[v1] resumed upstream job %s → event %s succeeded", ev.UpstreamJob, ev.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// refundStale returns the up-front charge for an abandoned/resume-dead event,
|
||||||
|
// claimed exactly once via the refunded flag.
|
||||||
|
func (s *V1Service) refundStale(ctx context.Context, ev repo.StaleEvent) {
|
||||||
|
if ev.UserID == "" || ev.Cost <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claimed, err := s.events.MarkRefunded(ctx, ev.ID)
|
||||||
|
if err != nil || !claimed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.users.RefundCredits(ctx, ev.UserID, ev.Cost); err != nil {
|
||||||
|
log.Printf("[v1] refund stale event %s (user %s, %.0f): %v", ev.ID, ev.UserID, ev.Cost, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func resultExt(contentType, kind string) string {
|
func resultExt(contentType, kind string) string {
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(contentType, "webp"):
|
case strings.Contains(contentType, "webp"):
|
||||||
@@ -1300,7 +1404,7 @@ func (s *V1Service) prepareImage(ctx context.Context, principal *APIPrincipal, i
|
|||||||
}
|
}
|
||||||
return nil, "", "", 0, err
|
return nil, "", "", 0, err
|
||||||
}
|
}
|
||||||
if !modelItem.Enabled || modelItem.Type != "image" {
|
if modelItem.Type != "image" {
|
||||||
return nil, "", "", 0, ErrUnknownModel
|
return nil, "", "", 0, ErrUnknownModel
|
||||||
}
|
}
|
||||||
// Fail fast before charging if no channel serves this model.
|
// Fail fast before charging if no channel serves this model.
|
||||||
@@ -1361,7 +1465,7 @@ func (s *V1Service) prepareVideo(ctx context.Context, principal *APIPrincipal, i
|
|||||||
}
|
}
|
||||||
return nil, "", "", "", 0, err
|
return nil, "", "", "", 0, err
|
||||||
}
|
}
|
||||||
if !modelItem.Enabled || modelItem.Type != "video" {
|
if modelItem.Type != "video" {
|
||||||
return nil, "", "", "", 0, ErrUnknownModel
|
return nil, "", "", "", 0, ErrUnknownModel
|
||||||
}
|
}
|
||||||
// Validate duration against the model's spec (options list or min/max range).
|
// Validate duration against the model's spec (options list or min/max range).
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ type RemoteModel struct {
|
|||||||
// Combined reference duration caps in seconds; 0 = unlimited.
|
// Combined reference duration caps in seconds; 0 = unlimited.
|
||||||
RefVideoSeconds int64 `json:"refVideoSeconds,omitempty"`
|
RefVideoSeconds int64 `json:"refVideoSeconds,omitempty"`
|
||||||
RefAudioSeconds int64 `json:"refAudioSeconds,omitempty"`
|
RefAudioSeconds int64 `json:"refAudioSeconds,omitempty"`
|
||||||
|
// Bound is set by the admin fetch-models endpoint only: true when this
|
||||||
|
// upstream id is already bound to that channel locally.
|
||||||
|
Bound bool `json:"bound,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoteDuration mirrors the duration spec of a vivid model item.
|
// RemoteDuration mirrors the duration spec of a vivid model item.
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ watch(genMsg, (v) => {
|
|||||||
if (v) genMsgTimer = setTimeout(() => { genMsg.value = '' }, 3500)
|
if (v) genMsgTimer = setTimeout(() => { genMsg.value = '' }, 3500)
|
||||||
})
|
})
|
||||||
const kindModels = computed(() =>
|
const kindModels = computed(() =>
|
||||||
(allModels.value || []).filter((m) => m.enabled !== false && m.type === gen.kind),
|
(allModels.value || []).filter((m) => m.sellable && m.type === gen.kind),
|
||||||
)
|
)
|
||||||
const model = computed(() => kindModels.value.find((m) => m.id === gen.modelId) || null)
|
const model = computed(() => kindModels.value.find((m) => m.id === gen.modelId) || null)
|
||||||
const ratios = computed(() => model.value?.ratios?.length ? model.value.ratios : ['1:1'])
|
const ratios = computed(() => model.value?.ratios?.length ? model.value.ratios : ['1:1'])
|
||||||
|
|||||||
@@ -9,20 +9,18 @@ const KIND_LABEL = { image: '图像', video: '视频', text: '文本' }
|
|||||||
const channels = ref([])
|
const channels = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const err = ref('')
|
const err = ref('')
|
||||||
|
const warn = ref('') // 保存后自动同步的非致命告警
|
||||||
|
|
||||||
// create/edit form
|
// create/edit form
|
||||||
const form = ref(null)
|
const form = ref(null)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const formErr = ref('')
|
const formErr = ref('')
|
||||||
|
|
||||||
// fetch-models → pick → import
|
// fetch-models → 勾选即绑定(选中自动导入到模型列表,取消即解绑)
|
||||||
const fetchTarget = ref(null) // channel snapshot being imported into
|
const fetchTarget = ref(null) // channel snapshot being imported into
|
||||||
const fetching = ref(false)
|
const fetching = ref(false)
|
||||||
const fetchErr = ref('')
|
const fetchErr = ref('')
|
||||||
const specs = ref([])
|
const specs = ref([])
|
||||||
const picked = ref(new Set())
|
|
||||||
const importing = ref(false)
|
|
||||||
const managedIds = ref(new Set())
|
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -33,11 +31,6 @@ async function load() {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadManaged() {
|
|
||||||
const r = await api('/managed-models')
|
|
||||||
managedIds.value = new Set((r.data?.data || []).map((m) => m.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
function modelSummary(c) {
|
function modelSummary(c) {
|
||||||
const names = c.models || []
|
const names = c.models || []
|
||||||
if (!names.length) return ''
|
if (!names.length) return ''
|
||||||
@@ -67,6 +60,7 @@ async function save() {
|
|||||||
}))
|
}))
|
||||||
saving.value = false
|
saving.value = false
|
||||||
if (!r.ok) { formErr.value = r.data?.detail || '保存失败'; return }
|
if (!r.ok) { formErr.value = r.data?.detail || '保存失败'; return }
|
||||||
|
warn.value = r.data?.warning || ''
|
||||||
form.value = null
|
form.value = null
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
@@ -78,7 +72,8 @@ async function toggle(c) {
|
|||||||
id: c.id, name: c.name, base_url: c.base_url, api_key: '',
|
id: c.id, name: c.name, base_url: c.base_url, api_key: '',
|
||||||
enabled: c.enabled, concurrency: c.concurrency || 0, weight: c.weight || 1,
|
enabled: c.enabled, concurrency: c.concurrency || 0, weight: c.weight || 1,
|
||||||
}))
|
}))
|
||||||
if (!r.ok) { c.enabled = prev; err.value = r.data?.detail || '状态更新失败' }
|
if (!r.ok) { c.enabled = prev; err.value = r.data?.detail || '状态更新失败'; return }
|
||||||
|
warn.value = r.data?.warning || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del(c) {
|
async function del(c) {
|
||||||
@@ -100,7 +95,6 @@ function fetchFromForm() {
|
|||||||
async function openFetch(c) {
|
async function openFetch(c) {
|
||||||
fetchTarget.value = c
|
fetchTarget.value = c
|
||||||
specs.value = []
|
specs.value = []
|
||||||
picked.value = new Set()
|
|
||||||
fetchErr.value = ''
|
fetchErr.value = ''
|
||||||
fetching.value = true
|
fetching.value = true
|
||||||
const r = await api('/channels/fetch-models', jsonBody('POST', {
|
const r = await api('/channels/fetch-models', jsonBody('POST', {
|
||||||
@@ -108,15 +102,41 @@ async function openFetch(c) {
|
|||||||
}))
|
}))
|
||||||
fetching.value = false
|
fetching.value = false
|
||||||
if (!r.ok) { fetchErr.value = r.data?.detail || '拉取失败'; return }
|
if (!r.ok) { fetchErr.value = r.data?.detail || '拉取失败'; return }
|
||||||
specs.value = (r.data?.data || []).map((s) => ({ ...s, alias: s.alias || '', imported: managedIds.value.has(s.id) }))
|
specs.value = (r.data?.data || []).map((s) => ({ ...s, alias: s.alias || '', busy: false }))
|
||||||
picked.value = new Set(specs.value.filter((s) => !s.imported).map((s) => s.id))
|
|
||||||
if (!picked.value.size) picked.value = new Set(specs.value.map((s) => s.id))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePick(id) {
|
// 勾选 = 立即导入绑定;取消勾选 = 解绑(模型没有其他渠道接入时本地行一并删除)。
|
||||||
const next = new Set(picked.value)
|
// 新渠道尚未入库时,第一次勾选会先自动保存渠道再绑定。
|
||||||
next.has(id) ? next.delete(id) : next.add(id)
|
async function toggleBind(s) {
|
||||||
picked.value = next
|
if (s.busy) return
|
||||||
|
s.busy = true
|
||||||
|
fetchErr.value = ''
|
||||||
|
try {
|
||||||
|
const target = fetchTarget.value
|
||||||
|
if (!s.bound) {
|
||||||
|
if (!target.id) {
|
||||||
|
const sr = await api('/channels', jsonBody('POST', {
|
||||||
|
id: '', name: target.name || target.base_url, base_url: target.base_url, api_key: target.api_key,
|
||||||
|
enabled: target.enabled !== false, concurrency: target.concurrency || 0, weight: target.weight || 1,
|
||||||
|
}))
|
||||||
|
if (!sr.ok) { fetchErr.value = sr.data?.detail || '渠道保存失败'; return }
|
||||||
|
if (sr.data?.warning) warn.value = sr.data.warning
|
||||||
|
target.id = sr.data?.id
|
||||||
|
form.value = null
|
||||||
|
}
|
||||||
|
const { bound, busy, ...rest } = s
|
||||||
|
const r = await api('/channels/import-models', jsonBody('POST', { id: target.id, models: [rest] }))
|
||||||
|
if (!r.ok) { fetchErr.value = r.data?.detail || '导入失败'; return }
|
||||||
|
s.bound = true
|
||||||
|
} else {
|
||||||
|
const r = await api('/channels/unbind-model', jsonBody('POST', { id: target.id, model: s.id }))
|
||||||
|
if (!r.ok) { fetchErr.value = r.data?.detail || '解绑失败'; return }
|
||||||
|
s.bound = false
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
} finally {
|
||||||
|
s.busy = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function specText(s) {
|
function specText(s) {
|
||||||
@@ -135,38 +155,7 @@ function specText(s) {
|
|||||||
return parts.join(' · ') || '-'
|
return parts.join(' · ') || '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doImport() {
|
onMounted(load)
|
||||||
if (importing.value || !picked.value.size) return
|
|
||||||
const models = specs.value
|
|
||||||
.filter((s) => picked.value.has(s.id))
|
|
||||||
.map((s) => {
|
|
||||||
const { imported, ...rest } = s
|
|
||||||
return rest
|
|
||||||
})
|
|
||||||
const target = fetchTarget.value
|
|
||||||
importing.value = true
|
|
||||||
try {
|
|
||||||
if (!target.id) {
|
|
||||||
// 表单预览路径:渠道尚未入库,先按快照保存再绑定;失败中止不建孤儿。
|
|
||||||
const sr = await api('/channels', jsonBody('POST', {
|
|
||||||
id: '', name: target.name || target.base_url, base_url: target.base_url, api_key: target.api_key,
|
|
||||||
enabled: target.enabled !== false, concurrency: target.concurrency || 0, weight: target.weight || 1,
|
|
||||||
}))
|
|
||||||
if (!sr.ok) { fetchErr.value = sr.data?.detail || '渠道保存失败'; return }
|
|
||||||
target.id = sr.data?.id
|
|
||||||
form.value = null
|
|
||||||
}
|
|
||||||
const r = await api('/channels/import-models', jsonBody('POST', { id: target.id, models }))
|
|
||||||
if (!r.ok) { fetchErr.value = r.data?.detail || '导入失败'; return }
|
|
||||||
fetchTarget.value = null
|
|
||||||
load()
|
|
||||||
loadManaged()
|
|
||||||
} finally {
|
|
||||||
importing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => { load(); loadManaged() })
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -174,7 +163,7 @@ onMounted(() => { load(); loadManaged() })
|
|||||||
<div class="card p-4 flex items-center justify-between gap-3 flex-wrap">
|
<div class="card p-4 flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm font-medium">渠道</div>
|
<div class="text-sm font-medium">渠道</div>
|
||||||
<p class="text-xs text-white/45 mt-0.5">同构 /v1 协议上游实例 · 模型经「拉取上游模型」导入 · 余额实时探测</p>
|
<p class="text-xs text-white/45 mt-0.5">同构 /v1 协议上游实例 · 保存时自动同步上游模型 · 余额实时探测</p>
|
||||||
</div>
|
</div>
|
||||||
<button @click="openAdd" class="btn-primary">
|
<button @click="openAdd" class="btn-primary">
|
||||||
<Icon name="plus" class="w-4 h-4" /> 添加渠道
|
<Icon name="plus" class="w-4 h-4" /> 添加渠道
|
||||||
@@ -182,6 +171,7 @@ onMounted(() => { load(); loadManaged() })
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="err" class="text-xs text-rose-300">{{ err }}</p>
|
<p v-if="err" class="text-xs text-rose-300">{{ err }}</p>
|
||||||
|
<p v-if="warn" class="text-xs text-amber-300">{{ warn }}</p>
|
||||||
|
|
||||||
<div class="card overflow-hidden">
|
<div class="card overflow-hidden">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
@@ -297,7 +287,7 @@ onMounted(() => { load(); loadManaged() })
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 拉取上游模型 → 勾选导入 -->
|
<!-- 拉取上游模型 → 勾选即导入绑定 -->
|
||||||
<div v-if="fetchTarget" class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4" @click.self="fetchTarget = null">
|
<div v-if="fetchTarget" class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4" @click.self="fetchTarget = null">
|
||||||
<div class="card !shadow-2xl my-12 w-full max-w-2xl">
|
<div class="card !shadow-2xl my-12 w-full max-w-2xl">
|
||||||
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
||||||
@@ -306,15 +296,16 @@ onMounted(() => { load(); loadManaged() })
|
|||||||
</div>
|
</div>
|
||||||
<div class="p-5">
|
<div class="p-5">
|
||||||
<div v-if="fetching" class="py-10 text-center text-white/40 text-sm">正在从上游拉取模型列表…</div>
|
<div v-if="fetching" class="py-10 text-center text-white/40 text-sm">正在从上游拉取模型列表…</div>
|
||||||
<div v-else-if="fetchErr" class="py-10 text-center text-rose-300 text-sm">{{ fetchErr }}</div>
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p class="text-xs text-white/45 mb-3">勾选要导入的模型:能力参数随模型带入,导入后到「模型管理」配价上架。别名即本站模型标识(留空=与上游同名);已导入的模型自动标记(重复导入会刷新参数)。</p>
|
<p class="text-xs text-white/45 mb-3">勾选即导入并绑定到本渠道,取消勾选即解绑(模型没有其他渠道接入时本地行一并删除)。能力参数随模型带入,导入后到「模型管理」配价上架。别名即本站模型标识(留空=与上游同名,绑定时生效)。注意:保存渠道会按上游清单全量重同步,手动解绑的模型若仍在上游列表中会被重新接入。</p>
|
||||||
|
<p v-if="fetchErr" class="text-xs text-rose-300 mb-3">{{ fetchErr }}</p>
|
||||||
<div v-if="!specs.length" class="py-10 text-center text-white/40 text-sm">上游没有可售模型(模型需在上游已配价)</div>
|
<div v-if="!specs.length" class="py-10 text-center text-white/40 text-sm">上游没有可售模型(模型需在上游已配价)</div>
|
||||||
<div v-else class="space-y-1.5 max-h-[380px] overflow-y-auto">
|
<div v-else class="space-y-1.5 max-h-[380px] overflow-y-auto">
|
||||||
<div v-for="s in specs" :key="s.id" @click="togglePick(s.id)"
|
<div v-for="s in specs" :key="s.id" @click="toggleBind(s)"
|
||||||
class="flex items-start gap-2.5 p-2.5 rounded-lg ring-1 ring-white/10 hover:bg-white/[0.04] cursor-pointer transition-colors"
|
class="flex items-start gap-2.5 p-2.5 rounded-lg ring-1 ring-white/10 hover:bg-white/[0.04] cursor-pointer transition-colors"
|
||||||
:class="!picked.has(s.id) && 'opacity-50'">
|
:class="[!s.bound && 'opacity-50', s.busy && 'pointer-events-none']">
|
||||||
<input type="checkbox" :checked="picked.has(s.id)" @click.stop @change="togglePick(s.id)" class="mt-1 accent-sky-400" />
|
<span class="mt-1 inline-block h-3.5 w-3.5 shrink-0 rounded-full ring-1 transition-all"
|
||||||
|
:class="s.bound ? 'bg-sky-400 ring-sky-300' : 'bg-transparent ring-white/25'"></span>
|
||||||
<div class="flex-1 min-w-0 space-y-1.5">
|
<div class="flex-1 min-w-0 space-y-1.5">
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<code class="font-mono text-xs text-white/85">{{ s.id }}</code>
|
<code class="font-mono text-xs text-white/85">{{ s.id }}</code>
|
||||||
@@ -323,13 +314,14 @@ onMounted(() => { load(); loadManaged() })
|
|||||||
{{ KIND_LABEL[s.kind] || s.kind }}
|
{{ KIND_LABEL[s.kind] || s.kind }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-[11px] text-white/40">{{ specText(s) }}</span>
|
<span class="text-[11px] text-white/40">{{ specText(s) }}</span>
|
||||||
<span v-if="s.imported" class="ml-auto px-1.5 py-0.5 rounded bg-emerald-400/15 text-emerald-300 text-[10px] font-semibold">已导入</span>
|
<span v-if="s.busy" class="ml-auto text-[10px] text-white/40">处理中…</span>
|
||||||
|
<span v-else-if="s.bound" class="ml-auto px-1.5 py-0.5 rounded bg-emerald-400/15 text-emerald-300 text-[10px] font-semibold">已接入</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2" @click.stop>
|
<div class="flex items-center gap-2" @click.stop>
|
||||||
<span class="text-[11px] text-white/35 shrink-0">别名</span>
|
<span class="text-[11px] text-white/35 shrink-0">别名</span>
|
||||||
<input v-model.trim="s.alias" type="text" maxlength="64" :disabled="!picked.has(s.id)"
|
<input v-model.trim="s.alias" type="text" maxlength="64" :disabled="s.bound"
|
||||||
class="flex-1 min-w-0 px-2.5 py-1 rounded-lg bg-white/[0.04] ring-1 ring-white/10 text-xs text-white/80 disabled:opacity-40"
|
class="flex-1 min-w-0 px-2.5 py-1 rounded-lg bg-white/[0.04] ring-1 ring-white/10 text-xs text-white/80 disabled:opacity-40"
|
||||||
placeholder="留空 = 与上游同名" />
|
:placeholder="s.bound ? '已绑定,到模型管理改别名' : '留空 = 与上游同名'" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -338,9 +330,6 @@ onMounted(() => { load(); loadManaged() })
|
|||||||
</div>
|
</div>
|
||||||
<div class="px-5 py-4 border-t border-white/[0.06] flex justify-end gap-2">
|
<div class="px-5 py-4 border-t border-white/[0.06] flex justify-end gap-2">
|
||||||
<button @click="fetchTarget = null" class="btn-ghost">关闭</button>
|
<button @click="fetchTarget = null" class="btn-ghost">关闭</button>
|
||||||
<button @click="doImport" :disabled="importing || !picked.size" class="btn-primary">
|
|
||||||
{{ importing ? '导入中…' : (fetchTarget?.id ? `导入选中(${picked.size})` : `保存渠道并导入(${picked.size})`) }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const models = ref([])
|
|||||||
const presets = ref({}) // /video-presets: key → 预设(参考资产分类上限的真源)
|
const presets = ref({}) // /video-presets: key → 预设(参考资产分类上限的真源)
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const [r, p] = await Promise.all([api('/managed-models'), api('/video-presets')])
|
const [r, p] = await Promise.all([api('/managed-models'), api('/video-presets')])
|
||||||
if (r.ok) models.value = (r.data?.data || []).filter((m) => m.enabled !== false)
|
if (r.ok) models.value = (r.data?.data || []).filter((m) => m.sellable)
|
||||||
if (p.ok) presets.value = presetMap(p.data?.data)
|
if (p.ok) presets.value = presetMap(p.data?.data)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ async function refresh() {
|
|||||||
api('/showcase'),
|
api('/showcase'),
|
||||||
])
|
])
|
||||||
stats.value = s.data || { generated_count: 0, recent: [] }
|
stats.value = s.data || { generated_count: 0, recent: [] }
|
||||||
managed.value = (m.data?.data || []).filter((x) => x.enabled !== false)
|
managed.value = (m.data?.data || []).filter((x) => x.sellable)
|
||||||
showcase.value = sc.data?.data || { hero: [], bento: [], work: [] }
|
showcase.value = sc.data?.data || { hero: [], bento: [], work: [] }
|
||||||
const top3 = (showcase.value.hero || []).slice(0, 3)
|
const top3 = (showcase.value.hero || []).slice(0, 3)
|
||||||
const sameSet = heroDeck.value.length === top3.length
|
const sameSet = heroDeck.value.length === top3.length
|
||||||
@@ -92,7 +92,7 @@ const avgLabel = computed(() => {
|
|||||||
return Math.round(avgElapsed.value / 1000) + 's'
|
return Math.round(avgElapsed.value / 1000) + 's'
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- capability cards, derived from the enabled models ----
|
// ---- capability cards, derived from the sellable models ----
|
||||||
function refs(m) { return m.image_to_image === true || Number(m.max_reference_images || 0) > 0 }
|
function refs(m) { return m.image_to_image === true || Number(m.max_reference_images || 0) > 0 }
|
||||||
const durationRange = computed(() => {
|
const durationRange = computed(() => {
|
||||||
const secs = []
|
const secs = []
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const editing = ref(null) // null = add, object = edit
|
|||||||
const testing = ref(null) // model being tested, or null
|
const testing = ref(null) // model being tested, or null
|
||||||
|
|
||||||
const kindFilter = ref('') // '' | 'image' | 'video'
|
const kindFilter = ref('') // '' | 'image' | 'video'
|
||||||
const statusFilter = ref('') // '' | 'enabled' | 'disabled'
|
const saleFilter = ref('') // '' | 'sellable' | 'unsellable'
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
|
|
||||||
const TYPE_LABEL = { image: '生图', video: '生视频' }
|
const TYPE_LABEL = { image: '生图', video: '生视频' }
|
||||||
@@ -42,20 +42,6 @@ async function loadModels() {
|
|||||||
function openEdit(m) { editing.value = { ...m }; showForm.value = true }
|
function openEdit(m) { editing.value = { ...m }; showForm.value = true }
|
||||||
function onSaved() { showForm.value = false; loadModels() }
|
function onSaved() { showForm.value = false; loadModels() }
|
||||||
|
|
||||||
async function toggleEnabled(m) {
|
|
||||||
// Optimistic: flip the switch instantly, persist in the background, revert on
|
|
||||||
// failure. Avoids the lag of awaiting the PATCH + a full table reload before
|
|
||||||
// the toggle visibly moves.
|
|
||||||
const cur = m.enabled !== false
|
|
||||||
const next = !cur
|
|
||||||
m.enabled = next
|
|
||||||
const r = await api(`/managed-models/${encodeURIComponent(m.id)}`, {
|
|
||||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ enabled: next }),
|
|
||||||
})
|
|
||||||
if (!r.ok) m.enabled = cur
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove(m) {
|
async function remove(m) {
|
||||||
if (!confirm(`确认删除模型 ${m.id}?`)) return
|
if (!confirm(`确认删除模型 ${m.id}?`)) return
|
||||||
await api(`/managed-models/${encodeURIComponent(m.id)}`, { method: 'DELETE' })
|
await api(`/managed-models/${encodeURIComponent(m.id)}`, { method: 'DELETE' })
|
||||||
@@ -66,16 +52,16 @@ const stats = computed(() => {
|
|||||||
const total = models.value.length
|
const total = models.value.length
|
||||||
const image = models.value.filter((m) => m.type === 'image').length
|
const image = models.value.filter((m) => m.type === 'image').length
|
||||||
const video = models.value.filter((m) => m.type === 'video').length
|
const video = models.value.filter((m) => m.type === 'video').length
|
||||||
const enabled = models.value.filter((m) => m.enabled !== false).length
|
const sellable = models.value.filter((m) => m.sellable).length
|
||||||
return { total, image, video, enabled, disabled: total - enabled }
|
return { total, image, video, sellable }
|
||||||
})
|
})
|
||||||
|
|
||||||
const filtered = computed(() => {
|
const filtered = computed(() => {
|
||||||
const q = search.value.trim().toLowerCase()
|
const q = search.value.trim().toLowerCase()
|
||||||
return models.value.filter((m) => {
|
return models.value.filter((m) => {
|
||||||
if (kindFilter.value && m.type !== kindFilter.value) return false
|
if (kindFilter.value && m.type !== kindFilter.value) return false
|
||||||
if (statusFilter.value === 'enabled' && m.enabled === false) return false
|
if (saleFilter.value === 'sellable' && !m.sellable) return false
|
||||||
if (statusFilter.value === 'disabled' && m.enabled !== false) return false
|
if (saleFilter.value === 'unsellable' && m.sellable) return false
|
||||||
if (q && !(m.id.toLowerCase().includes(q) || (m.alias || '').toLowerCase().includes(q) || (m.name || '').toLowerCase().includes(q))) return false
|
if (q && !(m.id.toLowerCase().includes(q) || (m.alias || '').toLowerCase().includes(q) || (m.name || '').toLowerCase().includes(q))) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
@@ -101,8 +87,8 @@ onMounted(loadModels)
|
|||||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-rose-300">{{ stats.video }}</div>
|
<div class="text-2xl font-semibold mt-1 tabular-nums text-rose-300">{{ stats.video }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="text-[11px] uppercase tracking-wider text-emerald-300/80">启用</div>
|
<div class="text-[11px] uppercase tracking-wider text-emerald-300/80">上架</div>
|
||||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.enabled }}<span class="text-white/35 text-lg ml-1">/ {{ stats.total }}</span></div>
|
<div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.sellable }}<span class="text-white/35 text-lg ml-1">/ {{ stats.total }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -115,23 +101,20 @@ onMounted(loadModels)
|
|||||||
</div>
|
</div>
|
||||||
<div class="w-px h-5 bg-white/10"></div>
|
<div class="w-px h-5 bg-white/10"></div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<button @click="statusFilter = ''" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button>
|
<button @click="saleFilter = ''" class="fp" :class="saleFilter === '' && 'fp-on'">全部</button>
|
||||||
<button @click="statusFilter = 'enabled'" class="fp" :class="statusFilter === 'enabled' && 'fp-emerald'">
|
<button @click="saleFilter = 'sellable'" class="fp" :class="saleFilter === 'sellable' && 'fp-emerald'">
|
||||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>启用
|
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>上架
|
||||||
</button>
|
</button>
|
||||||
<button @click="statusFilter = 'disabled'" class="fp" :class="statusFilter === 'disabled' && 'fp-white'">
|
<button @click="saleFilter = 'unsellable'" class="fp" :class="saleFilter === 'unsellable' && 'fp-white'">
|
||||||
<span class="w-1.5 h-1.5 rounded-full bg-white/40"></span>停用
|
<span class="w-1.5 h-1.5 rounded-full bg-white/40"></span>未上架
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-[200px]">
|
<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 / 别名…" />
|
||||||
</div>
|
</div>
|
||||||
<button @click="loadModels" class="btn-soft">
|
<button @click="loadModels" class="btn-soft">
|
||||||
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
|
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
|
||||||
</button>
|
</button>
|
||||||
<router-link to="/admin/channels" class="btn-primary inline-flex items-center gap-1">
|
|
||||||
<Icon name="plug" class="w-3.5 h-3.5" /> 渠道导入模型
|
|
||||||
</router-link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Table -->
|
<!-- Table -->
|
||||||
@@ -139,7 +122,8 @@ onMounted(loadModels)
|
|||||||
<div v-if="loading && !models.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
|
<div v-if="loading && !models.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
|
||||||
<div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20">
|
<div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20">
|
||||||
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center"><Icon name="models" class="w-6 h-6" /></span>
|
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center"><Icon name="models" class="w-6 h-6" /></span>
|
||||||
<span class="text-sm">{{ models.length ? '没有匹配的模型' : '还没有模型,先到「渠道管理」拉取上游模型导入' }}</span>
|
<span v-if="models.length" class="text-sm">没有匹配的模型</span>
|
||||||
|
<router-link v-else to="/admin/channels" class="text-sm text-sky-300 underline underline-offset-2">还没有模型,先到「渠道管理」勾选上游模型自动导入</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table v-else class="w-full text-sm table-fixed">
|
<table v-else class="w-full text-sm table-fixed">
|
||||||
@@ -150,7 +134,7 @@ onMounted(loadModels)
|
|||||||
<col /> <!-- capability -->
|
<col /> <!-- capability -->
|
||||||
<col class="w-20" /> <!-- weight -->
|
<col class="w-20" /> <!-- weight -->
|
||||||
<col class="w-24" /> <!-- generation count -->
|
<col class="w-24" /> <!-- generation count -->
|
||||||
<col class="w-20" /> <!-- status switch -->
|
<col class="w-20" /> <!-- sellable badge -->
|
||||||
<col class="w-36" /> <!-- actions -->
|
<col class="w-36" /> <!-- actions -->
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -161,7 +145,7 @@ onMounted(loadModels)
|
|||||||
<th class="text-left px-3 py-3 font-medium">能力</th>
|
<th class="text-left px-3 py-3 font-medium">能力</th>
|
||||||
<th class="text-right px-3 py-3 font-medium">权重</th>
|
<th class="text-right px-3 py-3 font-medium">权重</th>
|
||||||
<th class="text-right px-3 py-3 font-medium">生图次数</th>
|
<th class="text-right px-3 py-3 font-medium">生图次数</th>
|
||||||
<th class="text-left px-3 py-3 font-medium">状态</th>
|
<th class="text-left px-3 py-3 font-medium">上架</th>
|
||||||
<th class="text-right px-5 py-3 font-medium">操作</th>
|
<th class="text-right px-5 py-3 font-medium">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -268,13 +252,15 @@ onMounted(loadModels)
|
|||||||
{{ (m.generation_count || 0).toLocaleString('en-US') }}
|
{{ (m.generation_count || 0).toLocaleString('en-US') }}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Status as a real toggle so admins read it as on/off, not a
|
<!-- 配价即上架(无开关):全档位有价 + 绑定了启用渠道才显示「已上架」 -->
|
||||||
button to delete or whatever. -->
|
|
||||||
<td class="px-3 py-3.5 align-middle">
|
<td class="px-3 py-3.5 align-middle">
|
||||||
<button class="sw" :class="m.enabled !== false && 'sw-on'"
|
<span class="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
|
||||||
:aria-pressed="m.enabled !== false" @click="toggleEnabled(m)">
|
:class="m.sellable
|
||||||
<span class="sw-thumb"></span>
|
? 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30'
|
||||||
</button>
|
: 'bg-white/[0.05] text-white/40 ring-white/10'"
|
||||||
|
:title="m.sellable ? '全部档位有价且有启用渠道,对前台可见' : '配齐全部档位价格并绑定启用渠道后自动上架'">
|
||||||
|
{{ m.sellable ? '已上架' : '未上架' }}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Actions — small soft buttons, danger variant for delete -->
|
<!-- Actions — small soft buttons, danger variant for delete -->
|
||||||
@@ -378,27 +364,6 @@ onMounted(loadModels)
|
|||||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- iOS-style toggle for 启用/停用 */
|
|
||||||
.sw {
|
|
||||||
position: relative;
|
|
||||||
width: 2.25rem; height: 1.3rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: rgb(255 255 255 / 0.12);
|
|
||||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
|
||||||
transition: background 0.18s ease;
|
|
||||||
}
|
|
||||||
.sw-thumb {
|
|
||||||
position: absolute;
|
|
||||||
top: 2px; left: 2px;
|
|
||||||
width: calc(1.3rem - 4px); height: calc(1.3rem - 4px);
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: white;
|
|
||||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.3);
|
|
||||||
transition: transform 0.18s ease;
|
|
||||||
}
|
|
||||||
.sw-on { background: rgb(16 185 129 / 0.7); box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.5); }
|
|
||||||
.sw-on .sw-thumb { transform: translateX(calc(2.25rem - 1.3rem)); }
|
|
||||||
|
|
||||||
/* --- danger variant for 删除 (kept for any other .btn-soft callers) */
|
/* --- danger variant for 删除 (kept for any other .btn-soft callers) */
|
||||||
.btn-soft.danger {
|
.btn-soft.danger {
|
||||||
color: rgb(253 164 175);
|
color: rgb(253 164 175);
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ const displayItems = computed(() => [...tasks.value, ...history.value].slice(0,
|
|||||||
|
|
||||||
// ---- derived ----
|
// ---- derived ----
|
||||||
const models = computed(() =>
|
const models = computed(() =>
|
||||||
allModels.value.filter((m) => m.enabled !== false && m.type === mode.value),
|
allModels.value.filter((m) => m.sellable && m.type === mode.value),
|
||||||
)
|
)
|
||||||
const modelOptions = computed(() =>
|
const modelOptions = computed(() =>
|
||||||
models.value.map((m) => ({ value: m.id, label: m.alias || m.name || m.id })),
|
models.value.map((m) => ({ value: m.id, label: m.alias || m.name || m.id })),
|
||||||
@@ -231,7 +231,7 @@ function setMode(m) {
|
|||||||
mode.value = m
|
mode.value = m
|
||||||
clearRefs() // 切换生图/生视频清空参考文件
|
clearRefs() // 切换生图/生视频清空参考文件
|
||||||
// pick a default model of the new kind, if any
|
// pick a default model of the new kind, if any
|
||||||
const first = allModels.value.find((x) => x.enabled !== false && x.type === m)
|
const first = allModels.value.find((x) => x.sellable && x.type === m)
|
||||||
modelId.value = first?.id || ''
|
modelId.value = first?.id || ''
|
||||||
applyModelDefaults()
|
applyModelDefaults()
|
||||||
}
|
}
|
||||||
@@ -789,17 +789,17 @@ onMounted(async () => {
|
|||||||
if (qPrompt) prompt.value = qPrompt
|
if (qPrompt) prompt.value = qPrompt
|
||||||
let selected = null
|
let selected = null
|
||||||
if (qModel) {
|
if (qModel) {
|
||||||
selected = allModels.value.find((m) => m.id === qModel && m.enabled !== false)
|
selected = allModels.value.find((m) => m.id === qModel && m.sellable)
|
||||||
}
|
}
|
||||||
// If the draft already points at a still-available model AND no fresher
|
// If the draft already points at a still-available model AND no fresher
|
||||||
// intent came in from the URL, keep the draft as-is. Otherwise fall back to
|
// intent came in from the URL, keep the draft as-is. Otherwise fall back to
|
||||||
// the first usable model.
|
// the first usable model.
|
||||||
const draftModel = !qModel && modelId.value
|
const draftModel = !qModel && modelId.value
|
||||||
? allModels.value.find((m) => m.id === modelId.value && m.enabled !== false)
|
? allModels.value.find((m) => m.id === modelId.value && m.sellable)
|
||||||
: null
|
: null
|
||||||
if (!selected && !draftModel) {
|
if (!selected && !draftModel) {
|
||||||
selected = allModels.value.find((m) => m.enabled !== false && m.type === 'image')
|
selected = allModels.value.find((m) => m.sellable && m.type === 'image')
|
||||||
|| allModels.value.find((m) => m.enabled !== false)
|
|| allModels.value.find((m) => m.sellable)
|
||||||
}
|
}
|
||||||
// Always re-apply defaults — even when restoring the persisted draft model —
|
// Always re-apply defaults — even when restoring the persisted draft model —
|
||||||
// so a stale ratio/resolution that the model no longer supports (e.g. a saved
|
// so a stale ratio/resolution that the model no longer supports (e.g. a saved
|
||||||
|
|||||||
Reference in New Issue
Block a user