diff --git a/backend/internal/app/model_availability.go b/backend/internal/app/model_availability.go new file mode 100644 index 000000000..04ff1b6bb --- /dev/null +++ b/backend/internal/app/model_availability.go @@ -0,0 +1,165 @@ +package app + +import ( + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" +) + +const publicModelAvailabilityMinimumSamples = 10 + +type publicModelAvailabilityKey struct { + channelID string + modelKey string +} + +type publicModelAvailabilityAccumulator struct { + total int + success int + latest time.Time +} + +// publicModelAvailability builds a deliberately small public read model. The +// current binary Available field remains owned by the catalog/route-health +// logic; these values describe only recorded user-facing create outcomes. +func (s *Service) publicModelAvailability(channels []PublicChannelCatalog, now time.Time) (map[publicModelAvailabilityKey]*PublicModelAvailability, error) { + if now.IsZero() { + now = time.Now().UTC() + } + now = now.UTC() + dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + trendFrom := dayStart.AddDate(0, 0, -6) + channelIDs := make([]string, 0, len(channels)) + known := make(map[publicModelAvailabilityKey]struct{}) + for _, channel := range channels { + if channel.ID == "" { + continue + } + channelIDs = append(channelIDs, channel.ID) + for _, item := range channel.Models { + known[publicModelAvailabilityKey{channelID: channel.ID, modelKey: item.ModelKey}] = struct{}{} + } + } + result := make(map[publicModelAvailabilityKey]*PublicModelAvailability, len(known)) + if len(channelIDs) == 0 || len(known) == 0 { + return result, nil + } + + from := trendFrom + logs, err := s.repo.PublicModelAvailabilityLogs(repository.PublicModelAvailabilityFilter{From: from, To: now, ChannelIDs: channelIDs}) + if err != nil { + return nil, err + } + + last24 := make(map[publicModelAvailabilityKey]*publicModelAvailabilityAccumulator) + trend := make([]map[publicModelAvailabilityKey]*publicModelAvailabilityAccumulator, 7) + for index := range trend { + trend[index] = make(map[publicModelAvailabilityKey]*publicModelAvailabilityAccumulator) + } + for _, log := range logs { + key := publicModelAvailabilityKey{channelID: log.ChannelID, modelKey: log.Model} + if _, ok := known[key]; !ok { + continue + } + if !log.CreatedAt.Before(now.Add(-24 * time.Hour)) { + addAvailabilityLog(last24, key, log) + } + segment := int(log.CreatedAt.Sub(from) / (24 * time.Hour)) + if segment >= 0 && segment < len(trend) { + addAvailabilityLog(trend[segment], key, log) + } + } + + for key := range known { + primary := last24[key] + item := &PublicModelAvailability{ + Trend7d: make([]PublicModelAvailabilityDay, 0, 7), + DataState: publicAvailabilityState(sampleCount(primary), publicModelAvailabilityMinimumSamples), + ComputedAt: now, + } + if primary != nil { + item.SampleCount = primary.total + if primary.total >= publicModelAvailabilityMinimumSamples { + item.Rate24h = publicAvailabilityRate(primary) + } + if !primary.latest.IsZero() { + latest := primary.latest.UTC() + item.DataThrough = &latest + } + } + for index := range trend { + bucket := trend[index][key] + bucketStart := from.Add(time.Duration(index) * 24 * time.Hour) + day := PublicModelAvailabilityDay{ + Day: bucketStart.UTC().Format("2006-01-02"), + SampleCount: sampleCount(bucket), + DataState: publicAvailabilityState(sampleCount(bucket), publicModelAvailabilityMinimumSamples), + } + if bucket != nil { + if bucket.total >= publicModelAvailabilityMinimumSamples { + day.Rate = publicAvailabilityRate(bucket) + } + if item.DataThrough == nil || bucket.latest.After(*item.DataThrough) { + latest := bucket.latest.UTC() + item.DataThrough = &latest + } + } + item.Trend7d = append(item.Trend7d, day) + } + result[key] = item + } + return result, nil +} + +func addAvailabilityLog(target map[publicModelAvailabilityKey]*publicModelAvailabilityAccumulator, key publicModelAvailabilityKey, log model.ApiCallLog) { + item := target[key] + if item == nil { + item = &publicModelAvailabilityAccumulator{} + target[key] = item + } + item.total++ + if log.Status == model.ApiCallStatusSucceeded { + item.success++ + } + if log.CreatedAt.After(item.latest) { + item.latest = log.CreatedAt + } +} + +func sampleCount(item *publicModelAvailabilityAccumulator) int { + if item == nil { + return 0 + } + return item.total +} + +func publicAvailabilityRate(item *publicModelAvailabilityAccumulator) *float64 { + if item == nil || item.total == 0 { + return nil + } + rate := float64(item.success) / float64(item.total) + return &rate +} + +func publicAvailabilityState(samples int, minimum int) PublicModelAvailabilityDataState { + switch { + case samples == 0: + return PublicModelAvailabilityNoData + case samples < minimum: + return PublicModelAvailabilityInsufficient + default: + return PublicModelAvailabilityReady + } +} + +func attachPublicModelAvailability(channels []PublicChannelCatalog, availability map[publicModelAvailabilityKey]*PublicModelAvailability) { + for channelIndex := range channels { + for modelIndex := range channels[channelIndex].Models { + item := channels[channelIndex].Models[modelIndex] + if value := availability[publicModelAvailabilityKey{channelID: channels[channelIndex].ID, modelKey: item.ModelKey}]; value != nil { + channels[channelIndex].Models[modelIndex].Availability = value + } + } + } +} diff --git a/backend/internal/app/model_availability_test.go b/backend/internal/app/model_availability_test.go new file mode 100644 index 000000000..0648fdc9e --- /dev/null +++ b/backend/internal/app/model_availability_test.go @@ -0,0 +1,81 @@ +package app + +import ( + "math" + "testing" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestPublicModelAvailabilityUsesCreateAttemptsAndFixedSevenDayTrend(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.ApiCallLog{}); err != nil { + t.Fatal(err) + } + svc := &Service{repo: repository.New(db)} + now := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC) + logs := make([]model.ApiCallLog, 0, 20) + for index := 0; index < 12; index++ { + status := model.ApiCallStatusSucceeded + if index >= 9 { + status = model.ApiCallStatusFailed + } + logs = append(logs, model.ApiCallLog{ + ID: "ready-" + string(rune('a'+index)), + UserID: "user-1", + ChannelID: "channel-1", + Model: "model-a", + RequestKind: "create", + Status: status, + CreatedAt: now.Add(-time.Duration(index+1) * time.Hour), + }) + } + logs = append(logs, + model.ApiCallLog{ID: "insufficient-1", UserID: "user-1", ChannelID: "channel-1", Model: "model-b", RequestKind: "create", Status: model.ApiCallStatusSucceeded, CreatedAt: now.Add(-2 * time.Hour)}, + model.ApiCallLog{ID: "insufficient-2", UserID: "user-1", ChannelID: "channel-1", Model: "model-b", RequestKind: "create", Status: model.ApiCallStatusFailed, CreatedAt: now.Add(-3 * time.Hour)}, + model.ApiCallLog{ID: "poll-only", UserID: "user-1", ChannelID: "channel-1", Model: "model-a", RequestKind: "poll", Status: model.ApiCallStatusFailed, CreatedAt: now.Add(-30 * time.Minute)}, + model.ApiCallLog{ID: "download-only", UserID: "user-1", ChannelID: "channel-1", Model: "model-a", RequestKind: "download", Status: model.ApiCallStatusFailed, CreatedAt: now.Add(-20 * time.Minute)}, + ) + if err := db.Create(&logs).Error; err != nil { + t.Fatal(err) + } + + channels := []PublicChannelCatalog{{ID: "channel-1", Models: []PublicChannelModel{ + {ModelKey: "model-a"}, + {ModelKey: "model-b"}, + {ModelKey: "model-c"}, + }}} + availability, err := svc.publicModelAvailability(channels, now) + if err != nil { + t.Fatal(err) + } + + ready := availability[publicModelAvailabilityKey{channelID: "channel-1", modelKey: "model-a"}] + if ready == nil || ready.DataState != PublicModelAvailabilityReady || ready.SampleCount != 12 || ready.Rate24h == nil { + t.Fatalf("ready availability = %#v", ready) + } + if math.Abs(*ready.Rate24h-0.75) > 0.0001 { + t.Fatalf("ready rate = %v, want 0.75", *ready.Rate24h) + } + if len(ready.Trend7d) != 7 { + t.Fatalf("trend length = %d, want 7", len(ready.Trend7d)) + } + + insufficient := availability[publicModelAvailabilityKey{channelID: "channel-1", modelKey: "model-b"}] + if insufficient == nil || insufficient.DataState != PublicModelAvailabilityInsufficient || insufficient.SampleCount != 2 || insufficient.Rate24h != nil { + t.Fatalf("insufficient availability = %#v", insufficient) + } + + noData := availability[publicModelAvailabilityKey{channelID: "channel-1", modelKey: "model-c"}] + if noData == nil || noData.DataState != PublicModelAvailabilityNoData || noData.SampleCount != 0 || noData.Rate24h != nil { + t.Fatalf("no-data availability = %#v", noData) + } +} diff --git a/backend/internal/app/model_catalog.go b/backend/internal/app/model_catalog.go index 1d08078dc..8fedca9dd 100644 --- a/backend/internal/app/model_catalog.go +++ b/backend/internal/app/model_catalog.go @@ -26,6 +26,33 @@ type ModelCatalogResponse struct { Channels []PublicChannelCatalog `json:"channels"` } +// PublicModelAvailability is the historical signal shown next to a model +// without changing the binary current availability decision above it. +// Rate24h is nil for no-data or insufficient-sample windows. +type PublicModelAvailability struct { + Rate24h *float64 `json:"rate24h"` + SampleCount int `json:"sampleCount24h"` + Trend7d []PublicModelAvailabilityDay `json:"trend7d"` + DataState PublicModelAvailabilityDataState `json:"dataState"` + ComputedAt time.Time `json:"computedAt"` + DataThrough *time.Time `json:"dataThrough,omitempty"` +} + +type PublicModelAvailabilityDataState string + +const ( + PublicModelAvailabilityReady PublicModelAvailabilityDataState = "ready" + PublicModelAvailabilityInsufficient PublicModelAvailabilityDataState = "insufficient" + PublicModelAvailabilityNoData PublicModelAvailabilityDataState = "no_data" +) + +type PublicModelAvailabilityDay struct { + Day string `json:"day"` + Rate *float64 `json:"rate"` + SampleCount int `json:"sampleCount"` + DataState PublicModelAvailabilityDataState `json:"dataState"` +} + // PublicChannelCatalog 公开的渠道目录信息(脱敏) type PublicChannelCatalog struct { ID string `json:"id"` @@ -54,6 +81,7 @@ type PublicChannelModel struct { DisplayPrice *int64 `json:"displayPrice,omitempty"` PriceLabel string `json:"priceLabel"` Available bool `json:"available"` + Availability *PublicModelAvailability `json:"availability,omitempty"` } // PublicChannelModelPriceTier 公开的渠道模型价格档(脱敏) @@ -80,6 +108,13 @@ func (s *Service) ModelCatalog(intent *ModelRequestIntent) (*ModelCatalogRespons } response.Source = ModelCatalogSourceSystem response.Channels = append(response.Channels, channels...) + if availability, availabilityErr := s.publicModelAvailability(response.Channels, time.Now().UTC()); availabilityErr != nil { + // Historical analytics are additive. A delayed or unavailable log store + // must not hide the existing catalog or change route selection behavior. + slog.Warn("public model availability metrics unavailable", "error", availabilityErr) + } else { + attachPublicModelAvailability(response.Channels, availability) + } return response, nil } diff --git a/backend/internal/repository/analytics.go b/backend/internal/repository/analytics.go index 8f1f06f75..d7bd2e6e4 100644 --- a/backend/internal/repository/analytics.go +++ b/backend/internal/repository/analytics.go @@ -29,6 +29,16 @@ type APICallLogFilter struct { Limit int } +// PublicModelAvailabilityFilter limits the public availability read model to +// system catalog channel models. Keeping this query separate from admin +// analytics avoids exposing the admin payload or accidentally including user +// owned channels in a public response. +type PublicModelAvailabilityFilter struct { + From time.Time + To time.Time + ChannelIDs []string +} + func (r *Repository) RecordUserActivity(userID string, event string, count int, now time.Time) error { if userID == "" { return nil @@ -98,6 +108,24 @@ func (r *Repository) AnalyticsAPICallLogs(filter AnalyticsFilter) ([]model.ApiCa return logs, query.Omit("RequestBody", "ResponseBody").Find(&logs).Error } +// PublicModelAvailabilityLogs returns the compact, user-facing create +// outcomes used by the model catalog availability read model. Polling, +// downloads, uploads and local bookkeeping are deliberately excluded here. +func (r *Repository) PublicModelAvailabilityLogs(filter PublicModelAvailabilityFilter) ([]model.ApiCallLog, error) { + var logs []model.ApiCallLog + if len(filter.ChannelIDs) == 0 || !filter.To.After(filter.From) { + return logs, nil + } + query := whereTimeRange(r.db.Select("channel_id", "model", "status", "created_at"), "api_call_logs.created_at", filter.From, filter.To). + Where("api_call_logs.channel_id IN ?", filter.ChannelIDs). + Where("api_call_logs.user_id <> ''"). + Where("api_call_logs.model <> ''"). + Where("api_call_logs.request_kind = ?", "create"). + Where("api_call_logs.status IN ?", []model.ApiCallStatus{model.ApiCallStatusSucceeded, model.ApiCallStatusFailed}). + Order("api_call_logs.created_at asc") + return logs, query.Find(&logs).Error +} + func (r *Repository) AnalyticsActivities(filter AnalyticsFilter) ([]model.UserDailyActivity, error) { var activities []model.UserDailyActivity query := r.db.Where("day >= ? AND day < ?", filter.From, filter.To) diff --git a/backend/internal/service/aliases_types.go b/backend/internal/service/aliases_types.go index 905a08db1..f6446c9bc 100644 --- a/backend/internal/service/aliases_types.go +++ b/backend/internal/service/aliases_types.go @@ -239,6 +239,9 @@ type ( PublicLinuxDOSetting = app.PublicLinuxDOSetting PublicLogicalModel = app.PublicLogicalModel PublicLogicalModelPriceTier = app.PublicLogicalModelPriceTier + PublicModelAvailability = app.PublicModelAvailability + PublicModelAvailabilityDataState = app.PublicModelAvailabilityDataState + PublicModelAvailabilityDay = app.PublicModelAvailabilityDay PublicModelChannel = app.PublicModelChannel PublicOSSSetting = app.PublicOSSSetting PublicRegistrationSetting = app.PublicRegistrationSetting diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx index 58550f653..af94cdbc6 100644 --- a/web/src/components/model-picker.tsx +++ b/web/src/components/model-picker.tsx @@ -13,7 +13,7 @@ import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; import { useUserStore } from "@/stores/use-user-store"; import { ModelLogo } from "@/components/model-logo"; import { ModelTags } from "@/components/model-tags"; -import { quoteModel, type LogicalModelQuote } from "@/services/api/logical-models"; +import { quoteModel, type LogicalModelQuote, type PublicChannelModelAvailability } from "@/services/api/logical-models"; type ModelPickerProps = { config: AiConfig; @@ -329,6 +329,7 @@ export function ModelLabel({ ) : null} + {capabilitySummary} @@ -509,6 +510,58 @@ function ModelPrice({ price, quote, compact = false }: { price: ModelMenuPrice | ); } +function modelAvailability(config: AiConfig, model: string): PublicChannelModelAvailability | undefined { + if (!model) return undefined; + const channel = resolveModelChannel(config, model); + return channel.modelCosts?.find((item) => item.model === modelOptionName(model))?.availability; +} + +function ModelAvailabilityBadge({ availability, available, showAvailability }: { availability?: PublicChannelModelAvailability; available: boolean; showAvailability: boolean }) { + if (!showAvailability) return null; + const label = availability?.dataState === "ready" && availability.rate24h !== null + ? `${Math.round(availability.rate24h * 100)}%` + : availability?.dataState === "insufficient" + ? "数据不足" + : "暂无数据"; + const trend = availability?.trend7d || []; + const trendSamples = trend.reduce((total, item) => total + item.sampleCount, 0); + const summary = availability + ? `当前${available ? "可用" : "不可用"};近24小时${label},样本 ${availability.sampleCount24h} 条` + : `当前${available ? "可用" : "不可用"};历史成功率暂不可用`; + const content = availability ? ( +
+
+ 近24小时成功率 + {label} +
+
{availability.sampleCount24h} 次 create 尝试 · 7天样本 {trendSamples} 条
+
+ {trend.map((item) => { + const dayLabel = item.rate === null ? "暂无数据" : `${Math.round(item.rate * 100)}%`; + return ; + })} +
+
7天分段趋势 · 悬停查看每日样本
+
+ ) : null; + return ( + + event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > + + + ); +} function modelMenuMeta(model: string, capability?: ModelCapability): { description: string; time?: string } { const name = modelOptionName(model).toLowerCase(); if (capability === "image") { diff --git a/web/src/lib/user-session.ts b/web/src/lib/user-session.ts index 414814fb0..9880b6997 100644 --- a/web/src/lib/user-session.ts +++ b/web/src/lib/user-session.ts @@ -230,6 +230,8 @@ export function systemChannelModelChannels(channels: PublicChannelCatalog[]): Mo channelId: channel.id, modelKey: model.modelKey, logicalPriceTiers, + availability: model.availability, + available: model.available, }; }), }; diff --git a/web/src/services/api/logical-models.ts b/web/src/services/api/logical-models.ts index 266510672..0679b9a92 100644 --- a/web/src/services/api/logical-models.ts +++ b/web/src/services/api/logical-models.ts @@ -181,6 +181,23 @@ export type PublicChannelModel = { displayPrice?: number; priceLabel: string; available: boolean; + availability?: PublicChannelModelAvailability; +}; + +export type PublicChannelModelAvailability = { + rate24h: number | null; + sampleCount24h: number; + trend7d: PublicChannelModelAvailabilityDay[]; + dataState: "ready" | "insufficient" | "no_data"; + computedAt: string; + dataThrough?: string; +}; + +export type PublicChannelModelAvailabilityDay = { + day: string; + rate: number | null; + sampleCount: number; + dataState: "ready" | "insufficient" | "no_data"; }; export type PublicChannelModelPriceTier = { diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index 7307f950c..5092cb426 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -8,7 +8,7 @@ import { modelProtocolCapability, normalizeModelProtocol, type ModelProtocol } f import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options"; import { type ModelCapabilityConfig } from "@/lib/model-capabilities"; import { useUserStore } from "@/stores/use-user-store"; -import type { CapabilitySpec, PublicLogicalModelPriceTier } from "@/services/api/logical-models"; +import type { CapabilitySpec, PublicChannelModelAvailability, PublicLogicalModelPriceTier } from "@/services/api/logical-models"; import { type WorkflowFieldMapping, normalizeRunningHubCapability, normalizeRunningHubWorkflowKind, normalizeSavedWorkflowFields, mergeWorkflowFieldMappings, normalizeWorkflowFieldMappings } from "./config-workflow-fields"; import { decodeChannelModel, @@ -109,6 +109,8 @@ export type ModelChannel = { logicalCapabilityProfiles?: CapabilitySpec[]; logicalPriceTiers?: PublicLogicalModelPriceTier[]; defaultOptions?: Record; + availability?: PublicChannelModelAvailability; + available?: boolean; }>; }; diff --git a/web/src/styles/shared/model-picker.css b/web/src/styles/shared/model-picker.css index d018ba950..989a021b1 100644 --- a/web/src/styles/shared/model-picker.css +++ b/web/src/styles/shared/model-picker.css @@ -770,6 +770,101 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca overflow-wrap: anywhere; } +.model-picker-availability { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 4px; + border: 1px solid color-mix(in srgb, currentColor 16%, transparent); + border-radius: 999px; + padding: 2px 6px; + color: color-mix(in srgb, currentColor 68%, transparent); + font-size: 10px; + font-weight: 650; + line-height: 1.3; + white-space: nowrap; + cursor: help; +} + +.model-picker-availability:focus-visible { + outline: 2px solid color-mix(in srgb, currentColor 45%, transparent); + outline-offset: 2px; +} + +.model-picker-availability-dot { + width: 6px; + height: 6px; + flex: 0 0 6px; + border-radius: 50%; + background: #48a86a; + box-shadow: 0 0 0 2px color-mix(in srgb, #48a86a 15%, transparent); +} + +.model-picker-availability.is-insufficient .model-picker-availability-dot { + background: #d59b35; + box-shadow: 0 0 0 2px color-mix(in srgb, #d59b35 15%, transparent); +} + +.model-picker-availability.is-no_data, +.model-picker-availability.is-unavailable { + color: color-mix(in srgb, currentColor 50%, transparent); +} + +.model-picker-availability.is-no_data .model-picker-availability-dot, +.model-picker-availability.is-unavailable .model-picker-availability-dot { + background: #9aa0a8; + box-shadow: 0 0 0 2px color-mix(in srgb, #9aa0a8 15%, transparent); +} + +.model-picker-availability-popover { + width: 210px; + color: var(--user-ink, #20242a); +} + +.model-picker-availability-popover-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + font-size: 12px; +} + +.model-picker-availability-popover-head span { + color: #2e8d58; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.model-picker-availability-popover-meta, +.model-picker-availability-popover-foot { + color: color-mix(in srgb, currentColor 54%, transparent); + font-size: 10px; + line-height: 1.5; +} + +.model-picker-availability-popover-meta { margin-top: 4px; } +.model-picker-availability-popover-foot { margin-top: 6px; } + +.model-picker-availability-trend { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 4px; + margin-top: 10px; +} + +.model-picker-availability-segment { + height: 20px; + border-radius: 4px; + background: #4aa96c; +} + +.model-picker-availability-segment.is-insufficient { background: #d59b35; } +.model-picker-availability-segment.is-no_data { background: color-mix(in srgb, currentColor 14%, transparent); } + +.dark .model-picker-availability-popover { color: #f0f1f2; } +.dark .model-picker-availability-popover-head span { color: #7bd598; } +.dark .model-picker-availability-segment.is-no_data { background: rgba(255, 255, 255, .14); } + @media (prefers-reduced-motion: reduce) { .canvas-model-picker-option, .canvas-model-picker-description { transition: none !important; }