Files
ttstd-infinite-canvas/backend/internal/app/model_availability.go
xupengchong-del 8ec7e5a9a7 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` 补充左侧列表挂载断言并通过
2026-10-14 09:37:30 +08:00

185 lines
5.8 KiB
Go

package app
import (
"time"
"yingce/backend/internal/model"
"yingce/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
}
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 {
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]
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),
RecentResults: results,
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
}
}
}
}