feat(gateway): admin 列表端点接真实数据——persona/session/memory 补 List() 并注入 gateway.Deps
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

根因:gateway.Server 定义 SessionStore/PersonaStore/MemoryStore 接口但生产从未满足:
- 底层 manager 无 List() 方法;
- wireFrontend 的 gateway.Deps{} 未传 Session/Personas/Memories。
→ /api/v1/sessions|personas 与 /api/v1/admin/{sessions,personas,memories} 恒返回 []。

改动:
- persona/registry:Registry.List() []Character(并发安全、值拷贝)。
- channel/session:SessionManager.List(ctx)(查 channel_sessions 表+按 actor_role 重建)。
- runtime/memory:Store.List(ctx)(全表,复用 scanItems)。
- gateway 三 Store 接口改为返回域类型(Character/MemoryItem),映射统一放 collect* 侧。
- cmd/cyrene/wire.go:Deps 补传 Session/Personas/Memories。
- .gitignore:cyrene→/cyrene(锚定根,不再误伤 cmd/cyrene 目录)。

实机验证:/api/v1/admin/personas 返回 {id:cyrene,name:昔涟,isDefault:true}。
This commit is contained in:
Aska committed 2026-09-06 14:05:34 +08:00
1 parent 5dae2b9741
commit 04bfa2c7a7
11 files changed
+312 -18

No files matched your search

+18
View File
@@ -7,6 +7,7 @@ package registry
import (
"fmt"
"sort"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
@@ -184,3 +185,20 @@ func (r *Registry) CountCharacters() int {
defer r.mu.RUnlock()
return len(r.characters)
}
// List returns every registered character, sorted by ID. Each returned Character
// is a copy so callers cannot mutate the registry's internal state. It is safe
// for concurrent use.
func (r *Registry) List() []Character {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]Character, 0, len(r.characters))
for _, c := range r.characters {
ch := *c
ch.Modes = append([]string(nil), c.Modes...)
out = append(out, ch)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
@@ -167,6 +167,53 @@ func TestRegistry_EmotionsFollowPersona(t *testing.T) {
}
}
func TestRegistry_List(t *testing.T) {
t.Parallel()
r := New()
// An empty registry lists nothing but stays non-nil.
if got := r.List(); got == nil || len(got) != 0 {
t.Fatalf("List() on empty registry = %#v, want empty non-nil", got)
}
if err := r.RegisterPersona(mkPersona("pb", "c", "")); err != nil {
t.Fatalf("RegisterPersona(pb): %v", err)
}
if err := r.RegisterPersona(mkPersona("pa", "c", "")); err != nil {
t.Fatalf("RegisterPersona(pa): %v", err)
}
if err := r.RegisterCharacter(mkChar("cb", "pb", false)); err != nil {
t.Fatalf("RegisterCharacter(cb): %v", err)
}
if err := r.RegisterCharacter(mkChar("ca", "pa", true)); err != nil {
t.Fatalf("RegisterCharacter(ca): %v", err)
}
got := r.List()
if len(got) != 2 {
t.Fatalf("List() = %d characters, want 2", len(got))
}
// Sorted by ID: ca before cb.
if got[0].ID != "ca" || got[1].ID != "cb" {
t.Fatalf("List() order = %q,%q, want ca,cb", got[0].ID, got[1].ID)
}
if got[0].PersonaID != "pa" || !got[0].Default || got[1].Default {
t.Fatalf("default/persona refs wrong: %+v", got)
}
if len(got[0].Modes) != 2 || got[0].Modes[0] != "chat" {
t.Fatalf("Modes = %v, want [chat home]", got[0].Modes)
}
// The returned slice is a copy: mutating it must not touch the registry.
got[0].Modes[0] = "hacked"
got[0].Name = "hacked"
// Re-read and assert the registry is unchanged.
ch, ok := r.Character("ca")
if !ok || ch.Name != "ca" || ch.Modes[0] != "chat" {
t.Fatalf("registry mutated through List copy: %+v", ch)
}
}
func TestRegistry_ConcurrentLifecycle(t *testing.T) {
t.Parallel()