根因: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}。
212 lines
7.5 KiB
Go
212 lines
7.5 KiB
Go
package session
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
|
)
|
|
|
|
// DDL for the channel↔session mapping. The composite primary key over
|
|
// (platform, channel_id) is the unique key the design requires: each real-world
|
|
// channel maps to exactly one internal session_id. created_at/updated_at follow
|
|
// the project's timestamp convention; actor_role records the identity lane so the
|
|
// mapping survives restarts.
|
|
const createTableSQL = `
|
|
CREATE TABLE IF NOT EXISTS channel_sessions (
|
|
platform TEXT NOT NULL,
|
|
channel_id TEXT NOT NULL,
|
|
session_id TEXT NOT NULL,
|
|
actor_role TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
PRIMARY KEY (platform, channel_id)
|
|
)`
|
|
|
|
const createIndexSQL = `
|
|
CREATE INDEX IF NOT EXISTS idx_channel_sessions_actor_role ON channel_sessions(actor_role)`
|
|
|
|
const insertOrIgnoreSessionSQL = `
|
|
INSERT OR IGNORE INTO channel_sessions
|
|
(platform, channel_id, session_id, actor_role, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)`
|
|
|
|
const selectSessionSQL = `
|
|
SELECT session_id FROM channel_sessions WHERE platform = ? AND channel_id = ?`
|
|
|
|
// listSessionsSQL selects every persisted channel mapping, newest first. Group
|
|
// sessions key on a channel_id carrying groupChannelPrefix; direct sessions use
|
|
// their channel id.
|
|
const listSessionSQL = `
|
|
SELECT platform, channel_id, session_id, actor_role FROM channel_sessions
|
|
ORDER BY created_at DESC`
|
|
|
|
// groupChannelPrefix marks a channel id as a group conversation so a session
|
|
// reconstructed from the backing table (where no IsGroup flag is persisted) can
|
|
// still report group membership.
|
|
const groupChannelPrefix = "group:"
|
|
|
|
// SessionManager owns the channel↔session mapping. It backs the map with the
|
|
// SQLite table channel_sessions so a channel keeps its internal session across
|
|
// restarts, and it caches lookups in memory to avoid a query per message. The
|
|
// cache is guarded by an RWMutex for concurrent adapter use.
|
|
type SessionManager struct {
|
|
mu sync.RWMutex
|
|
db *sql.DB
|
|
log *slog.Logger
|
|
cache map[string]Session
|
|
}
|
|
|
|
// New builds a SessionManager, ensuring the channel_sessions table and its index
|
|
// exist. It performs no migration bookkeeping: the schema is created here so the
|
|
// module is self-contained. A nil db is rejected. ctx is used for DDL.
|
|
func New(ctx context.Context, db *sql.DB, log *slog.Logger) (*SessionManager, error) {
|
|
if db == nil {
|
|
return nil, ErrNilDB
|
|
}
|
|
log = logger.WithSource(log, "channel/session")
|
|
if _, err := db.ExecContext(ctx, createTableSQL); err != nil {
|
|
return nil, fmt.Errorf("session: ensure channel_sessions: %w", err)
|
|
}
|
|
if _, err := db.ExecContext(ctx, createIndexSQL); err != nil {
|
|
return nil, fmt.Errorf("session: ensure channel_sessions index: %w", err)
|
|
}
|
|
return &SessionManager{db: db, log: log, cache: make(map[string]Session)}, nil
|
|
}
|
|
|
|
// GetOrCreate resolves the actor for msg and returns the Session bound to the
|
|
// (platform, channel) the message represents, creating the mapping on first
|
|
// sight. The owner whitelist is applied here (ResolveActor), so the owner flag
|
|
// is set structurally rather than from prompt text. Concurrent calls for the
|
|
// same channel return the same session.
|
|
func (m *SessionManager) GetOrCreate(ctx context.Context, msg adapter.UnifiedMessage, owner *user.OwnerProfile) (Session, error) {
|
|
actor := ResolveActor(msg, owner)
|
|
cid := channelID(msg)
|
|
if cid == "" {
|
|
return Session{}, fmt.Errorf("session: get-or-create %s: %w", msg.Platform, ErrInvalidChannel)
|
|
}
|
|
key := msg.Platform + "\x00" + cid
|
|
|
|
if s, ok := m.lookupCached(key); ok {
|
|
return s, nil
|
|
}
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if s, ok := m.cache[key]; ok {
|
|
return s, nil
|
|
}
|
|
|
|
sessionID, err := m.persist(ctx, msg.Platform, cid, actor)
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
|
|
s := Session{
|
|
Platform: msg.Platform,
|
|
ChannelID: cid,
|
|
SessionID: sessionID,
|
|
ActorRole: actor.Role,
|
|
Owner: actor.IsOwner(),
|
|
IsGroup: actor.IsGroupChat(),
|
|
MemWritable: actor.IsOwner() && !actor.IsGroupChat(),
|
|
}
|
|
m.cache[key] = s
|
|
return s, nil
|
|
}
|
|
|
|
// List returns every known session, newest first, across the backing table and
|
|
// the in-memory cache. Rows that are still in the cache are returned with their
|
|
// full derived metadata (Owner/IsGroup/MemWritable from the original resolve);
|
|
// rows recovered from the table (for example from a previous process) are
|
|
// reconstructed from the persisted actor role. The returned slice is non-nil. It
|
|
// is safe for concurrent use.
|
|
func (m *SessionManager) List(ctx context.Context) ([]Session, error) {
|
|
rows, err := m.db.QueryContext(ctx, listSessionSQL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("session: list: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
sessions := make([]Session, 0)
|
|
for rows.Next() {
|
|
var platform, cid, sessionID, roleStr string
|
|
if err := rows.Scan(&platform, &cid, &sessionID, &roleStr); err != nil {
|
|
return nil, fmt.Errorf("session: scan list: %w", err)
|
|
}
|
|
key := platform + "\x00" + cid
|
|
if cached, ok := m.lookupCached(key); ok {
|
|
sessions = append(sessions, cached)
|
|
continue
|
|
}
|
|
role := adapter.Role(roleStr)
|
|
owner := role == adapter.RoleOwner
|
|
isGroup := strings.HasPrefix(cid, groupChannelPrefix)
|
|
sessions = append(sessions, Session{
|
|
Platform: platform,
|
|
ChannelID: cid,
|
|
SessionID: sessionID,
|
|
ActorRole: role,
|
|
Owner: owner,
|
|
IsGroup: isGroup,
|
|
MemWritable: owner && !isGroup,
|
|
})
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("session: iterate list: %w", err)
|
|
}
|
|
return sessions, nil
|
|
}
|
|
|
|
// lookupCached returns the cached session for key under a read lock.
|
|
func (m *SessionManager) lookupCached(key string) (Session, bool) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
s, ok := m.cache[key]
|
|
return s, ok
|
|
}
|
|
|
|
// persist writes the channel mapping if it does not exist yet and reads back the
|
|
// canonical session_id. INSERT OR IGNORE makes it safe under concurrency: the
|
|
// winning writer's session_id becomes the binding. It is called under the write
|
|
// lock so the in-memory cache and the table stay in agreement.
|
|
func (m *SessionManager) persist(ctx context.Context, platform, cid string, actor adapter.Actor) (string, error) {
|
|
now := time.Now().UTC().Format(time.RFC3339Nano)
|
|
if _, err := m.db.ExecContext(ctx, insertOrIgnoreSessionSQL, platform, cid, newSessionID(), string(actor.Role), now, now); err != nil {
|
|
return "", fmt.Errorf("session: persist %s/%s: %w", platform, cid, err)
|
|
}
|
|
var sessionID string
|
|
if err := m.db.QueryRowContext(ctx, selectSessionSQL, platform, cid).Scan(&sessionID); err != nil {
|
|
return "", fmt.Errorf("session: read back %s/%s: %w", platform, cid, err)
|
|
}
|
|
if m.log != nil {
|
|
m.log.Debug("channel session bound",
|
|
"platform", platform, "channel_id", cid, "session_id", sessionID, "actor_role", actor.Role)
|
|
}
|
|
return sessionID, nil
|
|
}
|
|
|
|
// channelID derives the stable channel key used for session mapping. Group
|
|
// messages key on the group (prefixed so a group never collides with a direct
|
|
// channel of the same id); direct messages key on their channel id, falling back
|
|
// to the sender id when no explicit channel id is present.
|
|
func channelID(msg adapter.UnifiedMessage) string {
|
|
if msg.IsGroup() {
|
|
if msg.GroupID != "" {
|
|
return groupChannelPrefix + msg.GroupID
|
|
}
|
|
return msg.ChannelID
|
|
}
|
|
if msg.ChannelID != "" {
|
|
return msg.ChannelID
|
|
}
|
|
return msg.SenderID
|
|
}
|