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