feat(history): 会话列表lastMessage预览(B3) — 单查询无N+1
- history/session_preview.go: SessionMeta.LastMessage + 单条SQL(LEFT JOIN取每会话ORDER BY created_at DESC,rowid DESC LIMIT 1,走覆盖索引)
- ListSessions 迁出重写(签名不变), content折叠空白截前60rune
- gateway: historySessionSummary 增 lastMessage{role,content,createdAt}(omitempty), /sessions与/admin/sessions共用
- 新增测试(session_preview_test, history_lastmessage_test)
- Docs/api-client.md v0.22 同步
This commit is contained in:
1 parent
89c5fe8027
commit
d102f75469
6 files changed
+331
-35
No files matched your search
@@ -0,0 +1,103 @@
|
||||
package history
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// This file owns the last-message preview carried in the session list
|
||||
// (GET /api/v1/sessions): a display-only summary of each session's newest message so a
|
||||
// client can render a conversation row without one request per session. The preview is
|
||||
// truncated for display and never 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 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. Ordering keeps the main session first, then subs oldest-first.
|
||||
const listSessionsQuery = `SELECT s.id, s.type, s.title, s.created_at,
|
||||
m.role, m.content, m.created_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.type = ?) DESC, s.created_at ASC`
|
||||
|
||||
// ListSessions returns the main session first, then every sub session, oldest first,
|
||||
// each carrying a LastMessage preview of its newest message (nil when the session has
|
||||
// none). Sessions and previews 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 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.
|
||||
func scanSessionMeta(sc rowScanner) (SessionMeta, error) {
|
||||
var (
|
||||
meta SessionMeta
|
||||
typeStr string
|
||||
role sql.NullString
|
||||
content sql.NullString
|
||||
lastAt sql.NullInt64
|
||||
)
|
||||
if err := sc.Scan(&meta.ID, &typeStr, &meta.Title, &meta.CreatedAt, &role, &content, &lastAt); err != nil {
|
||||
return SessionMeta{}, err
|
||||
}
|
||||
meta.Type = typeStr
|
||||
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]) + "…"
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package history
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// sessionOf returns the SessionMeta of one session from ListSessions, failing the test
|
||||
// when the session is absent.
|
||||
func sessionOf(t *testing.T, st *Store, id string) SessionMeta {
|
||||
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
|
||||
}
|
||||
}
|
||||
t.Fatalf("session %q not found in %+v", id, sessions)
|
||||
return SessionMeta{}
|
||||
}
|
||||
|
||||
func TestListSessionsLastMessagePreview(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// The empty main session has no preview.
|
||||
if got := sessionOf(t, st, MainSessionID); got.LastMessage != nil {
|
||||
t.Fatalf("empty main LastMessage = %+v, want nil", got.LastMessage)
|
||||
}
|
||||
|
||||
sub, err := st.NewSubSession(ctx, "owner")
|
||||
if err != nil {
|
||||
t.Fatalf("new sub: %v", err)
|
||||
}
|
||||
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "first", CreatedAt: 1000}); err != nil {
|
||||
t.Fatalf("append main user: %v", err)
|
||||
}
|
||||
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "latest reply", CreatedAt: 2000}); err != nil {
|
||||
t.Fatalf("append main assistant: %v", err)
|
||||
}
|
||||
if err := st.Append(ctx, Message{SessionID: sub, Role: RoleUser, Content: "sub question", CreatedAt: 1500}); err != nil {
|
||||
t.Fatalf("append sub: %v", err)
|
||||
}
|
||||
|
||||
// Main stays first and carries its newest message, not the first one.
|
||||
sessions, err := st.ListSessions(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list sessions: %v", err)
|
||||
}
|
||||
if len(sessions) != 2 || sessions[0].ID != MainSessionID {
|
||||
t.Fatalf("sessions = %+v, want main first", sessions)
|
||||
}
|
||||
main := sessions[0].LastMessage
|
||||
if main == nil || main.Role != RoleAssistant || main.Content != "latest reply" || main.CreatedAt != 2000 {
|
||||
t.Fatalf("main LastMessage = %+v, want assistant/latest reply/2000", main)
|
||||
}
|
||||
subPreview := sessions[1].LastMessage
|
||||
if subPreview == nil || subPreview.Role != RoleUser || subPreview.Content != "sub question" || subPreview.CreatedAt != 1500 {
|
||||
t.Fatalf("sub LastMessage = %+v, want user/sub question/1500", subPreview)
|
||||
}
|
||||
|
||||
// A sub session created after the main one but never written has no preview.
|
||||
empty, err := st.NewSubSession(ctx, "owner")
|
||||
if err != nil {
|
||||
t.Fatalf("new empty sub: %v", err)
|
||||
}
|
||||
if got := sessionOf(t, st, empty); got.LastMessage != nil {
|
||||
t.Fatalf("empty sub LastMessage = %+v, want nil", got.LastMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSessionsLastMessageTruncatesAndCollapses(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
long := strings.Repeat("字", lastMessagePreviewRunes+10) + "\n\n尾部"
|
||||
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: long, CreatedAt: 1000}); err != nil {
|
||||
t.Fatalf("append long: %v", err)
|
||||
}
|
||||
want := strings.Repeat("字", lastMessagePreviewRunes) + "…"
|
||||
if got := sessionOf(t, st, MainSessionID).LastMessage; got == nil || got.Content != want {
|
||||
t.Fatalf("truncated preview = %+v, want %q", got, want)
|
||||
}
|
||||
|
||||
// Whitespace runs (including newlines) collapse to single spaces, and a short
|
||||
// message is returned whole.
|
||||
sub, err := st.NewSubSession(ctx, "owner")
|
||||
if err != nil {
|
||||
t.Fatalf("new sub: %v", err)
|
||||
}
|
||||
if err := st.Append(ctx, Message{SessionID: sub, Role: RoleUser, Content: " 你好\n\t世界 ", CreatedAt: 1000}); err != nil {
|
||||
t.Fatalf("append spaced: %v", err)
|
||||
}
|
||||
if got := sessionOf(t, st, sub).LastMessage; got == nil || got.Content != "你好 世界" {
|
||||
t.Fatalf("collapsed preview = %+v, want %q", got, "你好 世界")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSessionsLastMessageTieBreaksByInsertion(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Two messages sharing a millisecond timestamp: the later insert is the newest.
|
||||
for _, content := range []string{"older", "newer"} {
|
||||
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: content, CreatedAt: 5000}); err != nil {
|
||||
t.Fatalf("append %s: %v", content, err)
|
||||
}
|
||||
}
|
||||
if got := sessionOf(t, st, MainSessionID).LastMessage; got == nil || got.Content != "newer" {
|
||||
t.Fatalf("tie-break preview = %+v, want newer", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewContentBounds(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := previewContent(""); got != "" {
|
||||
t.Fatalf("empty preview = %q, want empty", got)
|
||||
}
|
||||
exact := strings.Repeat("a", lastMessagePreviewRunes)
|
||||
if got := previewContent(exact); got != exact {
|
||||
t.Fatalf("exact-length preview changed: %q", got)
|
||||
}
|
||||
over := strings.Repeat("a", lastMessagePreviewRunes+1)
|
||||
if got := previewContent(over); got != exact+"…" {
|
||||
t.Fatalf("over-length preview = %q, want ellipsis", got)
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,11 @@ type SessionMeta struct {
|
||||
Type string // "main" | "sub"
|
||||
Title string
|
||||
CreatedAt int64
|
||||
// LastMessage is a display-only preview of the session's newest message, filled
|
||||
// by ListSessions in the same query. It is nil for a session with no messages.
|
||||
// A client uses it to render the conversation row without a per-session request;
|
||||
// identity and permissions never derive from it.
|
||||
LastMessage *MessageSummary
|
||||
}
|
||||
|
||||
// HistoryStore abstracts the chat-history store so callers (the gateway) and tests can
|
||||
@@ -481,36 +486,6 @@ func (s *Store) NewSubSession(ctx context.Context, ownerID string) (string, erro
|
||||
return sessionID, nil
|
||||
}
|
||||
|
||||
// ListSessions returns the main session first, then every sub session, oldest first. The
|
||||
// returned slice is non-nil.
|
||||
func (s *Store) ListSessions(ctx context.Context) ([]SessionMeta, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, type, title, created_at FROM chat_sessions
|
||||
ORDER BY (type = ?) DESC, created_at ASC`, SessionMain)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("history: list sessions: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
sessions := make([]SessionMeta, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
meta SessionMeta
|
||||
typeStr string
|
||||
createdAt int64
|
||||
)
|
||||
if err := rows.Scan(&meta.ID, &typeStr, &meta.Title, &createdAt); err != nil {
|
||||
return nil, fmt.Errorf("history: scan session: %w", err)
|
||||
}
|
||||
meta.Type = typeStr
|
||||
meta.CreatedAt = createdAt
|
||||
sessions = append(sessions, meta)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("history: iterate sessions: %w", err)
|
||||
}
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
// ensureMainSession inserts the fixed main session row when it is absent, so ListSessions
|
||||
// always reports the main session even before any message is written.
|
||||
func (s *Store) ensureMainSession(ctx context.Context) error {
|
||||
|
||||
@@ -20,6 +20,18 @@ type historySessionSummary struct {
|
||||
Type string `json:"type"` // "main" | "sub"
|
||||
Title string `json:"title,omitempty"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
// LastMessage is a display-only preview of the session's newest message, so the
|
||||
// client can render a conversation row (e.g. the Android session list) without a
|
||||
// per-session request. It is omitted for a session with no messages. Content is
|
||||
// already truncated by the store.
|
||||
LastMessage *historyLastMessage `json:"lastMessage,omitempty"`
|
||||
}
|
||||
|
||||
// historyLastMessage is the wire shape of the last-message preview in a session summary.
|
||||
type historyLastMessage struct {
|
||||
Role string `json:"role"` // "user" | "assistant"
|
||||
Content string `json:"content"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
// historyMessage is the wire shape of one persisted chat message in the
|
||||
@@ -60,15 +72,25 @@ func (s *Server) handleHistorySessions(w http.ResponseWriter, r *http.Request) {
|
||||
out := make([]historySessionSummary, 0, len(meta))
|
||||
for _, m := range meta {
|
||||
out = append(out, historySessionSummary{
|
||||
ID: m.ID,
|
||||
Type: m.Type,
|
||||
Title: m.Title,
|
||||
CreatedAt: m.CreatedAt,
|
||||
ID: m.ID,
|
||||
Type: m.Type,
|
||||
Title: m.Title,
|
||||
CreatedAt: m.CreatedAt,
|
||||
LastMessage: toHistoryLastMessage(m.LastMessage),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// toHistoryLastMessage maps a stored last-message preview to its wire shape, returning
|
||||
// nil for a session that has no messages (so the field is omitted from the JSON).
|
||||
func toHistoryLastMessage(m *history.MessageSummary) *historyLastMessage {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return &historyLastMessage{Role: m.Role, Content: m.Content, CreatedAt: m.CreatedAt}
|
||||
}
|
||||
|
||||
// handleCreateSubSession creates a new sub session and returns its sessionId. It is a
|
||||
// user-side write, so it is owner/admin-gated like /api/v1/chat.
|
||||
func (s *Server) handleCreateSubSession(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
)
|
||||
|
||||
// TestHistorySessionsLastMessage asserts the GET /api/v1/sessions wire contract for the
|
||||
// last-message preview (B3): every session carries lastMessage {role,content,createdAt}
|
||||
// with the newest message's whitespace-collapsed content, and a message-less session
|
||||
// omits the field.
|
||||
func TestHistorySessionsLastMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt, st := newHistoryEnv(t)
|
||||
ctx := context.Background()
|
||||
if err := st.Append(ctx, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "你好", CreatedAt: 1000}); err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
if err := st.Append(ctx, history.Message{SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: " 在的\n请说 ", CreatedAt: 2000}); err != nil {
|
||||
t.Fatalf("seed assistant: %v", err)
|
||||
}
|
||||
sub, err := st.NewSubSession(ctx, "owner")
|
||||
if err != nil {
|
||||
t.Fatalf("new sub: %v", err)
|
||||
}
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
// Decode into a JSON-tagged shape so the wire contract (key names) is asserted,
|
||||
// not just the in-package struct.
|
||||
var sessions []struct {
|
||||
ID string `json:"id"`
|
||||
LastMessage *struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
} `json:"lastMessage"`
|
||||
}
|
||||
decodeDataSlice(t, body, &sessions)
|
||||
if len(sessions) != 2 || sessions[0].ID != history.MainSessionID {
|
||||
t.Fatalf("sessions = %+v, want main first", sessions)
|
||||
}
|
||||
main := sessions[0].LastMessage
|
||||
if main == nil || main.Role != history.RoleAssistant || main.Content != "在的 请说" || main.CreatedAt != 2000 {
|
||||
t.Fatalf("main lastMessage = %+v, want assistant/在的 请说/2000", main)
|
||||
}
|
||||
// The empty sub session omits lastMessage entirely.
|
||||
if sessions[1].ID != sub {
|
||||
t.Fatalf("sessions[1].id = %q, want %q", sessions[1].ID, sub)
|
||||
}
|
||||
if sessions[1].LastMessage != nil {
|
||||
t.Fatalf("empty sub lastMessage = %+v, want omitted", sessions[1].LastMessage)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user