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 同步
This commit is contained in:
1 parent
747b9ad414
commit
89c5fe8027
9 files changed
+466
-4
No files matched your search
@@ -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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
|
||||
@@ -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]) + "…"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user