feat(gateway): 会话title生成+PATCH改名(A4) — 首条用户消息自动命名+可重命名
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- history/title.go: RenameSession(改名字段)+ensureTitle(首条用户消息自动命名,折叠空白取前20rune,纯空白回落主会话/次级会话)
- store.go insert() 前调用 ensureTitle(覆盖Web+IM,最小侵入), HistoryStore 新增 RenameSession
- gateway: PATCH /api/v1/sessions/{id} (owner/admin, ≤80rune, 主会话可改名), handleRenameSession
- 新增/扩展测试(title_test, history_handler_test, fake同步)
- Docs/api-client.md v0.21 同步
This commit is contained in:
AskaEth committed 2026-09-08 21:13:36 +08:00
1 parent 747b9ad414
commit 89c5fe8027
9 files changed
+466 -4

No files matched your search

+7
View File
@@ -151,6 +151,10 @@ type HistoryStore interface {
// DeleteSession removes a sub session and all its messages. The main session is
// immutable and returns ErrImmutableMain.
DeleteSession(ctx context.Context, sessionID string) error
// RenameSession sets a session's display title. Both the main session and sub
// sessions may be renamed (a title is display-only metadata); an unknown id
// returns ErrSessionNotFound.
RenameSession(ctx context.Context, sessionID, title string) error
// NewSubSession creates a new sub session and returns its id (MainSessionID:uuid).
NewSubSession(ctx context.Context, ownerID string) (string, error)
// ListSessions returns the main session first then every sub session, oldest first.
@@ -229,6 +233,9 @@ func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
if msg.Platform == "" {
msg.Platform = PlatformWeb
}
if msg.Role == RoleUser {
s.ensureTitle(ctx, msg.SessionID, msg.Content)
}
_, err := s.db.ExecContext(ctx, `INSERT INTO chat_messages
(id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+86
View File
@@ -0,0 +1,86 @@
package history
import (
"context"
"errors"
"fmt"
"strings"
)
// This file owns the two session-title paths: auto-naming a session from its first
// user message and an explicit rename. A title is display-only metadata (design/16
// §2.1) and is never used to identify a session, a sender or a permission — identity
// always comes from the channel actor metadata / JWT (project red line).
const (
// autoTitleMaxRunes bounds an auto-generated title so a session list row never
// overflows; a longer first message is truncated with an ellipsis.
autoTitleMaxRunes = 20
// titleMainFallback and titleSubFallback name a session whose first user
// message carries no text (e.g. an image-only turn), so the session list is
// never nameless.
titleMainFallback = "主会话"
titleSubFallback = "次级会话"
)
// ErrSessionNotFound is returned by RenameSession when no chat_sessions row carries
// the requested id.
var ErrSessionNotFound = errors.New("history: session not found")
// RenameSession sets the display title of sessionID. Both the fixed main session
// (id "web") and sub sessions may be renamed: renaming is not a destructive identity
// change, unlike deletion. An unknown id returns ErrSessionNotFound. The caller
// validates the title (non-empty, length-bounded); the store persists it verbatim.
func (s *Store) RenameSession(ctx context.Context, sessionID, title string) error {
res, err := s.db.ExecContext(ctx, `UPDATE chat_sessions SET title = ? WHERE id = ?`, title, sessionID)
if err != nil {
return fmt.Errorf("history: rename session %s: %w", sessionID, err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("history: rename session %s rows: %w", sessionID, err)
}
if n == 0 {
return fmt.Errorf("history: rename session %s: %w", sessionID, ErrSessionNotFound)
}
return nil
}
// ensureTitle names a session from its first user message. It is a no-op unless the
// session row exists in chat_sessions, its title is still empty, and no user message
// has been persisted for it yet (so the caller must invoke it before inserting the
// message). A message that is all whitespace falls back to the session-type label.
// The title is display-only metadata, so a failure is logged and swallowed rather
// than failing the message write it accompanies.
func (s *Store) ensureTitle(ctx context.Context, sessionID, content string) {
if sessionID == "" {
return
}
title := deriveTitle(content)
_, err := s.db.ExecContext(ctx, `UPDATE chat_sessions
SET title = CASE WHEN ? = '' THEN CASE WHEN type = ? THEN ? ELSE ? END ELSE ? END
WHERE id = ? AND title = ''
AND NOT EXISTS (SELECT 1 FROM chat_messages WHERE session_id = ? AND role = ?)`,
title, SessionMain, titleMainFallback, titleSubFallback, title,
sessionID, sessionID, RoleUser)
if err != nil && s.log != nil {
s.log.Warn("auto title failed", "err", err, "session_id", sessionID)
}
}
// deriveTitle turns a user message into a session title: whitespace runs (newlines
// included) collapse to a single space, then the result is truncated to
// autoTitleMaxRunes runes with an ellipsis. A message that is all whitespace yields
// "" so ensureTitle falls back to the session-type label.
func deriveTitle(content string) string {
fields := strings.Fields(content)
if len(fields) == 0 {
return ""
}
joined := strings.Join(fields, " ")
runes := []rune(joined)
if len(runes) <= autoTitleMaxRunes {
return joined
}
return string(runes[:autoTitleMaxRunes]) + "…"
}
+152
View File
@@ -0,0 +1,152 @@
package history
import (
"context"
"errors"
"strings"
"testing"
)
// titleOf returns the stored title of a session, failing the test when it is absent.
func titleOf(t *testing.T, st *Store, id string) string {
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.Title
}
}
t.Fatalf("session %q not found in %+v", id, sessions)
return ""
}
func TestEnsureTitleFromFirstUserMessage(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
// The main session starts nameless.
if got := titleOf(t, st, MainSessionID); got != "" {
t.Fatalf("initial main title = %q, want empty", got)
}
// The first user message names it, with whitespace runs collapsed.
if _, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: " 你好\n世界 "}); err != nil {
t.Fatalf("append first user: %v", err)
}
if got := titleOf(t, st, MainSessionID); got != "你好 世界" {
t.Fatalf("main title = %q, want 你好 世界", got)
}
// A later user message does not overwrite the established title.
if _, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "second"}); err != nil {
t.Fatalf("append second user: %v", err)
}
if got := titleOf(t, st, MainSessionID); got != "你好 世界" {
t.Fatalf("main title after second = %q, want unchanged", got)
}
}
func TestEnsureTitleTruncatesAndFallsBack(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
// A long first user message is truncated to 20 runes plus an ellipsis.
sub, err := st.NewSubSession(ctx, "owner")
if err != nil {
t.Fatalf("new sub: %v", err)
}
long := strings.Repeat("字", autoTitleMaxRunes+5)
if _, err := st.AppendFull(ctx, Message{SessionID: sub, Role: RoleUser, Content: long}); err != nil {
t.Fatalf("append long: %v", err)
}
want := strings.Repeat("字", autoTitleMaxRunes) + "…"
if got := titleOf(t, st, sub); got != want {
t.Fatalf("sub title = %q, want %q", got, want)
}
// An all-whitespace first message (e.g. an image-only turn) falls back to the
// session-type label, for both a sub and the main session.
blank, err := st.NewSubSession(ctx, "owner")
if err != nil {
t.Fatalf("new blank sub: %v", err)
}
if _, err := st.AppendFull(ctx, Message{SessionID: blank, Role: RoleUser, Content: " \n\t "}); err != nil {
t.Fatalf("append blank: %v", err)
}
if got := titleOf(t, st, blank); got != titleSubFallback {
t.Fatalf("blank sub title = %q, want %q", got, titleSubFallback)
}
st2 := newStore(t)
if _, err := st2.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: " "}); err != nil {
t.Fatalf("append blank main: %v", err)
}
if got := titleOf(t, st2, MainSessionID); got != titleMainFallback {
t.Fatalf("blank main title = %q, want %q", got, titleMainFallback)
}
}
func TestEnsureTitleOnlyFromFirstUserMessage(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
// An assistant message first does not name the session.
sub, err := st.NewSubSession(ctx, "owner")
if err != nil {
t.Fatalf("new sub: %v", err)
}
if _, err := st.AppendFull(ctx, Message{SessionID: sub, Role: RoleAssistant, Content: "hello"}); err != nil {
t.Fatalf("append assistant: %v", err)
}
if got := titleOf(t, st, sub); got != "" {
t.Fatalf("sub title after assistant = %q, want empty", got)
}
// The first user message afterwards still names it.
if _, err := st.AppendFull(ctx, Message{SessionID: sub, Role: RoleUser, Content: "real question"}); err != nil {
t.Fatalf("append user: %v", err)
}
if got := titleOf(t, st, sub); got != "real question" {
t.Fatalf("sub title after user = %q, want real question", got)
}
}
func TestRenameSession(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
// The fixed main session may be renamed (unlike deletion).
if err := st.RenameSession(ctx, MainSessionID, "工作台"); err != nil {
t.Fatalf("rename main: %v", err)
}
if got := titleOf(t, st, MainSessionID); got != "工作台" {
t.Fatalf("main title = %q, want 工作台", got)
}
// A sub session may be renamed, and a rename overwrites an auto title.
sub, err := st.NewSubSession(ctx, "owner")
if err != nil {
t.Fatalf("new sub: %v", err)
}
if _, err := st.AppendFull(ctx, Message{SessionID: sub, Role: RoleUser, Content: "auto"}); err != nil {
t.Fatalf("append: %v", err)
}
if err := st.RenameSession(ctx, sub, "手动标题"); err != nil {
t.Fatalf("rename sub: %v", err)
}
if got := titleOf(t, st, sub); got != "手动标题" {
t.Fatalf("sub title = %q, want 手动标题", got)
}
// An unknown id is reported as not found.
if err := st.RenameSession(ctx, "web:missing", "x"); !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("rename unknown err = %v, want ErrSessionNotFound", err)
}
}