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

+54
View File
@@ -1,9 +1,12 @@
package gateway
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"unicode/utf8"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
@@ -257,6 +260,57 @@ func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"deleted": id})
}
// renameSessionRequest is the PATCH /api/v1/sessions/{id} body.
type renameSessionRequest struct {
Title string `json:"title"`
}
// maxSessionTitleRunes bounds a client-supplied session title so a display row never
// overflows. It is a display-only bound: identity and permissions never derive from
// the title (project red line).
const maxSessionTitleRunes = 80
// handleRenameSession renames a persistent session (main or sub). The title is
// display-only metadata, so unlike DELETE it is allowed on the fixed main session. An
// empty/whitespace title or one longer than maxSessionTitleRunes answers 400; an
// unknown session id answers 404. It is owner/admin-gated like the other history
// writes.
func (s *Server) handleRenameSession(w http.ResponseWriter, r *http.Request) {
if s.history == nil {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
return
}
id := r.PathValue("id")
if id == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "session id is required"))
return
}
var req renameSessionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
title := strings.TrimSpace(req.Title)
if title == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "title is required"))
return
}
if utf8.RuneCountInString(title) > maxSessionTitleRunes {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "title is too long (max 80 characters)"))
return
}
if err := s.history.RenameSession(r.Context(), id, title); err != nil {
if errors.Is(err, history.ErrSessionNotFound) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "session %q not found", id))
return
}
s.log.Error("history rename session", "err", err, "session", id)
writeError(w, apperr.Wrap(apperr.ErrInternal, "session_failed", "failed to rename session"))
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id, "title": title})
}
// toHistoryMessage maps a stored history.Message to its wire shape.
func toHistoryMessage(m history.Message) historyMessage {
return historyMessage{