- 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 同步
87 lines
3.4 KiB
Go
87 lines
3.4 KiB
Go
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]) + "…"
|
|
}
|