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
@@ -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