feat(history): 消息搜索/编辑/删除 + 会话置顶(D2/D3/D5)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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), 下批
This commit is contained in:
AskaEth committed 2026-09-08 23:59:13 +08:00
1 parent c7b2cd8ae0
commit 844b2a74c1
15 files changed
+1091 -73

No files matched your search

+11 -8
View File
@@ -29,8 +29,9 @@ type MessageSummary struct {
// 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,
// 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 = (
@@ -39,12 +40,12 @@ const listSessionsQuery = `SELECT s.id, s.type, s.title, s.created_at,
ORDER BY created_at DESC, rowid DESC
LIMIT 1
)
ORDER BY (s.type = ?) DESC, s.created_at ASC`
ORDER BY s.pinned DESC, (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.
// 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 {
@@ -73,14 +74,16 @@ 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, &meta.CreatedAt, &role, &content, &lastAt); err != nil {
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,