feat(model-catalog): 模型选择器 - 支持展示往前推最近10次调用红绿状态指示条 (#768)
* feat(catalog): 模型可用性 - 模型窗口支持展示往前推最近10次调用的红绿状态条 在模型选择弹窗中,单纯展示 24h 成功率难以反映模型短期的真实状态。 现支持往前推取最近最多 10 次实际调用(create 请求)的执行结果: - 成功一次展示绿色小点,失败一次展示红色小点,一共固定显示 10 个槽位点 - 槽位未发生调用时展示半透明灰色点;鼠标悬浮显示成功与失败次数的详细统计 - 后端从当前时刻往前推查询最近的 create 日志,按时间演进正序(最右侧为最新)输出 recentResults 切片 验证: - 后端:`go test -v ./internal/app -run TestPublicModelAvailability` 通过 - 前端:`tsc --noEmit`、`bun run build` 通过;`bun test` 2210 项通过 - 新增 `web/test/model-picker-availability-runs.test.ts` 单测并完成反向验证(改坏时测试变红) - 后端已无损平滑重启并监听 8080 端口 * feat(create): 模型选择器 - 左侧列表模型项直接展示10点可用性状态条 用户在模型选择弹窗中,希望无需点进二级卡片就能直接感知模型的可用率。 现将 10 点状态指示条挂载到左侧模型目录列表的每一项中: - 在每一个模型的子描述栏右侧常驻展示 10 个圆点的状态条 - 成功=绿点、失败=红点、未调用=灰点;鼠标悬浮显示最近成功与失败统计 - 保持列表项排版整齐,小点与渠道描述自适应两端对齐 验证: - `tsc --noEmit`、`bun run build` 通过 - `bun test` 2211 项测试通过(唯一失败为上游基线问题) - `test/model-picker-availability-runs.test.ts` 补充左侧列表挂载断言并通过
This commit is contained in:
@@ -52,6 +52,18 @@ func (s *Service) publicModelAvailability(channels []PublicChannelCatalog, now t
|
|||||||
return nil, err
|
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)
|
last24 := make(map[publicModelAvailabilityKey]*publicModelAvailabilityAccumulator)
|
||||||
trend := make([]map[publicModelAvailabilityKey]*publicModelAvailabilityAccumulator, 7)
|
trend := make([]map[publicModelAvailabilityKey]*publicModelAvailabilityAccumulator, 7)
|
||||||
for index := range trend {
|
for index := range trend {
|
||||||
@@ -73,10 +85,17 @@ func (s *Service) publicModelAvailability(channels []PublicChannelCatalog, now t
|
|||||||
|
|
||||||
for key := range known {
|
for key := range known {
|
||||||
primary := last24[key]
|
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{
|
item := &PublicModelAvailability{
|
||||||
Trend7d: make([]PublicModelAvailabilityDay, 0, 7),
|
Trend7d: make([]PublicModelAvailabilityDay, 0, 7),
|
||||||
DataState: publicAvailabilityState(sampleCount(primary), publicModelAvailabilityMinimumSamples),
|
RecentResults: results,
|
||||||
ComputedAt: now,
|
DataState: publicAvailabilityState(sampleCount(primary), publicModelAvailabilityMinimumSamples),
|
||||||
|
ComputedAt: now,
|
||||||
}
|
}
|
||||||
if primary != nil {
|
if primary != nil {
|
||||||
item.SampleCount = primary.total
|
item.SampleCount = primary.total
|
||||||
|
|||||||
@@ -68,14 +68,23 @@ func TestPublicModelAvailabilityUsesCreateAttemptsAndFixedSevenDayTrend(t *testi
|
|||||||
if len(ready.Trend7d) != 7 {
|
if len(ready.Trend7d) != 7 {
|
||||||
t.Fatalf("trend length = %d, want 7", len(ready.Trend7d))
|
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"}]
|
insufficient := availability[publicModelAvailabilityKey{channelID: "channel-1", modelKey: "model-b"}]
|
||||||
if insufficient == nil || insufficient.DataState != PublicModelAvailabilityInsufficient || insufficient.SampleCount != 2 || insufficient.Rate24h != nil {
|
if insufficient == nil || insufficient.DataState != PublicModelAvailabilityInsufficient || insufficient.SampleCount != 2 || insufficient.Rate24h != nil {
|
||||||
t.Fatalf("insufficient availability = %#v", insufficient)
|
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"}]
|
noData := availability[publicModelAvailabilityKey{channelID: "channel-1", modelKey: "model-c"}]
|
||||||
if noData == nil || noData.DataState != PublicModelAvailabilityNoData || noData.SampleCount != 0 || noData.Rate24h != nil {
|
if noData == nil || noData.DataState != PublicModelAvailabilityNoData || noData.SampleCount != 0 || noData.Rate24h != nil {
|
||||||
t.Fatalf("no-data availability = %#v", noData)
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,12 +30,13 @@ type ModelCatalogResponse struct {
|
|||||||
// without changing the binary current availability decision above it.
|
// without changing the binary current availability decision above it.
|
||||||
// Rate24h is nil for no-data or insufficient-sample windows.
|
// Rate24h is nil for no-data or insufficient-sample windows.
|
||||||
type PublicModelAvailability struct {
|
type PublicModelAvailability struct {
|
||||||
Rate24h *float64 `json:"rate24h"`
|
Rate24h *float64 `json:"rate24h"`
|
||||||
SampleCount int `json:"sampleCount24h"`
|
SampleCount int `json:"sampleCount24h"`
|
||||||
Trend7d []PublicModelAvailabilityDay `json:"trend7d"`
|
Trend7d []PublicModelAvailabilityDay `json:"trend7d"`
|
||||||
DataState PublicModelAvailabilityDataState `json:"dataState"`
|
RecentResults []bool `json:"recentResults"`
|
||||||
ComputedAt time.Time `json:"computedAt"`
|
DataState PublicModelAvailabilityDataState `json:"dataState"`
|
||||||
DataThrough *time.Time `json:"dataThrough,omitempty"`
|
ComputedAt time.Time `json:"computedAt"`
|
||||||
|
DataThrough *time.Time `json:"dataThrough,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PublicModelAvailabilityDataState string
|
type PublicModelAvailabilityDataState string
|
||||||
|
|||||||
@@ -126,6 +126,26 @@ func (r *Repository) PublicModelAvailabilityLogs(filter PublicModelAvailabilityF
|
|||||||
return logs, query.Find(&logs).Error
|
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) {
|
func (r *Repository) AnalyticsActivities(filter AnalyticsFilter) ([]model.UserDailyActivity, error) {
|
||||||
var activities []model.UserDailyActivity
|
var activities []model.UserDailyActivity
|
||||||
query := r.db.Where("day >= ? AND day < ?", filter.From, filter.To)
|
query := r.db.Where("day >= ? AND day < ?", filter.From, filter.To)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
|
|||||||
import { modelCapabilityConfigFor, videoDurationOptions } from "@/lib/model-capabilities";
|
import { modelCapabilityConfigFor, videoDurationOptions } from "@/lib/model-capabilities";
|
||||||
import { formatPriceRange, modelQuoteDescription, modelQuoteRequest, normalizeTierResolution, priceTierSummaryLabel, priceTiersForCurrentSelection } from "@/lib/model-pricing";
|
import { formatPriceRange, modelQuoteDescription, modelQuoteRequest, normalizeTierResolution, priceTierSummaryLabel, priceTiersForCurrentSelection } from "@/lib/model-pricing";
|
||||||
import { compatibleModelInGroup, configuredModelDisplayName, modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
|
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 { cn } from "@/lib/utils";
|
||||||
import { modelDisplayName, modelIcon, modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
import { modelDisplayName, modelIcon, modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-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));
|
const groupCurrent = group.models.find((item) => item.models.includes(current));
|
||||||
return <button key={group.key} type="button" data-model-picker-item className={cn("canvas-model-picker-brand", groupCurrent && "is-active")} aria-pressed={Boolean(groupCurrent)} onClick={() => { setActiveGroupKey(group.key); focusMenuOption(); }}>
|
return <button key={group.key} type="button" data-model-picker-item className={cn("canvas-model-picker-brand", groupCurrent && "is-active")} aria-pressed={Boolean(groupCurrent)} onClick={() => { setActiveGroupKey(group.key); focusMenuOption(); }}>
|
||||||
<span className="canvas-model-picker-brand-icon"><ModelLogo icon={group.icon} size={22} /></span>
|
<span className="canvas-model-picker-brand-icon"><ModelLogo icon={group.icon} size={22} /></span>
|
||||||
<span className="canvas-model-picker-brand-copy"><strong>{group.label}</strong><small>{group.models.length} 个{group.kind === "product" ? "渠道" : "模型"}{group.scope ? ` · ${group.scope}` : ""}</small></span>
|
<span className="canvas-model-picker-brand-copy">
|
||||||
|
<strong>{group.label}</strong>
|
||||||
|
<small className="canvas-model-picker-brand-meta">
|
||||||
|
<span className="canvas-model-picker-brand-subtext">{group.models.length} 个{group.kind === "product" ? "渠道" : "模型"}{group.scope ? ` · ${group.scope}` : ""}</span>
|
||||||
|
<ModelRecentHealthTrack availability={groupAvailability(config, group)} />
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
<ChevronDown className="canvas-model-picker-brand-arrow" aria-hidden="true" />
|
<ChevronDown className="canvas-model-picker-brand-arrow" aria-hidden="true" />
|
||||||
</button>;
|
</button>;
|
||||||
})}
|
})}
|
||||||
@@ -200,7 +206,13 @@ export function ModelPicker({
|
|||||||
{optionGroups.map((group) => {
|
{optionGroups.map((group) => {
|
||||||
return <button key={group.key} type="button" className={cn("canvas-model-picker-brand", activeGroupKey === group.key && "is-active")} aria-pressed={activeGroupKey === group.key} onClick={() => setActiveGroupKey(group.key)}>
|
return <button key={group.key} type="button" className={cn("canvas-model-picker-brand", activeGroupKey === group.key && "is-active")} aria-pressed={activeGroupKey === group.key} onClick={() => setActiveGroupKey(group.key)}>
|
||||||
<span className="canvas-model-picker-brand-icon"><ModelLogo icon={group.icon} size={22} /></span>
|
<span className="canvas-model-picker-brand-icon"><ModelLogo icon={group.icon} size={22} /></span>
|
||||||
<span className="canvas-model-picker-brand-copy"><strong>{group.label}</strong><small>{group.models.length} 个{group.kind === "product" ? "渠道" : "模型"}{group.scope ? ` · ${group.scope}` : ""}</small></span>
|
<span className="canvas-model-picker-brand-copy">
|
||||||
|
<strong>{group.label}</strong>
|
||||||
|
<small className="canvas-model-picker-brand-meta">
|
||||||
|
<span className="canvas-model-picker-brand-subtext">{group.models.length} 个{group.kind === "product" ? "渠道" : "模型"}{group.scope ? ` · ${group.scope}` : ""}</span>
|
||||||
|
<ModelRecentHealthTrack availability={groupAvailability(config, group)} />
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
<ChevronDown className="canvas-model-picker-brand-arrow" aria-hidden="true" />
|
<ChevronDown className="canvas-model-picker-brand-arrow" aria-hidden="true" />
|
||||||
</button>;
|
</button>;
|
||||||
})}
|
})}
|
||||||
@@ -554,8 +566,50 @@ function modelAvailability(config: AiConfig, model: string): PublicChannelModelA
|
|||||||
return channel.modelCosts?.find((item) => item.model === modelOptionName(model))?.availability;
|
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 (
|
||||||
|
<span className={cn("model-picker-availability-track", inPopover && "in-popover")} title={title} aria-label={title}>
|
||||||
|
{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 (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className={cn("model-picker-availability-pill", inPopover && "in-popover", stateClass)}
|
||||||
|
title={inPopover ? (hasRun ? `第 ${idx + 1} 次:${isSuccess ? "成功" : "失败"}` : `槽位 ${idx + 1}:未发生调用`) : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ModelAvailabilityBadge({ availability, available, showAvailability }: { availability?: PublicChannelModelAvailability; available: boolean; showAvailability: boolean }) {
|
function ModelAvailabilityBadge({ availability, available, showAvailability }: { availability?: PublicChannelModelAvailability; available: boolean; showAvailability: boolean }) {
|
||||||
if (!showAvailability) return null;
|
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
|
const label = availability?.dataState === "ready" && availability.rate24h !== null
|
||||||
? `${Math.round(availability.rate24h * 100)}%`
|
? `${Math.round(availability.rate24h * 100)}%`
|
||||||
: availability?.dataState === "insufficient"
|
: availability?.dataState === "insufficient"
|
||||||
@@ -563,12 +617,27 @@ function ModelAvailabilityBadge({ availability, available, showAvailability }: {
|
|||||||
: "暂无数据";
|
: "暂无数据";
|
||||||
const trend = availability?.trend7d || [];
|
const trend = availability?.trend7d || [];
|
||||||
const trendSamples = trend.reduce((total, item) => total + item.sampleCount, 0);
|
const trendSamples = trend.reduce((total, item) => total + item.sampleCount, 0);
|
||||||
const summary = availability
|
const summary = hasRuns
|
||||||
? `当前${available ? "可用" : "不可用"};近24小时${label},样本 ${availability.sampleCount24h} 条`
|
? `最近${recentResults.length}次调用:${successCount}成功、${failCount}失败;当前${available ? "可用" : "不可用"}`
|
||||||
: `当前${available ? "可用" : "不可用"};历史成功率暂不可用`;
|
: availability
|
||||||
|
? `当前${available ? "可用" : "不可用"};近24小时${label},暂无近期调用记录`
|
||||||
|
: `当前${available ? "可用" : "不可用"};历史成功率暂不可用`;
|
||||||
|
|
||||||
const content = availability ? (
|
const content = availability ? (
|
||||||
<div className="model-picker-availability-popover">
|
<div className="model-picker-availability-popover">
|
||||||
<div className="model-picker-availability-popover-head">
|
<div className="model-picker-availability-popover-head">
|
||||||
|
<strong>最近 10 次调用</strong>
|
||||||
|
<span className={failCount > 0 ? "has-failures" : ""}>
|
||||||
|
{hasRuns ? `${successCount} 成功 / ${failCount} 失败` : "暂无调用记录"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="model-picker-availability-popover-meta">
|
||||||
|
{hasRuns ? `共记录最近 ${recentResults.length} 次执行(绿=成功 红=失败 灰=无记录)` : "该模型近期尚无调用记录,默认展示10个空槽"}
|
||||||
|
</div>
|
||||||
|
<div style={{ margin: "6px 0 8px" }}>
|
||||||
|
<ModelRecentHealthTrack availability={availability} inPopover />
|
||||||
|
</div>
|
||||||
|
<div className="model-picker-availability-popover-head" style={{ marginTop: 8 }}>
|
||||||
<strong>近24小时成功率</strong>
|
<strong>近24小时成功率</strong>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -579,7 +648,7 @@ function ModelAvailabilityBadge({ availability, available, showAvailability }: {
|
|||||||
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 }} />;
|
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>
|
||||||
<div className="model-picker-availability-popover-foot">7天分段趋势 · 悬停查看每日样本</div>
|
<div className="model-picker-availability-popover-foot">绿点=成功 · 红点=失败 · 灰点=空槽</div>
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
return (
|
return (
|
||||||
@@ -594,8 +663,10 @@ function ModelAvailabilityBadge({ availability, available, showAvailability }: {
|
|||||||
onMouseDown={(event) => event.stopPropagation()}
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<span className="model-picker-availability-dot" aria-hidden="true" />
|
<ModelRecentHealthTrack availability={availability} />
|
||||||
<span className="model-picker-availability-label">近24h {label}</span>
|
<span className="model-picker-availability-label">
|
||||||
|
{hasRuns ? `${successCount}/${recentResults.length}` : label}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ export type PublicChannelModelAvailability = {
|
|||||||
rate24h: number | null;
|
rate24h: number | null;
|
||||||
sampleCount24h: number;
|
sampleCount24h: number;
|
||||||
trend7d: PublicChannelModelAvailabilityDay[];
|
trend7d: PublicChannelModelAvailabilityDay[];
|
||||||
|
recentResults?: boolean[];
|
||||||
dataState: "ready" | "insufficient" | "no_data";
|
dataState: "ready" | "insufficient" | "no_data";
|
||||||
computedAt: string;
|
computedAt: string;
|
||||||
dataThrough?: string;
|
dataThrough?: string;
|
||||||
|
|||||||
@@ -616,12 +616,26 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.canvas-model-picker-brand-copy small {
|
.canvas-model-picker-brand-copy small,
|
||||||
overflow: hidden;
|
.canvas-model-picker-brand-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 6px;
|
||||||
color: color-mix(in srgb, currentColor 55%, transparent);
|
color: color-mix(in srgb, currentColor 55%, transparent);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.canvas-model-picker-brand-subtext {
|
||||||
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
.canvas-model-picker-brand-copy .model-picker-availability-track {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
.canvas-model-picker-brand-arrow {
|
.canvas-model-picker-brand-arrow {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
@@ -805,6 +819,54 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.model-picker-availability-track {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2.5px;
|
||||||
|
padding: 0 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-picker-availability-pill {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
flex: 0 0 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 120ms ease, background-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-picker-availability-pill.is-success {
|
||||||
|
background: #34c759;
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, #34c759 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-picker-availability-pill.is-failed {
|
||||||
|
background: #ff3b30;
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, #ff3b30 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-picker-availability-pill.is-empty {
|
||||||
|
background: color-mix(in srgb, currentColor 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-picker-availability-track.in-popover {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3.5px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, currentColor 6%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-picker-availability-pill.in-popover {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
flex: 0 0 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-picker-availability-popover-head span.has-failures {
|
||||||
|
color: #e5484d;
|
||||||
|
}
|
||||||
|
|
||||||
.model-picker-availability-dot {
|
.model-picker-availability-dot {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
@@ -916,8 +978,8 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca
|
|||||||
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand { display: flex; min-height: 58px; align-items: center; gap: 10px; border: 1px solid transparent; border-radius: 13px; padding: 8px 10px; background: transparent; color: var(--user-ink); text-align: left; transition: none; }
|
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand { display: flex; min-height: 58px; align-items: center; gap: 10px; border: 1px solid transparent; border-radius: 13px; padding: 8px 10px; background: transparent; color: var(--user-ink); text-align: left; transition: none; }
|
||||||
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand-icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 11px; background: var(--user-surface-muted); color: var(--user-ink-muted); }
|
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand-icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 11px; background: var(--user-surface-muted); color: var(--user-ink-muted); }
|
||||||
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand-copy { display: grid; min-width: 0; flex: 1; gap: 4px; }
|
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand-copy { display: grid; min-width: 0; flex: 1; gap: 4px; }
|
||||||
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand-copy strong { overflow: hidden; font-size: 13px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
|
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand-copy strong { font-size: 13px; font-weight: 650; white-space: normal; overflow-wrap: anywhere; word-break: break-word; line-height: 1.35; }
|
||||||
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand-copy small { color: var(--user-ink-muted); font-size: 10px; }
|
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand-copy small { display: flex; align-items: center; justify-content: space-between; gap: 6px; color: var(--user-ink-muted); font-size: 10px; }
|
||||||
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand-arrow { width: 15px; height: 15px; color: var(--user-ink-soft); transform: rotate(-90deg); }
|
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-brand-arrow { width: 15px; height: 15px; color: var(--user-ink-soft); transform: rotate(-90deg); }
|
||||||
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-secondary-head { display: flex; align-items: center; gap: 8px; padding: 0 2px 9px; }
|
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-secondary-head { display: flex; align-items: center; gap: 8px; padding: 0 2px 9px; }
|
||||||
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-back { display: grid; width: 28px; height: 28px; place-items: center; border: 1px solid var(--user-border); border-radius: 9px; background: var(--user-surface-muted); color: var(--user-ink-muted); }
|
.app-user-workspace .creation-model-picker-menu .canvas-model-picker-back { display: grid; width: 28px; height: 28px; place-items: center; border: 1px solid var(--user-border); border-radius: 9px; background: var(--user-surface-muted); color: var(--user-ink-muted); }
|
||||||
@@ -948,8 +1010,8 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca
|
|||||||
.creation-model-picker-surface .canvas-model-picker-brand { display: flex; min-height: 58px; align-items: center; gap: 10px; border: 1px solid transparent; border-radius: 13px; padding: 8px 10px; background: transparent; color: var(--foreground); text-align: left; transition: none; }
|
.creation-model-picker-surface .canvas-model-picker-brand { display: flex; min-height: 58px; align-items: center; gap: 10px; border: 1px solid transparent; border-radius: 13px; padding: 8px 10px; background: transparent; color: var(--foreground); text-align: left; transition: none; }
|
||||||
.creation-model-picker-surface .canvas-model-picker-brand-icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 11px; background: var(--workspace-surface-hover); color: var(--foreground); }
|
.creation-model-picker-surface .canvas-model-picker-brand-icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 11px; background: var(--workspace-surface-hover); color: var(--foreground); }
|
||||||
.creation-model-picker-surface .canvas-model-picker-brand-copy { display: grid; min-width: 0; flex: 1; gap: 4px; }
|
.creation-model-picker-surface .canvas-model-picker-brand-copy { display: grid; min-width: 0; flex: 1; gap: 4px; }
|
||||||
.creation-model-picker-surface .canvas-model-picker-brand-copy strong { overflow: hidden; font-size: 13px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
|
.creation-model-picker-surface .canvas-model-picker-brand-copy strong { font-size: 13px; font-weight: 650; white-space: normal; overflow-wrap: anywhere; word-break: break-word; line-height: 1.35; }
|
||||||
.creation-model-picker-surface .canvas-model-picker-brand-copy small { color: color-mix(in srgb, var(--foreground) 55%, transparent); font-size: 10px; }
|
.creation-model-picker-surface .canvas-model-picker-brand-copy small { display: flex; align-items: center; justify-content: space-between; gap: 6px; color: color-mix(in srgb, var(--foreground) 55%, transparent); font-size: 10px; }
|
||||||
.creation-model-picker-surface .canvas-model-picker-brand-arrow { width: 15px; height: 15px; color: color-mix(in srgb, var(--foreground) 48%, transparent); transform: rotate(-90deg); }
|
.creation-model-picker-surface .canvas-model-picker-brand-arrow { width: 15px; height: 15px; color: color-mix(in srgb, var(--foreground) 48%, transparent); transform: rotate(-90deg); }
|
||||||
.creation-model-picker-surface .canvas-model-picker-secondary-head { display: flex; align-items: center; gap: 8px; padding: 0 2px 9px; }
|
.creation-model-picker-surface .canvas-model-picker-secondary-head { display: flex; align-items: center; gap: 8px; padding: 0 2px 9px; }
|
||||||
.creation-model-picker-surface .canvas-model-picker-back { display: grid; width: 28px; height: 28px; place-items: center; border: 1px solid var(--workspace-border); border-radius: 9px; background: var(--workspace-surface-hover); color: var(--foreground); }
|
.creation-model-picker-surface .canvas-model-picker-back { display: grid; width: 28px; height: 28px; place-items: center; border: 1px solid var(--workspace-border); border-radius: 9px; background: var(--workspace-surface-hover); color: var(--foreground); }
|
||||||
|
|||||||
32
web/test/model-picker-availability-runs.test.ts
Normal file
32
web/test/model-picker-availability-runs.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
|
||||||
|
async function pickerSources() {
|
||||||
|
const [component, styles] = await Promise.all([
|
||||||
|
Bun.file(new URL("../src/components/model-picker.tsx", import.meta.url)).text(),
|
||||||
|
Bun.file(new URL("../src/styles/shared/model-picker.css", import.meta.url)).text(),
|
||||||
|
]);
|
||||||
|
return { component, styles };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("模型窗口渲染10个状态点的可用性指示条,区分成功、失败与未调用槽位", async () => {
|
||||||
|
const { component, styles } = await pickerSources();
|
||||||
|
|
||||||
|
// 状态条固定渲染 10 个点
|
||||||
|
expect(component).toContain("model-picker-availability-track");
|
||||||
|
expect(component).toContain("Array.from({ length: 10 })");
|
||||||
|
expect(component).toContain("model-picker-availability-pill");
|
||||||
|
expect(component).toContain("is-success");
|
||||||
|
expect(component).toContain("is-failed");
|
||||||
|
expect(component).toContain("is-empty");
|
||||||
|
|
||||||
|
// CSS 样式定义了成功绿点、失败红点、未调用灰点
|
||||||
|
expect(styles).toContain(".model-picker-availability-track");
|
||||||
|
expect(styles).toContain(".model-picker-availability-pill.is-success");
|
||||||
|
expect(styles).toContain(".model-picker-availability-pill.is-failed");
|
||||||
|
expect(styles).toContain(".model-picker-availability-pill.is-empty");
|
||||||
|
|
||||||
|
// 左侧目录/品牌栏中直接常驻挂载状态条,无需点进二级卡片即可直观感知模型可用性
|
||||||
|
expect(component).toContain("<ModelRecentHealthTrack availability={groupAvailability(config, group)} />");
|
||||||
|
expect(styles).toContain(".canvas-model-picker-brand-meta");
|
||||||
|
expect(styles).toContain(".canvas-model-picker-brand-copy .model-picker-availability-track");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user