feat(platform): 模型可用性 - 目录附带近24小时成功率与7天趋势

公开目录只统计系统渠道的 create 成败,样本不足或日志不可用时不改变现有可用判断;模型选择器展示当前可用状态和历史成功率。
This commit is contained in:
ddcat666
2026-10-08 10:54:40 +08:00
parent 9997422fcd
commit f73d773995
10 changed files with 483 additions and 2 deletions

View File

@@ -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") {

View File

@@ -230,6 +230,8 @@ export function systemChannelModelChannels(channels: PublicChannelCatalog[]): Mo
channelId: channel.id,
modelKey: model.modelKey,
logicalPriceTiers,
availability: model.availability,
available: model.available,
};
}),
};

View File

@@ -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 = {

View File

@@ -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;
}>;
};

View File

@@ -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; }