按 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)
115 lines
4.5 KiB
Go
115 lines
4.5 KiB
Go
package history
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// This file owns the two derived per-session display fields carried in the session
|
|
// list (GET /api/v1/sessions): the last-message preview and the unread count. Both are
|
|
// read-only projections so a client can render a conversation row (with its unread
|
|
// badge) without one request per session. The preview is truncated for display, and
|
|
// neither field ever feeds identity or permission decisions — identity comes from
|
|
// channel actor metadata / JWT (project red line).
|
|
|
|
// lastMessagePreviewRunes bounds the preview content so a long message cannot bloat the
|
|
// session-list response. A longer message is whitespace-collapsed and truncated to this
|
|
// many runes with an ellipsis.
|
|
const lastMessagePreviewRunes = 60
|
|
|
|
// MessageSummary is a display-only preview of a session's most recent message.
|
|
type MessageSummary struct {
|
|
Role string // "user" | "assistant"
|
|
Content string // whitespace-collapsed, truncated preview
|
|
CreatedAt int64 // Unix milliseconds
|
|
}
|
|
|
|
// listSessionsQuery reads every session plus its newest message and its unread count
|
|
// in one statement. The LEFT JOIN's correlated subquery resolves each session's newest
|
|
// row via the (session_id, created_at DESC) index (rowid DESC breaks a same-millisecond
|
|
// tie by insertion order), so a session with no messages still appears with NULL preview
|
|
// columns. The unread count is a correlated COUNT(*) over the same index
|
|
// (session_id = s.id AND created_at > s.read_at is an equality + range scan), so no
|
|
// 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, 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)
|
|
FROM chat_sessions s
|
|
LEFT JOIN chat_messages m ON m.rowid = (
|
|
SELECT rowid FROM chat_messages
|
|
WHERE session_id = s.id
|
|
ORDER BY created_at DESC, rowid DESC
|
|
LIMIT 1
|
|
)
|
|
ORDER BY s.pinned DESC, (s.type = ?) DESC, s.created_at ASC`
|
|
|
|
// ListSessions returns pinned sessions first, then the main session, then every sub
|
|
// session, oldest first, each carrying a LastMessage preview of its newest message (nil
|
|
// when the session has none) and the UnreadCount derived from the session's read cursor.
|
|
// Sessions, previews and unread counts are read in a single query, so the query count
|
|
// does not grow with the number of sessions. The returned slice is non-nil.
|
|
func (s *Store) ListSessions(ctx context.Context) ([]SessionMeta, error) {
|
|
rows, err := s.db.QueryContext(ctx, listSessionsQuery, SessionMain)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("history: list sessions: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
sessions := make([]SessionMeta, 0)
|
|
for rows.Next() {
|
|
meta, err := scanSessionMeta(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("history: scan session: %w", err)
|
|
}
|
|
sessions = append(sessions, meta)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("history: iterate sessions: %w", err)
|
|
}
|
|
return sessions, nil
|
|
}
|
|
|
|
// scanSessionMeta reads one joined chat_sessions + last-message + unread-count row. The
|
|
// three message columns are NULL for a session that has never received a message, so they
|
|
// scan into nullable holders and only produce a LastMessage when a row is present. The
|
|
// unread count is a non-NULL COUNT(*), so it scans into a plain int.
|
|
func scanSessionMeta(sc rowScanner) (SessionMeta, error) {
|
|
var (
|
|
meta SessionMeta
|
|
typeStr string
|
|
pinned int
|
|
role sql.NullString
|
|
content sql.NullString
|
|
lastAt sql.NullInt64
|
|
)
|
|
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
|
|
meta.Pinned = pinned != 0
|
|
if role.Valid && lastAt.Valid {
|
|
meta.LastMessage = &MessageSummary{
|
|
Role: role.String,
|
|
Content: previewContent(content.String),
|
|
CreatedAt: lastAt.Int64,
|
|
}
|
|
}
|
|
return meta, nil
|
|
}
|
|
|
|
// previewContent collapses whitespace runs (newlines included) to single spaces and
|
|
// truncates to lastMessagePreviewRunes runes with an ellipsis. It is display-only.
|
|
func previewContent(content string) string {
|
|
joined := strings.Join(strings.Fields(content), " ")
|
|
runes := []rune(joined)
|
|
if len(runes) <= lastMessagePreviewRunes {
|
|
return joined
|
|
}
|
|
return string(runes[:lastMessagePreviewRunes]) + "…"
|
|
}
|