package history import ( "context" "errors" "fmt" "strings" ) // 本文件负责两条会话标题路径:从第一条用户消息为会话自动命名,以及显式 // 重命名。标题只是展示元数据(design/16 §2.1),绝不用于标识会话、发送者 // 或权限 —— 身份永远来自渠道 actor 元数据 / JWT(项目红线)。 const ( // autoTitleMaxRunes 限制自动生成标题的长度,使会话列表行不会溢出; // 更长的首条消息会被截断并加省略号。 autoTitleMaxRunes = 20 // titleMainFallback 与 titleSubFallback 用于给首条用户消息没有任何 // 文本的会话(例如纯图片回合)命名,使会话列表不会无名。 titleMainFallback = "主会话" titleSubFallback = "次级会话" ) // ErrSessionNotFound 在没有任何 chat_sessions 行携带所请求 id 时由 // RenameSession 返回。 var ErrSessionNotFound = errors.New("history: session not found") // RenameSession 设置 sessionID 的显示标题。固定主会话(id "web")与子会话 // 都可重命名:与删除不同,重命名不是破坏性的身份变更。未知 id 返回 // ErrSessionNotFound。标题由调用方校验(非空、限长);存储原样持久化它。 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 用第一条用户消息为会话命名。除非该会话行已存在于 // chat_sessions、其标题仍为空、且尚未为它持久化任何用户消息(因此调用方 // 必须在插入消息之前调用它),否则它是空操作。纯空白的消息回退到会话类型 // 标签。标题只是展示元数据,因此失败只记日志并吞掉,而不是让它伴随的 // 消息写入失败。 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 把一条用户消息变成会话标题:空白串(含换行)折叠为单个空格, // 再把结果截断到 autoTitleMaxRunes 个 rune 并加省略号。纯空白的消息得到 // 空串,使 ensureTitle 回退到会话类型标签。 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]) + "…" }