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/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
|
|
||||||
|
|
||||||
|
|||||||
@ -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