返回 DeepSeek-Reasonix
stats_app.go
根目录 / desktop / stats_app.go
1 package main
2
3 import (
4 "context"
5 "fmt"
6 "strconv"
7 "time"
8
9 "reasonix/internal/config"
10 "reasonix/internal/stats"
11 )
12
13 // UsageStatsRequest asks for the usage statistics panel aggregate.
14 // Range days: 7 / 14 / 30 / 90, or a custom From/To pair (inclusive, local
15 // dates). Source "" or "all" aggregates every entry point (desktop, cli, serve,
16 // bot, remote); any other value filters to that source's records.
17 type UsageStatsRequest struct {
18 Range string `json:"range"` // "7" | "14" | "30" | "90" | "custom"
19 From string `json:"from,omitempty"` // "2006-01-02", custom only
20 To string `json:"to,omitempty"`
21 Source string `json:"source,omitempty"` // "" | "all" | "desktop" | "cli" | "serve" | "bot" | "remote"
22 }
23
24 // UsageStatsRange is the aggregate response. Fields map 1:1 to the settings
25 // panel sections (totals, derived stats, daily trend, per-model split).
26 type UsageStatsRange struct {
27 From string `json:"from"`
28 To string `json:"to"`
29 Tokens int64 `json:"tokens"`
30 Requests int `json:"requests"`
31 Turns int `json:"turns"`
32 CacheHit int64 `json:"cacheHit"`
33 CacheMiss int64 `json:"cacheMiss"`
34 ActiveDays int `json:"activeDays"`
35 TopModel string `json:"topModel"`
36 TopProvider string `json:"topProvider"`
37 Daily []stats.DailyTokens `json:"daily"`
38 Models []stats.ModelUsage `json:"models"`
39 Providers []stats.ProviderUsage `json:"providers"`
40 }
41
42 // UsageStats aggregates recorded usage over the requested range. It is a pure
43 // read of the stats files under the user state root; it never blocks on or
44 // mutates any active controller. An empty stats dir yields an all-zero range.
45 func (a *App) UsageStats(req UsageStatsRequest) (UsageStatsRange, error) {
46 from, to, err := resolveStatsRange(req)
47 if err != nil {
48 return UsageStatsRange{}, err
49 }
50 // Recording is asynchronous so chat completion never waits for disk. Give the
51 // settings-only read a short read-your-own-writes window; lock contention may
52 // return slightly stale statistics, but cannot stall the chat runtime.
53 flushCtx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
54 _ = stats.Flush(flushCtx, config.StatsDir())
55 cancel()
56 w := stats.NewWriter(config.StatsDir())
57 res, err := w.Query(stats.SourceFilter{From: from, To: to, Source: req.Source})
58 if err != nil {
59 return UsageStatsRange{}, err
60 }
61 return UsageStatsRange{
62 From: res.From,
63 To: res.To,
64 Tokens: res.Tokens,
65 Requests: res.Requests,
66 Turns: res.Turns,
67 CacheHit: res.CacheHit,
68 CacheMiss: res.CacheMiss,
69 ActiveDays: res.ActiveDays,
70 TopModel: res.TopModel,
71 TopProvider: res.TopProvider,
72 Daily: res.Daily,
73 Models: res.Models,
74 Providers: res.Providers,
75 }, nil
76 }
77
78 const (
79 dateLayout = "2006-01-02"
80 maxStatsCustomRangeDays = 3660
81 )
82
83 // resolveStatsRange maps a request's Range into an inclusive [from, to] pair.
84 // "custom" requires valid From/To dates; the presets end today.
85 func resolveStatsRange(req UsageStatsRequest) (from, to time.Time, err error) {
86 now := time.Now()
87 to = time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location())
88 switch req.Range {
89 case "7", "14", "30", "90":
90 n, err := strconv.Atoi(req.Range)
91 if err != nil || n <= 0 {
92 n = 7
93 }
94 from = to.AddDate(0, 0, -(n - 1))
95 from = time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, now.Location())
96 case "custom":
97 f, ferr := time.ParseInLocation(dateLayout, req.From, now.Location())
98 t, terr := time.ParseInLocation(dateLayout, req.To, now.Location())
99 if ferr != nil || terr != nil {
100 return time.Time{}, time.Time{}, fmt.Errorf("usage stats: custom range needs valid from/to dates (2006-01-02)")
101 }
102 if t.Before(f) {
103 return time.Time{}, time.Time{}, fmt.Errorf("usage stats: custom range from date must not be after to date")
104 }
105 today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
106 if t.After(today) {
107 return time.Time{}, time.Time{}, fmt.Errorf("usage stats: custom range to date must not be in the future")
108 }
109 fUTC := time.Date(f.Year(), f.Month(), f.Day(), 0, 0, 0, 0, time.UTC)
110 tUTC := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
111 if days := int(tUTC.Sub(fUTC)/(24*time.Hour)) + 1; days > maxStatsCustomRangeDays {
112 return time.Time{}, time.Time{}, fmt.Errorf("usage stats: custom range cannot exceed %d days", maxStatsCustomRangeDays)
113 }
114 from = time.Date(f.Year(), f.Month(), f.Day(), 0, 0, 0, 0, now.Location())
115 to = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, now.Location())
116 default:
117 // Unknown/empty range defaults to the last 7 days.
118 from = to.AddDate(0, 0, -6)
119 from = time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, now.Location())
120 }
121 return from, to, nil
122 }
123
123 lines GO