feat(session,runtime): 多角色并存—按会话选角(迁移0011 + 按回合解析 + agent池 + 提取器修正)
按 DevDocs/design/22 批次 1 后端(§2/§3/§4.1-4.5/§4.7/§5 验收 1-6)。
- 迁移 0011:chat_sessions.persona_id + chat_messages.persona_id(均 DEFAULT '',无回填 → 升级零风险);同步包内幂等 DDL;**真实库升级验证**(拷贝 data/cyrene.db 到临时目录跑 MigrateUp:既有 1 会话/109 消息 persona 仍为 '',原库未被触碰)
- history:SessionMeta/Message 加 PersonaID + SetSessionPersona;ListSessions/List/scan/Append 带上
- runtime:SessionEnv.PersonaID + loop.personaFor(ctx) 替换固定 l.personaID(memory/request/turn)+ **agent 池**(Options.Agents,按角色取 instructions,未命中回落)+ **提取器不再把 persona 烤死在构造期**(多角色下记忆不再永远记在 cyrene 名下)+ **thinking 旋钮改为按会话角色解析**(否则角色 Y 会用角色 X 的预算/展示)
- gateway:会话 DTO/POST/PATCH 支持 personaId;ws/rest/voice 的 OwnerEnv 硬编改为**按会话解析角色**(actor 元数据语义不变);admin memories 搜索去掉硬编 cyrene
- Docs/api-client v0.42;**两处有意的行为收紧**已写明:POST /sessions body 非法 JSON → 400(原先完全忽略 body;空/null/{} 仍 201);POST /admin/memories/search 省略 personaId 且无默认角色 → 400(原先默认查 cyrene)
- 测试:design/22 §5 验收 1-6(记忆隔离/提示词首条 system/情绪按角色/提取 persona 归属/迁移兼容)+ 进程内 e2e(断言 adapter 收到的 model.Request.Messages[0] 即该角色的 systemPrompt)
- **已知未做(留批次 2 / 待确认)**:① IM 与渠道级未做 → **§7-3「owner 私聊镜像随主会话角色」尚未生效**;② 请求级覆盖未实现(本批只做会话级);③ **§7-5 的「上下文发送角色标记」只完成存储+DTO 半边**——消息 persona 已落库且 DTO 已带,但模型上下文不从 chat_messages 重建,「第 N 条是谁说的」尚未进上下文(需确认是否属批次 2);④「切角色重建摘要」当前是空操作(压缩未接线);⑤ thinking 旋钮仅在流式路径生效(非流式 agent.RunStep 不带,A 批次既有性质)
- 验收:go build/vet/test 全绿 + linux/amd64 与 darwin/arm64 交叉编译(既有 flake 本轮未触发;同批 session_orchestrator.go 一度 501 行 → 抽出 session_tail.go)
This commit is contained in:
1 parent
66a96d2f93
commit
0624d9ee36
46 files changed
+1908
-259
No files matched your search
@@ -16,7 +16,7 @@ import (
|
||||
// [1, maxPageLimit].
|
||||
func (s *Store) ListAfter(ctx context.Context, sessionID string, after int64, limit int, platform string) ([]Message, error) {
|
||||
limit = clampLimit(limit)
|
||||
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
|
||||
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at
|
||||
FROM chat_messages WHERE session_id = ? AND created_at > ?`
|
||||
args := []any{sessionID, after}
|
||||
if platform != "" {
|
||||
|
||||
@@ -27,7 +27,7 @@ func (s *Store) Search(ctx context.Context, sessionID, query string, before int6
|
||||
if query == "" {
|
||||
return make([]Message, 0), nil
|
||||
}
|
||||
sqlText := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
|
||||
sqlText := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at
|
||||
FROM chat_messages WHERE session_id = ? AND content LIKE ? ESCAPE '\'`
|
||||
args := []any{sessionID, "%" + escapeLike(query) + "%"}
|
||||
if before > 0 {
|
||||
@@ -103,7 +103,7 @@ func (s *Store) GetMessage(ctx context.Context, id string) (Message, error) {
|
||||
// messageByID reads one message by id, returning ErrMessageNotFound when the row is
|
||||
// absent.
|
||||
func (s *Store) messageByID(ctx context.Context, id string) (Message, error) {
|
||||
row := s.db.QueryRowContext(ctx, `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
|
||||
row := s.db.QueryRowContext(ctx, `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at
|
||||
FROM chat_messages WHERE id = ?`, id)
|
||||
m, err := scanMessage(row)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package history
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// This file owns the per-session role binding (DevDocs/design/22 多角色并存 +
|
||||
// 按会话选角 §2/§4.1). A session's role is the single source of truth for "which
|
||||
// role answers in this conversation": the loop resolves the system prompt, the
|
||||
// memory scope, the emotion machine and the thinking budget from it. The value
|
||||
// is display/scope metadata only — identity and permissions are still decided by
|
||||
// the channel actor metadata (project red line).
|
||||
//
|
||||
// '' means "inherit the current global default role", so it is both the column
|
||||
// default and a legal explicit value: setting it clears an explicit binding and
|
||||
// lets the session follow a default-role change again (design/22 §7-6).
|
||||
|
||||
// NewSubSessionFor creates a sub session bound to personaID and returns its id
|
||||
// (MainSessionID:uuid). A blank personaID binds no explicit role, so the session
|
||||
// inherits the global default — the same behavior as NewSubSession. ownerID is
|
||||
// accepted for symmetry with NewSubSession (the Web owner is identified by JWT,
|
||||
// not stored per session); it is logged at debug and not persisted.
|
||||
func (s *Store) NewSubSessionFor(ctx context.Context, ownerID, personaID string) (string, error) {
|
||||
id, err := newID()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("history: new sub id: %w", err)
|
||||
}
|
||||
sessionID := MainSessionID + ":" + id
|
||||
now := time.Now().UnixMilli()
|
||||
if _, err := s.db.ExecContext(ctx, `INSERT INTO chat_sessions (id, type, title, persona_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)`, sessionID, SessionSub, "", strings.TrimSpace(personaID), now); err != nil {
|
||||
return "", fmt.Errorf("history: insert sub session: %w", err)
|
||||
}
|
||||
if s.log != nil {
|
||||
s.log.Debug("sub session created", "session_id", sessionID, "owner_id", ownerID, "persona_id", strings.TrimSpace(personaID))
|
||||
}
|
||||
return sessionID, nil
|
||||
}
|
||||
|
||||
// SetSessionPersona binds (or clears, when personaID is blank) a session's role.
|
||||
// Both the fixed main session and sub sessions may be retargeted: the binding is
|
||||
// scope metadata, not identity. An unknown id returns ErrSessionNotFound. It
|
||||
// never touches the conversation: the stored messages keep the role that spoke
|
||||
// them (design/22 §7-5), and the next turn simply resolves the new role.
|
||||
func (s *Store) SetSessionPersona(ctx context.Context, sessionID, personaID string) error {
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE chat_sessions SET persona_id = ? WHERE id = ?`,
|
||||
strings.TrimSpace(personaID), sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("history: set session persona %s: %w", sessionID, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("history: set session persona %s: %w", sessionID, err)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("history: set session persona %s: %w", sessionID, ErrSessionNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SessionPersona returns the role bound to sessionID, or "" when the session
|
||||
// inherits the global default. An unknown id returns ErrSessionNotFound so a
|
||||
// caller can tell "no explicit role" (a real session, empty binding) from "no
|
||||
// such session"; a caller that only needs the effective role treats both as
|
||||
// "use the default".
|
||||
func (s *Store) SessionPersona(ctx context.Context, sessionID string) (string, error) {
|
||||
var personaID string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT persona_id FROM chat_sessions WHERE id = ?`, sessionID).Scan(&personaID)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return "", fmt.Errorf("history: session persona %s: %w", sessionID, ErrSessionNotFound)
|
||||
case err != nil:
|
||||
return "", fmt.Errorf("history: session persona %s: %w", sessionID, err)
|
||||
}
|
||||
return personaID, nil
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package history
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// personaOf returns the stored role binding of a session, failing the test when the
|
||||
// session is absent from the list.
|
||||
func personaOf(t *testing.T, st *Store, id string) string {
|
||||
t.Helper()
|
||||
sessions, err := st.ListSessions(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("list sessions: %v", err)
|
||||
}
|
||||
for _, m := range sessions {
|
||||
if m.ID == id {
|
||||
return m.PersonaID
|
||||
}
|
||||
}
|
||||
t.Fatalf("session %q not found in %+v", id, sessions)
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestSessionPersonaRoundTrip is design/22 acceptance 1: the session-level role is
|
||||
// the single source of truth, so a value written through SetSessionPersona must come
|
||||
// back from both the direct read and the list projection (the DTO the client gets),
|
||||
// and the main session — which always exists — starts out unbound ('' = inherit the
|
||||
// global default). An explicit "" clears the binding back to that same state.
|
||||
func TestSessionPersonaRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if got := personaOf(t, st, MainSessionID); got != "" {
|
||||
t.Fatalf("main session persona_id = %q, want '' (inherit the global default)", got)
|
||||
}
|
||||
if got, err := st.SessionPersona(ctx, MainSessionID); err != nil || got != "" {
|
||||
t.Fatalf("SessionPersona(main) = (%q, %v), want ('', nil)", got, err)
|
||||
}
|
||||
|
||||
for _, personaID := range []string{"cyrene", "yuki"} {
|
||||
if err := st.SetSessionPersona(ctx, MainSessionID, personaID); err != nil {
|
||||
t.Fatalf("SetSessionPersona(%s): %v", personaID, err)
|
||||
}
|
||||
if got, err := st.SessionPersona(ctx, MainSessionID); err != nil || got != personaID {
|
||||
t.Fatalf("SessionPersona(main) = (%q, %v), want (%q, nil)", got, err, personaID)
|
||||
}
|
||||
if got := personaOf(t, st, MainSessionID); got != personaID {
|
||||
t.Fatalf("ListSessions persona_id = %q, want %q", got, personaID)
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit empty value is the documented "unbind" (follow the default again),
|
||||
// not a no-op.
|
||||
if err := st.SetSessionPersona(ctx, MainSessionID, ""); err != nil {
|
||||
t.Fatalf("SetSessionPersona(\"\"): %v", err)
|
||||
}
|
||||
if got := personaOf(t, st, MainSessionID); got != "" {
|
||||
t.Fatalf("after unbind persona_id = %q, want ''", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionPersonaUnknownSession covers the error contract: binding or reading the
|
||||
// role of a session that does not exist reports ErrSessionNotFound (so the HTTP layer
|
||||
// answers 404) instead of silently creating/looking up nothing.
|
||||
func TestSessionPersonaUnknownSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := st.SetSessionPersona(ctx, "web:missing", "cyrene"); !errors.Is(err, ErrSessionNotFound) {
|
||||
t.Fatalf("SetSessionPersona(missing) err = %v, want ErrSessionNotFound", err)
|
||||
}
|
||||
if _, err := st.SessionPersona(ctx, "web:missing"); !errors.Is(err, ErrSessionNotFound) {
|
||||
t.Fatalf("SessionPersona(missing) err = %v, want ErrSessionNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewSubSessionForBindsPersona locks the create path: a sub session created with a
|
||||
// role carries it from its first read (no extra PATCH needed), and the plain
|
||||
// NewSubSession still creates an unbound session — the pre-multi-character contract,
|
||||
// which every existing caller relies on.
|
||||
func TestNewSubSessionForBindsPersona(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
bound, err := st.NewSubSessionFor(ctx, "owner", "yuki")
|
||||
if err != nil {
|
||||
t.Fatalf("NewSubSessionFor: %v", err)
|
||||
}
|
||||
if got := personaOf(t, st, bound); got != "yuki" {
|
||||
t.Fatalf("bound sub persona_id = %q, want yuki", got)
|
||||
}
|
||||
|
||||
plain, err := st.NewSubSession(ctx, "owner")
|
||||
if err != nil {
|
||||
t.Fatalf("NewSubSession: %v", err)
|
||||
}
|
||||
if got := personaOf(t, st, plain); got != "" {
|
||||
t.Fatalf("plain sub persona_id = %q, want ''", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMessagePersonaPersisted proves design/22 §7-5's storage half: a session may
|
||||
// switch roles while keeping its context, so every message records the role that
|
||||
// SPOKE it. The column survives the write/read round trip through the same scanner
|
||||
// the REST/WS DTOs use, and messages written without a role stay '' (= the default
|
||||
// role), which is what keeps pre-upgrade history readable.
|
||||
func TestMessagePersonaPersisted(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "yuki 你好", PersonaID: "yuki"}); err != nil {
|
||||
t.Fatalf("append with persona: %v", err)
|
||||
}
|
||||
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "在~"}); err != nil {
|
||||
t.Fatalf("append without persona: %v", err)
|
||||
}
|
||||
|
||||
msgs, err := st.List(ctx, MainSessionID, 0, 10, "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("listed %d messages, want 2", len(msgs))
|
||||
}
|
||||
byRole := map[string]string{}
|
||||
for _, m := range msgs {
|
||||
byRole[m.Role] = m.PersonaID
|
||||
}
|
||||
if got := byRole[RoleUser]; got != "yuki" {
|
||||
t.Fatalf("user message persona_id = %q, want yuki", got)
|
||||
}
|
||||
if got := byRole[RoleAssistant]; got != "" {
|
||||
t.Fatalf("unattributed message persona_id = %q, want ''", got)
|
||||
}
|
||||
|
||||
// The persona column must also survive Recent (the WS replay path) and
|
||||
// AppendFull (the orchestrator's realtime-sync path).
|
||||
recent, err := st.Recent(ctx, MainSessionID, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("recent: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, m := range recent {
|
||||
if m.PersonaID == "yuki" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("Recent lost the message persona: %+v", recent)
|
||||
}
|
||||
|
||||
full, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "记住了", PersonaID: "yuki"})
|
||||
if err != nil {
|
||||
t.Fatalf("append full: %v", err)
|
||||
}
|
||||
if full.PersonaID != "yuki" {
|
||||
t.Fatalf("AppendFull returned persona_id = %q, want yuki", full.PersonaID)
|
||||
}
|
||||
after, err := st.ListAfter(ctx, MainSessionID, 0, 10, "")
|
||||
if err != nil {
|
||||
t.Fatalf("list after: %v", err)
|
||||
}
|
||||
if len(after) == 0 || after[len(after)-1].PersonaID != "yuki" {
|
||||
t.Fatalf("ListAfter lost the message persona: %+v", after)
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ type MessageSummary struct {
|
||||
// per-session round trip (N+1) is needed and no extra index is required. Ordering floats
|
||||
// pinned sessions first, then keeps the main session before unpinned subs, then subs
|
||||
// oldest-first.
|
||||
const listSessionsQuery = `SELECT s.id, s.type, s.title, s.pinned, s.created_at,
|
||||
const listSessionsQuery = `SELECT s.id, s.type, s.title, s.pinned, s.created_at, s.persona_id,
|
||||
m.role, m.content, m.created_at,
|
||||
(SELECT COUNT(*) FROM chat_messages c
|
||||
WHERE c.session_id = s.id AND c.created_at > s.read_at)
|
||||
@@ -87,7 +87,7 @@ func scanSessionMeta(sc rowScanner) (SessionMeta, error) {
|
||||
content sql.NullString
|
||||
lastAt sql.NullInt64
|
||||
)
|
||||
if err := sc.Scan(&meta.ID, &typeStr, &meta.Title, &pinned, &meta.CreatedAt, &role, &content, &lastAt, &meta.UnreadCount); err != nil {
|
||||
if err := sc.Scan(&meta.ID, &typeStr, &meta.Title, &pinned, &meta.CreatedAt, &meta.PersonaID, &role, &content, &lastAt, &meta.UnreadCount); err != nil {
|
||||
return SessionMeta{}, err
|
||||
}
|
||||
meta.Type = typeStr
|
||||
|
||||
@@ -186,9 +186,9 @@ func TestMarkSessionReadUnknownSession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// migrationReadAtSQL reads one of the 0010 migration files from the source tree, so the
|
||||
// test applies the same SQL text the embedded migration runs rather than a copy.
|
||||
func migrationReadAtSQL(t *testing.T, name string) string {
|
||||
// migrationFileSQL reads one migration file from the source tree, so the test applies
|
||||
// the same SQL text the embedded migration runs rather than a copy.
|
||||
func migrationFileSQL(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join("..", "..", "db", "migrations", name))
|
||||
if err != nil {
|
||||
@@ -286,9 +286,16 @@ func TestMigration0010ReadAtBackfill(t *testing.T) {
|
||||
t.Fatal("legacy schema should not have read_at before the migration")
|
||||
}
|
||||
|
||||
if _, err := conn.ExecContext(ctx, migrationReadAtSQL(t, "0010_chat_session_read_at.up.sql")); err != nil {
|
||||
if _, err := conn.ExecContext(ctx, migrationFileSQL(t, "0010_chat_session_read_at.up.sql")); err != nil {
|
||||
t.Fatalf("apply 0010 up: %v", err)
|
||||
}
|
||||
// Bring the simulated legacy database up to the current head (0011 adds the
|
||||
// session/message persona columns), because the store reads the canonical schema:
|
||||
// a database left one migration behind would lack persona_id. Upgrade ordering in
|
||||
// production is db.MigrateUp, so this mirrors what a real upgrade produces.
|
||||
if _, err := conn.ExecContext(ctx, migrationFileSQL(t, "0011_session_persona.up.sql")); err != nil {
|
||||
t.Fatalf("apply 0011 up: %v", err)
|
||||
}
|
||||
if !columnPresent(t, conn, "chat_sessions", "read_at") {
|
||||
t.Fatal("read_at should exist after the 0010 up migration")
|
||||
}
|
||||
@@ -342,7 +349,7 @@ func TestMigration0010ReadAtBackfill(t *testing.T) {
|
||||
t.Fatalf("unreadCount after mark = %d, want 0", got)
|
||||
}
|
||||
|
||||
if _, err := conn.ExecContext(ctx, migrationReadAtSQL(t, "0010_chat_session_read_at.down.sql")); err != nil {
|
||||
if _, err := conn.ExecContext(ctx, migrationFileSQL(t, "0010_chat_session_read_at.down.sql")); err != nil {
|
||||
t.Fatalf("apply 0010 down: %v", err)
|
||||
}
|
||||
if columnPresent(t, conn, "chat_sessions", "read_at") {
|
||||
|
||||
@@ -104,7 +104,15 @@ type Message struct {
|
||||
// account comes from env. IM adapter-originated messages carry it; the Web main
|
||||
// session leaves it empty, so it is always '' for Web messages.
|
||||
AdapterID string
|
||||
CreatedAt int64
|
||||
// PersonaID is the role that SPOKE this message (design/22 §7-5). A session
|
||||
// may switch roles while keeping its context, so the authoring role is recorded
|
||||
// per message and the context/UI can tell "who said the Nth turn". Empty means
|
||||
// "inherit the current global default role" — the pre-multi-character rows and
|
||||
// any message written without an explicit role, so they read exactly as before.
|
||||
// It is display/attribution metadata only: identity and permissions are decided
|
||||
// by the channel actor metadata (project red line).
|
||||
PersonaID string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
// SessionMeta is the wire-facing metadata of one persistent session.
|
||||
@@ -127,6 +135,14 @@ type SessionMeta struct {
|
||||
// read for every end of the account. Like LastMessage it is display-only —
|
||||
// identity and permissions never derive from it.
|
||||
UnreadCount int
|
||||
// PersonaID is the role this session belongs to (design/22 §2/§4.1). It is the
|
||||
// single source of truth for "which role answers in this conversation": the loop
|
||||
// resolves memory scope, system prompt, emotion and thinking budget from it. Empty
|
||||
// means "inherit the current global default role" (owner.yaml:defaultPersona), so
|
||||
// every pre-existing session and any session created without an explicit role
|
||||
// keeps the pre-multi-character behavior (design/22 §4.7) and still follows a
|
||||
// default-role change (design/22 §7-6).
|
||||
PersonaID string
|
||||
}
|
||||
|
||||
// HistoryStore abstracts the chat-history store so callers (the gateway) and tests can
|
||||
@@ -202,6 +218,11 @@ type HistoryStore interface {
|
||||
// ErrSessionNotFound.
|
||||
SetPinned(ctx context.Context, sessionID string, pinned bool) error
|
||||
// NewSubSession creates a new sub session and returns its id (MainSessionID:uuid).
|
||||
// It binds no explicit role, so the new session inherits the global default
|
||||
// (persona_id = ''); *Store additionally offers NewSubSessionFor for the
|
||||
// role-bound variant (design/22 §4.1), which this narrow consumer-side interface
|
||||
// deliberately omits because neither channelbridge nor backthink ever creates a
|
||||
// session.
|
||||
NewSubSession(ctx context.Context, ownerID string) (string, error)
|
||||
// ListSessions returns pinned sessions first, then the main session, then every
|
||||
// sub session oldest first.
|
||||
@@ -284,10 +305,10 @@ func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
|
||||
s.ensureTitle(ctx, msg.SessionID, msg.Content)
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO chat_messages
|
||||
(id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
(id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
msg.ID, msg.SessionID, msg.Role, msg.Content, msg.Mode, msg.Mood,
|
||||
msg.Image, msg.Thinking, msg.Platform, msg.DeviceID, msg.SenderName, msg.AdapterID, msg.CreatedAt)
|
||||
msg.Image, msg.Thinking, msg.Platform, msg.DeviceID, msg.SenderName, msg.AdapterID, msg.PersonaID, msg.CreatedAt)
|
||||
if err != nil {
|
||||
return Message{}, fmt.Errorf("history: append %s to %s: %w", msg.Role, msg.SessionID, err)
|
||||
}
|
||||
@@ -303,7 +324,7 @@ func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
|
||||
// limit is clamped to [1, maxPageLimit].
|
||||
func (s *Store) List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]Message, error) {
|
||||
limit = clampLimit(limit)
|
||||
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
|
||||
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at
|
||||
FROM chat_messages WHERE session_id = ?`
|
||||
args := []any{sessionID}
|
||||
if before > 0 {
|
||||
@@ -344,7 +365,7 @@ func (s *Store) List(ctx context.Context, sessionID string, before int64, limit
|
||||
// only strictly older messages are returned. The returned slice is non-nil.
|
||||
func (s *Store) ListPlatformLog(ctx context.Context, platform string, before int64, limit int) ([]Message, error) {
|
||||
limit = clampLimit(limit)
|
||||
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
|
||||
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at
|
||||
FROM chat_messages WHERE platform = ? AND session_id != ?`
|
||||
args := []any{platform, MainSessionID}
|
||||
if before > 0 {
|
||||
@@ -386,7 +407,7 @@ func (s *Store) ListAdapterLog(ctx context.Context, adapterID string, before int
|
||||
if adapterID == "" {
|
||||
return make([]Message, 0), nil
|
||||
}
|
||||
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
|
||||
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at
|
||||
FROM chat_messages WHERE adapter_id = ?`
|
||||
args := []any{adapterID}
|
||||
if before > 0 {
|
||||
@@ -422,7 +443,7 @@ func (s *Store) ListAdapterLog(ctx context.Context, adapterID string, before int
|
||||
// returned slice is non-nil.
|
||||
func (s *Store) ListAllLog(ctx context.Context, before int64, limit int) ([]Message, error) {
|
||||
limit = clampLimit(limit)
|
||||
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
|
||||
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at
|
||||
FROM chat_messages WHERE adapter_id != ''`
|
||||
var args []any
|
||||
if before > 0 {
|
||||
@@ -458,7 +479,7 @@ func (s *Store) Recent(ctx context.Context, sessionID string, n int) ([]Message,
|
||||
if n < 1 {
|
||||
n = defaultPageLimit
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at
|
||||
FROM chat_messages WHERE session_id = ? ORDER BY created_at DESC LIMIT ?`, sessionID, n)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("history: recent %s: %w", sessionID, err)
|
||||
@@ -508,24 +529,13 @@ func (s *Store) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewSubSession creates a sub session and returns its id (MainSessionID:uuid). ownerID is
|
||||
// accepted for interface completeness (the Web owner is identified by JWT, not stored per
|
||||
// session); it is logged at debug and not persisted.
|
||||
// NewSubSession creates a sub session with no explicit role and returns its id
|
||||
// (MainSessionID:uuid). The new session therefore inherits the global default role
|
||||
// (persona_id = ''); use NewSubSessionFor to bind a role at creation time. ownerID
|
||||
// is accepted for interface completeness (the Web owner is identified by JWT, not
|
||||
// stored per session); it is logged at debug and not persisted.
|
||||
func (s *Store) NewSubSession(ctx context.Context, ownerID string) (string, error) {
|
||||
id, err := newID()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("history: new sub id: %w", err)
|
||||
}
|
||||
sessionID := MainSessionID + ":" + id
|
||||
now := time.Now().UnixMilli()
|
||||
if _, err := s.db.ExecContext(ctx, `INSERT INTO chat_sessions (id, type, title, created_at)
|
||||
VALUES (?, ?, ?, ?)`, sessionID, SessionSub, "", now); err != nil {
|
||||
return "", fmt.Errorf("history: insert sub session: %w", err)
|
||||
}
|
||||
if s.log != nil {
|
||||
s.log.Debug("sub session created", "session_id", sessionID, "owner_id", ownerID)
|
||||
}
|
||||
return sessionID, nil
|
||||
return s.NewSubSessionFor(ctx, ownerID, "")
|
||||
}
|
||||
|
||||
// ensureMainSession inserts the fixed main session row when it is absent, so ListSessions
|
||||
@@ -543,7 +553,7 @@ func (s *Store) ensureMainSession(ctx context.Context) error {
|
||||
func scanMessage(sc rowScanner) (Message, error) {
|
||||
var m Message
|
||||
var mode, mood, platform, deviceID, senderName, adapterID string
|
||||
if err := sc.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &mode, &mood, &m.Image, &m.Thinking, &platform, &deviceID, &senderName, &adapterID, &m.CreatedAt); err != nil {
|
||||
if err := sc.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &mode, &mood, &m.Image, &m.Thinking, &platform, &deviceID, &senderName, &adapterID, &m.PersonaID, &m.CreatedAt); err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
m.Mode, m.Mood, m.Platform, m.DeviceID, m.SenderName, m.AdapterID = mode, mood, platform, deviceID, senderName, adapterID
|
||||
@@ -590,6 +600,7 @@ CREATE TABLE IF NOT EXISTS chat_sessions (
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
read_at INTEGER NOT NULL DEFAULT 0,
|
||||
persona_id TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
)`
|
||||
|
||||
@@ -607,6 +618,7 @@ CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
device_id TEXT NOT NULL DEFAULT '',
|
||||
sender_name TEXT NOT NULL DEFAULT '',
|
||||
adapter_id TEXT NOT NULL DEFAULT '',
|
||||
persona_id TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
)`
|
||||
|
||||
|
||||
Reference in new issue
Block a user