From 8ec7e5a9a7edac17ebf6b7849eb4d26555f2c5b6 Mon Sep 17 00:00:00 2001 From: xupengchong-del Date: Wed, 14 Oct 2026 09:37:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(model-catalog):=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=99=A8=20-=20=E6=94=AF=E6=8C=81=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E5=BE=80=E5=89=8D=E6=8E=A8=E6=9C=80=E8=BF=9110?= =?UTF-8?q?=E6=AC=A1=E8=B0=83=E7=94=A8=E7=BA=A2=E7=BB=BF=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=8C=87=E7=A4=BA=E6=9D=A1=20(#768)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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` 补充左侧列表挂载断言并通过 --- backend/internal/app/model_availability.go | 25 +++++- .../internal/app/model_availability_test.go | 9 ++ backend/internal/app/model_catalog.go | 13 +-- backend/internal/repository/analytics.go | 20 +++++ web/src/components/model-picker.tsx | 89 +++++++++++++++++-- web/src/services/api/logical-models.ts | 1 + web/src/styles/shared/model-picker.css | 74 +++++++++++++-- .../model-picker-availability-runs.test.ts | 32 +++++++ 8 files changed, 239 insertions(+), 24 deletions(-) create mode 100644 web/test/model-picker-availability-runs.test.ts 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 ? (
+ 最近 10 次调用 + 0 ? "has-failures" : ""}> + {hasRuns ? `${successCount} 成功 / ${failCount} 失败` : "暂无调用记录"} + +
+
+ {hasRuns ? `共记录最近 ${recentResults.length} 次执行(绿=成功 红=失败 灰=无记录)` : "该模型近期尚无调用记录,默认展示10个空槽"} +
+
+ +
+
近24小时成功率 {label}
@@ -579,7 +648,7 @@ function ModelAvailabilityBadge({ availability, available, showAvailability }: { return ; })}
-
7天分段趋势 · 悬停查看每日样本
+
绿点=成功 · 红点=失败 · 灰点=空槽
) : null; return ( @@ -594,8 +663,10 @@ function ModelAvailabilityBadge({ availability, available, showAvailability }: { onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} > - ); diff --git a/web/src/services/api/logical-models.ts b/web/src/services/api/logical-models.ts index 41daf0437..dbcfb3eb2 100644 --- a/web/src/services/api/logical-models.ts +++ b/web/src/services/api/logical-models.ts @@ -184,6 +184,7 @@ export type PublicChannelModelAvailability = { rate24h: number | null; sampleCount24h: number; trend7d: PublicChannelModelAvailabilityDay[]; + recentResults?: boolean[]; dataState: "ready" | "insufficient" | "no_data"; computedAt: string; dataThrough?: string; diff --git a/web/src/styles/shared/model-picker.css b/web/src/styles/shared/model-picker.css index b5f98dacf..4321a2cf5 100644 --- a/web/src/styles/shared/model-picker.css +++ b/web/src/styles/shared/model-picker.css @@ -616,12 +616,26 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca text-overflow: ellipsis; white-space: nowrap; } -.canvas-model-picker-brand-copy small { - overflow: hidden; +.canvas-model-picker-brand-copy small, +.canvas-model-picker-brand-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; color: color-mix(in srgb, currentColor 55%, transparent); font-size: 10px; + line-height: 1.4; +} +.canvas-model-picker-brand-subtext { + overflow: hidden; text-overflow: ellipsis; 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 { width: 14px; @@ -805,6 +819,54 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca 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 { width: 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-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 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 small { color: var(--user-ink-muted); font-size: 10px; } +.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 { 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-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); } @@ -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-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 strong { overflow: hidden; font-size: 13px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; } -.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 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 { 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-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); } diff --git a/web/test/model-picker-availability-runs.test.ts b/web/test/model-picker-availability-runs.test.ts new file mode 100644 index 000000000..6a7e72d8e --- /dev/null +++ b/web/test/model-picker-availability-runs.test.ts @@ -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(""); + expect(styles).toContain(".canvas-model-picker-brand-meta"); + expect(styles).toContain(".canvas-model-picker-brand-copy .model-picker-availability-track"); +});