diff --git a/backend/internal/app/model_availability.go b/backend/internal/app/model_availability.go index 235eb23f9..6258546ab 100644 --- a/backend/internal/app/model_availability.go +++ b/backend/internal/app/model_availability.go @@ -52,6 +52,18 @@ func (s *Service) publicModelAvailability(channels []PublicChannelCatalog, now t return nil, err } + recentLogs, _ := s.repo.PublicModelRecentLogs(channelIDs, 1000) + recentMap := make(map[publicModelAvailabilityKey][]bool) + for _, log := range recentLogs { + key := publicModelAvailabilityKey{channelID: log.ChannelID, modelKey: log.Model} + if _, ok := known[key]; !ok { + continue + } + if len(recentMap[key]) < 10 { + recentMap[key] = append(recentMap[key], log.Status == model.ApiCallStatusSucceeded) + } + } + last24 := make(map[publicModelAvailabilityKey]*publicModelAvailabilityAccumulator) trend := make([]map[publicModelAvailabilityKey]*publicModelAvailabilityAccumulator, 7) for index := range trend { @@ -73,10 +85,17 @@ func (s *Service) publicModelAvailability(channels []PublicChannelCatalog, now t for key := range known { primary := last24[key] + results := make([]bool, len(recentMap[key])) + copy(results, recentMap[key]) + // 将倒序取出的切片反转,使得从左到右表示时间演进(最右侧为最新一次调用) + for i, j := 0, len(results)-1; i < j; i, j = i+1, j-1 { + results[i], results[j] = results[j], results[i] + } item := &PublicModelAvailability{ - Trend7d: make([]PublicModelAvailabilityDay, 0, 7), - DataState: publicAvailabilityState(sampleCount(primary), publicModelAvailabilityMinimumSamples), - ComputedAt: now, + Trend7d: make([]PublicModelAvailabilityDay, 0, 7), + RecentResults: results, + DataState: publicAvailabilityState(sampleCount(primary), publicModelAvailabilityMinimumSamples), + ComputedAt: now, } if primary != nil { item.SampleCount = primary.total diff --git a/backend/internal/app/model_availability_test.go b/backend/internal/app/model_availability_test.go index d797712c0..fafbb5ff0 100644 --- a/backend/internal/app/model_availability_test.go +++ b/backend/internal/app/model_availability_test.go @@ -68,14 +68,23 @@ func TestPublicModelAvailabilityUsesCreateAttemptsAndFixedSevenDayTrend(t *testi if len(ready.Trend7d) != 7 { t.Fatalf("trend length = %d, want 7", len(ready.Trend7d)) } + if len(ready.RecentResults) != 10 { + t.Fatalf("ready recent results length = %d, want 10", len(ready.RecentResults)) + } 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) } + if len(insufficient.RecentResults) != 2 || insufficient.RecentResults[0] != false || insufficient.RecentResults[1] != true { + t.Fatalf("insufficient recent results = %#v, want [false, true]", insufficient.RecentResults) + } 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) } + if len(noData.RecentResults) != 0 { + t.Fatalf("no-data recent results length = %d, want 0", len(noData.RecentResults)) + } } diff --git a/backend/internal/app/model_catalog.go b/backend/internal/app/model_catalog.go index f42cc14fc..6d3ef4c91 100644 --- a/backend/internal/app/model_catalog.go +++ b/backend/internal/app/model_catalog.go @@ -30,12 +30,13 @@ type ModelCatalogResponse struct { // 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"` + Rate24h *float64 `json:"rate24h"` + SampleCount int `json:"sampleCount24h"` + Trend7d []PublicModelAvailabilityDay `json:"trend7d"` + RecentResults []bool `json:"recentResults"` + DataState PublicModelAvailabilityDataState `json:"dataState"` + ComputedAt time.Time `json:"computedAt"` + DataThrough *time.Time `json:"dataThrough,omitempty"` } type PublicModelAvailabilityDataState string diff --git a/backend/internal/repository/analytics.go b/backend/internal/repository/analytics.go index 000227d12..36ef94362 100644 --- a/backend/internal/repository/analytics.go +++ b/backend/internal/repository/analytics.go @@ -126,6 +126,26 @@ func (r *Repository) PublicModelAvailabilityLogs(filter PublicModelAvailabilityF return logs, query.Find(&logs).Error } +// PublicModelRecentLogs 从当前时间往前推查询指定渠道最近的调用日志(按时间倒序),用于计算每个模型最近的执行结果状态点。 +func (r *Repository) PublicModelRecentLogs(channelIDs []string, limit int) ([]model.ApiCallLog, error) { + var logs []model.ApiCallLog + if len(channelIDs) == 0 { + return logs, nil + } + if limit <= 0 || limit > 1000 { + limit = 500 + } + query := r.db.Select("channel_id", "model", "status", "created_at"). + Where("api_call_logs.channel_id IN ?", 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 desc"). + Limit(limit) + 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/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx index 59e7f77eb..6c7993406 100644 --- a/web/src/components/model-picker.tsx +++ b/web/src/components/model-picker.tsx @@ -7,7 +7,7 @@ import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme"; import { modelCapabilityConfigFor, videoDurationOptions } from "@/lib/model-capabilities"; import { formatPriceRange, modelQuoteDescription, modelQuoteRequest, normalizeTierResolution, priceTierSummaryLabel, priceTiersForCurrentSelection } from "@/lib/model-pricing"; import { compatibleModelInGroup, configuredModelDisplayName, modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection"; -import { groupModelsForPicker, isDirectSystemModel, modelChannelLabel } from "@/lib/model-picker-groups"; +import { groupModelsForPicker, isDirectSystemModel, modelChannelLabel, type ModelPickerGroup } from "@/lib/model-picker-groups"; import { cn } from "@/lib/utils"; import { modelDisplayName, modelIcon, modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store"; import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; @@ -190,7 +190,13 @@ export function ModelPicker({ const groupCurrent = group.models.find((item) => item.models.includes(current)); return ; })} @@ -200,7 +206,13 @@ export function ModelPicker({ {optionGroups.map((group) => { return ; })} @@ -554,8 +566,50 @@ function modelAvailability(config: AiConfig, model: string): PublicChannelModelA return channel.modelCosts?.find((item) => item.model === modelOptionName(model))?.availability; } +function groupAvailability(config: AiConfig, group: ModelPickerGroup): PublicChannelModelAvailability | undefined { + for (const mg of group.models) { + for (const m of mg.models) { + const avail = modelAvailability(config, m); + if (avail) return avail; + } + } + return undefined; +} + +export function ModelRecentHealthTrack({ availability, inPopover = false }: { availability?: PublicChannelModelAvailability; inPopover?: boolean }) { + const recentResults = availability?.recentResults ?? []; + const successCount = recentResults.filter(Boolean).length; + const failCount = recentResults.filter((item) => item === false).length; + const hasRuns = recentResults.length > 0; + const title = hasRuns + ? `最近 ${recentResults.length} 次调用:${successCount} 成功,${failCount} 失败` + : "近期暂无调用记录"; + + return ( + + {Array.from({ length: 10 }).map((_, idx) => { + const hasRun = idx < recentResults.length; + const isSuccess = hasRun ? recentResults[idx] : null; + const stateClass = isSuccess === true ? "is-success" : isSuccess === false ? "is-failed" : "is-empty"; + return ( + + ); + })} + + ); +} + function ModelAvailabilityBadge({ availability, available, showAvailability }: { availability?: PublicChannelModelAvailability; available: boolean; showAvailability: boolean }) { if (!showAvailability) return null; + const recentResults = availability?.recentResults ?? []; + const successCount = recentResults.filter(Boolean).length; + const failCount = recentResults.filter((item) => item === false).length; + const hasRuns = recentResults.length > 0; + const label = availability?.dataState === "ready" && availability.rate24h !== null ? `${Math.round(availability.rate24h * 100)}%` : availability?.dataState === "insufficient" @@ -563,12 +617,27 @@ function ModelAvailabilityBadge({ availability, available, showAvailability }: { : "暂无数据"; const trend = availability?.trend7d || []; const trendSamples = trend.reduce((total, item) => total + item.sampleCount, 0); - const summary = availability - ? `当前${available ? "可用" : "不可用"};近24小时${label},样本 ${availability.sampleCount24h} 条` - : `当前${available ? "可用" : "不可用"};历史成功率暂不可用`; + const summary = hasRuns + ? `最近${recentResults.length}次调用:${successCount}成功、${failCount}失败;当前${available ? "可用" : "不可用"}` + : availability + ? `当前${available ? "可用" : "不可用"};近24小时${label},暂无近期调用记录` + : `当前${available ? "可用" : "不可用"};历史成功率暂不可用`; + const content = availability ? (