- history/message_ops.go(新): Search(会话内, q/after互斥, LIKE参数化+%/_转义+大小写不敏感, 严格限定会话doc) / UpdateMessage(返回更新后Message) / DeleteMessage / messageByID / escapeLike
- history/session_pin.go(新): SetPinned; 迁移0009 chat_sessions加pinned INTEGER DEFAULT 0; store.go SessionMeta.Pinned + createSessionsTable加列
- session_preview.go: 查询加s.pinned + ORDER BY pinned DESC,(type=main) DESC,created_at ASC
- gateway history_write.go(新,保持history.go≤500): handlePatchSession(pinned+title) / handlePatchMessage / handleDeleteMessage; server.go加PATCH/DELETE /api/v1/messages/{id} + /sessions/{id}置顶
- history.go: handleListMessages读q分流Search; 契约PATCH title/置顶
- D2会话内搜索(不做跨会话,隔离会话不外泄红线); D3不做重发端点(客户端编辑后再发); D5用pinned bool
- 测试(message_ops/history_messages/migrate_pinned), build/vet/test绿+3平台交叉编译; Docs/api-client v0.30
- 未做: 编辑/删除WS实时广播(message_edited/deleted), 下批
107 lines
3.9 KiB
Go
107 lines
3.9 KiB
Go
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 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,
|
|
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.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). 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
|
|
pinned int
|
|
role sql.NullString
|
|
content sql.NullString
|
|
lastAt sql.NullInt64
|
|
)
|
|
if err := sc.Scan(&meta.ID, &typeStr, &meta.Title, &pinned, &meta.CreatedAt, &role, &content, &lastAt); 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]) + "…"
|
|
}
|