Wrap Stage 7.1 participant RPCs and add a version-aware ParticipantCache with batch hydration helpers for agents and services. Co-authored-by: Cursor <cursoragent@cursor.com>
194 lines
4.6 KiB
Go
194 lines
4.6 KiB
Go
package chatclient
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"time"
|
|
|
|
pb "gitea.fe.kaien.ru/kaien/l-ai-chat-proto"
|
|
)
|
|
|
|
// ParticipantCache is a bounded in-memory cache keyed by author_id.
|
|
// Callers should prefer BatchGetParticipants over N+1 GetParticipant.
|
|
type ParticipantCache struct {
|
|
mu sync.RWMutex
|
|
byID map[string]*pb.Participant
|
|
maxSize int
|
|
}
|
|
|
|
// NewParticipantCache creates a cache. maxSize <= 0 defaults to 512.
|
|
func NewParticipantCache(maxSize int) *ParticipantCache {
|
|
if maxSize <= 0 {
|
|
maxSize = 512
|
|
}
|
|
return &ParticipantCache{
|
|
byID: make(map[string]*pb.Participant),
|
|
maxSize: maxSize,
|
|
}
|
|
}
|
|
|
|
// Get returns a cached participant if present.
|
|
func (c *ParticipantCache) Get(authorID string) (*pb.Participant, bool) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
p, ok := c.byID[authorID]
|
|
return p, ok
|
|
}
|
|
|
|
// Put stores a participant, preferring newer profile/presence versions.
|
|
func (c *ParticipantCache) Put(p *pb.Participant) {
|
|
if p == nil || p.GetAuthorId() == "" {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if existing, ok := c.byID[p.GetAuthorId()]; ok {
|
|
if p.GetProfileVersion() < existing.GetProfileVersion() {
|
|
// Keep newer profile fields but allow presence-only updates below.
|
|
merged := cloneParticipant(existing)
|
|
if p.GetPresenceVersion() > existing.GetPresenceVersion() {
|
|
merged.Online = p.GetOnline()
|
|
merged.PresenceVersion = p.GetPresenceVersion()
|
|
merged.LastSeenAt = p.GetLastSeenAt()
|
|
}
|
|
c.byID[p.GetAuthorId()] = merged
|
|
return
|
|
}
|
|
if p.GetProfileVersion() == existing.GetProfileVersion() &&
|
|
p.GetPresenceVersion() < existing.GetPresenceVersion() {
|
|
return
|
|
}
|
|
}
|
|
if len(c.byID) >= c.maxSize {
|
|
// Simple eviction: clear half (bounded cache, not LRU).
|
|
n := 0
|
|
for id := range c.byID {
|
|
delete(c.byID, id)
|
|
n++
|
|
if n >= c.maxSize/2 {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
c.byID[p.GetAuthorId()] = cloneParticipant(p)
|
|
}
|
|
|
|
// PutAll stores many participants.
|
|
func (c *ParticipantCache) PutAll(ps []*pb.Participant) {
|
|
for _, p := range ps {
|
|
c.Put(p)
|
|
}
|
|
}
|
|
|
|
// MissingIDs returns author IDs not present in the cache.
|
|
func (c *ParticipantCache) MissingIDs(ids []string) []string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
var missing []string
|
|
seen := make(map[string]struct{}, len(ids))
|
|
for _, id := range ids {
|
|
if id == "" {
|
|
continue
|
|
}
|
|
if _, dup := seen[id]; dup {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
if _, ok := c.byID[id]; !ok {
|
|
missing = append(missing, id)
|
|
}
|
|
}
|
|
return missing
|
|
}
|
|
|
|
func cloneParticipant(p *pb.Participant) *pb.Participant {
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
out := &pb.Participant{
|
|
AuthorId: p.AuthorId,
|
|
AuthorType: p.AuthorType,
|
|
DisplayName: p.DisplayName,
|
|
Role: p.Role,
|
|
Bio: p.Bio,
|
|
StatusText: p.StatusText,
|
|
AvatarHash: p.AvatarHash,
|
|
Online: p.Online,
|
|
ProfileVersion: p.ProfileVersion,
|
|
PresenceVersion: p.PresenceVersion,
|
|
AgentId: p.AgentId,
|
|
CreatedAt: p.CreatedAt,
|
|
UpdatedAt: p.UpdatedAt,
|
|
LastSeenAt: p.LastSeenAt,
|
|
}
|
|
if len(p.Aliases) > 0 {
|
|
out.Aliases = make(map[string]string, len(p.Aliases))
|
|
for k, v := range p.Aliases {
|
|
out.Aliases[k] = v
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// EnsureParticipants fetches missing IDs via BatchGetParticipants and caches them.
|
|
func (c *Client) EnsureParticipants(ctx context.Context, cache *ParticipantCache, authorIDs []string) error {
|
|
if cache == nil {
|
|
return nil
|
|
}
|
|
missing := cache.MissingIDs(authorIDs)
|
|
if len(missing) == 0 {
|
|
return nil
|
|
}
|
|
ps, err := c.BatchGetParticipants(ctx, missing)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cache.PutAll(ps)
|
|
return nil
|
|
}
|
|
|
|
// ResolveDisplayName picks a localized alias then falls back to display_name.
|
|
// locale examples: "ru-RU", "ru", "en-US".
|
|
func ResolveDisplayName(p *pb.Participant, locale string) string {
|
|
if p == nil {
|
|
return ""
|
|
}
|
|
if locale != "" && len(p.Aliases) > 0 {
|
|
if v, ok := p.Aliases[locale]; ok && v != "" {
|
|
return v
|
|
}
|
|
// primary language subtag
|
|
if i := indexByte(locale, '-'); i > 0 {
|
|
lang := locale[:i]
|
|
if v, ok := p.Aliases[lang]; ok && v != "" {
|
|
return v
|
|
}
|
|
}
|
|
}
|
|
if p.DisplayName != "" {
|
|
return p.DisplayName
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func indexByte(s string, c byte) int {
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == c {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// AvatarUploadNeeded reports whether localHash differs from the server hash
|
|
// (empty local means skip; empty server means upload if local present).
|
|
func AvatarUploadNeeded(serverHash, localHash string) bool {
|
|
if localHash == "" {
|
|
return false
|
|
}
|
|
return serverHash != localHash
|
|
}
|
|
|
|
// Idle helper kept for callers that want a soft TTL hint (cache itself is version-based).
|
|
func CacheSoftTTL() time.Duration { return 5 * time.Minute }
|