From bd639745acf9ecccbbc4245b7265173e1e088cfa Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Mon, 7 Sep 2026 14:03:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(*):=20=E5=88=9B=E4=BD=9C=E4=B8=8E=E8=BF=90?= =?UTF-8?q?=E7=BB=B4=20-=20=E6=96=B0=E5=A2=9E=E5=9B=BE=E7=89=87=E5=B0=BA?= =?UTF-8?q?=E5=AF=B8=E9=A2=84=E8=AE=BE=E5=92=8C=E7=B3=BB=E7=BB=9F=E6=80=A7?= =?UTF-8?q?=E8=83=BD=E9=9D=A2=E6=9D=BF=E5=B9=B6=E4=BC=98=E5=8C=96=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E7=BB=84=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 1 + .../handler/admin_system_performance.go | 45 +++ .../handler/admin_system_performance_test.go | 30 ++ .../internal/repository/system_performance.go | 107 ++++++ .../service/admin_system_performance.go | 346 ++++++++++++++++++ .../service/admin_system_performance_test.go | 238 ++++++++++++ .../service/image_size_presets_test.go | 85 +++++ backend/internal/service/model_capability.go | 95 ++++- backend/internal/service/read_cache.go | 6 + .../internal/service/system_metrics_darwin.go | 19 + .../internal/service/system_metrics_linux.go | 43 +++ .../internal/service/system_metrics_other.go | 7 + .../internal/service/system_metrics_unix.go | 36 ++ .../content/docs/backend/backend-database.mdx | 8 + docs/content/docs/progress/pending-test.mdx | 31 ++ .../components/canvas/canvas-node-content.tsx | 28 +- web/src/components/canvas/canvas-node.tsx | 10 +- .../canvas/canvas-workspace-overlays.tsx | 10 +- web/src/components/image-settings-panel.tsx | 193 +--------- web/src/components/image-size-picker.css | 264 +++++++++++++ web/src/components/image-size-picker.tsx | 174 +++++++++ .../components/image-size-presets-editor.tsx | 134 +++++++ .../components/model-capability-editor.tsx | 126 ++----- web/src/lib/image-resolution-tiers.ts | 6 +- web/src/lib/image-size-presets.ts | 95 +++++ web/src/lib/model-capabilities.ts | 2 + web/src/lib/model-selection.ts | 4 +- .../pages/admin/components/admin-shell.tsx | 2 + .../settings/system-performance-page.tsx | 215 +++++++++++ .../canvas-image-generation-executor.ts | 23 +- .../canvas/canvas-project-world-layers.tsx | 10 +- .../pages/canvas/use-canvas-node-editor.ts | 22 +- web/src/pages/create/index.tsx | 38 +- .../detail/workflow-production-workbench.tsx | 14 +- web/src/router.tsx | 2 + web/src/services/api/image-validation.ts | 17 +- web/src/services/api/system-performance.ts | 125 +++++++ web/src/styles/admin-ui.css | 313 ++++++++++++++++ web/test/canvas-batch-node-animation.test.tsx | 46 +++ web/test/image-resolution-tiers.test.ts | 12 + web/test/image-size-presets.test.tsx | 156 ++++++++ 41 files changed, 2785 insertions(+), 353 deletions(-) create mode 100644 backend/internal/handler/admin_system_performance.go create mode 100644 backend/internal/handler/admin_system_performance_test.go create mode 100644 backend/internal/repository/system_performance.go create mode 100644 backend/internal/service/admin_system_performance.go create mode 100644 backend/internal/service/admin_system_performance_test.go create mode 100644 backend/internal/service/image_size_presets_test.go create mode 100644 backend/internal/service/system_metrics_darwin.go create mode 100644 backend/internal/service/system_metrics_linux.go create mode 100644 backend/internal/service/system_metrics_other.go create mode 100644 backend/internal/service/system_metrics_unix.go create mode 100644 web/src/components/image-size-picker.css create mode 100644 web/src/components/image-size-picker.tsx create mode 100644 web/src/components/image-size-presets-editor.tsx create mode 100644 web/src/lib/image-size-presets.ts create mode 100644 web/src/pages/admin/settings/system-performance-page.tsx create mode 100644 web/src/services/api/system-performance.ts create mode 100644 web/test/canvas-batch-node-animation.test.tsx create mode 100644 web/test/image-size-presets.test.tsx diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index ff44fde3c..d99a0f399 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -120,6 +120,7 @@ func run(ctx context.Context) error { handler.RegisterAdminAnalyticsRoutes(api, svc) handler.RegisterAdminStorageRoutes(api, svc) handler.RegisterAdminUpdateRoutes(api, svc) + handler.RegisterAdminSystemPerformanceRoutes(api, svc) handler.RegisterAnnouncementRoutes(api, svc) handler.RegisterFinanceRoutes(api, svc) handler.RegisterPaymentRoutes(api, svc) diff --git a/backend/internal/handler/admin_system_performance.go b/backend/internal/handler/admin_system_performance.go new file mode 100644 index 000000000..869ab6c6c --- /dev/null +++ b/backend/internal/handler/admin_system_performance.go @@ -0,0 +1,45 @@ +package handler + +import ( + "net/http" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +func RegisterAdminSystemPerformanceRoutes(r *gin.RouterGroup, svc *service.Service) { + r.GET("/admin/system-performance", func(c *gin.Context) { + actor, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + result, err := svc.AdminSystemPerformance(c.Request.Context(), actor) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) + + r.POST("/admin/system-performance/cache/clear", func(c *gin.Context) { + actor, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 4<<10) + var request service.AdminCacheClearRequest + if err := c.ShouldBindJSON(&request); err != nil { + failService(c, service.BadAuthRequest("缓存清理请求无效")) + return + } + result, err := svc.ClearAdminRuntimeCache(c.Request.Context(), actor, request) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) +} diff --git a/backend/internal/handler/admin_system_performance_test.go b/backend/internal/handler/admin_system_performance_test.go new file mode 100644 index 000000000..fca44ed82 --- /dev/null +++ b/backend/internal/handler/admin_system_performance_test.go @@ -0,0 +1,30 @@ +package handler + +import ( + "testing" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +func TestAdminSystemPerformanceRoutesAreRegistered(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + RegisterAdminSystemPerformanceRoutes(router.Group("/api"), &service.Service{}) + wanted := map[string]bool{ + "GET /api/admin/system-performance": false, + "POST /api/admin/system-performance/cache/clear": false, + } + for _, route := range router.Routes() { + key := route.Method + " " + route.Path + if _, exists := wanted[key]; exists { + wanted[key] = true + } + } + for route, found := range wanted { + if !found { + t.Errorf("route %s is not registered", route) + } + } +} diff --git a/backend/internal/repository/system_performance.go b/backend/internal/repository/system_performance.go new file mode 100644 index 000000000..c013fc26b --- /dev/null +++ b/backend/internal/repository/system_performance.go @@ -0,0 +1,107 @@ +package repository + +import ( + "context" + "database/sql" + "strconv" + "time" + + "infinite-canvas/backend/internal/database" +) + +type DatabasePoolStats struct { + MaxOpenConnections int `json:"maxOpenConnections"` + OpenConnections int `json:"openConnections"` + InUse int `json:"inUse"` + Idle int `json:"idle"` + WaitCount int64 `json:"waitCount"` + WaitDuration time.Duration `json:"-"` + WaitDurationMs int64 `json:"waitDurationMs"` + MaxIdleClosed int64 `json:"maxIdleClosed"` + MaxLifetimeClosed int64 `json:"maxLifetimeClosed"` +} + +type PostgresRuntimeStats struct { + ServerVersion string `json:"serverVersion,omitempty"` + DatabaseBytes int64 `json:"databaseBytes,omitempty"` + Connections int64 `json:"connections,omitempty"` + MaxConnections int64 `json:"maxConnections,omitempty"` + Transactions int64 `json:"transactions,omitempty"` + Rollbacks int64 `json:"rollbacks,omitempty"` + CacheHitRate float64 `json:"cacheHitRate,omitempty"` + TempFiles int64 `json:"tempFiles,omitempty"` + TempBytes int64 `json:"tempBytes,omitempty"` + Deadlocks int64 `json:"deadlocks,omitempty"` +} + +type DatabaseRuntimeStats struct { + Driver string `json:"driver"` + Connected bool `json:"connected"` + LatencyMs int64 `json:"latencyMs"` + DatabaseBytes int64 `json:"databaseBytes,omitempty"` + Schema database.SchemaStatus `json:"schema"` + Pool DatabasePoolStats `json:"pool"` + Postgres *PostgresRuntimeStats `json:"postgres,omitempty"` +} + +func (r *Repository) DatabaseRuntimeStats(ctx context.Context) (DatabaseRuntimeStats, error) { + result := DatabaseRuntimeStats{Driver: r.Dialect(), Schema: database.SchemaStatus{Expected: database.CurrentSchemaVersion}} + sqlDB, err := r.db.DB() + if err != nil { + return result, err + } + started := time.Now() + if err := sqlDB.PingContext(ctx); err != nil { + return result, err + } + result.Connected = true + result.LatencyMs = max(1, time.Since(started).Milliseconds()) + result.Pool = databasePoolStats(sqlDB.Stats()) + if schema, schemaErr := database.ReadSchemaStatus(r.db.WithContext(ctx)); schemaErr == nil { + result.Schema = schema + } + if result.Driver == "postgres" { + result.Postgres, err = r.postgresRuntimeStats(ctx) + if result.Postgres != nil { + result.DatabaseBytes = result.Postgres.DatabaseBytes + } + return result, err + } + if result.Driver == "sqlite" { + var pageCount, pageSize int64 + if r.db.WithContext(ctx).Raw("PRAGMA page_count").Scan(&pageCount).Error == nil && r.db.WithContext(ctx).Raw("PRAGMA page_size").Scan(&pageSize).Error == nil { + result.DatabaseBytes = pageCount * pageSize + } + } + return result, nil +} + +func databasePoolStats(stats sql.DBStats) DatabasePoolStats { + return DatabasePoolStats{ + MaxOpenConnections: stats.MaxOpenConnections, OpenConnections: stats.OpenConnections, InUse: stats.InUse, Idle: stats.Idle, + WaitCount: stats.WaitCount, WaitDuration: stats.WaitDuration, WaitDurationMs: stats.WaitDuration.Milliseconds(), + MaxIdleClosed: stats.MaxIdleClosed, MaxLifetimeClosed: stats.MaxLifetimeClosed, + } +} + +func (r *Repository) postgresRuntimeStats(ctx context.Context) (*PostgresRuntimeStats, error) { + stats := &PostgresRuntimeStats{} + if err := r.db.WithContext(ctx).Raw("SHOW server_version").Scan(&stats.ServerVersion).Error; err != nil { + return stats, err + } + var maxConnections string + if err := r.db.WithContext(ctx).Raw("SHOW max_connections").Scan(&maxConnections).Error; err == nil { + stats.MaxConnections, _ = strconv.ParseInt(maxConnections, 10, 64) + } + var blocksRead, blocksHit int64 + err := r.db.WithContext(ctx).Raw(` + SELECT pg_database_size(current_database()) AS database_bytes, + numbackends AS connections, xact_commit AS transactions, xact_rollback AS rollbacks, + blks_read, blks_hit, temp_files, temp_bytes, deadlocks + FROM pg_stat_database WHERE datname = current_database() + `).Row().Scan(&stats.DatabaseBytes, &stats.Connections, &stats.Transactions, &stats.Rollbacks, &blocksRead, &blocksHit, &stats.TempFiles, &stats.TempBytes, &stats.Deadlocks) + if blocksRead+blocksHit > 0 { + stats.CacheHitRate = float64(blocksHit) / float64(blocksRead+blocksHit) * 100 + } + return stats, err +} diff --git a/backend/internal/service/admin_system_performance.go b/backend/internal/service/admin_system_performance.go new file mode 100644 index 000000000..2839a112b --- /dev/null +++ b/backend/internal/service/admin_system_performance.go @@ -0,0 +1,346 @@ +package service + +import ( + "context" + "errors" + "os" + "runtime" + "strconv" + "strings" + "time" + + "infinite-canvas/backend/internal/buildinfo" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "github.com/redis/go-redis/v9" +) + +var systemProcessStartedAt = time.Now() + +const systemPerformanceTimeout = 3 * time.Second + +type AdminSystemPerformance struct { + CollectedAt time.Time `json:"collectedAt"` + Status string `json:"status"` + Host SystemPerformanceHost `json:"host"` + Memory SystemPerformanceMemory `json:"memory"` + Disk SystemPerformanceDisk `json:"disk"` + Database repository.DatabaseRuntimeStats `json:"database"` + Redis SystemPerformanceRedis `json:"redis"` + Build buildinfo.Info `json:"build"` +} + +type SystemPerformanceHost struct { + Hostname string `json:"hostname"` + OS string `json:"os"` + Arch string `json:"arch"` + CPUCores int `json:"cpuCores"` + GOMAXPROCS int `json:"gomaxprocs"` + ProcessID int `json:"processId"` + UptimeSeconds int64 `json:"uptimeSeconds"` + Goroutines int `json:"goroutines"` + ActiveWorkerTasks int64 `json:"activeWorkerTasks"` + LoadAverage [3]float64 `json:"loadAverage"` + LoadAverageAvailable bool `json:"loadAverageAvailable"` +} + +type SystemPerformanceMemory struct { + SystemAvailable bool `json:"systemAvailable"` + TotalBytes uint64 `json:"totalBytes"` + UsedBytes uint64 `json:"usedBytes"` + AvailableBytes uint64 `json:"availableBytes"` + UsagePercent float64 `json:"usagePercent"` + HeapAllocBytes uint64 `json:"heapAllocBytes"` + HeapSysBytes uint64 `json:"heapSysBytes"` + SysBytes uint64 `json:"sysBytes"` + HeapObjects uint64 `json:"heapObjects"` + GCCount uint32 `json:"gcCount"` + LastGCAt *time.Time `json:"lastGCAt,omitempty"` +} + +type SystemPerformanceDisk struct { + Available bool `json:"available"` + Writable bool `json:"writable"` + TotalBytes uint64 `json:"totalBytes"` + UsedBytes uint64 `json:"usedBytes"` + FreeBytes uint64 `json:"freeBytes"` + UsagePercent float64 `json:"usagePercent"` +} + +type SystemPerformanceRedisPool struct { + Hits uint32 `json:"hits"` + Misses uint32 `json:"misses"` + Timeouts uint32 `json:"timeouts"` + TotalConnections uint32 `json:"totalConnections"` + IdleConnections uint32 `json:"idleConnections"` + StaleConnections uint32 `json:"staleConnections"` + PendingRequests uint32 `json:"pendingRequests"` +} + +type SystemPerformanceCacheGroup struct { + ID string `json:"id"` + Label string `json:"label"` + Keys int64 `json:"keys"` + Clearable bool `json:"clearable"` +} + +type SystemPerformanceRedis struct { + Configured bool `json:"configured"` + Connected bool `json:"connected"` + Mode string `json:"mode"` + LatencyMs int64 `json:"latencyMs,omitempty"` + Version string `json:"version,omitempty"` + UptimeSeconds int64 `json:"uptimeSeconds,omitempty"` + UsedMemoryBytes int64 `json:"usedMemoryBytes,omitempty"` + PeakMemoryBytes int64 `json:"peakMemoryBytes,omitempty"` + MaxMemoryBytes int64 `json:"maxMemoryBytes,omitempty"` + MaxMemoryPolicy string `json:"maxMemoryPolicy,omitempty"` + Keys int64 `json:"keys,omitempty"` + Clients int64 `json:"clients,omitempty"` + BlockedClients int64 `json:"blockedClients,omitempty"` + OpsPerSecond int64 `json:"opsPerSecond,omitempty"` + HitRate float64 `json:"hitRate,omitempty"` + ExpiredKeys int64 `json:"expiredKeys,omitempty"` + EvictedKeys int64 `json:"evictedKeys,omitempty"` + Pool SystemPerformanceRedisPool `json:"pool"` + CacheGroups []SystemPerformanceCacheGroup `json:"cacheGroups"` + StatusMessage string `json:"statusMessage,omitempty"` +} + +type AdminCacheClearRequest struct { + Scope string `json:"scope"` +} + +type AdminCacheClearGroupResult struct { + ID string `json:"id"` + DeletedKeys int64 `json:"deletedKeys"` +} + +type AdminCacheClearResult struct { + Scope string `json:"scope"` + DeletedKeys int64 `json:"deletedKeys"` + MemoryEntries int `json:"memoryEntries"` + Groups []AdminCacheClearGroupResult `json:"groups"` + ClearedAt time.Time `json:"clearedAt"` +} + +type runtimeCacheDefinition struct { + id, label, pattern string +} + +var runtimeCacheDefinitions = []runtimeCacheDefinition{ + {id: "rateLimits", label: "请求频控", pattern: "canvas:rate:*"}, + {id: "circuitFailures", label: "渠道失败计数", pattern: "canvas:circuit:failures:*"}, + {id: "circuitOpen", label: "渠道熔断状态", pattern: "canvas:circuit:open:*"}, + {id: "routeHealth", label: "线路临时禁用", pattern: "canvas:logical-route-health:*"}, +} + +func (s *Service) AdminSystemPerformance(ctx context.Context, actor *model.User) (*AdminSystemPerformance, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(ctx, systemPerformanceTimeout) + defer cancel() + + hostname, _ := os.Hostname() + load, loadAvailable := platformLoadAverage() + memory := collectSystemMemory() + disk := collectSystemDisk(s.dataDir) + databaseStats, databaseErr := s.repo.DatabaseRuntimeStats(ctx) + redisStats := s.collectRedisPerformance(ctx) + status := "healthy" + if databaseErr != nil || !databaseStats.Connected || !disk.Available || !disk.Writable || s.ValidateRuntime() != nil || (redisStats.Configured && !redisStats.Connected) { + status = "degraded" + } + return &AdminSystemPerformance{ + CollectedAt: time.Now(), Status: status, + Host: SystemPerformanceHost{Hostname: hostname, OS: runtime.GOOS, Arch: runtime.GOARCH, CPUCores: runtime.NumCPU(), GOMAXPROCS: runtime.GOMAXPROCS(0), ProcessID: os.Getpid(), UptimeSeconds: int64(time.Since(systemProcessStartedAt).Seconds()), Goroutines: runtime.NumGoroutine(), ActiveWorkerTasks: s.ActiveWorkerTasks(), LoadAverage: load, LoadAverageAvailable: loadAvailable}, + Memory: memory, Disk: disk, Database: databaseStats, Redis: redisStats, Build: buildinfo.Current(), + }, nil +} + +func collectSystemMemory() SystemPerformanceMemory { + var stats runtime.MemStats + runtime.ReadMemStats(&stats) + total, available, ok := platformMemory() + used := uint64(0) + percentage := float64(0) + if ok && total >= available { + used = total - available + if total > 0 { + percentage = float64(used) / float64(total) * 100 + } + } + result := SystemPerformanceMemory{SystemAvailable: ok, TotalBytes: total, UsedBytes: used, AvailableBytes: available, UsagePercent: percentage, HeapAllocBytes: stats.HeapAlloc, HeapSysBytes: stats.HeapSys, SysBytes: stats.Sys, HeapObjects: stats.HeapObjects, GCCount: stats.NumGC} + if stats.LastGC > 0 { + last := time.Unix(0, int64(stats.LastGC)) + result.LastGCAt = &last + } + return result +} + +func (s *Service) collectRedisPerformance(ctx context.Context) SystemPerformanceRedis { + result := SystemPerformanceRedis{Mode: "local", StatusMessage: "未配置 Redis,当前使用单实例本地协调", CacheGroups: make([]SystemPerformanceCacheGroup, 0, len(runtimeCacheDefinitions))} + if s.coordinator == nil { + result.CacheGroups = append(result.CacheGroups, SystemPerformanceCacheGroup{ID: "rateLimits", Label: "本地请求频控", Clearable: true}) + return result + } + if s.coordinator.redis == nil { + s.coordinator.localMu.Lock() + localRateKeys := int64(len(s.coordinator.localRate)) + s.coordinator.localMu.Unlock() + result.CacheGroups = append(result.CacheGroups, SystemPerformanceCacheGroup{ID: "rateLimits", Label: "本地请求频控", Keys: localRateKeys, Clearable: true}) + return result + } + result.Configured, result.Mode = true, "redis" + started := time.Now() + if err := s.coordinator.redis.Ping(ctx).Err(); err != nil { + result.StatusMessage = "Redis 暂时不可用" + return result + } + result.Connected = true + result.LatencyMs = max(1, time.Since(started).Milliseconds()) + result.StatusMessage = "Redis 连接正常" + if size, err := s.coordinator.redis.DBSize(ctx).Result(); err == nil { + result.Keys = size + } + if info, err := s.coordinator.redis.Info(ctx, "server", "clients", "memory", "stats").Result(); err == nil { + values := parseRedisInfo(info) + result.Version = values["redis_version"] + result.UptimeSeconds = redisInfoInt(values, "uptime_in_seconds") + result.UsedMemoryBytes = redisInfoInt(values, "used_memory") + result.PeakMemoryBytes = redisInfoInt(values, "used_memory_peak") + result.MaxMemoryBytes = redisInfoInt(values, "maxmemory") + result.MaxMemoryPolicy = values["maxmemory_policy"] + result.Clients = redisInfoInt(values, "connected_clients") + result.BlockedClients = redisInfoInt(values, "blocked_clients") + result.OpsPerSecond = redisInfoInt(values, "instantaneous_ops_per_sec") + result.ExpiredKeys = redisInfoInt(values, "expired_keys") + result.EvictedKeys = redisInfoInt(values, "evicted_keys") + hits, misses := redisInfoInt(values, "keyspace_hits"), redisInfoInt(values, "keyspace_misses") + if hits+misses > 0 { + result.HitRate = float64(hits) / float64(hits+misses) * 100 + } + } + pool := s.coordinator.redis.PoolStats() + result.Pool = SystemPerformanceRedisPool{Hits: pool.Hits, Misses: pool.Misses, Timeouts: pool.Timeouts, TotalConnections: pool.TotalConns, IdleConnections: pool.IdleConns, StaleConnections: pool.StaleConns, PendingRequests: pool.PendingRequests} + for _, definition := range runtimeCacheDefinitions { + count, _ := scanRedisKeyCount(ctx, s.coordinator.redis, definition.pattern) + result.CacheGroups = append(result.CacheGroups, SystemPerformanceCacheGroup{ID: definition.id, Label: definition.label, Keys: count, Clearable: true}) + } + return result +} + +func (s *Service) ClearAdminRuntimeCache(ctx context.Context, actor *model.User, request AdminCacheClearRequest) (*AdminCacheClearResult, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + if strings.TrimSpace(request.Scope) != "runtime" { + return nil, BadAuthRequest("仅支持清理运行时缓存") + } + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + result := &AdminCacheClearResult{Scope: "runtime", ClearedAt: time.Now(), Groups: make([]AdminCacheClearGroupResult, 0, len(runtimeCacheDefinitions))} + if s.coordinator != nil { + if s.coordinator.redis != nil { + for _, definition := range runtimeCacheDefinitions { + deleted, err := deleteRedisKeysByPattern(ctx, s.coordinator.redis, definition.pattern) + if err != nil { + return nil, errors.New("清理 Redis 运行时缓存失败") + } + result.Groups = append(result.Groups, AdminCacheClearGroupResult{ID: definition.id, DeletedKeys: deleted}) + result.DeletedKeys += deleted + } + } else { + s.coordinator.localMu.Lock() + count := len(s.coordinator.localRate) + s.coordinator.localRate = map[string]localRateEntry{} + s.coordinator.localMu.Unlock() + result.DeletedKeys += int64(count) + result.Groups = append(result.Groups, AdminCacheClearGroupResult{ID: "rateLimits", DeletedKeys: int64(count)}) + } + } + s.initReadCaches() + result.MemoryEntries += s.concurrencyReadCache.clearAndCount() + result.MemoryEntries += s.textReplayReadCache.clearAndCount() + result.MemoryEntries += s.routeVersionReadCache.clearAndCount() + s.routeHealthMu.Lock() + result.MemoryEntries += len(s.routeHealthBlocked) + s.routeHealthBlocked = make(map[string]time.Time) + s.routeHealthMu.Unlock() + // 与路由目录刷新保持同一加锁顺序,避免清理动作和回源刷新并发写入重试状态。 + s.routeCatalogRefreshMu.Lock() + s.routeCatalogMu.Lock() + if s.routeCatalog != nil { + result.MemoryEntries++ + } + s.routeCatalog = nil + s.routeCatalogVersion = 0 + s.routeCatalogRetryAt = time.Time{} + s.routeCatalogRefreshError = nil + s.routeCatalogMu.Unlock() + s.routeCatalogRefreshMu.Unlock() + if err := s.appendAdminAudit(actor, "system.runtime_cache.clear", "system", "runtime-cache", "清理安全运行时缓存", map[string]any{"deletedKeys": result.DeletedKeys, "memoryEntries": result.MemoryEntries}); err != nil { + return nil, err + } + return result, nil +} + +func parseRedisInfo(info string) map[string]string { + values := make(map[string]string) + for _, line := range strings.Split(info, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, found := strings.Cut(line, ":") + if found { + values[key] = strings.TrimSpace(value) + } + } + return values +} + +func redisInfoInt(values map[string]string, key string) int64 { + value, _ := strconv.ParseInt(values[key], 10, 64) + return value +} + +func scanRedisKeyCount(ctx context.Context, client *redis.Client, pattern string) (int64, error) { + var cursor uint64 + var total int64 + for { + keys, next, err := client.Scan(ctx, cursor, pattern, 200).Result() + if err != nil { + return total, err + } + total += int64(len(keys)) + cursor = next + if cursor == 0 { + return total, nil + } + } +} + +func deleteRedisKeysByPattern(ctx context.Context, client *redis.Client, pattern string) (int64, error) { + var cursor uint64 + var deleted int64 + for { + keys, next, err := client.Scan(ctx, cursor, pattern, 200).Result() + if err != nil { + return deleted, err + } + if len(keys) > 0 { + count, err := client.Unlink(ctx, keys...).Result() + if err != nil { + return deleted, err + } + deleted += count + } + cursor = next + if cursor == 0 { + return deleted, nil + } + } +} diff --git a/backend/internal/service/admin_system_performance_test.go b/backend/internal/service/admin_system_performance_test.go new file mode 100644 index 000000000..d5d075e5e --- /dev/null +++ b/backend/internal/service/admin_system_performance_test.go @@ -0,0 +1,238 @@ +package service + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "infinite-canvas/backend/internal/database" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "github.com/redis/go-redis/v9" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestAdminSystemPerformanceRequiresAdmin(t *testing.T) { + svc := &Service{} + user := &model.User{ID: "user-1", Role: model.UserRoleUser} + + if _, err := svc.AdminSystemPerformance(context.Background(), user); authStatus(err) != 403 { + t.Fatalf("AdminSystemPerformance() error = %#v, want 403", err) + } + if _, err := svc.ClearAdminRuntimeCache(context.Background(), user, AdminCacheClearRequest{Scope: "runtime"}); authStatus(err) != 403 { + t.Fatalf("ClearAdminRuntimeCache() error = %#v, want 403", err) + } + if _, err := svc.AdminSystemPerformance(context.Background(), nil); authStatus(err) != 401 { + t.Fatalf("AdminSystemPerformance(nil) error = %#v, want 401", err) + } +} + +func TestAdminSystemPerformanceReportsSQLiteWithoutRedis(t *testing.T) { + svc, db := newSystemPerformanceTestService(t) + admin := &model.User{ID: "admin-1", Role: model.UserRoleAdmin} + + result, err := svc.AdminSystemPerformance(context.Background(), admin) + if err != nil { + t.Fatal(err) + } + if result.Status != "healthy" { + t.Fatalf("status = %q, want healthy", result.Status) + } + if result.Database.Driver != "sqlite" || !result.Database.Connected || result.Database.Postgres != nil { + t.Fatalf("database = %#v", result.Database) + } + if result.Database.DatabaseBytes <= 0 { + t.Fatalf("database bytes = %d, want > 0", result.Database.DatabaseBytes) + } + if result.Database.Schema.Expected != database.CurrentSchemaVersion { + t.Fatalf("schema = %#v", result.Database.Schema) + } + if result.Redis.Configured || result.Redis.Connected || result.Redis.Mode != "local" { + t.Fatalf("redis = %#v", result.Redis) + } + if len(result.Redis.CacheGroups) != 1 || result.Redis.CacheGroups[0].ID != "rateLimits" { + t.Fatalf("cache groups = %#v", result.Redis.CacheGroups) + } + if !result.Disk.Available || !result.Disk.Writable { + t.Fatalf("disk = %#v", result.Disk) + } + if result.Host.CPUCores < 1 || result.Host.GOMAXPROCS < 1 || result.Host.ProcessID < 1 { + t.Fatalf("host = %#v", result.Host) + } + + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + if stats := sqlDB.Stats(); result.Database.Pool.OpenConnections != stats.OpenConnections { + t.Fatalf("pool open connections = %d, want %d", result.Database.Pool.OpenConnections, stats.OpenConnections) + } +} + +func TestClearAdminRuntimeCacheRejectsUnknownScopeAndAuditsLocalClear(t *testing.T) { + svc, db := newSystemPerformanceTestService(t) + admin := &model.User{ID: "admin-1", Role: model.UserRoleAdmin} + svc.coordinator.localRate["user-1"] = localRateEntry{started: time.Now(), count: 2} + + if _, err := svc.ClearAdminRuntimeCache(context.Background(), admin, AdminCacheClearRequest{Scope: "all"}); authStatus(err) != 400 { + t.Fatalf("unknown scope error = %#v, want 400", err) + } + if len(svc.coordinator.localRate) != 1 { + t.Fatal("invalid scope changed local runtime cache") + } + + svc.initReadCaches() + if _, err := svc.routeVersionReadCache.get(context.Background(), "route-version", func(context.Context) (int64, int, error) { + return 7, 256, nil + }); err != nil { + t.Fatal(err) + } + svc.routeHealthBlocked["route-1"] = time.Now().Add(time.Minute) + svc.routeCatalog = &routeCatalogSnapshot{} + svc.routeCatalogRetryAt = time.Now().Add(time.Minute) + svc.routeCatalogRefreshError = errors.New("cached refresh failure") + + result, err := svc.ClearAdminRuntimeCache(context.Background(), admin, AdminCacheClearRequest{Scope: "runtime"}) + if err != nil { + t.Fatal(err) + } + if result.DeletedKeys != 1 || len(svc.coordinator.localRate) != 0 { + t.Fatalf("local clear result = %#v, remaining = %d", result, len(svc.coordinator.localRate)) + } + if result.MemoryEntries != 3 { + t.Fatalf("memory entries = %d, want 3", result.MemoryEntries) + } + if svc.routeCatalog != nil || !svc.routeCatalogRetryAt.IsZero() || svc.routeCatalogRefreshError != nil || len(svc.routeHealthBlocked) != 0 { + t.Fatalf("route caches were not reset") + } + var audit model.AdminAuditEvent + if err := db.Where("action = ?", "system.runtime_cache.clear").First(&audit).Error; err != nil { + t.Fatal(err) + } + if audit.ActorUserID != admin.ID || audit.TargetID != "runtime-cache" { + t.Fatalf("audit = %#v", audit) + } +} + +func TestClearAdminRuntimeCacheUsesRedisWhitelist(t *testing.T) { + client := startSystemPerformanceRedis(t) + svc, db := newSystemPerformanceTestService(t) + svc.coordinator = &runtimeCoordinator{redis: client, instanceID: "system-performance-test", localRate: map[string]localRateEntry{}, localSlots: map[string]map[string]time.Time{}} + admin := &model.User{ID: "admin-redis", Role: model.UserRoleAdmin} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + clearable := []string{ + "canvas:rate:user-1", + "canvas:circuit:failures:channel-1", + "canvas:circuit:open:channel-1", + "canvas:logical-route-health:route-1", + } + preserved := []string{ + "canvas:slots:channel:channel-1", + "canvas:logical-model-route-catalog:version", + "canvas:task:task-1", + } + for _, key := range append(append([]string{}, clearable...), preserved...) { + if err := client.Set(ctx, key, "1", 0).Err(); err != nil { + t.Fatal(err) + } + } + + before := svc.collectRedisPerformance(ctx) + if !before.Configured || !before.Connected || before.Mode != "redis" || before.Keys != int64(len(clearable)+len(preserved)) { + t.Fatalf("redis performance = %#v", before) + } + + result, err := svc.ClearAdminRuntimeCache(ctx, admin, AdminCacheClearRequest{Scope: "runtime"}) + if err != nil { + t.Fatal(err) + } + if result.DeletedKeys != int64(len(clearable)) || len(result.Groups) != len(runtimeCacheDefinitions) { + t.Fatalf("clear result = %#v", result) + } + if remaining, err := client.Exists(ctx, clearable...).Result(); err != nil || remaining != 0 { + t.Fatalf("clearable keys remaining = %d, err = %v", remaining, err) + } + if remaining, err := client.Exists(ctx, preserved...).Result(); err != nil || remaining != int64(len(preserved)) { + t.Fatalf("preserved keys remaining = %d, err = %v", remaining, err) + } + var auditCount int64 + if err := db.Model(&model.AdminAuditEvent{}).Where("action = ?", "system.runtime_cache.clear").Count(&auditCount).Error; err != nil { + t.Fatal(err) + } + if auditCount != 1 { + t.Fatalf("audit count = %d, want 1", auditCount) + } +} + +func newSystemPerformanceTestService(t *testing.T) (*Service, *gorm.DB) { + t.Helper() + dataDir := t.TempDir() + db, err := gorm.Open(sqlite.Open(filepath.Join(dataDir, "system-performance.db")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.AdminAuditEvent{}); err != nil { + t.Fatal(err) + } + if sqlDB, err := db.DB(); err == nil { + sqlDB.SetMaxOpenConns(1) + t.Cleanup(func() { _ = sqlDB.Close() }) + } + return &Service{ + repo: repository.New(db), + dataDir: dataDir, + coordinator: &runtimeCoordinator{instanceID: "local-test", localRate: map[string]localRateEntry{}, localSlots: map[string]map[string]time.Time{}}, + routeCatalogTTL: 30 * time.Second, + routeCatalogMaxStale: 5 * time.Minute, + routeHealthBlocked: map[string]time.Time{}, + }, db +} + +func startSystemPerformanceRedis(t *testing.T) *redis.Client { + t.Helper() + binary, err := exec.LookPath("redis-server") + if err != nil { + t.Skip("redis-server unavailable; Redis cache whitelist integration not run") + } + dir, err := os.MkdirTemp("", "canvas-performance-redis-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + socket := filepath.Join(dir, "r.sock") + cmd := exec.Command(binary, "--port", "0", "--unixsocket", socket, "--unixsocketperm", "700", "--save", "", "--appendonly", "no", "--dir", dir) + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = cmd.Process.Kill() + _ = cmd.Wait() + }) + client := redis.NewClient(&redis.Options{Network: "unix", Addr: socket, MaxRetries: -1, ContextTimeoutEnabled: true}) + t.Cleanup(func() { _ = client.Close() }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + for client.Ping(ctx).Err() != nil { + if ctx.Err() != nil { + t.Fatal("temporary Redis did not start") + } + time.Sleep(20 * time.Millisecond) + } + return client +} + +func authStatus(err error) int { + var authErr *AuthError + if errors.As(err, &authErr) { + return authErr.Status + } + return 0 +} diff --git a/backend/internal/service/image_size_presets_test.go b/backend/internal/service/image_size_presets_test.go new file mode 100644 index 000000000..624516998 --- /dev/null +++ b/backend/internal/service/image_size_presets_test.go @@ -0,0 +1,85 @@ +package service + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestImageSizePresetsValidationAndRoundTrip(t *testing.T) { + profile := DefaultImageCapabilityConfig("openai-image", "test") + profile.Size = ImageSizeConfig{Parameter: "size", Values: []string{"1824x1024", "1920x1080"}, Default: "1824x1024", Presets: []ImageSizePreset{ + {Tier: "1k", Ratio: "16:9", Size: "1824x1024", Width: 1824, Height: 1024}, + {Tier: "2k", Ratio: "16:9", Size: "1920x1080", Width: 1920, Height: 1080}, + }} + if err := validateImageCapabilityConfig(profile); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(profile) + if err != nil { + t.Fatal(err) + } + var restored ImageCapabilityConfig + if err := json.Unmarshal(data, &restored); err != nil || len(restored.Size.Presets) != 2 || restored.Size.Presets[1].Size != "1920x1080" { + t.Fatalf("presets did not survive JSON round trip: %v", err) + } + + for _, tc := range []struct { + name string + edit func(*ImageCapabilityConfig) + want string + }{ + {"invalid tier", func(p *ImageCapabilityConfig) { p.Size.Presets[0].Tier = "8k" }, "档位"}, + {"zero ratio", func(p *ImageCapabilityConfig) { p.Size.Presets[0].Ratio = "0:9" }, "比例"}, + {"mismatched ratio", func(p *ImageCapabilityConfig) { p.Size.Presets[0].Ratio = "9:16" }, "不一致"}, + {"tiny size", func(p *ImageCapabilityConfig) { + p.Size.Presets[0].Width = 16 + p.Size.Presets[0].Height = 16 + p.Size.Presets[0].Size = "16x16" + }, "像素"}, + {"size mismatch", func(p *ImageCapabilityConfig) { p.Size.Presets[0].Size = "1024x1024" }, "像素"}, + {"not supported", func(p *ImageCapabilityConfig) { p.Size.Values = []string{"1824x1024"} }, "支持值"}, + {"duplicate equivalent ratio", func(p *ImageCapabilityConfig) { p.Size.Presets[1].Tier = "1k"; p.Size.Presets[1].Ratio = "32:18" }, "重复"}, + } { + t.Run(tc.name, func(t *testing.T) { + var p ImageCapabilityConfig + if err := json.Unmarshal(data, &p); err != nil { + t.Fatal(err) + } + tc.edit(&p) + if err := validateImageCapabilityConfig(&p); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("expected %s, got %v", tc.want, err) + } + }) + } +} + +func TestImageSizePresetsEnforceResolutionRatioPair(t *testing.T) { + profile := DefaultImageCapabilityConfig("grok-image", "grok-imagine-image") + profile.Quality.Default = "1k" + profile.Size = ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"1:1", "16:9"}, Default: "1:1", Presets: []ImageSizePreset{ + {Tier: "1k", Ratio: "1:1", Size: "1024x1024", Width: 1024, Height: 1024}, + {Tier: "2k", Ratio: "16:9", Size: "2752x1536", Width: 2752, Height: 1536}, + }} + if err := validateImageCapabilityConfig(profile); err != nil { + t.Fatal(err) + } + profile.Quality.Default = "2k" + if err := validateImageCapabilityConfig(profile); err == nil || !strings.Contains(err.Error(), "默认图片分辨率") { + t.Fatalf("expected invalid default pair error, got %v", err) + } + profile.Quality.Default = "1k" + input := canvasGenerationInput{Config: providerConfig{Size: "1:1", Quality: "2k"}} + if err := validateImageTask(profile, input); err == nil || !strings.Contains(err.Error(), "当前分辨率") { + t.Fatalf("expected pair validation error, got %v", err) + } + input.Config.Size = "16:9" + if err := validateImageTask(profile, input); err != nil { + t.Fatal(err) + } + profile.Size.AllowCustom = true + input.Config.Size = "1:1" + if err := validateImageTask(profile, input); err != nil { + t.Fatal(err) + } +} diff --git a/backend/internal/service/model_capability.go b/backend/internal/service/model_capability.go index 2af034c2a..8c8457876 100644 --- a/backend/internal/service/model_capability.go +++ b/backend/internal/service/model_capability.go @@ -50,10 +50,19 @@ type ImageReferenceConfig struct { } type ImageSizeConfig struct { - Parameter string `json:"parameter"` - Values []string `json:"values"` - Default string `json:"default"` - AllowCustom bool `json:"allowCustom"` + Parameter string `json:"parameter"` + Values []string `json:"values"` + Default string `json:"default"` + AllowCustom bool `json:"allowCustom"` + Presets []ImageSizePreset `json:"presets,omitempty"` +} + +type ImageSizePreset struct { + Tier string `json:"tier"` + Ratio string `json:"ratio"` + Size string `json:"size"` + Width int `json:"width"` + Height int `json:"height"` } type ImageQualityConfig struct { @@ -494,6 +503,7 @@ func validateImageCapabilityConfig(value *ImageCapabilityConfig) error { switch value.Size.Parameter { case "none": value.Size.Values = []string{} + value.Size.Presets = nil value.Size.Default = "auto" value.Size.AllowCustom = false case "size", "aspect_ratio": @@ -506,6 +516,48 @@ func validateImageCapabilityConfig(value *ImageCapabilityConfig) error { default: return BadAuthRequest("尺寸参数仅支持不发送、size 或 aspect_ratio") } + seenPresets := make(map[string]bool) + for _, preset := range value.Size.Presets { + if preset.Tier != "1k" && preset.Tier != "2k" && preset.Tier != "4k" { + return BadAuthRequest("图片分辨率档位仅支持 1K、2K、4K") + } + parts := strings.Split(preset.Ratio, ":") + if len(parts) != 2 { + return BadAuthRequest("图片预设比例格式无效") + } + w, ew := strconv.Atoi(parts[0]) + h, eh := strconv.Atoi(parts[1]) + if ew != nil || eh != nil || w <= 0 || h <= 0 || w > 100000 || h > 100000 || max(w, h) > min(w, h)*3 { + return BadAuthRequest("图片预设比例无效") + } + if preset.Width <= 0 || preset.Height <= 0 || max(preset.Width, preset.Height) > 3840 || max(preset.Width, preset.Height) > min(preset.Width, preset.Height)*3 || preset.Width*preset.Height < 655360 || preset.Width*preset.Height > 8294400 || preset.Size != fmt.Sprintf("%dx%d", preset.Width, preset.Height) { + return BadAuthRequest("图片预设像素尺寸无效") + } + // 容许像素取整误差,但不能将横屏尺寸标记成竖屏或其他比例。 + difference := preset.Width*h - preset.Height*w + if difference < 0 { + difference = -difference + } + if difference*1000 > preset.Height*w*25 { + return BadAuthRequest("图片预设像素尺寸与宽高比不一致") + } + a, b := w, h + for b != 0 { + a, b = b, a%b + } + key := fmt.Sprintf("%s:%d:%d", preset.Tier, w/a, h/a) + if seenPresets[key] { + return BadAuthRequest("图片尺寸预设重复") + } + seenPresets[key] = true + requestValue := preset.Size + if value.Size.Parameter == "aspect_ratio" { + requestValue = preset.Ratio + } + if !containsCapabilityString(value.Size.Values, requestValue) { + return BadAuthRequest("图片预设必须包含在尺寸支持值中") + } + } if value.Quality.Supported { if len(value.Quality.Values) == 0 || strings.TrimSpace(value.Quality.Default) == "" || !containsCapabilityString(value.Quality.Values, value.Quality.Default) { return BadAuthRequest("请配置图片质量支持值和默认值") @@ -514,6 +566,9 @@ func validateImageCapabilityConfig(value *ImageCapabilityConfig) error { value.Quality.Values = []string{} value.Quality.Default = "auto" } + if err := validateImagePresetSelection(value, value.Quality.Default, value.Size.Default); err != nil { + return BadAuthRequest("默认图片分辨率与宽高比不在已配置的组合中") + } if !value.TransparentBackground.Supported { value.TransparentBackground.Default = false } @@ -770,6 +825,9 @@ func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInp if profile.Quality.Supported && strings.TrimSpace(input.Config.Quality) != "" && !containsCapabilityString(profile.Quality.Values, input.Config.Quality) { return BadAuthRequest("图片质量不在当前模型支持范围内") } + if err := validateImagePresetSelection(profile, firstNonEmpty(input.Config.Quality, profile.Quality.Default), firstNonEmpty(input.Config.Size, profile.Size.Default)); err != nil { + return err + } count, err := strconv.Atoi(strings.TrimSpace(input.Config.Count)) if err == nil && count > profile.MaxOutputs { return BadAuthRequest(fmt.Sprintf("当前图片模型单次最多生成 %d 张", profile.MaxOutputs)) @@ -777,6 +835,35 @@ func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInp return nil } +func validateImagePresetSelection(profile *ImageCapabilityConfig, quality, ratio string) error { + if profile.Size.Parameter != "aspect_ratio" || profile.Size.AllowCustom || len(profile.Size.Presets) == 0 || ratio == "auto" { + return nil + } + tier := imageResolutionTier(quality) + if tier == "" { + return nil + } + for _, preset := range profile.Size.Presets { + if preset.Tier == tier && preset.Ratio == ratio { + return nil + } + } + return BadAuthRequest("当前分辨率不支持所选图片宽高比") +} + +func imageResolutionTier(quality string) string { + switch strings.ToLower(strings.TrimSpace(quality)) { + case "1k", "low": + return "1k" + case "2k", "medium": + return "2k" + case "4k", "high": + return "4k" + default: + return "" + } +} + func validateWorkflowProviderPromptLength(input canvasGenerationInput) error { profile := input.Config.CapabilityConfig if input.Mode != "video" || profile == nil || profile.Video == nil { diff --git a/backend/internal/service/read_cache.go b/backend/internal/service/read_cache.go index 33531cafa..79e0dcc5d 100644 --- a/backend/internal/service/read_cache.go +++ b/backend/internal/service/read_cache.go @@ -139,9 +139,15 @@ func (c *boundedReadCache[K, V]) remove(e *readCacheEntry[K, V]) { } func (c *boundedReadCache[K, V]) clear() { + c.clearAndCount() +} + +func (c *boundedReadCache[K, V]) clearAndCount() int { c.mu.Lock() defer c.mu.Unlock() + count := len(c.entries) c.entries = make(map[K]*readCacheEntry[K, V]) c.lru.Init() c.bytes = 0 + return count } diff --git a/backend/internal/service/system_metrics_darwin.go b/backend/internal/service/system_metrics_darwin.go new file mode 100644 index 000000000..14b2c53c2 --- /dev/null +++ b/backend/internal/service/system_metrics_darwin.go @@ -0,0 +1,19 @@ +//go:build darwin + +package service + +import "golang.org/x/sys/unix" + +func platformMemory() (uint64, uint64, bool) { + total, err := unix.SysctlUint64("hw.memsize") + if err != nil || total == 0 { + return 0, 0, false + } + // hw.memsize 只能稳定提供物理内存总量;没有可靠可用量时不要把 available=0 + // 误算成 100% 占用,页面会明确降级展示 Go 进程堆内存。 + return total, 0, false +} + +func platformLoadAverage() ([3]float64, bool) { + return [3]float64{}, false +} diff --git a/backend/internal/service/system_metrics_linux.go b/backend/internal/service/system_metrics_linux.go new file mode 100644 index 000000000..2ce36b840 --- /dev/null +++ b/backend/internal/service/system_metrics_linux.go @@ -0,0 +1,43 @@ +//go:build linux + +package service + +import ( + "os" + "strconv" + "strings" +) + +func platformMemory() (uint64, uint64, bool) { + data, err := os.ReadFile("/proc/meminfo") + if err != nil { + return 0, 0, false + } + values := map[string]uint64{} + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + value, _ := strconv.ParseUint(fields[1], 10, 64) + values[strings.TrimSuffix(fields[0], ":")] = value * 1024 + } + total, available := values["MemTotal"], values["MemAvailable"] + return total, available, total > 0 +} + +func platformLoadAverage() ([3]float64, bool) { + var result [3]float64 + data, err := os.ReadFile("/proc/loadavg") + if err != nil { + return result, false + } + fields := strings.Fields(string(data)) + if len(fields) < 3 { + return result, false + } + for index := range 3 { + result[index], _ = strconv.ParseFloat(fields[index], 64) + } + return result, true +} diff --git a/backend/internal/service/system_metrics_other.go b/backend/internal/service/system_metrics_other.go new file mode 100644 index 000000000..5b109cede --- /dev/null +++ b/backend/internal/service/system_metrics_other.go @@ -0,0 +1,7 @@ +//go:build !linux && !darwin + +package service + +func platformMemory() (uint64, uint64, bool) { return 0, 0, false } +func platformLoadAverage() ([3]float64, bool) { return [3]float64{}, false } +func collectSystemDisk(string) SystemPerformanceDisk { return SystemPerformanceDisk{} } diff --git a/backend/internal/service/system_metrics_unix.go b/backend/internal/service/system_metrics_unix.go new file mode 100644 index 000000000..0d7018863 --- /dev/null +++ b/backend/internal/service/system_metrics_unix.go @@ -0,0 +1,36 @@ +//go:build linux || darwin + +package service + +import ( + "os" + "path/filepath" + "syscall" +) + +func collectSystemDisk(path string) SystemPerformanceDisk { + if path == "" { + path = "." + } + path, _ = filepath.Abs(path) + var stats syscall.Statfs_t + if err := syscall.Statfs(path, &stats); err != nil { + return SystemPerformanceDisk{} + } + blockSize := uint64(stats.Bsize) + total := stats.Blocks * blockSize + free := stats.Bavail * blockSize + used := total - stats.Bfree*blockSize + usage := float64(0) + if total > 0 { + usage = float64(used) / float64(total) * 100 + } + writable := false + if file, err := os.CreateTemp(path, ".system-performance-write-check-"); err == nil { + writable = true + name := file.Name() + _ = file.Close() + _ = os.Remove(name) + } + return SystemPerformanceDisk{Available: true, Writable: writable, TotalBytes: total, UsedBytes: used, FreeBytes: free, UsagePercent: usage} +} diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 68feb432e..516f35a89 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -118,6 +118,14 @@ description: 影策后端核心持久化表与状态边界 | `channel_model_price_tiers` | 保存系统模型按规格匹配的上游 SKU 与价格 | 价格档与渠道模型同事务保存;未启用或未完成定价的规格不进入普通用户目录 | | `logical_models` | 保存前台模型的公开名称、Logo、能力类型和归档状态 | 前台模型与系统渠道模型分别维护展示信息;供应线路通过 revision 和 route 关联;`code` 仅在 `archived_at IS NULL` 的活动记录中唯一,归档后可用同 code 重建目录项 | +图片分档尺寸保存在现有模型能力 JSON 的 `image.size.presets` 中,不新增表、列或 SQL 迁移。每项结构为 `{ tier, ratio, size, width, height }`,例如 `{ "tier": "1k", "ratio": "16:9", "size": "1824x1024", "width": 1824, "height": 1024 }`。 + +- `tier` 仅允许 `1k`、`2k`、`4k`,管理员分别配置各档支持的比例;新增比例自动计算像素,原有精确尺寸不被重算。 +- 同档等价比例不能重复。比例须为正整数比且不超过 3:1;像素最长边不超过 3840,总像素为 655360–8294400,并与比例及 `size` 一致(允许 2.5% 比例取整误差)。这些档位是工作台预设,不保证上游返回完全相同的像素。 +- `parameter=size` 的支持值与请求使用精确像素;`parameter=aspect_ratio` 使用比例,独立分辨率由协议质量值映射。未允许自定义且质量值对应明确档位时,保存默认配置和创建任务均校验已声明的“档位 × 比例”组合,不能借用其他档位的比例。 +- `allowCustom` 只开放支持值之外的输入,不新增上游分辨率能力;比例协议未配置对应质量值时不开放该档位。模型声明的精确像素可不满足 16px 对齐;自定义像素仍须满足请求端的 16px 对齐及尺寸限制。 +- 未声明 `presets` 时,界面从已有支持值推导候选;禁用尺寸参数时清除分档配置。发布与回滚均无需数据库迁移,但旧程序不识别分档组合约束,回滚前应核对相关模型配置。 + ## 插件管理 | 表 | 用途 | 关键边界 | diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 0825d8782..014b78dd0 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,37 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 管理后台系统性能 + +- 管理后台「系统配置」新增独立「系统性能」页面,集中展示主机与 Go 进程、数据目录所在磁盘、系统/进程内存、数据库连接池与 Schema、PostgreSQL 统计、Redis 运行状态和运行时缓存 key 数量;连接地址、账号、密码、DSN 与 Redis URL 不返回前端。 +- 页面提供 15 秒自动刷新、手动刷新、降级提示和紧凑 KPI。SQLite 环境明确隐藏 PostgreSQL 专属统计,Redis 未配置时显示单实例本地协调状态;macOS 无可靠系统可用内存时降级展示 Go 堆内存,避免把未知可用量误报为 100% 占用。 +- 缓存维护仅接受 `runtime` scope,通过 Redis `SCAN + UNLINK` 清理请求频控、渠道失败/熔断和线路临时禁用四类白名单;保留活动并发租约、跨实例目录版本以及用户、任务、素材、模型配置和数据库数据,同时清理可重建的进程内读缓存并写入管理员审计。 +- [x] 后端专项测试覆盖管理员权限、SQLite/无 Redis 状态、非法 scope 拒绝、本地缓存清理和审计写入;专项竞态测试、handler 路由注册、repository/service 和前端生产构建通过。Redis 白名单删除与保留边界的集成用例已实现,但本机无 `redis-server`,本轮自动跳过。 +- [x] 浏览器 mock 已验证 PostgreSQL + Redis 完整态、SQLite + 本地协调降级态、桌面浅色与 390px 深色布局、无横向溢出,以及缓存清理二次确认文案;未调用真实清理接口。 +- [ ] 在真实部署的 `/admin/settings/system-performance` 验证 PostgreSQL 统计、Redis 指标、自动刷新和 Redis 断连降级;仅在测试环境执行缓存清理,确认流量高峰期间不影响活动并发租约和业务数据。 + +## 图片组预览、连线菜单与尺寸设置 + +- 折叠图片组背板读取真实子图,排除当前主图、最多显示五张,并复用资源缓存与视口懒加载;未完成的子图保留占位背板。 +- 连线创建菜单的说明与禁用原因可换行,菜单限制在视口宽高内并允许纵向滚动。隔离组件页已通过浏览器确认真实图片背板与长说明显示。 +- 管理员固定展示 1K / 2K / 4K 三组,可选择或手动输入每档比例,等价比例归一去重;1K + 16:9 自动换算为 1824x1024,已有精确像素保持原值。未配置分档时,从已有尺寸按总像素推导档位(例如 1920x1080 为 2K)。 +- 图片协议的尺寸参数独占首行,输出数量与可选参数移到下方;每个分辨率档位用横向比例卡片直接点选,额外比例通过输入框添加,默认输出也改为直接点选,不再使用比例下拉框。窄容器自动改为单列,视频协议布局保持不变。 +- 后台未保存的新增模型表单已验证:比例选中/取消、32:18 等价去重、17:11 添加与像素展示、0:9 错误提示、默认输出切换、Enter 添加而非提交表单;1440px 桌面尺寸卡片独占整行,390px 明暗主题和长比例标签无横向溢出,焦点轮廓可见。验证后已取消表单、放弃测试修改并恢复原主题。截图工具未成功,本轮布局依据浏览器 DOM 几何测量与交互结果,视觉效果仍待人工确认。 +- 画布、创作和短剧生产接入同一“分辨率 → 宽高比 → 像素展示”组件。用户端仅显示协议支持且已配置比例的档位:例如 2K 比例为空时隐藏 2K,允许自定义也不额外显示空档位;剩余按钮等宽填充。管理员仍保留三档配置入口。允许自定义时开放比例或像素输入,切档尽量保持比例;重新打开保留真实自定义尺寸展示,不将已隐藏档位的旧尺寸伪装成其他档位选中,也不自动改写尺寸。 +- 空档位隐藏已在隔离组件页验证:仅配置 1K/4K 时无 2K 按钮,选择 4K 返回 3840x2160;仅一档时按钮铺满可用宽度,全空时隐藏分辨率标题和按钮,保留自动尺寸与自定义入口。未保存模型或发起生成,真实路由仍待人工复验。 +- 比例请求配置 aspect_ratio;精确像素配置 size。独立分辨率字段由对应模型协议适配,仅比例不能保证输出像素。模型声明的精确像素预设不受自定义尺寸的 16 倍数对齐限制,保持原值发送,其余尺寸边界继续校验。 +- 浏览器隔离组件页已确认:旧比例配置切 4K 实际交给请求转换函数的是 3840x2160;自定义 2:1 后切 2K 保持比例;0:9 显式报错且不改已选值;管理员输入 32:18 去重、17:11 自动计算并同步用户选项;Grok 禁用 4K。390px 视口内的 264px 面板在明暗主题下测得无横向溢出,含长比例标签。以上不等同于真实上游生成或三个路由的完整验收。 +- 前端 29 项尺寸、协议及能力专项测试和生产构建通过,包含空档位隐藏、比例无下拉框、禁用状态与图片/视频布局隔离回归;此前后端尺寸预设、能力规格转换及图片任务相关测试通过,覆盖默认组合拒绝、跨档位比例拒绝及 JSON 往返,本轮纯 UI 改造未重跑后端。构建仍有既有的大分包与无效动态导入警告。 +- [ ] 复验画布、创作、短剧真实路由中的模型切换、保存后重开、默认档位与比例组合、明暗主题和键盘交互;管理员真实保存由用户确认后执行。 +- [ ] 用真实模型确认新增尺寸是否受上游支持、实际输出与计费档位是否一致;本次未修改已保存模型配置或执行收费生成。 + +## 画布多图分组与展开动画 + +- 新生成的多图组默认收起;复制图片或替换节点生成多图时,结果也保留分组归属,可展开、收起和设置主图。 +- 子图入场动画仅在主动展开图片组时触发;普通选择、拖动或重新进入视口不播放入场动画,快速切换展开与收起时清理上一轮动画状态。 +- 22 项相关测试及前端生产构建通过,包含子图普通挂载、选中与展开/收起的组件渲染回归;浏览器已确认本地画布可加载,尚未复验截图中的多图场景。 +- [ ] 人工确认多图生成后的堆叠预览、数量按钮展开/收起、子图点击与拖动、主图切换,以及保存后重新打开画布。已有画布保留用户保存的展开状态。 + ## v1.2.6.rc3 候选版本发布 - 本版本在 rc2 基础上纳入此前未提交的模型编辑弹窗、协议浏览器、后台切页与画布网格源码及相邻回归测试;版本为 `v1.2.6.rc3`,镜像标签为 `1.2.6.rc3`,不覆盖 rc2。 diff --git a/web/src/components/canvas/canvas-node-content.tsx b/web/src/components/canvas/canvas-node-content.tsx index f2d18d8bc..0af4b7f2f 100644 --- a/web/src/components/canvas/canvas-node-content.tsx +++ b/web/src/components/canvas/canvas-node-content.tsx @@ -43,6 +43,7 @@ export type CanvasNodeContentProps = { textareaRef: RefObject; isBatchRoot: boolean; batchCount: number; + batchPreviewNodes?: CanvasNodeData[]; batchExpanded: boolean; batchOpening: boolean; batchRecovering: boolean; @@ -398,13 +399,13 @@ function ImageNodeContent(props: CanvasNodeContentProps) { : props.node.metadata?.status === "error" ? : ; - return {content}; + return {content}; } if (!props.node.metadata?.content) return ; - return ; + return ; } -function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch }: CanvasNodeContentProps) { +function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchPreviewNodes, batchExpanded, batchOpening, batchRecovering, onToggleBatch }: CanvasNodeContentProps) { const isCharacterReference = node.metadata?.workflowKind === "character" && node.metadata?.characterView === "multi"; const content = (
@@ -417,7 +418,7 @@ function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded ) : 空图片节点}
); - if (isBatchRoot) return {content}; + if (isBatchRoot) return {content}; return content; } @@ -571,7 +572,7 @@ function EmptyMediaContent({ icon, label, color }: { icon: ReactNode; label: str return
{icon}{label}
; } -function ImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch }: Pick) { +function ImageContent({ node, theme, isBatchRoot, batchCount, batchPreviewNodes, batchExpanded, batchOpening, batchRecovering, onToggleBatch }: Pick) { const imageContainerRef = useRef(null); const nearViewport = useNearViewport(imageContainerRef); const { url, loading } = useNodeResourceUrl(node, nearViewport); @@ -615,7 +616,7 @@ function ImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded, bat }; return ( - +
{url ? {node.title} event.preventDefault()} onLoad={(event) => fitToImage(event.currentTarget)} className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`} /> :
{loading ? : }
}
@@ -695,7 +696,14 @@ export function CanvasNodeImageInfo({ node }: { node: CanvasNodeData }) { return {width} x {height}{size ? ` · ${size}` : ""}; } -function BatchFrame({ batchCount, batchExpanded, batchOpening, batchRecovering, theme, onToggleBatch, children }: { batchCount: number; batchExpanded: boolean; batchOpening: boolean; batchRecovering: boolean; theme: CanvasTheme; onToggleBatch?: () => void; children: ReactNode }) { +function BatchPreviewImage({ node }: { node: CanvasNodeData }) { + const ref = useRef(null); + const nearViewport = useNearViewport(ref); + const { url } = useNodeResourceUrl(node, nearViewport); + return
{url ? {`子图预览:${node.title}`} : null}
; +} + +function BatchFrame({ batchCount, batchPreviewNodes, batchExpanded, batchOpening, batchRecovering, theme, onToggleBatch, children }: { batchCount: number; batchPreviewNodes?: CanvasNodeData[]; batchExpanded: boolean; batchOpening: boolean; batchRecovering: boolean; theme: CanvasTheme; onToggleBatch?: () => void; children: ReactNode }) { const isBatchRoot = batchCount > 1; return (
{ event.stopPropagation(); onToggleBatch?.(); } : undefined}> @@ -704,7 +712,7 @@ function BatchFrame({ batchCount, batchExpanded, batchOpening, batchRecovering, {Array.from({ length: Math.min(batchCount - 1, 5) }).map((_, index) => (
+ > + {batchPreviewNodes?.[index] ? : null} +
))}
) : null} diff --git a/web/src/components/canvas/canvas-node.tsx b/web/src/components/canvas/canvas-node.tsx index 960f9e030..037a9488f 100644 --- a/web/src/components/canvas/canvas-node.tsx +++ b/web/src/components/canvas/canvas-node.tsx @@ -37,6 +37,7 @@ type CanvasNodeProps = { renderNodeContent?: (node: CanvasNodeData) => ReactNode; drawingProjectId?: string; batchCount?: number; + batchPreviewNodes?: CanvasNodeData[]; batchExpanded?: boolean; batchClosing?: boolean; batchOpening?: boolean; @@ -83,6 +84,7 @@ export const CanvasNode = React.memo(function CanvasNode({ renderNodeContent, drawingProjectId, batchCount = 0, + batchPreviewNodes, batchExpanded = false, batchClosing = false, batchOpening = false, @@ -347,8 +349,8 @@ export const CanvasNode = React.memo(function CanvasNode({ "--batch-from-x": `${batchMotion?.x || 0}px`, "--batch-from-y": `${batchMotion?.y || 0}px`, "--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`, - animation: data.metadata?.batchRootId ? (batchClosing ? `canvas-batch-child-out var(--motion-dur-base-calc) var(--motion-ease-in-out) both` : `canvas-batch-child-in var(--motion-dur-slow-calc) var(--motion-ease-out) both`) : undefined, - animationDelay: data.metadata?.batchRootId ? `${batchClosing ? 0 : 45 + (batchMotion?.index || 0) * 24}ms` : undefined, + animation: isBatchChild && (batchClosing || batchOpening) ? (batchClosing ? `canvas-batch-child-out var(--motion-dur-base-calc) var(--motion-ease-in-out) both` : `canvas-batch-child-in var(--motion-dur-slow-calc) var(--motion-ease-out) both`) : undefined, + animationDelay: isBatchChild && (batchClosing || batchOpening) ? `${batchClosing ? 0 : 45 + (batchMotion?.index || 0) * 24}ms` : undefined, } as React.CSSProperties } > @@ -363,6 +365,7 @@ export const CanvasNode = React.memo(function CanvasNode({ textareaRef={textareaRef} isBatchRoot={isBatchRoot} batchCount={batchCount} + batchPreviewNodes={batchPreviewNodes} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} @@ -494,6 +497,7 @@ function areCanvasNodePropsEqual(previous: CanvasNodeProps, next: CanvasNodeProp previous.renderNodeContent === next.renderNodeContent && previous.drawingProjectId === next.drawingProjectId && previous.batchCount === next.batchCount && + previous.batchPreviewNodes === next.batchPreviewNodes && previous.batchExpanded === next.batchExpanded && previous.batchClosing === next.batchClosing && previous.batchOpening === next.batchOpening && @@ -548,7 +552,7 @@ function NodeLockBadge({ theme }: { theme: CanvasTheme }) { function BatchToggleBadge({ count, expanded, theme, onToggle }: { count: number; expanded: boolean; theme: CanvasTheme; onToggle: () => void }) { return ( - diff --git a/web/src/components/canvas/canvas-workspace-overlays.tsx b/web/src/components/canvas/canvas-workspace-overlays.tsx index deafe14f1..b05c57f00 100644 --- a/web/src/components/canvas/canvas-workspace-overlays.tsx +++ b/web/src/components/canvas/canvas-workspace-overlays.tsx @@ -158,7 +158,7 @@ export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, co const reducedMotion = useReducedMotion(); const menuRef = useRef(null); const { bringToFront, zIndex } = useCanvasOverlayLayer("connection-create-menu", "var(--z-modal-overlay)"); - const menuWidth = 248; + const menuWidth = Math.min(288, viewportSize.width - 24); const menuHeight = canCreateDrawing ? 456 : 412; const gap = 12; const initialPosition = getConnectionMenuPosition(pending.position, viewport, viewportSize, menuWidth, menuHeight, gap); @@ -188,10 +188,10 @@ export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, co initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.97, rotateX: 2 }} animate={{ opacity: 1, y: 0, scale: 1, rotateX: 0 }} transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }} - className="aceternity-floating-panel absolute w-[248px] origin-top-left overflow-hidden rounded-[var(--r-2xl)] border p-2 backdrop-blur-2xl" + className="aceternity-floating-panel absolute origin-top-left overflow-x-hidden overflow-y-auto rounded-[var(--r-2xl)] border p-2 backdrop-blur-2xl" data-canvas-no-zoom data-connection-create-menu - style={{ left: initialPosition.left, top: initialPosition.top, zIndex, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }} + style={{ width: menuWidth, maxHeight: Math.max(120, viewportSize.height - 84), left: initialPosition.left, top: initialPosition.top, zIndex, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()} onPointerDownCapture={bringToFront} onFocusCapture={bringToFront} @@ -205,7 +205,7 @@ export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, co -
+
} title="文本生成" disabledReason={getDisabledReason(CanvasNodeType.Text)} onClick={() => onCreate(CanvasNodeType.Text)} /> } title="分镜脚本" disabledReason={getDisabledReason(CanvasNodeType.Script)} onClick={() => onCreate(CanvasNodeType.Script)} /> } title="图片生成" disabledReason={getDisabledReason(CanvasNodeType.Image)} onClick={() => onCreate(CanvasNodeType.Image)} /> @@ -225,7 +225,7 @@ function ConnectionCreateOption({ motionEnabled, icon, title, description, disab {icon} {title} - {disabledReason || description ? {disabledReason || description} : null} + {disabledReason || description ? {disabledReason || description} : null} diff --git a/web/src/components/image-settings-panel.tsx b/web/src/components/image-settings-panel.tsx index d48021bd6..1fe93ecb3 100644 --- a/web/src/components/image-settings-panel.tsx +++ b/web/src/components/image-settings-panel.tsx @@ -1,8 +1,10 @@ -import { type ReactNode, useState } from "react"; +import { ImageSizePicker } from "./image-size-picker"; +import { imageResolutionUsesQuality } from "@/lib/image-size-presets"; +import { type ReactNode } from "react"; import { ConfigProvider, Switch } from "antd"; import { type CanvasTheme } from "@/lib/canvas-theme"; -import { buildImageResolutionOptions, formatImageResolutionSize, imageRatioForSize, imageResolutionChoices, imageResolutionOption, imageSizeForResolution, supportsImageResolutionPresets, type ImageResolutionChoice } from "@/lib/image-resolution-tiers"; +import { buildImageResolutionOptions, formatImageResolutionSize } from "@/lib/image-resolution-tiers"; import { modelCapabilityConfigFor, normalizeImageValue, type ImageCapabilityConfig } from "@/lib/model-capabilities"; import { mergedImageCapabilityConfig } from "@/lib/model-selection"; import { modelOptionName, resolveModelChannel, type AiConfig } from "@/stores/use-config-store"; @@ -14,8 +16,8 @@ const qualityOptions = [ { value: "low", label: "低" }, { value: "1k", label: "1K" }, { value: "2k", label: "2K" }, + { value: "4k", label: "4K" }, ]; -const DIMENSION_STEP = 16; type AspectOption = { value: string; label: string; width: number; height: number; icon: string; size?: string }; @@ -50,7 +52,6 @@ type ImageSettingsPanelProps = { }; export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = true, showCount = true, className = "w-[304px] space-y-3 rounded-2xl px-1 py-0.5", maxCount = 15, quickCount = 3 }: ImageSettingsPanelProps) { - const [snapDimensionToStep, setSnapDimensionToStep] = useState(true); const profile = mergedImageCapabilityConfig(config, config.model || config.imageModel); const normalized = normalizeImageValue(profile, config); const quality = normalized.quality; @@ -58,43 +59,8 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = const effectiveMaxCount = Math.min(maxCount, profile.maxOutputs); const count = Math.max(1, Math.min(effectiveMaxCount, Number(normalized.count))); const activeSize = normalized.size; - const pixelSizeValues = profile.size.values.filter((value) => value.trim().toLowerCase() !== "auto"); - const hasResolutionPresets = supportsImageResolutionPresets(profile.size); - const resolutionOptions = hasResolutionPresets ? buildImageResolutionOptions(pixelSizeValues) : []; - const activeResolution = activeSize === "auto" ? undefined : imageResolutionOption(resolutionOptions, activeSize); - const activeRatio = activeResolution?.ratio || imageRatioForSize(activeSize); - const resolutionChoices = hasResolutionPresets ? imageResolutionChoices(profile.size.values) : []; - // 只有一个分辨率层级时,分辨率切换器没有实际选择意义;更重要的是不能因此把比例列表裁剪成当前层级的 3 个像素尺寸。 - // 例如历史 `*` 配置恢复为标准值后,虽然包含 1024x1024/1536x1024/1024x1536,实际仍应展示完整的比例和尺寸选项。 - const usesResolutionPicker = resolutionChoices.length > 1; - const availableAspects: AspectOption[] = usesResolutionPicker && activeSize === "auto" - ? [] - : usesResolutionPicker && activeResolution - ? resolutionOptions.filter((item) => item.tier === activeResolution.tier).map((item) => ({ value: item.ratio, label: item.ratio, size: item.size, width: item.width, height: item.height, icon: item.width === item.height ? "square" : item.width > item.height ? "landscape" : "portrait" })) - : imageAspectOptions(profile); - const selectedAspect = availableAspects.find((item) => imageOptionValue(profile, item) === activeSize || item.value === activeSize) || availableAspects.find((item) => item.label === activeRatio); - const dimensions = readSizeDimensions(activeSize, selectedAspect || aspectOptions[0]); - const activeQualityOptions = profile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value }); - const priceTiers = imageModelPriceTiers(config); - const selectAspect = (value: string) => { - const option = availableAspects.find((item) => item.value === value); - onConfigChange("size", option ? imageOptionValue(profile, option) : "auto"); - }; - const selectResolution = (choice: ImageResolutionChoice) => { - if (choice === "auto") { - onConfigChange("size", "auto"); - return; - } - const ratio = activeRatio || availableAspects[0]?.label; - const size = imageSizeForResolution(resolutionOptions, choice, ratio) || resolutionOptions.find((item) => item.tier === choice)?.size; - if (size) onConfigChange("size", size); - }; - const updateDimension = (key: "width" | "height", value: number | null) => { - const next = Math.max(1, Math.floor(value || dimensions[key] || 1024)); - const width = key === "width" ? next : dimensions.width; - const height = key === "height" ? next : dimensions.height; - onConfigChange("size", `${alignDimension(width, snapDimensionToStep)}x${alignDimension(height, snapDimensionToStep)}`); - }; + const activeQualityOptions = profile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value }); + const priceTiers = imageModelPriceTiers(config); return ( @@ -108,7 +74,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = }} > {showTitle ?
图像设置
: null} - {profile.quality.supported ?
+ {profile.quality.supported && !imageResolutionUsesQuality(profile) ?
{isGrokResolutionQuality(profile) ? "分辨率" : "质量"}
{activeQualityOptions.map((item) => ( @@ -133,52 +99,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = />
: null} - {resolutionChoices.length ?
- 分辨率 -
- {resolutionChoices.map((choice) => ( - selectResolution(choice)}> - {choice === "auto" ? "自动" : choice.toUpperCase()} - - ))} -
-
: null} - {profile.size.allowCustom ?
-
- 尺寸 -
- - 16倍数对齐 - - event.stopPropagation()}> - - -
-
-
- updateDimension("width", value)} /> - ↔ - updateDimension("height", value)} /> -
-
: null} - {availableAspects.length ?
- 尺寸或比例 -
- {availableAspects.map((item) => ( - - ))} -
-
: null} + { onConfigChange("size", size); if (nextQuality) onConfigChange("quality", nextQuality); }} /> {showCount && effectiveMaxCount > 1 ? (
生成张数 @@ -197,45 +118,6 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = ); } -function imageOptionAllowed(profile: ImageCapabilityConfig, option: AspectOption) { - if (profile.size.parameter === "none") return false; - if (profile.size.allowCustom && profile.size.values.length === 0) return true; - return [option.value, option.size, option.width && option.height ? `${option.width}x${option.height}` : ""].filter(Boolean).some((value) => profile.size.values.includes(String(value))); -} - -// 宽高比选项直接取模型配置 values(与创作页面一致),不在白名单里的比例(如 8:1)也能显示。 -function imageAspectOptions(profile: ImageCapabilityConfig): AspectOption[] { - if (profile.size.parameter === "none") return []; - const values = profile.size.values.filter((value) => value.trim().toLowerCase() !== "auto"); - if (!values.length) return profile.size.allowCustom ? aspectOptions.filter((item) => item.value !== "auto") : []; - return values.map((value) => { - const known = aspectOptions.find((item) => (item.size || item.value) === value || item.value === value); - if (known) return known; - const parts = ratioParts(value); - return { value, label: value, size: value, width: parts?.width || 0, height: parts?.height || 0, icon: "custom" }; - }); -} - -function ratioParts(value: string) { - const pixel = value.trim().match(/^(\d+)x(\d+)$/i); - if (pixel) { - const divisor = gcd(Number(pixel[1]), Number(pixel[2])); - return { width: Number(pixel[1]) / divisor, height: Number(pixel[2]) / divisor }; - } - const ratio = value.trim().match(/^(\d+):(\d+)$/); - if (!ratio) return undefined; - return { width: Number(ratio[1]), height: Number(ratio[2]) }; -} - -function gcd(a: number, b: number): number { - return b ? gcd(b, a % b) : a; -} - -function imageOptionValue(profile: ImageCapabilityConfig, option: AspectOption) { - const candidates = [option.size, option.value, option.width && option.height ? `${option.width}x${option.height}` : ""].filter(Boolean).map(String); - return candidates.find((value) => profile.size.values.includes(value)) || option.size || option.value || "auto"; -} - export function ImageSettingsTheme({ theme, children }: { theme: CanvasTheme; children: ReactNode }) { return ( )[value] || value || "默认"; + return ({ auto: "自动", high: "高", medium: "中", low: "低", "1k": "1K", "2k": "2K", "4k": "4K" } as Record)[value.toLowerCase()] || value || "默认"; } function isGrokResolutionQuality(profile: ImageCapabilityConfig) { const values = profile.quality.values.map((item) => item.toLowerCase()); - return values.includes("1k") || values.includes("2k"); + return values.some((value) => ["1k", "2k", "4k"].includes(value)); } export function imageSizeLabel(size: string) { @@ -292,35 +174,6 @@ function OptionPill({ selected, disabled = false, theme, onClick, children }: { ); } -function DimensionInput({ prefix, value, disabled, theme, alignToStep, onChange }: { prefix: string; value: number; disabled: boolean; theme: CanvasTheme; alignToStep: boolean; onChange: (value: number | null) => void }) { - const commit = (input: HTMLInputElement) => { - const next = alignDimension(Math.max(1, Math.floor(Number(input.value) || value || 1024)), alignToStep); - input.value = String(next); - onChange(next); - }; - - return ( - - ); -} - function CountInput({ value, quickCount, max, theme, onChange }: { value: number; quickCount: number; max: number; theme: CanvasTheme; onChange: (value: number | null) => void }) { const commit = (input: HTMLInputElement) => { const next = Math.max(1, Math.min(max, Math.floor(Number(input.value) || 1))); @@ -349,18 +202,6 @@ function CountInput({ value, quickCount, max, theme, onChange }: { value: number ); } -function AspectIcon({ type, width, height, color }: { type: string; width: number; height: number; color: string }) { - if (type === "auto") return null; - const ratio = width / Math.max(1, height); - const boxWidth = ratio >= 1 ? 22 : Math.max(9, 22 * ratio); - const boxHeight = ratio >= 1 ? Math.max(9, 22 / ratio) : 22; - return ( - - - - ); -} - function SettingTitle({ children, color }: { children: string; color: string }) { return (
@@ -368,15 +209,3 @@ function SettingTitle({ children, color }: { children: string; color: string })
); } - -function readSizeDimensions(size: string, fallback: { width: number; height: number }) { - const match = size?.match(/^(\d+)x(\d+)$/); - return { - width: match ? Number(match[1]) : fallback.width, - height: match ? Number(match[2]) : fallback.height, - }; -} - -function alignDimension(value: number, enabled: boolean) { - return enabled ? Math.ceil(value / DIMENSION_STEP) * DIMENSION_STEP : value; -} diff --git a/web/src/components/image-size-picker.css b/web/src/components/image-size-picker.css new file mode 100644 index 000000000..87e4f7da8 --- /dev/null +++ b/web/src/components/image-size-picker.css @@ -0,0 +1,264 @@ +.image-size-picker, +.image-size-presets-editor { + display: grid; + min-width: 0; + gap: 10px; + width: 100%; +} +.image-size-label { + font-size: var(--fs-label); + font-weight: 600; +} +.image-size-tiers { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + gap: 6px; +} +.image-size-tiers button, +.image-size-ratios button, +.image-size-auto { + border: 0; + border-radius: var(--r-md); + color: inherit; + background: var(--library-surface); + padding: 8px; + min-width: 0; + cursor: pointer; +} +.image-size-tiers button[aria-pressed="true"], +.image-size-ratios button[aria-pressed="true"], +.image-size-auto[aria-pressed="true"] { + background: var(--library-surface-hover); + box-shadow: inset 0 0 0 1px var(--library-selected-stroke); +} +.image-size-tiers button:disabled { + opacity: 0.3; + cursor: not-allowed; +} +.image-size-picker button:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; +} +.image-size-ratios { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(62px, 1fr)); + gap: 6px; +} +.image-size-ratios button { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 62px; + font-size: var(--fs-label); + overflow-wrap: anywhere; +} +.image-size-ratio-icon { + display: block; + width: 26px; + border: 1px solid currentColor; + border-radius: 2px; + opacity: 0.6; +} +.image-size-output { + padding: 9px 10px; + border-radius: var(--r-md); + background: var(--library-surface); + font-size: var(--fs-label); + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} +.image-size-hint, +.image-size-error { + margin: 0; + font-size: var(--fs-label); + line-height: 1.6; + overflow-wrap: anywhere; +} +.image-size-hint { + opacity: 0.6; +} +.image-size-error { + color: var(--destructive); +} +.image-size-custom summary { + cursor: pointer; + font-size: var(--fs-label); + padding: 6px 0; +} +.image-size-custom-input { + display: flex; + min-width: 0; + gap: 6px; + margin-top: 8px; +} +.image-size-custom-input input { + min-width: 0; +} +.image-size-preset-group { + display: grid; + grid-template-columns: 64px minmax(0, 1fr); + min-width: 0; + gap: 16px; + padding: 16px; + border-radius: var(--r-lg); + background: var(--library-surface); +} +.image-size-presets-editor { + container: image-presets / inline-size; +} +.image-size-editor-intro { + margin: 0 0 2px; + color: var(--muted-foreground); + font-size: var(--fs-label); + line-height: 1.6; +} +.image-size-preset-heading { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + padding-top: 4px; +} +.image-size-preset-heading strong { + font-size: var(--fs-body); + font-variant-numeric: tabular-nums; +} +.image-size-preset-heading > span, +.image-size-defaults-heading > span { + color: var(--muted-foreground); + font-size: var(--fs-label); +} +.image-size-preset-body { + display: grid; + gap: 10px; + min-width: 0; +} +.image-size-preset-options { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 84px), 1fr)); + gap: 6px; +} +.image-size-preset-option, +.image-size-default-options button { + min-width: 0; + border: 0; + border-radius: var(--r-md); + background: var(--library-icon-surface); + color: inherit; + font-size: var(--fs-label); + cursor: pointer; +} +.image-size-preset-option { + display: grid; + gap: 5px; + padding: 10px 8px; + text-align: left; + overflow-wrap: anywhere; +} +.image-size-preset-option-label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + font-weight: 600; +} +.image-size-preset-option-label svg { + flex: 0 0 auto; +} +.image-size-preset-option small { + color: var(--muted-foreground); + font-size: var(--fs-label); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.image-size-preset-option:hover:not(:disabled), +.image-size-default-options button:hover:not(:disabled) { + background: var(--library-surface-hover); +} +.image-size-presets-editor button[aria-pressed="true"] { + background: var(--library-surface-hover); + box-shadow: inset 0 0 0 1px var(--library-selected-stroke); +} +.image-size-presets-editor button:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; +} +.image-size-presets-editor button:disabled { + cursor: not-allowed; + opacity: 0.45; +} +.image-size-preset-add { + display: flex; + gap: 8px; + width: min(100%, 304px); + min-width: 0; +} +.image-size-preset-add input { + min-width: 0; +} +.image-size-defaults { + display: grid; + gap: 10px; + padding-block: 6px; + min-width: 0; +} +.image-size-defaults-heading { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 8px; + font-size: var(--fs-label); +} +.image-size-default-options { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.image-size-default-options button { + padding: 7px 10px; + background: var(--library-surface); + overflow-wrap: anywhere; +} +.admin-image-protocol-editor { + container: image-protocol / inline-size; +} +.admin-capability-protocol-grid.admin-image-protocol-grid { + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); + align-items: start; + gap: 16px; +} +.admin-image-size-card { + grid-column: 1 / -1; +} +.admin-image-size-intro { + display: grid; + grid-template-columns: minmax(0, 320px) minmax(0, 1fr); + align-items: center; + gap: 16px; + margin-bottom: 4px; +} +@container image-presets (max-width: 560px) { + .image-size-preset-group { + grid-template-columns: minmax(0, 1fr); + gap: 10px; + padding: 12px; + } + .image-size-preset-heading { + flex-direction: row; + align-items: baseline; + gap: 8px; + padding-top: 0; + } +} +@container image-protocol (max-width: 640px) { + .admin-capability-protocol-grid.admin-image-protocol-grid, + .admin-image-size-intro { + grid-template-columns: minmax(0, 1fr); + } + .admin-image-size-intro { + gap: 8px; + } +} diff --git a/web/src/components/image-size-picker.tsx b/web/src/components/image-size-picker.tsx new file mode 100644 index 000000000..b74a64996 --- /dev/null +++ b/web/src/components/image-size-picker.tsx @@ -0,0 +1,174 @@ +import { useState } from "react"; +import "./image-size-picker.css"; +import { Input, Button } from "antd"; +import type { ImageCapabilityConfig } from "@/lib/model-capabilities"; +import { IMAGE_RESOLUTIONS, imagePresetForRatio, imagePresetValue, imageQualityForTier, imageResolutionUsesQuality, imageSizePresets, imageTierAvailable } from "@/lib/image-size-presets"; +import { buildImageResolutionOptions, type ImageResolutionOption, type ImageResolutionTier } from "@/lib/image-resolution-tiers"; + +import { resolveImageRequestSize, validateImageSize } from "@/services/api/image-validation"; + +export function ImageSizePicker({ profile, size, quality, onChange }: { profile: ImageCapabilityConfig; size: string; quality?: string; onChange: (size: string, quality?: string) => void }) { + const presets = imageSizePresets(profile).filter((preset) => imageTierAvailable(profile, preset.tier)); + const visibleTiers = IMAGE_RESOLUTIONS.filter((value) => presets.some((preset) => preset.tier === value)); + const [chosenTier, setChosenTier] = useState("1k"); + const [customPreset, setCustomPreset] = useState(); + const [custom, setCustom] = useState(""); + const [error, setError] = useState(""); + const matches = (preset: ImageResolutionOption) => + imagePresetValue(profile, preset) === size && (profile.size.parameter !== "aspect_ratio" || !imageResolutionUsesQuality(profile) || imageQualityForTier(profile, preset.tier) === (quality || profile.quality.default)); + let resolvedLegacySize: string | undefined; + if (profile.size.parameter === "size" && size.includes(":")) { + try { + resolvedLegacySize = resolveImageRequestSize(profile, quality, size)?.value; + } catch { + /* 保留旧值,交由请求校验报告错误。 */ + } + } + let restoredCustom: ImageResolutionOption | undefined; + if (profile.size.allowCustom && size !== "auto") { + try { + if (profile.size.parameter === "size") restoredCustom = buildImageResolutionOptions([resolvedLegacySize || size])[0]; + else if (profile.size.parameter === "aspect_ratio") { + const restoredTier = IMAGE_RESOLUTIONS.find((value) => imageQualityForTier(profile, value) === (quality || profile.quality.default)) || "1k"; + restoredCustom = imagePresetForRatio(restoredTier, size); + } + } catch { + /* 无效配置不伪装成已选中的有效预设,交由请求校验处理。 */ + } + } + const active = + (profile.size.allowCustom && customPreset && imageTierAvailable(profile, customPreset.tier) && matches(customPreset) ? customPreset : undefined) || + presets.find(matches) || + presets.find((preset) => preset.size === resolvedLegacySize) || + (restoredCustom && imageTierAvailable(profile, restoredCustom.tier) ? restoredCustom : undefined); + const available = (value: ImageResolutionTier) => imageTierAvailable(profile, value) && (profile.size.allowCustom || presets.some((preset) => preset.tier === value)); + const tier = + (active && visibleTiers.includes(active.tier) ? active.tier : undefined) || + (profile.size.parameter === "aspect_ratio" ? visibleTiers.find((value) => imageQualityForTier(profile, value) === (quality || profile.quality.default)) : undefined) || + (visibleTiers.includes(chosenTier) ? chosenTier : visibleTiers[0]) || + "1k"; + const choices = presets.filter((preset) => preset.tier === tier); + const select = (preset: ImageResolutionOption) => { + if (!available(preset.tier)) return; + setChosenTier(preset.tier); + setCustomPreset(preset); + setError(""); + onChange(imagePresetValue(profile, preset), profile.size.parameter === "aspect_ratio" ? imageQualityForTier(profile, preset.tier) : undefined); + }; + const makeCustom = (next: ImageResolutionTier, ratio: string) => { + const preset = imagePresetForRatio(next, ratio); + // 精确支持值保持原值;支持值之外的像素仍遵守请求端的 16px 步长。 + if (profile.size.parameter === "size" && !profile.size.values.includes(preset.size)) { + preset.width = Math.round(preset.width / 16) * 16; + preset.height = Math.round(preset.height / 16) * 16; + preset.size = `${preset.width}x${preset.height}`; + validateImageSize(preset.width, preset.height); + } + return preset; + }; + const changeTier = (next: ImageResolutionTier) => { + const ratio = active?.ratio || (size.includes(":") ? size : "1:1"); + const candidate = presets.find((preset) => preset.tier === next && preset.ratio === ratio); + try { + if (candidate) select(candidate); + else if (profile.size.allowCustom) select(makeCustom(next, ratio)); + else { + const first = presets.find((preset) => preset.tier === next); + if (first) select(first); + } + } catch (reason) { + setError(reason instanceof Error ? reason.message : "尺寸格式无效"); + } + }; + const applyCustom = () => { + try { + if (custom.includes(":") || custom.includes(":")) { + select(makeCustom(tier, custom)); + return; + } + const match = custom.trim().match(/^(\d+)\s*[x×]\s*(\d+)$/i); + if (!match) throw new Error("输入比例 16:9,或像素 1824x1024"); + const width = Number(match[1]), + height = Number(match[2]); + if (profile.size.parameter === "aspect_ratio") { + select(makeCustom(tier, `${width}:${height}`)); + return; + } + const value = `${width}x${height}`; + validateImageSize(width, height, !profile.size.values.includes(value)); + const preset = presets.find((item) => item.size === value) || { + ...imagePresetForRatio(width * height <= 2_000_000 ? "1k" : width * height <= 4_300_000 ? "2k" : "4k", `${width}:${height}`), + width, + height, + size: value, + }; + select(preset); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "尺寸格式无效"); + } + }; + if (profile.size.parameter === "none") return null; + return ( +
+ {visibleTiers.length ? ( + <> +
分辨率
+
+ {visibleTiers.map((value) => ( + + ))} +
+ + ) : null} + {profile.size.values.includes("auto") ? ( + + ) : null} + {choices.length ?
宽高比
: null} +
+ {choices.map((preset) => ( + + ))} +
+ {!choices.length ?

未配置预设尺寸{profile.size.allowCustom ? ",可输入自定义比例或尺寸" : ""}

: null} + + {size === "auto" ? "尺寸由模型决定" : active ? `${active.ratio} · ${active.width} × ${active.height} px` : size.replace("x", " × ")} + + {profile.size.parameter === "aspect_ratio" ?

尺寸为换算参考,实际像素由模型决定{!imageResolutionUsesQuality(profile) ? ";当前协议未配置独立分辨率" : ""}。

: null} + {profile.size.allowCustom ? ( +
+ 自定义比例或尺寸 +
+ setCustom(event.target.value)} onPressEnter={applyCustom} /> + +
+
+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/web/src/components/image-size-presets-editor.tsx b/web/src/components/image-size-presets-editor.tsx new file mode 100644 index 000000000..367fe22ce --- /dev/null +++ b/web/src/components/image-size-presets-editor.tsx @@ -0,0 +1,134 @@ +import { useId, useState } from "react"; +import { Button, Input } from "antd"; +import { Check, Plus } from "lucide-react"; +import "./image-size-picker.css"; +import type { ImageCapabilityConfig } from "@/lib/model-capabilities"; +import { IMAGE_RATIOS, IMAGE_RESOLUTIONS, imagePresetForRatio, imageSizeConfigWithPresets, imageSizePresets, imageTierAvailable } from "@/lib/image-size-presets"; +import type { ImageResolutionTier } from "@/lib/image-resolution-tiers"; + +export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profile: ImageCapabilityConfig; disabled?: boolean; onChange: (size: ImageCapabilityConfig["size"]) => void }) { + const presets = imageSizePresets(profile); + const id = useId(); + const [drafts, setDrafts] = useState>>({}); + const [errors, setErrors] = useState>>({}); + const update = (tier: ImageResolutionTier, ratios: string[]) => { + if (disabled) return false; + try { + const next = [ + ...new Map( + ratios.map((ratio) => { + const normalized = imagePresetForRatio(tier, ratio); + const preset = presets.find((preset) => preset.tier === tier && preset.ratio === normalized.ratio) || normalized; + return [preset.ratio, preset] as const; + }), + ).values(), + ]; + onChange(imageSizeConfigWithPresets(profile, [...presets.filter((preset) => preset.tier !== tier), ...next])); + setErrors((current) => ({ ...current, [tier]: "" })); + return true; + } catch (reason) { + setErrors((current) => ({ ...current, [tier]: reason instanceof Error ? reason.message : "比例格式无效" })); + return false; + } + }; + const addRatio = (tier: ImageResolutionTier) => { + const ratios = (drafts[tier] || "") + .split(/[,,\n]/) + .map((value) => value.trim()) + .filter(Boolean); + if (!ratios.length) return; + if (update(tier, [...presets.filter((preset) => preset.tier === tier).map((preset) => preset.ratio), ...ratios])) { + setDrafts((current) => ({ ...current, [tier]: "" })); + } + }; + return ( +
+

点选支持的比例,再次点击可移除。尺寸自动换算,已有精确尺寸保持不变。

+ {IMAGE_RESOLUTIONS.map((tier) => { + const items = presets.filter((preset) => preset.tier === tier); + const ratios = [...new Set([...IMAGE_RATIOS, ...items.map((item) => item.ratio)])]; + return ( +
+
+ {tier.toUpperCase()} + {items.length ? `${items.length} 个比例` : "未启用"} +
+
+ {!imageTierAvailable(profile, tier) ?

需先配置此档位对应的质量值,用户端才会开放。

: null} +
+ {ratios.map((ratio) => { + const selected = items.find((item) => item.ratio === ratio); + const dimensions = selected || imagePresetForRatio(tier, ratio); + return ( + + ); + })} +
+
+ { + setDrafts((current) => ({ ...current, [tier]: event.target.value })); + setErrors((current) => ({ ...current, [tier]: "" })); + }} + onPressEnter={(event) => { + event.preventDefault(); + event.stopPropagation(); + addRatio(tier); + }} + /> + +
+ {errors[tier] ? ( + + ) : null} +
+
+ ); + })} +
+
+ 默认输出 + 用户选择模型时的初始尺寸 +
+
+ {profile.size.values.map((value) => { + const preset = presets.find((item) => (profile.size.parameter === "aspect_ratio" ? item.ratio === value : item.size === value)); + const label = value === "auto" ? "自动" : preset && profile.size.parameter === "size" ? `${preset.tier.toUpperCase()} · ${preset.ratio}` : value; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/web/src/components/model-capability-editor.tsx b/web/src/components/model-capability-editor.tsx index d1f2a37c0..8b1487bc6 100644 --- a/web/src/components/model-capability-editor.tsx +++ b/web/src/components/model-capability-editor.tsx @@ -1,3 +1,5 @@ +import { ImageSizePresetsEditor } from "./image-size-presets-editor"; +import { imageSizeConfigWithPresets, imageSizePresets } from "@/lib/image-size-presets"; import { Input, InputNumber, Segmented, Select, Switch } from "antd"; import type { ReactNode } from "react"; @@ -18,20 +20,8 @@ const operationOptions = [ { label: "音频生视频", value: "audio_to_video" }, ]; -function formatImageSizeTagLabel(size: string): string { - const raw = size.trim(); - if (raw === "1:1") return "1:1 (方图)"; - if (raw === "16:9") return "16:9 (横屏)"; - if (raw === "9:16") return "9:16 (竖屏)"; - if (raw === "1024x1024") return "1024x1024 (1:1)"; - if (raw === "2048x2048") return "2048x2048 (2K 方)"; - if (raw === "3840x2160") return "3840x2160 (4K 横屏)"; - if (raw === "2160x3840") return "2160x3840 (4K 竖屏)"; - return raw; -} - -function imageSizeOptions(values: string[]) { - return values.map((v) => ({ label: formatImageSizeTagLabel(v), value: v })); +function ImageSizeHelp() { + return

按分辨率配置支持的比例,尺寸自动换算。size 发送像素,aspect_ratio 发送比例;独立分辨率由模型协议适配。实际输出尺寸以上游为准。

; } type Props = { @@ -434,57 +424,40 @@ function ImageCapabilityEditor({ value, onChange, protocol, model, disabled, sec if (section === "protocol") { return ( -
-
- - update({ maxOutputs: maxOutputs || 1 })} /> - - - { - const parameter = value as ImageCapabilityConfig["size"]["parameter"]; - updateSize( - parameter === "none" - ? { parameter, values: [], default: "auto", allowCustom: false } - : { parameter, values: profile.size.values.length ? profile.size.values : ["1:1"], default: profile.size.default === "auto" ? "1:1" : profile.size.default }, - ); - }} - /> +
+
+ +
+ { + const parameter = value as ImageCapabilityConfig["size"]["parameter"]; + updateSize( + parameter === "none" + ? { parameter, values: [], presets: undefined, default: "auto", allowCustom: false } + : imageSizeConfigWithPresets({ ...profile, size: { ...profile.size, parameter } }, imageSizePresets(profile)), + ); + }} + /> + +
{profile.size.parameter !== "none" ? ( <> - - ({ label: item, value: item }))} - onChange={(defaultValue) => updateSize({ default: defaultValue })} - /> - + updateSize({ allowCustom })} /> ) : null}
+ + update({ maxOutputs: maxOutputs || 1 })} /> + updateQuality({ supported })} /> {profile.quality.supported ? ( @@ -543,6 +516,7 @@ function ImageCapabilityEditor({ value, onChange, protocol, model, disabled, sec update({ maxOutputs: maxOutputs || 1 })} /> + { const parameter = value as ImageCapabilityConfig["size"]["parameter"]; updateSize( - parameter === "none" - ? { parameter, values: [], default: "auto", allowCustom: false } - : { parameter, values: profile.size.values.length ? profile.size.values : ["1:1"], default: profile.size.default === "auto" ? "1:1" : profile.size.default }, + parameter === "none" ? { parameter, values: [], presets: undefined, default: "auto", allowCustom: false } : imageSizeConfigWithPresets({ ...profile, size: { ...profile.size, parameter } }, imageSizePresets(profile)), ); }} /> {profile.size.parameter !== "none" ? ( <> - - ({ label: item, value: item }))} - onChange={(defaultValue) => updateSize({ default: defaultValue })} - /> - - updateSize({ allowCustom })} /> -
+ + updateSize({ allowCustom })} /> ) : null} @@ -665,9 +615,9 @@ function ReferenceCard({ title, description, children }: { title: string; descri ); } -function ProtocolParameterCard({ step, title, description, children }: { step: string; title: string; description: string; children: ReactNode }) { +function ProtocolParameterCard({ step, title, description, children, className = "" }: { step: string; title: string; description: string; children: ReactNode; className?: string }) { return ( -
+
{step}
diff --git a/web/src/lib/image-resolution-tiers.ts b/web/src/lib/image-resolution-tiers.ts index 975847e14..76a34b291 100644 --- a/web/src/lib/image-resolution-tiers.ts +++ b/web/src/lib/image-resolution-tiers.ts @@ -59,11 +59,13 @@ function parseImageResolutionOption(value: string): ImageResolutionOption | null if (!match) return null; const width = Number(match[1]); const height = Number(match[2]); + if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) return null; const pixels = width * height; const tier: ImageResolutionTier | null = pixels <= 2_000_000 ? "1k" : pixels <= 4_300_000 ? "2k" : pixels <= 8_294_400 ? "4k" : null; if (!tier) return null; - const ratio = closestRatio(width / height); - if (!ratio) return null; + let a = width, b = height; + while (b) [a, b] = [b, a % b]; + const ratio = closestRatio(width / height) || `${width / a}:${height / a}`; return { size: `${width}x${height}`, tier, ratio, width, height }; } diff --git a/web/src/lib/image-size-presets.ts b/web/src/lib/image-size-presets.ts new file mode 100644 index 000000000..cfc1bfcfb --- /dev/null +++ b/web/src/lib/image-size-presets.ts @@ -0,0 +1,95 @@ +import { buildImageResolutionOptions, type ImageResolutionOption, type ImageResolutionTier } from "./image-resolution-tiers"; +import type { ImageCapabilityConfig } from "./model-capabilities"; + +export const IMAGE_RESOLUTIONS: ImageResolutionTier[] = ["1k", "2k", "4k"]; +export const IMAGE_RATIOS = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "21:9"]; +const standardSizes: Record = { + "1:1": ["1024x1024", "2048x2048", "2880x2880"], + "16:9": ["1824x1024", "2752x1536", "3840x2160"], + "9:16": ["1024x1824", "1536x2752", "2160x3840"], + "4:3": ["1360x1024", "2304x1728", "3264x2448"], + "3:4": ["1024x1360", "1728x2304", "2448x3264"], + "3:2": ["1536x1024", "2496x1664", "3504x2336"], + "2:3": ["1024x1536", "1664x2496", "2336x3504"], + "21:9": ["2048x878", "3136x1344", "3808x1632"], +}; + +export function imagePresetForRatio(tier: ImageResolutionTier, input: string): ImageResolutionOption { + const match = input.trim().match(/^(\d{1,5})\s*[::]\s*(\d{1,5})$/); + if (!match) throw new Error("请输入宽高比,例如 16:9"); + let w = Number(match[1]), + h = Number(match[2]); + if (!w || !h || Math.max(w, h) / Math.min(w, h) > 3) throw new Error("宽高比必须为正数,且不超过 3:1"); + let a = w, + b = h; + while (b) [a, b] = [b, a % b]; + w /= a; + h /= a; + const ratio = + Object.keys(standardSizes).find((value) => { + const [width, height] = value.split(":").map(Number); + return width * h === height * w; + }) || `${w}:${h}`; + const index = IMAGE_RESOLUTIONS.indexOf(tier); + if (index < 0) throw new Error("分辨率仅支持 1K、2K、4K"); + const standard = standardSizes[ratio]?.[index]; + const pixels = [1_048_576, 4_194_304, 8_294_400][index]; + let width = Math.round(Math.sqrt((pixels * w) / h) / 16) * 16; + let height = Math.round((width * h) / w / 16) * 16; + while (width * height > pixels || Math.max(width, height) > 3840) { + if (width >= height) { + width -= 16; + height = Math.round((width * h) / w / 16) * 16; + } else { + height -= 16; + width = Math.round((height * w) / h / 16) * 16; + } + } + if (standard) [width, height] = standard.split("x").map(Number); + return { tier, ratio, width, height, size: `${width}x${height}` }; +} + +export function imageQualityForTier(profile: ImageCapabilityConfig, tier: ImageResolutionTier) { + const aliases = { "1k": ["1k", "low"], "2k": ["2k", "medium"], "4k": ["4k", "high"] }[tier]; + return profile.quality.supported ? profile.quality.values.find((value) => aliases.includes(value.toLowerCase())) : undefined; +} + +export function imageResolutionUsesQuality(profile: ImageCapabilityConfig) { + return profile.size.parameter === "aspect_ratio" && IMAGE_RESOLUTIONS.some((tier) => imageQualityForTier(profile, tier)); +} + +export function imageTierAvailable(profile: ImageCapabilityConfig, tier: ImageResolutionTier) { + // 比例协议必须能把档位传给上游;允许自定义比例不等于支持任意分辨率。 + if (profile.size.parameter !== "aspect_ratio") return profile.size.parameter !== "none"; + return imageResolutionUsesQuality(profile) ? Boolean(imageQualityForTier(profile, tier)) : tier === "1k"; +} + +export function imageSizePresets(profile: ImageCapabilityConfig): ImageResolutionOption[] { + if (profile.size.parameter === "none") return []; + if (profile.size.presets) return profile.size.presets; + const pixels = buildImageResolutionOptions(profile.size.values); + const tiers = profile.size.parameter === "aspect_ratio" ? IMAGE_RESOLUTIONS.filter((tier) => imageQualityForTier(profile, tier)) : ["1k" as const]; + const ratios = (tiers.length ? tiers : ["1k" as const]).flatMap((tier) => + profile.size.values.flatMap((ratio) => { + try { + const preset = imagePresetForRatio(tier, ratio); + return pixels.some((pixel) => pixel.ratio === preset.ratio) ? [] : [preset]; + } catch { + return []; + } + }), + ); + return [...pixels, ...ratios]; +} + +export function imagePresetValue(profile: ImageCapabilityConfig, preset: ImageResolutionOption) { + if (!profile.size.presets && !profile.size.allowCustom && preset.tier === "1k" && profile.size.values.includes(preset.ratio) && !profile.size.values.includes(preset.size)) return preset.ratio; + return profile.size.parameter === "aspect_ratio" ? preset.ratio : preset.size; +} + +export function imageSizeConfigWithPresets(profile: ImageCapabilityConfig, presets: ImageResolutionOption[]): ImageCapabilityConfig["size"] { + const values = [...new Set(presets.map((preset) => (profile.size.parameter === "aspect_ratio" ? preset.ratio : preset.size)))]; + if (profile.size.values.includes("auto")) values.unshift("auto"); + if (!values.length) values.push("auto"); + return { ...profile.size, presets, values, default: values.includes(profile.size.default) ? profile.size.default : values[0] || "auto" }; +} diff --git a/web/src/lib/model-capabilities.ts b/web/src/lib/model-capabilities.ts index 4c40c3184..0c4140db6 100644 --- a/web/src/lib/model-capabilities.ts +++ b/web/src/lib/model-capabilities.ts @@ -1,4 +1,5 @@ import type { ModelProtocol, ModelProtocolWorkflow } from "@/lib/model-protocols"; +import type { ImageResolutionOption } from "@/lib/image-resolution-tiers"; export type ModelCapabilityConfig = { version: number; @@ -31,6 +32,7 @@ export type ImageCapabilityConfig = { values: string[]; default: string; allowCustom: boolean; + presets?: ImageResolutionOption[]; }; quality: { supported: boolean; diff --git a/web/src/lib/model-selection.ts b/web/src/lib/model-selection.ts index fab37d7c3..729b744b4 100644 --- a/web/src/lib/model-selection.ts +++ b/web/src/lib/model-selection.ts @@ -1,5 +1,6 @@ import { defaultImageCapabilityConfig, modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, STANDARD_IMAGE_SIZE_VALUES, videoDurationAllowed, type ImageCapabilityConfig } from "@/lib/model-capabilities"; import { videoResolutionComparisonKey } from "@/lib/video-generation-options"; +import { imageSizePresets } from "@/lib/image-size-presets"; import { modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store"; export type ModelInputSummary = { @@ -229,7 +230,8 @@ export function mergedImageCapabilityConfig(config: AiConfig, selected: string): const allowCustom = profiles.some((profile) => profile.size.allowCustom || profile.size.values.includes("*")); const values = concreteValues.length ? concreteValues : allowCustom ? [...STANDARD_IMAGE_SIZE_VALUES] : []; const base = selectedProfile || profiles[0]; - return { ...base, size: { ...base.size, values, allowCustom } }; + const presets = profiles.some((profile) => profile.size.presets) ? [...new Map(profiles.flatMap((profile) => imageSizePresets(profile)).map((preset) => [`${preset.tier}:${preset.ratio}:${preset.size}`, preset])).values()] : undefined; + return { ...base, size: { ...base.size, values, allowCustom, presets } }; } // 切换模型后初始化图片参数为该模型能力默认值,避免旧参数在目标模型族不兼容导致无法切换。 diff --git a/web/src/pages/admin/components/admin-shell.tsx b/web/src/pages/admin/components/admin-shell.tsx index 212aeb9df..e41cf9d9f 100644 --- a/web/src/pages/admin/components/admin-shell.tsx +++ b/web/src/pages/admin/components/admin-shell.tsx @@ -1,6 +1,7 @@ import { ConfigProvider, Dropdown, Tooltip } from "antd"; import type { MenuProps } from "antd"; import { + Activity, ArrowLeft, BarChart3, BellRing, @@ -88,6 +89,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> = { path: "/admin/settings/features", label: "功能开放", description: "工作台、插件与模型能力", icon: }, { path: "/admin/settings/drawing-engine", label: "绘图工具", description: "画布绘图节点默认引擎", icon: }, { path: "/admin/settings/runtime-policy", label: "资源与策略", description: "配额、并发、频控与超时", icon: }, + { path: "/admin/settings/system-performance", label: "系统性能", description: "主机、数据库与缓存状态", icon: }, { path: "/admin/settings/access", label: "登录与注册", description: "账号创建与第三方登录", icon: }, { path: "/admin/settings/email", label: "邮件服务", description: "注册验证码与 SMTP", icon: }, { path: "/admin/settings/storage", label: "存储服务", description: "对象存储与资源存储", icon: }, diff --git a/web/src/pages/admin/settings/system-performance-page.tsx b/web/src/pages/admin/settings/system-performance-page.tsx new file mode 100644 index 000000000..944d5dad7 --- /dev/null +++ b/web/src/pages/admin/settings/system-performance-page.tsx @@ -0,0 +1,215 @@ +import { App, Button, Progress, Skeleton, Switch } from "antd"; +import { Activity, AlertTriangle, Cpu, Database, Gauge, HardDrive, MemoryStick, RefreshCw, Server, ShieldCheck, Trash2, Wifi } from "lucide-react"; +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; + +import { clearRuntimeCache, getSystemPerformance, type SystemPerformance } from "@/services/api/system-performance"; +import { AdminPageFrame } from "../components/admin-shell"; +import { AdminStatusBadge, SettingsSectionCard } from "../components/admin-ui"; + +const refreshIntervalMs = 15_000; + +function formatBytes(value?: number) { + if (!value || value < 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1); + const amount = value / 1024 ** index; + return `${amount >= 100 || index === 0 ? amount.toFixed(0) : amount.toFixed(1)} ${units[index]}`; +} + +function formatDuration(seconds?: number) { + if (!seconds) return "不足 1 分钟"; + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return [days ? `${days} 天` : "", hours ? `${hours} 小时` : "", minutes ? `${minutes} 分钟` : ""].filter(Boolean).slice(0, 2).join(" ") || "不足 1 分钟"; +} + +function formatNumber(value?: number) { + return new Intl.NumberFormat("zh-CN").format(value || 0); +} + +function metricTone(value: number) { + if (value >= 90) return "exception" as const; + if (value >= 75) return "active" as const; + return "normal" as const; +} + +function FactGrid({ children }: { children: ReactNode }) { + return
{children}
; +} + +function Fact({ label, value, mono = false }: { label: string; value: ReactNode; mono?: boolean }) { + return <>
{label}
{value}
; +} + +function PerformanceKpi({ icon, label, value, detail, percent }: { icon: ReactNode; label: string; value: string; detail: string; percent?: number }) { + return ( +
+
{icon}

{label}

+ {value} +

{detail}

+ {percent !== undefined ? : null} +
+ ); +} + +export default function SystemPerformancePage() { + const { message, modal } = App.useApp(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [clearing, setClearing] = useState(false); + const [autoRefresh, setAutoRefresh] = useState(true); + const [loadError, setLoadError] = useState(""); + const mountedRef = useRef(true); + + const load = useCallback(async (initial = false) => { + if (initial) setLoading(true); + else setRefreshing(true); + try { + const next = await getSystemPerformance(); + if (!mountedRef.current) return; + setData(next); + setLoadError(""); + } catch (error) { + if (!mountedRef.current) return; + setLoadError(error instanceof Error ? error.message : "读取系统性能失败"); + } finally { + if (mountedRef.current) { + setLoading(false); + setRefreshing(false); + } + } + }, []); + + useEffect(() => { + mountedRef.current = true; + void load(true); + return () => { mountedRef.current = false; }; + }, [load]); + + useEffect(() => { + if (!autoRefresh) return; + const timer = window.setInterval(() => void load(false), refreshIntervalMs); + return () => window.clearInterval(timer); + }, [autoRefresh, load]); + + const requestClear = () => { + modal.confirm({ + title: "清理运行时缓存?", + width: 560, + icon: , + content: ( +
+

将重置请求频控计数、渠道失败/熔断状态、线路临时禁用状态和本进程只读缓存。

+

不会删除用户、任务、素材、模型配置和数据库数据,也不会触碰正在使用的并发租约。

+

清理后短时间内上游线路会重新探测,请避开流量高峰频繁操作。

+
+ ), + okText: "确认清理", + okButtonProps: { danger: true, loading: clearing }, + cancelText: "取消", + onOk: async () => { + setClearing(true); + try { + const result = await clearRuntimeCache(); + message.success(`已清理 ${result.deletedKeys} 个 Redis/本地键、${result.memoryEntries} 个内存缓存项`); + await load(false); + } catch (error) { + message.error(error instanceof Error ? error.message : "清理运行时缓存失败"); + throw error; + } finally { + setClearing(false); + } + }, + }); + }; + + if (loading) { + return
; + } + + const memoryPercent = data?.memory.systemAvailable ? data.memory.usagePercent : undefined; + const databaseConnections = data?.database.postgres?.connections ?? data?.database.pool.openConnections ?? 0; + const databaseLimit = data?.database.postgres?.maxConnections || data?.database.pool.maxOpenConnections || 0; + const databasePercent = databaseLimit ? databaseConnections / databaseLimit * 100 : undefined; + const redisPoolRequests = (data?.redis.pool.hits || 0) + (data?.redis.pool.misses || 0); + const redisPoolHitRate = redisPoolRequests ? (data?.redis.pool.hits || 0) / redisPoolRequests * 100 : 0; + + return ( + } + > +
+ {loadError ?
{loadError}
: null} +
+
采集于 {data ? new Date(data.collectedAt).toLocaleString("zh-CN", { hour12: false }) : "--"}
+ 进程已运行 {formatDuration(data?.host.uptimeSeconds)} +
+ +
+ } label={memoryPercent !== undefined ? "系统内存" : "进程堆内存"} value={memoryPercent !== undefined ? `${memoryPercent.toFixed(1)}%` : formatBytes(data?.memory.heapAllocBytes)} detail={memoryPercent !== undefined ? `${formatBytes(data?.memory.usedBytes)} / ${formatBytes(data?.memory.totalBytes)}` : `Go 已保留 ${formatBytes(data?.memory.heapSysBytes)}`} percent={memoryPercent} /> + } label="数据磁盘" value={data?.disk.available ? `${data.disk.usagePercent.toFixed(1)}%` : "不可用"} detail={data?.disk.available ? `${formatBytes(data.disk.usedBytes)} / ${formatBytes(data.disk.totalBytes)}` : "无法读取文件系统指标"} percent={data?.disk.available ? data.disk.usagePercent : undefined} /> + } label={data?.database.driver === "postgres" ? "PostgreSQL 连接" : "数据库连接"} value={`${databaseConnections}${databaseLimit ? ` / ${databaseLimit}` : ""}`} detail={`${data?.database.driver || "--"} · ${data?.database.latencyMs || 0} ms`} percent={databasePercent} /> + } label="Redis" value={data?.redis.connected ? formatBytes(data.redis.usedMemoryBytes) : data?.redis.mode === "local" ? "本地模式" : "连接异常"} detail={data?.redis.connected ? `${formatNumber(data.redis.keys)} keys · ${formatNumber(data.redis.opsPerSecond)} ops/s` : data?.redis.statusMessage || "未连接"} /> +
+ +
+ } title="服务器与进程" description="操作系统、运行时和当前进程快照。" status={{ label: data?.disk.writable ? "数据目录可写" : "数据目录不可写", color: data?.disk.writable ? "success" : "error" }}> + + + + + value.toFixed(2)).join(" / ") : "当前平台未提供"} mono /> + + + + + +
+
Heap Alloc{formatBytes(data?.memory.heapAllocBytes)}
+
Go Sys{formatBytes(data?.memory.sysBytes)}
+
Heap Objects{formatNumber(data?.memory.heapObjects)}
+
GC 次数{formatNumber(data?.memory.gcCount)}
+
+
+ +
+ } title="数据库" description="连接池、结构版本与数据库内部统计。" status={{ label: data?.database.connected ? "连接正常" : "连接异常", color: data?.database.connected ? "success" : "error" }}> + + + + + + {data?.database.postgres ? <> : } + + + + } title="Redis" description="内存、客户端、命中率与连接池。" status={{ label: data?.redis.connected ? "连接正常" : data?.redis.mode === "local" ? "本地降级" : "连接异常", color: data?.redis.connected ? "success" : data?.redis.mode === "local" ? "warning" : "error" }}> + + + + + + + + + + + +
+
+ + } title="缓存维护" description="只清理可安全重建的运行时状态,保留业务数据与活动并发租约。" status={} footer={<>清理后频控与线路健康状态会重新计算}> +
+ {(data?.redis.cacheGroups || []).map((group) =>
{group.label}{formatNumber(group.keys)}keys
)} +
+
始终保留用户与任务数据素材和模型配置活动并发租约跨实例目录版本
+
+
+
+ ); +} diff --git a/web/src/pages/canvas/canvas-image-generation-executor.ts b/web/src/pages/canvas/canvas-image-generation-executor.ts index 46af1db0d..e80b0a2ca 100644 --- a/web/src/pages/canvas/canvas-image-generation-executor.ts +++ b/web/src/pages/canvas/canvas-image-generation-executor.ts @@ -52,7 +52,6 @@ export async function executeImageGeneration({ const isConfigNode = sourceNode?.type === CanvasNodeType.Config; const isImageNode = sourceNode?.type === CanvasNodeType.Image; const reuseSourceNode = canGenerateImageInPlace(sourceNode); - const directCopiedBatch = count > 1 && isImageNode && Boolean(sourceNode?.metadata?.content) && reuseSourceNode; // 已有图片生成新结果并保留旧版本;参考图只来自入边,避免把旧结果误当成自身输入。 const referenceImages = generationContext.referenceImages; const generationType = referenceImages.length ? ("edit" as const) : ("generation" as const); @@ -69,10 +68,10 @@ export async function executeImageGeneration({ const parentPosition = sourceNode?.position || { x: 0, y: 0 }; const parentWidth = sourceNode?.width || parentConfig.width; const parentHeight = sourceNode?.height || parentConfig.height; - const rootId = reuseSourceNode || directCopiedBatch ? nodeId : nanoid(); + const rootId = reuseSourceNode ? nodeId : nanoid(); const childIds = count > 1 ? Array.from({ length: count }, () => nanoid()) : []; const targetIds = count > 1 ? childIds : [rootId]; - registerPendingNodeIds(reuseSourceNode || directCopiedBatch ? childIds : [rootId, ...childIds]); + registerPendingNodeIds(reuseSourceNode ? childIds : [rootId, ...childIds]); const rootWidth = outputNodeSize.width; const rootHeight = outputNodeSize.height; const preferredPosition = { @@ -102,7 +101,7 @@ export async function executeImageGeneration({ ...generationMetadata, ...styleMetadata, ...skillMetadata, - imageBatchExpanded: count > 1 ? true : undefined, + imageBatchExpanded: count > 1 ? false : undefined, generationErrorCode: undefined, resourceReloadAvailable: undefined, failedPromptFingerprint: undefined, @@ -119,7 +118,7 @@ export async function executeImageGeneration({ ...canvasGenerationPromptMetadata(prompt, effectivePrompt), status: NODE_STATUS_LOADING, size: generationConfig.size, - batchRootId: count > 1 && !directCopiedBatch ? rootId : undefined, + batchRootId: rootId, ...generationMetadata, ...styleMetadata, ...skillMetadata, @@ -128,9 +127,7 @@ export async function executeImageGeneration({ failedPromptFingerprint: undefined, }, })); - const batchConnections = directCopiedBatch - ? childIds.map((childId) => ({ id: nanoid(), fromNodeId: nodeId, toNodeId: childId })) - : [...(reuseSourceNode ? [] : [{ id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }]), ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId }))]; + const batchConnections = [...(reuseSourceNode ? [] : [{ id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }]), ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId }))]; const nextNodes: CanvasNodeData[] = [ ...canvasNodes.map((node) => { @@ -147,7 +144,7 @@ export async function executeImageGeneration({ metadata: { ...node.metadata, content: prompt, richText: undefined, prompt, composerContent: prompt, status: NODE_STATUS_SUCCESS, fontSize: 14, errorDetails: undefined }, }; }), - ...(reuseSourceNode || directCopiedBatch ? [] : [rootNode]), + ...(reuseSourceNode ? [] : [rootNode]), ...childNodes, ]; @@ -164,7 +161,7 @@ export async function executeImageGeneration({ setDialogNodeId(nodeId); targetIds.forEach((targetId) => startGenerationRequest(targetId, nodeId, nodeId, controller)); - if (count > 1 && !directCopiedBatch) startGenerationRequest(rootId, nodeId, nodeId, controller); + if (count > 1) startGenerationRequest(rootId, nodeId, nodeId, controller); let hasSuccess = false; let hasFailure = false; let failureCount = 0; @@ -197,7 +194,7 @@ export async function executeImageGeneration({ consumeTask: (task) => applyGenerationTaskResult(targetId, task), }, ); - if (targetId !== rootId && !directCopiedBatch) { + if (targetId !== rootId) { setNodes((current) => { const child = current.find((node) => node.id === targetId); const root = current.find((node) => node.id === rootId); @@ -254,7 +251,7 @@ export async function executeImageGeneration({ } }), ); - if (count > 1 && !directCopiedBatch) finishGenerationRequest(rootId, controller); + if (count > 1) finishGenerationRequest(rootId, controller); if (controller.signal.aborted) { setNodes((current) => current.map((node) => (node.id === nodeId && isConfigNode && node.metadata?.status === NODE_STATUS_LOADING ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined } } : node))); return; @@ -272,7 +269,7 @@ export async function executeImageGeneration({ }, }; } - if (node.id === rootId && (reuseSourceNode || !directCopiedBatch)) { + if (node.id === rootId) { return { ...node, metadata: { diff --git a/web/src/pages/canvas/canvas-project-world-layers.tsx b/web/src/pages/canvas/canvas-project-world-layers.tsx index 4c50e0145..9c47691c6 100644 --- a/web/src/pages/canvas/canvas-project-world-layers.tsx +++ b/web/src/pages/canvas/canvas-project-world-layers.tsx @@ -90,6 +90,13 @@ export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(p ...props.visibleNodes.filter(isFrameNode), ...sortCanvasNodesByStackOrder(props.visibleNodes.filter((node) => !isFrameNode(node)), props.nodeStackOrder), ], [props.nodeStackOrder, props.visibleNodes]); + const batchPreviews = useMemo(() => new Map(props.visibleNodes.filter((node) => node.metadata?.isBatchRoot).map((node) => [ + node.id, + (node.metadata?.batchChildIds || []).filter((id) => id !== node.metadata?.primaryImageId) + .map((id) => props.nodeById.get(id)) + .filter((child): child is CanvasNodeData => Boolean(child && child.metadata?.batchRootId === node.id)) + .slice(0, 5), + ])), [props.visibleNodes, props.nodeById]); const framePreviewNodes = (node: CanvasNodeData) => { const assetFolderId = node.metadata?.folder?.assetFolderId; if (assetFolderId) return props.linkedFolderPreviewNodesById.get(assetFolderId) || EMPTY_CANVAS_NODES; @@ -155,8 +162,9 @@ export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(p forceInputVisible={Boolean(props.batchConnectionPreview)} batchCount={props.batchChildCountById.get(node.id) || 0} batchExpanded={Boolean(node.metadata?.imageBatchExpanded)} + batchPreviewNodes={batchPreviews.get(node.id)} batchClosing={Boolean(node.metadata?.batchRootId && props.collapsingBatchIds.has(node.metadata.batchRootId))} - batchOpening={props.openingBatchIds.has(node.id)} + batchOpening={props.openingBatchIds.has(node.metadata?.batchRootId || node.id)} batchRecovering={props.collapsingBatchIds.has(node.id)} batchPrimary={Boolean(node.metadata?.batchRootId && props.nodeById.get(node.metadata.batchRootId)?.metadata?.primaryImageId === node.id)} batchMotion={props.batchMotionById.get(node.id)} diff --git a/web/src/pages/canvas/use-canvas-node-editor.ts b/web/src/pages/canvas/use-canvas-node-editor.ts index d6df5323a..56406762f 100644 --- a/web/src/pages/canvas/use-canvas-node-editor.ts +++ b/web/src/pages/canvas/use-canvas-node-editor.ts @@ -1,4 +1,4 @@ -import { useCallback, useState, type Dispatch, type SetStateAction } from "react"; +import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { App } from "antd"; import { saveAs } from "file-saver"; @@ -41,6 +41,10 @@ export function useCanvasNodeEditor({ const queryClient = useQueryClient(); const [collapsingBatchIds, setCollapsingBatchIds] = useState>(new Set()); const [openingBatchIds, setOpeningBatchIds] = useState>(new Set()); + const batchMotionTimers = useRef(new Map()); + useEffect(() => () => { + batchMotionTimers.current.forEach((timer) => window.clearTimeout(timer)); + }, []); const handleNodeResize = useCallback((nodeId: string, width: number, height: number, position?: Position) => { setNodes((current) => { @@ -120,16 +124,26 @@ export function useCanvasNodeEditor({ }, [setNodes]); const toggleBatchExpanded = useCallback((nodeId: string) => { - const isExpanded = Boolean(nodesRef.current.find((node) => node.id === nodeId)?.metadata?.imageBatchExpanded); + const root = nodesRef.current.find((node) => node.id === nodeId); + if (!root?.metadata?.isBatchRoot) return; + const isExpanded = Boolean(root.metadata.imageBatchExpanded); + window.clearTimeout(batchMotionTimers.current.get(nodeId)); const updateMotionState = isExpanded ? setCollapsingBatchIds : setOpeningBatchIds; + const clearMotionState = isExpanded ? setOpeningBatchIds : setCollapsingBatchIds; + clearMotionState((current) => { + const next = new Set(current); + next.delete(nodeId); + return next; + }); updateMotionState((current) => new Set(current).add(nodeId)); - window.setTimeout(() => { + batchMotionTimers.current.set(nodeId, window.setTimeout(() => { + batchMotionTimers.current.delete(nodeId); updateMotionState((current) => { const next = new Set(current); next.delete(nodeId); return next; }); - }, isExpanded ? 320 : 260); + }, isExpanded ? 320 : 445 + (root.metadata.batchChildIds?.length || 1) * 24)); setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, imageBatchExpanded: !node.metadata?.imageBatchExpanded } } : node))); }, [nodesRef, setNodes]); diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index c31d11dec..9be3c18aa 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -1,3 +1,5 @@ +import { ImageSizePicker } from "@/components/image-size-picker"; +import { imageResolutionUsesQuality } from "@/lib/image-size-presets"; import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent, type ReactNode, type RefObject } from "react"; import { App, Button, Drawer, Modal, Popover, Spin, Tooltip } from "antd"; import { Reorder } from "motion/react"; @@ -21,7 +23,7 @@ import { formatShotOrdinal } from "@/lib/shot-label"; import { generationErrorCode, generationErrorMessage } from "@/lib/generation-error"; import { useCopyText } from "@/hooks/use-copy-text"; import { useExternalAssetSources } from "@/hooks/use-external-asset-sources"; -import { buildImageResolutionOptions, formatImageResolutionSize, imageRatioForSize, imageResolutionChoices, imageResolutionOption, imageSizeForResolution, supportsImageResolutionPresets, type ImageResolutionChoice } from "@/lib/image-resolution-tiers"; +import { buildImageResolutionOptions, formatImageResolutionSize, supportsImageResolutionPresets } from "@/lib/image-resolution-tiers"; import { formatVideoResolutionLabel as videoResolutionLabel, VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options"; import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationAllowed, videoDurationOptions, type ImageCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities"; import { inferVideoOperation, resolveCompatibleModel, mergedImageCapabilityConfig, type ModelRequirements } from "@/lib/model-selection"; @@ -1620,46 +1622,21 @@ function ModePicker({ mode, onModeChange }: { mode: CreationMode; onModeChange: function GenerationSettingsMenu(props: ComposerProps) { const [open, setOpen] = useState(false); - const [customRatioOpen, setCustomRatioOpen] = useState(!ratioOptions.some((option) => option.value === props.ratio)); const activeQualityOptions = props.imageProfile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value.toUpperCase(), description: "模型支持的质量/分辨率" }); const qualityLabel = activeQualityOptions.find((item) => item.value === props.quality)?.label || qualityOptions.find((item) => item.value === props.quality)?.label || props.quality || "自动"; // 尺寸/比例/分辨率选项取同显示名分组内全部模型的并集,路由模型只决定发送参数。 const mergedProfile = mergedImageCapabilityConfig(props.config, props.model || props.config.imageModel); const usesImageResolutionPicker = props.mode === "image" && supportsImageResolutionPresets(mergedProfile.size); const imageResolutionOptions = usesImageResolutionPicker ? buildImageResolutionOptions(mergedProfile.size.values) : []; - const activeImageResolution = usesImageResolutionPicker ? imageResolutionOption(imageResolutionOptions, props.ratio) : undefined; - const activeImageRatio = activeImageResolution?.ratio || imageRatioForSize(props.ratio) || (props.ratio.includes(":") ? props.ratio : "1:1"); - const activeImageResolutionChoice: ImageResolutionChoice = activeImageResolution?.tier || "auto"; - const imageResolutionChoiceOptions = usesImageResolutionPicker ? imageResolutionChoices(mergedProfile.size.values) : []; - const imageRatios = usesImageResolutionPicker - ? Array.from(new Set(imageResolutionOptions.filter((item) => !activeImageResolution || item.tier === activeImageResolution.tier).map((item) => item.ratio))) - : mergedProfile.size.values.length ? mergedProfile.size.values : ratioOptions.map((item) => item.value); - const ratios = props.mode === "video" ? props.videoProfile.ratios : imageRatios; + const ratios = props.videoProfile.ratios; const referenceImageSize = props.mode === "image" && mergedProfile.size.allowCustom ? props.referenceImageSize : undefined; const referenceImageSizeValue = referenceImageSize ? String(referenceImageSize.width) + "x" + String(referenceImageSize.height) : ""; const referenceImageSizeLabel = referenceImageSize ? String(referenceImageSize.width) + " × " + String(referenceImageSize.height) : ""; - const referenceImageSizeRatio = referenceImageSize ? String(referenceImageSize.width) + ":" + String(referenceImageSize.height) : ""; const referenceImageSizeSelected = Boolean(referenceImageSizeValue && props.ratio === referenceImageSizeValue); const resolutions = props.mode === "video" ? props.videoProfile.resolutions.map((value) => ({ value: value.replace(/p$/i, ""), label: videoResolutionLabel(value) })) : resolutionOptions; - const selectImageRatio = (nextRatio: string) => { - if (!usesImageResolutionPicker || activeImageResolutionChoice === "auto") { - props.setRatio(nextRatio); - return; - } - props.setRatio(imageSizeForResolution(imageResolutionOptions, activeImageResolutionChoice, nextRatio) || nextRatio); - }; - const selectImageResolution = (choice: ImageResolutionChoice) => { - if (choice === "auto") { - props.setRatio(mergedProfile.size.values.includes("auto") ? "auto" : activeImageRatio); - return; - } - const nextSize = imageSizeForResolution(imageResolutionOptions, choice, activeImageRatio) || imageResolutionOptions.find((item) => item.tier === choice)?.size; - if (nextSize) props.setRatio(nextSize); - }; const selectReferenceImageSize = () => { if (!referenceImageSizeValue) return; props.setRatio(referenceImageSizeValue); - setCustomRatioOpen(false); }; const videoResolutionSupported = props.mode === "video" && resolutions.length > 0; const imageSummary = [ @@ -1670,10 +1647,11 @@ function GenerationSettingsMenu(props: ComposerProps) { const videoRatioSupported = props.mode === "video" && ratios.length > 0; const summary = props.mode === "video" ? [...(videoRatioSupported ? [props.ratio] : []), ...(videoResolutionSupported ? [videoResolutionLabel(props.videoQuality)] : [])].join(" · ") : imageSummary; const panel =
- {videoRatioSupported || props.mode !== "video" && mergedProfile.size.parameter !== "none" ?
{referenceImageSizeValue ? : null}{ratios.map((value) => { const selected = props.mode === "image" && usesImageResolutionPicker ? value === activeImageRatio : value === props.ratio; return ; })}
{props.mode !== "video" && mergedProfile.size.allowCustom && (customRatioOpen ? : )}
: null} + {props.mode === "image" ? { props.setRatio(size); if (quality) props.setQuality(quality); }} /> : videoRatioSupported ?
{ratios.map((value) => )}
: null} + {props.mode === "image" && referenceImageSizeValue ? : null} {props.mode === "video" ? (videoResolutionSupported ?
{resolutions.map((option) => )}
: null) : <> - {imageResolutionChoiceOptions.length ?
{imageResolutionChoiceOptions.map((choice) => )}
: null} - {props.imageProfile.quality.supported ? item.value === "1k" || item.value === "2k") ? "分辨率" : "图片质量"} value={qualityLabel}>
{activeQualityOptions.map((option) => )}
: null} + + {props.imageProfile.quality.supported && !imageResolutionUsesQuality(mergedProfile) ? item.value === "1k" || item.value === "2k") ? "分辨率" : "图片质量"} value={qualityLabel}>
{activeQualityOptions.map((option) => )}
: null} {props.imageProfile.maxOutputs > 1 ?
{countOptions.filter((option) => Number(option) <= props.imageProfile.maxOutputs).map((option) => )}
: null} }
; diff --git a/web/src/pages/projects/detail/workflow-production-workbench.tsx b/web/src/pages/projects/detail/workflow-production-workbench.tsx index e52866155..89be185c8 100644 --- a/web/src/pages/projects/detail/workflow-production-workbench.tsx +++ b/web/src/pages/projects/detail/workflow-production-workbench.tsx @@ -1,3 +1,5 @@ +import { ImageSizePicker } from "@/components/image-size-picker"; +import { imageResolutionUsesQuality } from "@/lib/image-size-presets"; import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { App, Button, Empty, Form, Image, Input, InputNumber, Modal, Segmented, Select, Tag } from "antd"; @@ -505,20 +507,14 @@ export default function WorkflowProductionWorkbench(props: Props) { ? value !== "*") || []).map((value) => ({ value, label: value }))} - /> - + {generationCapability === "video" ? ({ value, label: formatVideoResolutionLabel(value) }))} /> - ) : imageProfile?.quality.supported ? ( + ) : imageProfile?.quality.supported && !imageResolutionUsesQuality(imageProfile) ? (