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 <cursoragent@cursor.com>
This commit is contained in:
parent
f624717f0d
commit
ba2fa0aa2d
3
go.mod
3
go.mod
@ -15,3 +15,6 @@ require (
|
|||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a // indirect
|
||||||
google.golang.org/protobuf v1.36.8 // indirect
|
google.golang.org/protobuf v1.36.8 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
replace gitea.fe.kaien.ru/kaien/l-ai-chat-proto => ./proto/l-ai-chat-proto
|
||||||
|
|
||||||
|
|||||||
193
participant_cache.go
Normal file
193
participant_cache.go
Normal file
@ -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 }
|
||||||
108
participants.go
Normal file
108
participants.go
Normal file
@ -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{})
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user