Compare commits
No commits in common. "2c1f9359faaeac6aded7b121d70b64de33f401ed" and "f624717f0dd5994aeae70fc16ea730c0fd5f0a06" have entirely different histories.
2c1f9359fa
...
f624717f0d
3
go.mod
3
go.mod
@ -15,6 +15,3 @@ 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
|
||||
|
||||
|
||||
@ -1,193 +0,0 @@
|
||||
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
108
participants.go
@ -1,108 +0,0 @@
|
||||
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 a50cd3a886fb9d9406e16537d90a6b07b530913a
|
||||
Subproject commit 32d924f965c625cf954dcca92970a1436f5a4ea1
|
||||
Loading…
Reference in New Issue
Block a user