Files
Cyrene_v2/internal/runtime/history/title.go
T
AskaEth 89c5fe8027
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): 会话title生成+PATCH改名(A4) — 首条用户消息自动命名+可重命名
- 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 同步
2026-09-08 21:13:36 +08:00

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]) + "…"
}