Compare commits

...

3 Commits

Author SHA1 Message Date
2c1f9359fa chore(proto): pin submodule to published participant registry commit
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:06:58 +03:00
c7f22b64e8 chore(proto): pin chat proto participant registry contract
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 13:49:42 +03:00
ba2fa0aa2d 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>
2026-08-10 13:49:19 +03:00
4 changed files with 305 additions and 1 deletions

3
go.mod
View File

@ -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
View 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
View 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{})
}

@ -1 +1 @@
Subproject commit 32d924f965c625cf954dcca92970a1436f5a4ea1 Subproject commit a50cd3a886fb9d9406e16537d90a6b07b530913a