diff --git a/Docs/api-client.md b/Docs/api-client.md index 6c9eed9..d3abc07 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -28,10 +28,11 @@ | 5 | `/api/v1/sessions` | POST | JWT(owner/admin) | ✅ | **新建次级会话** → `201 {data:{sessionId}}`(`sessionId="web:"`);未注入 HistoryStore 回 `501 not_implemented` | | 6 | `/api/v1/sessions/{id}/messages` | GET | JWT(owner/admin) | ✅ | **热分页拉历史**:`?limit=&before=&platform=` 默认 `limit=50`(上限 200)、`before`=上一页最旧 `createdAt`(毫秒),按时间倒序 `{data:[{id,sessionId,role,content,mode?,mood?,image?,thinking?,platform?,deviceId?,senderName?,createdAt}]}`;`platform` 为**可选**来源平台过滤(design/16 §4.4)——Web 端不传(=全量共享主会话),IM 适配器传**本平台**(如 `telegram`)只拉本渠道消息;`deviceId` 为**客户端具体端标识**(design/16 §4.5,如 `web:chrome:a1b2c3` / `weixin-ilink:conf0:wx_uid`);`senderName` 为**第三方发送者昵称**(design/16 §4.6,群聊/陌生人私聊消息带,主人私聊为空,`omitempty`);非法 `limit`/`before` 回 `400`;未注入 HistoryStore 回 `501` | | 7 | `/api/v1/sessions/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除次级会话及其消息** → `{data:{deleted:}}`;主会话不可删回 `403 forbidden`;未注入 HistoryStore 回 `501` | -| 8 | `/api/v1/personas` | GET | JWT | ✅ | `{data:[persona...]}` | -| 9 | `/api/v1/audio/transcribe` | POST | JWT(owner) | ✅ | **语音→文本**:body `{audio:"",format?,deviceId?}` → `{data:{text}}`(走后端 STT,百炼 `qwen-audio-3.0-asr-flash`) | -| 10 | `/api/v1/audio/speech` | POST | JWT(owner) | ✅ | **文本→音频**:body `{text,voice?,format?,instruction?}` → `200` 音频字节(`Content-Type: audio/`,非 JSON envelope,走后端 TTS,百炼 `cosyvoice-v3-flash`,复刻音色可带 `instruction` 情绪指令) | -| 11 | `/api/v1/audio/voice-clone` | POST | JWT(owner) | ✅ | **声音复刻建音色**:body `{url:"<公网参考音频URL>",prefix?}` → `{data:{voiceId}}`(走百炼 `voice-enrollment` create_voice) | +| 8 | `/api/v1/sessions/{id}` | PATCH | JWT(owner/admin) | ✅ | **会话改名**(A4):body `{title}`(trim、≤80 rune,空/超长回 `400`)→ `{data:{id,title}}`;不存在回 `404`;主会话(`id="web"`)可改名但不可删;未注入 HistoryStore 回 `501`。另:会话**首条用户消息到达时自动命名** title(取前 20 rune 折叠空白;纯空白回落 `主会话`/`次级会话`),列表不再空名 | +| 9 | `/api/v1/personas` | GET | JWT | ✅ | `{data:[persona...]}` | +| 10 | `/api/v1/audio/transcribe` | POST | JWT(owner) | ✅ | **语音→文本**:body `{audio:"",format?,deviceId?}` → `{data:{text}}`(走后端 STT,百炼 `qwen-audio-3.0-asr-flash`) | +| 11 | `/api/v1/audio/speech` | POST | JWT(owner) | ✅ | **文本→音频**:body `{text,voice?,format?,instruction?}` → `200` 音频字节(`Content-Type: audio/`,非 JSON envelope,走后端 TTS,百炼 `cosyvoice-v3-flash`,复刻音色可带 `instruction` 情绪指令) | +| 12 | `/api/v1/audio/voice-clone` | POST | JWT(owner) | ✅ | **声音复刻建音色**:body `{url:"<公网参考音频URL>",prefix?}` → `{data:{voiceId}}`(走百炼 `voice-enrollment` create_voice) | > **语音 REST 源(`/api/v1/audio/*`,均 owner-only + 审计)**:原生/移动端用它上传/获取音频,**绕开 WS 4KB 帧限**。 > - `POST /api/v1/audio/transcribe`:body `{audio:"", format?:"wav", deviceId?}` → `200 {data:{text}}`。`audio` 为 base64(或 data: URI);`format` 为编码(`wav`/`mp3`/`ogg`,缺省 `wav`);空 audio 回 `400`;未注入语音服务 `500 voice_unavailable`、识别失败 `500 stt_failed`。 @@ -204,6 +205,7 @@ - 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。 ## 5. 变更记录 +- `v0.21`(**A4 会话 title**):`PATCH /api/v1/sessions/{id}`(**新增**,body `{title}` ≤80 rune → `{data:{id,title}}`;空/超长 400、不存在 404、主会话可改名);会话**首条用户消息到达时自动命名**(`history.append` 前 `ensureTitle`:折叠空白取前 20 rune,纯空白回落 `主会话`/`次级会话`),仅首次命名、不覆盖。`HistoryStore` 新增 `RenameSession`;隔离会话(`sess-xxx`)不在 `chat_sessions` 有行、不自动命名(其本不在 `/sessions` 列表)。**边界**:前端 `createSubSession(title?)` 的 POST body 含 `title` 但后端暂忽略(会被首条消息自动命名覆盖),未处理。 - `v0.20`(**A3 消息 mode 落库**):`POST /api/v1/chat` 与 `ws.chat` 上行新增**可选 `mode`**(行为模式,design/01 §3 / design/16 §5.1)。后端把每条持久化消息的 `Mode` **真实写入**(IM 路径恒 `chat`;Web 路径经 ctx 透传本轮 `mode`,缺省回落 `chat`;恢复/续接路径沿用发起轮次的 `mode`),消息 DTO、`message_persisted`、`channel_persisted` 的 `mode` 不再恒空,供前端按模式渲染气泡(**仅 `chat` 解析 `

` 拆气泡**,其它模式原样整条)。 - `v0.19`(**百炼声音复刻 create_voice + TTS instruction 情绪驱动,主 TTS 用昔涟复刻音色**):`POST /api/v1/audio/voice-clone`(**新增**,owner-only + 审计,body `{url:"<公网参考音频URL>", prefix?}` → `{data:{voiceId}}`)——走后端 `voice.DashscopeCloner`(百炼 `voice-enrollment` create_voice,`POST {base}/services/audio/tts/customization`,`input{action:create_voice, target_model:VOICE_TTS_MODEL, prefix, url}`,**只收公网 URL 不收 base64**,返回 `output.voice_id`;**URL 不打日志不写审计**)。`POST /api/v1/audio/speech` 新增**可选 `instruction`**(per-request 情绪/语气指令,如 `"语气开朗俏皮,像女朋友撒娇"`),经 ctx 透传给 TTS adapter;**只对复刻/设计音色生效**(`cosyvoice-v3-flash` 复刻音色可任意指令;系统音色 `longxiaochun_v3` 需固定格式,服务端 `canInstruct` 判定后**永不发送**)。config 新增 `VOICE_TTS_INSTRUCTION`(默认 `""`)、`VOICE_CLONE_PREFIX`(默认 `cyrene`,≤10 字母/数字);`voice.Chain`/`voice.Service` 新增 `CloneVoice`,`VoiceCloner` 接口(cloud rung 提供;未配 cloud 回 `voice_unavailable`)。**⚠️ 真建昔涟音色仍待主人提供公网参考音频 URL(当前无)**,且 CosyVoice 声音复刻仅华北2北京地域(确认 key 地域);TTS instruction 用现有复刻音色或先仅单测请求体,避免改系统音色行为。 - `v0.18`(**后端语音接通阿里百炼 STT + TTS 主链路**):`POST /api/v1/audio/transcribe`(**新增**,owner + 审计,body `{audio,format?,deviceId?}` → `{data:{text}}`,走 STT);`POST /api/v1/audio/speech`(**新增**,owner + 审计,body `{text,voice?,format?}` → 音频字节 `audio/`,走 TTS)——**REST 语音源**,原生/移动端经它绕开 WS 4KB 帧限上传/获取音频;WS `voice_input` 上行新增 **`deviceId`**(随语音轮次落库,design/16 §4.5)。后端 `internal/runtime/multimodal/voice` 的 cloud rung 由空 endpoint stub 改为**真实百炼 adapter**:STT `qwen-audio-3.0-asr-flash`(base64 原始字节→文本)、TTS `cosyvoice-v3-flash`(按 `voice_id` 合成,取非流式返回的 24h 音频 URL 下载字节)。**实测**(`DASHSCOPE_API_KEY` 实跑):TTS「你好,我是昔涟」→ 106124 字节 wav;把该 wav 喂给 STT → 文本「你好,我是西联。」(TTS 发音把「昔涟」读得近似「西联」,STT 识别正确);语音链 (cloud→…→silent) 返回真实字节/文本。**已实测修正**:`cosyvoice-v3.5-plus`/`longxiaochun`(v2 音色)走 HTTP `SpeechSynthesizer` 会回 `418`(引擎拒绝),改用 **`cosyvoice-v3-flash` + `longxiaochun_v3`**(v3 音色,各模型音色不可混用)。**声音复刻(响昔涟音色)仍待**:需参考音频(公网 URL)在百炼建自定义音色后把 `voice_id` 填入 `VOICE_TTS_VOICE_ID`(本项目不做 create_voice)。config 新增 `DASHSCOPE_BASE_URL`/`VOICE_STT_\*`/`VOICE_TTS_\*`(`VOICE_TTS_SAMPLE_RATE` 默认 24000)。 diff --git a/internal/runtime/history/store.go b/internal/runtime/history/store.go index 8905d84..9f57313 100644 --- a/internal/runtime/history/store.go +++ b/internal/runtime/history/store.go @@ -151,6 +151,10 @@ type HistoryStore interface { // DeleteSession removes a sub session and all its messages. The main session is // immutable and returns ErrImmutableMain. DeleteSession(ctx context.Context, sessionID string) error + // RenameSession sets a session's display title. Both the main session and sub + // sessions may be renamed (a title is display-only metadata); an unknown id + // returns ErrSessionNotFound. + RenameSession(ctx context.Context, sessionID, title string) error // NewSubSession creates a new sub session and returns its id (MainSessionID:uuid). NewSubSession(ctx context.Context, ownerID string) (string, error) // ListSessions returns the main session first then every sub session, oldest first. @@ -229,6 +233,9 @@ func (s *Store) insert(ctx context.Context, msg Message) (Message, error) { if msg.Platform == "" { msg.Platform = PlatformWeb } + if msg.Role == RoleUser { + s.ensureTitle(ctx, msg.SessionID, msg.Content) + } _, err := s.db.ExecContext(ctx, `INSERT INTO chat_messages (id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, diff --git a/internal/runtime/history/title.go b/internal/runtime/history/title.go new file mode 100644 index 0000000..9196c73 --- /dev/null +++ b/internal/runtime/history/title.go @@ -0,0 +1,86 @@ +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]) + "…" +} diff --git a/internal/runtime/history/title_test.go b/internal/runtime/history/title_test.go new file mode 100644 index 0000000..e69653b --- /dev/null +++ b/internal/runtime/history/title_test.go @@ -0,0 +1,152 @@ +package history + +import ( + "context" + "errors" + "strings" + "testing" +) + +// titleOf returns the stored title of a session, failing the test when it is absent. +func titleOf(t *testing.T, st *Store, id string) string { + t.Helper() + sessions, err := st.ListSessions(context.Background()) + if err != nil { + t.Fatalf("list sessions: %v", err) + } + for _, m := range sessions { + if m.ID == id { + return m.Title + } + } + t.Fatalf("session %q not found in %+v", id, sessions) + return "" +} + +func TestEnsureTitleFromFirstUserMessage(t *testing.T) { + t.Parallel() + st := newStore(t) + ctx := context.Background() + + // The main session starts nameless. + if got := titleOf(t, st, MainSessionID); got != "" { + t.Fatalf("initial main title = %q, want empty", got) + } + + // The first user message names it, with whitespace runs collapsed. + if _, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: " 你好\n世界 "}); err != nil { + t.Fatalf("append first user: %v", err) + } + if got := titleOf(t, st, MainSessionID); got != "你好 世界" { + t.Fatalf("main title = %q, want 你好 世界", got) + } + + // A later user message does not overwrite the established title. + if _, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "second"}); err != nil { + t.Fatalf("append second user: %v", err) + } + if got := titleOf(t, st, MainSessionID); got != "你好 世界" { + t.Fatalf("main title after second = %q, want unchanged", got) + } +} + +func TestEnsureTitleTruncatesAndFallsBack(t *testing.T) { + t.Parallel() + st := newStore(t) + ctx := context.Background() + + // A long first user message is truncated to 20 runes plus an ellipsis. + sub, err := st.NewSubSession(ctx, "owner") + if err != nil { + t.Fatalf("new sub: %v", err) + } + long := strings.Repeat("字", autoTitleMaxRunes+5) + if _, err := st.AppendFull(ctx, Message{SessionID: sub, Role: RoleUser, Content: long}); err != nil { + t.Fatalf("append long: %v", err) + } + want := strings.Repeat("字", autoTitleMaxRunes) + "…" + if got := titleOf(t, st, sub); got != want { + t.Fatalf("sub title = %q, want %q", got, want) + } + + // An all-whitespace first message (e.g. an image-only turn) falls back to the + // session-type label, for both a sub and the main session. + blank, err := st.NewSubSession(ctx, "owner") + if err != nil { + t.Fatalf("new blank sub: %v", err) + } + if _, err := st.AppendFull(ctx, Message{SessionID: blank, Role: RoleUser, Content: " \n\t "}); err != nil { + t.Fatalf("append blank: %v", err) + } + if got := titleOf(t, st, blank); got != titleSubFallback { + t.Fatalf("blank sub title = %q, want %q", got, titleSubFallback) + } + + st2 := newStore(t) + if _, err := st2.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: " "}); err != nil { + t.Fatalf("append blank main: %v", err) + } + if got := titleOf(t, st2, MainSessionID); got != titleMainFallback { + t.Fatalf("blank main title = %q, want %q", got, titleMainFallback) + } +} + +func TestEnsureTitleOnlyFromFirstUserMessage(t *testing.T) { + t.Parallel() + st := newStore(t) + ctx := context.Background() + + // An assistant message first does not name the session. + sub, err := st.NewSubSession(ctx, "owner") + if err != nil { + t.Fatalf("new sub: %v", err) + } + if _, err := st.AppendFull(ctx, Message{SessionID: sub, Role: RoleAssistant, Content: "hello"}); err != nil { + t.Fatalf("append assistant: %v", err) + } + if got := titleOf(t, st, sub); got != "" { + t.Fatalf("sub title after assistant = %q, want empty", got) + } + + // The first user message afterwards still names it. + if _, err := st.AppendFull(ctx, Message{SessionID: sub, Role: RoleUser, Content: "real question"}); err != nil { + t.Fatalf("append user: %v", err) + } + if got := titleOf(t, st, sub); got != "real question" { + t.Fatalf("sub title after user = %q, want real question", got) + } +} + +func TestRenameSession(t *testing.T) { + t.Parallel() + st := newStore(t) + ctx := context.Background() + + // The fixed main session may be renamed (unlike deletion). + if err := st.RenameSession(ctx, MainSessionID, "工作台"); err != nil { + t.Fatalf("rename main: %v", err) + } + if got := titleOf(t, st, MainSessionID); got != "工作台" { + t.Fatalf("main title = %q, want 工作台", got) + } + + // A sub session may be renamed, and a rename overwrites an auto title. + sub, err := st.NewSubSession(ctx, "owner") + if err != nil { + t.Fatalf("new sub: %v", err) + } + if _, err := st.AppendFull(ctx, Message{SessionID: sub, Role: RoleUser, Content: "auto"}); err != nil { + t.Fatalf("append: %v", err) + } + if err := st.RenameSession(ctx, sub, "手动标题"); err != nil { + t.Fatalf("rename sub: %v", err) + } + if got := titleOf(t, st, sub); got != "手动标题" { + t.Fatalf("sub title = %q, want 手动标题", got) + } + + // An unknown id is reported as not found. + if err := st.RenameSession(ctx, "web:missing", "x"); !errors.Is(err, ErrSessionNotFound) { + t.Fatalf("rename unknown err = %v, want ErrSessionNotFound", err) + } +} diff --git a/internal/services/channelbridge/channelbridge_test.go b/internal/services/channelbridge/channelbridge_test.go index c1fd9d3..70d0d2e 100644 --- a/internal/services/channelbridge/channelbridge_test.go +++ b/internal/services/channelbridge/channelbridge_test.go @@ -276,6 +276,9 @@ func (c *captureHistory) ListAllLog(context.Context, int64, int) ([]history.Mess return nil, nil } func (c *captureHistory) DeleteSession(context.Context, string) error { return nil } +func (c *captureHistory) RenameSession(context.Context, string, string) error { + return nil +} func (c *captureHistory) NewSubSession(context.Context, string) (string, error) { return "", nil } diff --git a/internal/services/gateway/history.go b/internal/services/gateway/history.go index c8d85b1..bfc408d 100644 --- a/internal/services/gateway/history.go +++ b/internal/services/gateway/history.go @@ -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{ diff --git a/internal/services/gateway/history_handler_test.go b/internal/services/gateway/history_handler_test.go index 0cde6df..a281e48 100644 --- a/internal/services/gateway/history_handler_test.go +++ b/internal/services/gateway/history_handler_test.go @@ -13,6 +13,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/db" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // testLogger returns a logger that writes nowhere, keeping test output clean. @@ -284,6 +285,154 @@ func TestHistoryDeleteSubSession(t *testing.T) { wantErrorShape(t, body) } +// TestHistoryRenameSession covers PATCH /api/v1/sessions/{id}: renaming a sub and +// the fixed main session, the input bounds, RBAC and an unknown id. +func TestHistoryRenameSession(t *testing.T) { + t.Parallel() + _, ts, jwt, _ := newHistoryEnv(t) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + // Create a sub session to rename. + resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions", nil, tok) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create status = %d, want 201 (body=%s)", resp.StatusCode, body) + } + var created struct { + SessionID string `json:"sessionId"` + } + if err := json.Unmarshal(decodeBody(t, body)["data"], &created); err != nil { + t.Fatalf("decode create data: %v", err) + } + + // Rename it and assert the response envelope plus the persisted list. + resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": " 我的会话 "}, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("rename status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + var renamed struct { + ID string `json:"id"` + Title string `json:"title"` + } + if err := json.Unmarshal(decodeBody(t, body)["data"], &renamed); err != nil { + t.Fatalf("decode rename data: %v", err) + } + if renamed.ID != created.SessionID || renamed.Title != "我的会话" { + t.Fatalf("rename data = %+v, want id %s title 我的会话", renamed, created.SessionID) + } + resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + var sessions []historySessionSummary + decodeDataSlice(t, body, &sessions) + found := false + for _, m := range sessions { + if m.ID == created.SessionID { + found = true + if m.Title != "我的会话" { + t.Fatalf("listed sub title = %q, want 我的会话", m.Title) + } + } + } + if !found { + t.Fatalf("renamed sub %s missing from %+v", created.SessionID, sessions) + } + + // The fixed main session may be renamed even though it cannot be deleted. + resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+history.MainSessionID, map[string]any{"title": "主控台"}, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("rename main status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + var mainRenamed struct { + ID string `json:"id"` + Title string `json:"title"` + } + if err := json.Unmarshal(decodeBody(t, body)["data"], &mainRenamed); err != nil { + t.Fatalf("decode main rename data: %v", err) + } + if mainRenamed.ID != history.MainSessionID || mainRenamed.Title != "主控台" { + t.Fatalf("main rename data = %+v, want id web title 主控台", mainRenamed) + } + + // Empty / whitespace-only / over-long titles are 400. + for _, bad := range []string{"", " ", strings.Repeat("字", maxSessionTitleRunes+1)} { + resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": bad}, tok) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("title %q status = %d, want 400 (body=%s)", bad, resp.StatusCode, body) + } + wantErrorShape(t, body) + } + + // An unknown session id is 404. + resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/web:missing", map[string]any{"title": "x"}, tok) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("unknown rename status = %d, want 404 (body=%s)", resp.StatusCode, body) + } + + // A title of exactly the 80-rune bound is accepted. + exact := strings.Repeat("字", maxSessionTitleRunes) + resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": exact}, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("80-rune rename status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + + // A viewer cannot rename (owner/admin write). + viewer := signAny(t, jwt, "acct", auth.RoleViewer) + resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": "nope"}, viewer) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("viewer rename status = %d, want 403 (body=%s)", resp.StatusCode, body) + } +} + +// TestPersistUserMessageNamesSession exercises the Web persistence path end to end: +// persistUserMessages (the orchestrator hook the HTTP/WS boundary calls) writes the +// first user message through the real store, which names the session. A later user +// message leaves the title alone. +func TestPersistUserMessageNamesSession(t *testing.T) { + t.Parallel() + _, _, _, st := newHistoryEnv(t) + o := &Orchestrator{history: st, log: testLogger()} + ctx := context.Background() + + // A sub session starts nameless; the first user message names it. + sub, err := st.NewSubSession(ctx, "owner") + if err != nil { + t.Fatalf("new sub: %v", err) + } + o.persistUserMessages(ctx, sub, []model.Message{model.TextMessage(model.RoleUser, "帮我规划一次旅行")}, "web:test", "chat") + o.persistUserMessages(ctx, sub, []model.Message{model.TextMessage(model.RoleUser, "第二句话")}, "web:test", "chat") + + metas, err := st.ListSessions(ctx) + if err != nil { + t.Fatalf("list sessions: %v", err) + } + var gotSub string + for _, m := range metas { + if m.ID == sub { + gotSub = m.Title + } + } + if gotSub != "帮我规划一次旅行" { + t.Fatalf("sub title = %q, want 帮我规划一次旅行", gotSub) + } + + // The main session is named from its first Web user message too. + o.persistUserMessages(ctx, history.MainSessionID, []model.Message{model.TextMessage(model.RoleUser, " 今天\n天气如何 ")}, "web:test", "chat") + metas, err = st.ListSessions(ctx) + if err != nil { + t.Fatalf("list sessions again: %v", err) + } + var gotMain string + for _, m := range metas { + if m.ID == history.MainSessionID { + gotMain = m.Title + } + } + if gotMain != "今天 天气如何" { + t.Fatalf("main title = %q, want 今天 天气如何", gotMain) + } +} + func TestHistoryNotWired(t *testing.T) { t.Parallel() _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) @@ -296,6 +445,7 @@ func TestHistoryNotWired(t *testing.T) { }{ {http.MethodGet, "/api/v1/sessions/web/messages?limit=2", http.StatusNotImplemented}, {http.MethodPost, "/api/v1/sessions", http.StatusNotImplemented}, + {http.MethodPatch, "/api/v1/sessions/web", http.StatusNotImplemented}, } { resp, body := doReq(t, tc.method, ts.URL+tc.url, nil, tok) if resp.StatusCode != tc.want { diff --git a/internal/services/gateway/server.go b/internal/services/gateway/server.go index 884cdd2..1b03a4c 100644 --- a/internal/services/gateway/server.go +++ b/internal/services/gateway/server.go @@ -119,6 +119,9 @@ type HistoryStore interface { ListAdapterLog(ctx context.Context, adapterID string, before int64, limit int) ([]history.Message, error) ListAllLog(ctx context.Context, before int64, limit int) ([]history.Message, error) DeleteSession(ctx context.Context, sessionID string) error + // RenameSession sets a session's display title (main or sub). An unknown id + // returns history.ErrSessionNotFound. + RenameSession(ctx context.Context, sessionID, title string) error NewSubSession(ctx context.Context, ownerID string) (string, error) ListSessions(ctx context.Context) ([]history.SessionMeta, error) } @@ -400,6 +403,7 @@ func (s *Server) Handler() http.Handler { mux.Handle("GET /api/v1/sessions/{id}/messages", historyAuth(http.HandlerFunc(s.handleListMessages))) mux.Handle("POST /api/v1/sessions", historyAuth(http.HandlerFunc(s.handleCreateSubSession))) mux.Handle("DELETE /api/v1/sessions/{id}", historyAuth(http.HandlerFunc(s.handleDeleteSession))) + mux.Handle("PATCH /api/v1/sessions/{id}", historyAuth(http.HandlerFunc(s.handleRenameSession))) // Realtime. mux.HandleFunc("/ws/chat", s.handleWS) diff --git a/internal/services/gateway/session_orchestrator_test.go b/internal/services/gateway/session_orchestrator_test.go index 3e077c3..d93a1b6 100644 --- a/internal/services/gateway/session_orchestrator_test.go +++ b/internal/services/gateway/session_orchestrator_test.go @@ -199,6 +199,10 @@ func (f *fakeHistory) DeleteSession(context.Context, string) error { return nil } +func (f *fakeHistory) RenameSession(context.Context, string, string) error { + return nil +} + func (f *fakeHistory) NewSubSession(context.Context, string) (string, error) { return "", nil }