feat(platform): 模型可用性 - 目录附带近24小时成功率与7天趋势
公开目录只统计系统渠道的 create 成败,样本不足或日志不可用时不改变现有可用判断;模型选择器展示当前可用状态和历史成功率。
This commit is contained in:
165
backend/internal/app/model_availability.go
Normal file
165
backend/internal/app/model_availability.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
81
backend/internal/app/model_availability_test.go
Normal file
81
backend/internal/app/model_availability_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
<ModelPrice price={modelMenuPrice(config, model, capability, true, requirements)} />
|
||||
</span>
|
||||
) : null}
|
||||
<ModelAvailabilityBadge availability={modelAvailability(config, model)} available={logicalCost?.available ?? true} showAvailability={channel.scope === "system"} />
|
||||
</span>
|
||||
<span className={cn("canvas-model-picker-description mt-1 block", showDescription && "is-visible")} style={{ color: theme.node.muted }}>
|
||||
{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 ? (
|
||||
<div className="model-picker-availability-popover">
|
||||
<div className="model-picker-availability-popover-head">
|
||||
<strong>近24小时成功率</strong>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className="model-picker-availability-popover-meta">{availability.sampleCount24h} 次 create 尝试 · 7天样本 {trendSamples} 条</div>
|
||||
<div className="model-picker-availability-trend" aria-label="近7天成功率趋势">
|
||||
{trend.map((item) => {
|
||||
const dayLabel = item.rate === null ? "暂无数据" : `${Math.round(item.rate * 100)}%`;
|
||||
return <span key={item.day} className={`model-picker-availability-segment is-${item.dataState}`} title={`${item.day}:${dayLabel},${item.sampleCount} 条`} style={item.rate === null ? undefined : { opacity: 0.35 + item.rate * 0.65 }} />;
|
||||
})}
|
||||
</div>
|
||||
<div className="model-picker-availability-popover-foot">7天分段趋势 · 悬停查看每日样本</div>
|
||||
</div>
|
||||
) : null;
|
||||
return (
|
||||
<Popover content={content} trigger={["hover", "focus"]} placement="topRight" arrow={false}>
|
||||
<span
|
||||
className={`model-picker-availability is-${availability?.dataState || "unavailable"}`}
|
||||
role="img"
|
||||
tabIndex={0}
|
||||
aria-label={summary}
|
||||
title={summary}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<span className="model-picker-availability-dot" aria-hidden="true" />
|
||||
<span className="model-picker-availability-label">近24h {label}</span>
|
||||
</span>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
function modelMenuMeta(model: string, capability?: ModelCapability): { description: string; time?: string } {
|
||||
const name = modelOptionName(model).toLowerCase();
|
||||
if (capability === "image") {
|
||||
|
||||
@@ -230,6 +230,8 @@ export function systemChannelModelChannels(channels: PublicChannelCatalog[]): Mo
|
||||
channelId: channel.id,
|
||||
modelKey: model.modelKey,
|
||||
logicalPriceTiers,
|
||||
availability: model.availability,
|
||||
available: model.available,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
availability?: PublicChannelModelAvailability;
|
||||
available?: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user