Files
木子不是木子狸 c9b8263378 sessions, gateway: Sessions lists gateway sessions, and can keep their text on this computer when turned on (#1355, mu-zi-lee)
What changed: the Sessions page lists sessions seen only through the
gateway (from the usage ledger, read-only, a native session of the same id
wins), and shows in gateway mode. A Sessions switch, off by default and
confirmed before it turns on, records each gateway exchange's text under
the config dir (scrubbed, at most 7 days and 256 MiB, cleaned hourly).
Settings sync and restore can't turn it on; POST /api/sessions/recording
turns it on or off and can clear what was kept.

Why: users of magpie as a gateway (gateway mode, LAN clients) had no way
to see their conversations; the owner chose this design on the PR.

Squashed from PR head 6a343ba1 onto origin/main. Conflicts: gui-shell.md
(kept main's placeMenu text, took the PR's Sessions in gateway mode) and
settings_keep_test.go (main's fields plus GatewayConversations).

Run: go build ./..., go vet on gateway, gui, sessions, settings, usage;
go test -race -timeout 900s on those five packages: all ok.
2026-10-10 04:24:18 +08:00

94 lines
2.9 KiB
Go

package usage
import (
"crypto/sha256"
"encoding/json"
"fmt"
"slices"
"sync"
"time"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/settings"
)
const summaryCacheSessions = 1024
type summaryEntry struct {
version uint64
meta string
value Summary
}
var summaries struct {
sync.Mutex
entries map[Period]summaryEntry
}
func indexedSummary(p Period, now time.Time) Summary {
if p != Today && p != Week && p != Month {
p = All
}
snapshot := logSnapshotFor(true)
meta := usagePriceCacheKey(now)
summaries.Lock()
old, ok := summaries.entries[p]
summaries.Unlock()
if ok && old.version == snapshot.version && old.meta == meta {
return cloneSummary(old.value)
}
if snapshot.uncached && len(snapshot.blocks) == 0 {
snapshot = readLogSnapshot()
}
value := summarizeFrom(p, now, snapshot.first, snapshot.keyProviders, func(fn func(Record)) { snapshot.visit(p.Since(now), fn) })
// Keep every session in the returned result; limit retained cache data only.
if len(value.Sessions) > summaryCacheSessions {
summaries.Lock()
if previous := summaries.entries[p]; previous.version <= snapshot.version {
delete(summaries.entries, p)
}
summaries.Unlock()
return value
}
summaries.Lock()
if summaries.entries == nil {
summaries.entries = map[Period]summaryEntry{}
}
if previous := summaries.entries[p]; previous.version <= snapshot.version {
summaries.entries[p] = summaryEntry{snapshot.version, meta, value}
}
summaries.Unlock()
return cloneSummary(value)
}
// The exported result remains owned by its caller.
func cloneSummary(s Summary) Summary {
s.Series = slices.Clone(s.Series)
s.Agents = slices.Clone(s.Agents)
s.Models = slices.Clone(s.Models)
s.ProviderKeys = slices.Clone(s.ProviderKeys)
s.Accounts = slices.Clone(s.Accounts)
s.CallerKeys = slices.Clone(s.CallerKeys)
s.Sessions = slices.Clone(s.Sessions)
return s
}
// usagePriceCacheKey invalidates priced views when local prices or provider feeds change.
func usagePriceCacheKey(now time.Time) string {
_, offset := now.Zone()
meta := fmt.Sprintf("%s|%s|%s|%s|%s|%s|%d", statKey(settings.Path()), statKey(provider.Path()), statKey(catalog.CachePath()), statKey(catalog.LivePath("antigravity")), now.Format("2006-01-02"), now.Location(), offset)
// Sign-ins and plugin lists can change independently of providers.json.
// Their identity and price feeds affect both grouping and historical costs.
h := sha256.New()
prices, _ := json.Marshal(settings.Load().ModelPrices)
aliases, _ := json.Marshal(provider.Renamed())
fmt.Fprint(h, string(prices), string(aliases))
for _, current := range provider.All() {
fmt.Fprintf(h, "%q:%q:%v:%t:%t:%t;", current.ID, current.Where(), current.Catalogs(), current.IsPlugin(), current.Account == nil, current.Key != "")
fmt.Fprint(h, statKey(catalog.LivePath(current.ID)))
}
meta += fmt.Sprintf("|%x", h.Sum(nil))
return meta
}