From ba2fa0aa2d48b92931b53ec78a37bf2b814c3ab7 Mon Sep 17 00:00:00 2001 From: Kaien Date: Mon, 10 Aug 2026 13:49:19 +0300 Subject: [PATCH] feat: participant SDK APIs and bounded cache Wrap Stage 7.1 participant RPCs and add a version-aware ParticipantCache with batch hydration helpers for agents and services. Co-authored-by: Cursor --- go.mod | 3 + participant_cache.go | 193 +++++++++++++++++++++++++++++++++++++++++++ participants.go | 108 ++++++++++++++++++++++++ 3 files changed, 304 insertions(+) create mode 100644 participant_cache.go create mode 100644 participants.go diff --git a/go.mod b/go.mod index e98c0fb..1994eb6 100644 --- a/go.mod +++ b/go.mod @@ -15,3 +15,6 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a // indirect google.golang.org/protobuf v1.36.8 // indirect ) + +replace gitea.fe.kaien.ru/kaien/l-ai-chat-proto => ./proto/l-ai-chat-proto + diff --git a/participant_cache.go b/participant_cache.go new file mode 100644 index 0000000..250ef0e --- /dev/null +++ b/participant_cache.go @@ -0,0 +1,193 @@ +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 } diff --git a/participants.go b/participants.go new file mode 100644 index 0000000..bc99058 --- /dev/null +++ b/participants.go @@ -0,0 +1,108 @@ +package chatclient + +import ( + "context" + "fmt" + + pb "gitea.fe.kaien.ru/kaien/l-ai-chat-proto" +) + +// GetParticipant returns one participant by author_id. +func (c *Client) GetParticipant(ctx context.Context, authorID string) (*pb.Participant, error) { + if err := c.checkOpen(); err != nil { + return nil, err + } + if authorID == "" { + return nil, fmt.Errorf("%w: author id is required", ErrInvalidConfig) + } + return c.api.GetParticipant(ctx, &pb.GetParticipantRequest{AuthorId: authorID}) +} + +// BatchGetParticipants fetches participants for the given author IDs +// (server deduplicates and bounds the batch). +func (c *Client) BatchGetParticipants(ctx context.Context, authorIDs []string) ([]*pb.Participant, error) { + if err := c.checkOpen(); err != nil { + return nil, err + } + resp, err := c.api.BatchGetParticipants(ctx, &pb.BatchGetParticipantsRequest{AuthorIds: authorIDs}) + if err != nil { + return nil, err + } + return resp.GetParticipants(), nil +} + +// ListTopicParticipants returns the durable topic membership roster. +func (c *Client) ListTopicParticipants(ctx context.Context, topicID string) ([]*pb.Participant, error) { + if err := c.checkOpen(); err != nil { + return nil, err + } + if topicID == "" { + return nil, fmt.Errorf("%w: topic id is required", ErrInvalidConfig) + } + resp, err := c.api.ListTopicParticipants(ctx, &pb.ListTopicParticipantsRequest{TopicId: topicID}) + if err != nil { + return nil, err + } + return resp.GetParticipants(), nil +} + +// GetMyParticipant returns the authenticated caller's participant profile. +func (c *Client) GetMyParticipant(ctx context.Context) (*pb.Participant, error) { + if err := c.checkOpen(); err != nil { + return nil, err + } + return c.api.GetMyParticipant(ctx, &pb.GetMyParticipantRequest{}) +} + +// RegisterSelfProfileRequest carries mutable public agent profile fields only. +// Identity (author_id / agent_id) is derived server-side from the service token. +type RegisterSelfProfileRequest struct { + DisplayName string + Aliases map[string]string + Role string + Bio string + StatusText string +} + +// RegisterSelfProfile upserts the authenticated agent's public profile and +// returns the authoritative Participant (including canonical author_id). +func (c *Client) RegisterSelfProfile(ctx context.Context, req RegisterSelfProfileRequest) (*pb.Participant, error) { + if err := c.checkOpen(); err != nil { + return nil, err + } + return c.api.RegisterSelfProfile(ctx, &pb.RegisterSelfProfileRequest{ + DisplayName: req.DisplayName, + Aliases: req.Aliases, + Role: req.Role, + Bio: req.Bio, + StatusText: req.StatusText, + }) +} + +// SyncMyTopicMemberships replaces the authenticated agent's durable topic +// memberships with the full desired set. +func (c *Client) SyncMyTopicMemberships(ctx context.Context, topicIDs []string) (*pb.SyncMyTopicMembershipsResponse, error) { + if err := c.checkOpen(); err != nil { + return nil, err + } + return c.api.SyncMyTopicMemberships(ctx, &pb.SyncMyTopicMembershipsRequest{TopicIds: topicIDs}) +} + +// UploadSelfAvatar replaces the authenticated agent's avatar bytes. +func (c *Client) UploadSelfAvatar(ctx context.Context, content []byte, contentType string) (*pb.Participant, error) { + if err := c.checkOpen(); err != nil { + return nil, err + } + return c.api.UploadSelfAvatar(ctx, &pb.UploadSelfAvatarRequest{ + Content: content, + ContentType: contentType, + }) +} + +// DeleteSelfAvatar clears the authenticated agent's avatar. +func (c *Client) DeleteSelfAvatar(ctx context.Context) (*pb.Participant, error) { + if err := c.checkOpen(); err != nil { + return nil, err + } + return c.api.DeleteSelfAvatar(ctx, &pb.DeleteSelfAvatarRequest{}) +}