diff --git a/internal/chat/domain.go b/internal/chat/domain.go
index 4a0b919..41307b9 100644
--- a/internal/chat/domain.go
+++ b/internal/chat/domain.go
@@ -137,6 +137,7 @@ type Message struct {
PromptTokens *int
CompletionTokens *int
ThinkingTokens *int
+ Attachments []Attachment // 用户可读路径填充;composeHistory 不依赖此字段
CreatedAt, UpdatedAt time.Time
}
@@ -173,7 +174,7 @@ type ConversationReader interface {
type ConversationService interface {
Create(ctx context.Context, userID uuid.UUID, in CreateConversationInput) (*Conversation, error)
List(ctx context.Context, userID uuid.UUID, filter ConversationFilter) ([]Conversation, error)
- Get(ctx context.Context, userID, id uuid.UUID) (*Conversation, []Message, error)
+ Get(ctx context.Context, userID, id uuid.UUID, limit int) (*Conversation, []Message, error)
UpdateTitle(ctx context.Context, userID, id uuid.UUID, title string) error
SoftDelete(ctx context.Context, userID, id uuid.UUID) error
}
@@ -203,7 +204,7 @@ type MessageService interface {
Send(ctx context.Context, userID, conversationID uuid.UUID, content string, attachmentIDs []uuid.UUID) (userMsgID, assistantMsgID int64, err error)
Stream(ctx context.Context, userID, conversationID uuid.UUID, sinceMessageID int64) (<-chan SSEEvent, error)
Cancel(ctx context.Context, userID uuid.UUID, messageID int64) error
- Retry(ctx context.Context, userID uuid.UUID, messageID int64) (newAssistantMsgID int64, err error)
+ Retry(ctx context.Context, userID uuid.UUID, messageID int64) (newAssistantMsgID int64, conversationID uuid.UUID, err error)
ListMessages(ctx context.Context, userID, conversationID uuid.UUID, beforeID *int64, limit int) ([]Message, error)
}
diff --git a/internal/chat/dto.go b/internal/chat/dto.go
index cbc23de..df67e05 100644
--- a/internal/chat/dto.go
+++ b/internal/chat/dto.go
@@ -105,18 +105,19 @@ type ConversationDTO struct {
// MessageDTO is the JSON representation of a Message.
type MessageDTO struct {
- ID int64 `json:"id"`
- ConversationID uuid.UUID `json:"conversation_id"`
- Role string `json:"role"`
- Content string `json:"content"`
- Thinking *string `json:"thinking,omitempty"`
- Status string `json:"status"`
- ErrorMessage *string `json:"error_message,omitempty"`
- PromptTokens *int `json:"prompt_tokens,omitempty"`
- CompletionTokens *int `json:"completion_tokens,omitempty"`
- ThinkingTokens *int `json:"thinking_tokens,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID int64 `json:"id"`
+ ConversationID uuid.UUID `json:"conversation_id"`
+ Role string `json:"role"`
+ Content string `json:"content"`
+ Thinking *string `json:"thinking,omitempty"`
+ Status string `json:"status"`
+ ErrorMessage *string `json:"error_message,omitempty"`
+ PromptTokens *int `json:"prompt_tokens,omitempty"`
+ CompletionTokens *int `json:"completion_tokens,omitempty"`
+ ThinkingTokens *int `json:"thinking_tokens,omitempty"`
+ Attachments []AttachmentDTO `json:"attachments"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
// AttachmentDTO is the JSON representation of an Attachment.
@@ -205,11 +206,18 @@ type ModelUsageDTO struct {
CostUSD float64 `json:"cost_usd"`
}
+// AdminUsageItem is a single aggregated usage row keyed by user/model/day.
+type AdminUsageItem struct {
+ Key string `json:"key"`
+ PromptTokens int `json:"prompt_tokens"`
+ CompletionTokens int `json:"completion_tokens"`
+ ThinkingTokens int `json:"thinking_tokens"`
+ CostUSD float64 `json:"cost_usd"`
+}
+
// AdminUsageResp is the response body for GET /admin/usage.
type AdminUsageResp struct {
- ByUser []UserUsageDTO `json:"by_user"`
- ByModel []ModelUsageDTO `json:"by_model"`
- ByDay []DayUsageDTO `json:"by_day"`
+ Items []AdminUsageItem `json:"items"`
}
// ===== Conversion helpers =====
@@ -241,11 +249,29 @@ func toMessageDTO(m *Message) MessageDTO {
PromptTokens: m.PromptTokens,
CompletionTokens: m.CompletionTokens,
ThinkingTokens: m.ThinkingTokens,
+ Attachments: toAttachmentDTOs(m.Attachments),
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
}
}
+// toAttachmentDTOs maps a slice of Attachment to DTOs, always returning a
+// non-nil (possibly empty) slice so the JSON field serializes as [].
+func toAttachmentDTOs(atts []Attachment) []AttachmentDTO {
+ out := make([]AttachmentDTO, 0, len(atts))
+ for _, a := range atts {
+ out = append(out, AttachmentDTO{
+ ID: a.ID,
+ Filename: a.Filename,
+ MimeType: a.MimeType,
+ SizeBytes: a.SizeBytes,
+ SHA256: a.SHA256,
+ CreatedAt: a.CreatedAt,
+ })
+ }
+ return out
+}
+
func toTemplateDTO(t *PromptTemplate) TemplateDTO {
return TemplateDTO{
ID: t.ID,
diff --git a/internal/chat/handler.go b/internal/chat/handler.go
index e8617a3..3c84519 100644
--- a/internal/chat/handler.go
+++ b/internal/chat/handler.go
@@ -99,6 +99,9 @@ func (h *Handler) Mount(r chi.Router) {
// User usage.
r.Get("/users/me/usage", h.userDailyUsage)
+ // Public (non-admin) models list for chat composers.
+ r.Get("/models", h.listModelsPublic)
+
// Admin-only routes.
r.Group(func(r chi.Router) {
r.Use(h.adminGuard)
@@ -246,7 +249,13 @@ func (h *Handler) getConversation(w http.ResponseWriter, r *http.Request) {
writeErr(w, r, err)
return
}
- conv, msgs, err := h.convSvc.Get(r.Context(), uid, id)
+ limit := 50
+ if v := r.URL.Query().Get("limit"); v != "" {
+ if n, e := strconv.Atoi(v); e == nil && n > 0 {
+ limit = n
+ }
+ }
+ conv, msgs, err := h.convSvc.Get(r.Context(), uid, id, limit)
if err != nil {
writeErr(w, r, err)
return
@@ -398,12 +407,15 @@ func (h *Handler) retryMessage(w http.ResponseWriter, r *http.Request) {
writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "invalid message id"))
return
}
- newID, err := h.msgSvc.Retry(r.Context(), uid, msgID)
+ newID, convID, err := h.msgSvc.Retry(r.Context(), uid, msgID)
if err != nil {
writeErr(w, r, err)
return
}
- writeJSON(w, http.StatusOK, map[string]int64{"assistant_message_id": newID})
+ writeJSON(w, http.StatusOK, map[string]any{
+ "assistant_message_id": newID,
+ "stream_url": fmt.Sprintf("/api/v1/conversations/%s/stream", convID),
+ })
}
// ===== Template handlers =====
@@ -641,6 +653,22 @@ func (h *Handler) listModels(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, out)
}
+// listModelsPublic mirrors listModels but is reachable by any authenticated
+// user (non-admin), so chat composers can list available models.
+func (h *Handler) listModelsPublic(w http.ResponseWriter, r *http.Request) {
+ onlyEnabled := r.URL.Query().Get("only_enabled") == "true"
+ models, err := h.epSvc.ListModels(r.Context(), onlyEnabled)
+ if err != nil {
+ writeErr(w, r, err)
+ return
+ }
+ out := make([]ModelDTO, 0, len(models))
+ for i := range models {
+ out = append(out, toModelDTO(&models[i]))
+ }
+ writeJSON(w, http.StatusOK, out)
+}
+
func (h *Handler) createModel(w http.ResponseWriter, r *http.Request) {
var req CreateModelReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -648,18 +676,18 @@ func (h *Handler) createModel(w http.ResponseWriter, r *http.Request) {
return
}
m, err := h.epSvc.CreateModel(r.Context(), CreateModelInput{
- EndpointID: req.EndpointID,
- ModelID: req.ModelID,
- DisplayName: req.DisplayName,
- Capabilities: req.Capabilities,
- ContextWindow: req.ContextWindow,
- MaxOutputTokens: req.MaxOutputTokens,
- PromptPrice: req.PromptPricePerMillionUSD,
- CompletionPrice: req.CompletionPricePerMillionUSD,
- ThinkingPrice: req.ThinkingPricePerMillionUSD,
+ EndpointID: req.EndpointID,
+ ModelID: req.ModelID,
+ DisplayName: req.DisplayName,
+ Capabilities: req.Capabilities,
+ ContextWindow: req.ContextWindow,
+ MaxOutputTokens: req.MaxOutputTokens,
+ PromptPrice: req.PromptPricePerMillionUSD,
+ CompletionPrice: req.CompletionPricePerMillionUSD,
+ ThinkingPrice: req.ThinkingPricePerMillionUSD,
IsTitleGenerator: req.IsTitleGenerator,
- Enabled: req.Enabled,
- SortOrder: req.SortOrder,
+ Enabled: req.Enabled,
+ SortOrder: req.SortOrder,
})
if err != nil {
writeErr(w, r, err)
@@ -727,56 +755,62 @@ func (h *Handler) adminUsage(w http.ResponseWriter, r *http.Request) {
}
}
- byUser, err := h.usageSvc.SummarizeByUser(r.Context(), from, to)
- if err != nil {
- writeErr(w, r, err)
- return
+ groupBy := q.Get("group_by")
+ if groupBy == "" {
+ groupBy = "day"
}
- byModel, err := h.usageSvc.SummarizeByModel(r.Context(), from, to)
- if err != nil {
- writeErr(w, r, err)
- return
- }
- byDay, err := h.usageSvc.SummarizeByDay(r.Context(), from, to)
- if err != nil {
- writeErr(w, r, err)
+
+ items := make([]AdminUsageItem, 0)
+ switch groupBy {
+ case "user":
+ rows, err := h.usageSvc.SummarizeByUser(r.Context(), from, to)
+ if err != nil {
+ writeErr(w, r, err)
+ return
+ }
+ for _, row := range rows {
+ items = append(items, AdminUsageItem{
+ Key: row.UserID.String(),
+ PromptTokens: row.PromptTokens,
+ CompletionTokens: row.CompletionTokens,
+ ThinkingTokens: row.ThinkingTokens,
+ CostUSD: row.CostUSD,
+ })
+ }
+ case "model":
+ rows, err := h.usageSvc.SummarizeByModel(r.Context(), from, to)
+ if err != nil {
+ writeErr(w, r, err)
+ return
+ }
+ for _, row := range rows {
+ items = append(items, AdminUsageItem{
+ Key: row.ModelID.String(),
+ PromptTokens: row.PromptTokens,
+ CompletionTokens: row.CompletionTokens,
+ ThinkingTokens: row.ThinkingTokens,
+ CostUSD: row.CostUSD,
+ })
+ }
+ case "day":
+ rows, err := h.usageSvc.SummarizeByDay(r.Context(), from, to)
+ if err != nil {
+ writeErr(w, r, err)
+ return
+ }
+ for _, row := range rows {
+ items = append(items, AdminUsageItem{
+ Key: row.Day.Format(time.RFC3339),
+ PromptTokens: row.PromptTokens,
+ CompletionTokens: row.CompletionTokens,
+ ThinkingTokens: row.ThinkingTokens,
+ CostUSD: row.CostUSD,
+ })
+ }
+ default:
+ writeErr(w, r, errs.New(errs.CodeInvalidInput, "invalid group_by"))
return
}
- userDTOs := make([]UserUsageDTO, 0, len(byUser))
- for _, row := range byUser {
- userDTOs = append(userDTOs, UserUsageDTO{
- UserID: row.UserID,
- PromptTokens: row.PromptTokens,
- CompletionTokens: row.CompletionTokens,
- ThinkingTokens: row.ThinkingTokens,
- CostUSD: row.CostUSD,
- })
- }
- modelDTOs := make([]ModelUsageDTO, 0, len(byModel))
- for _, row := range byModel {
- modelDTOs = append(modelDTOs, ModelUsageDTO{
- ModelID: row.ModelID,
- PromptTokens: row.PromptTokens,
- CompletionTokens: row.CompletionTokens,
- ThinkingTokens: row.ThinkingTokens,
- CostUSD: row.CostUSD,
- })
- }
- dayDTOs := make([]DayUsageDTO, 0, len(byDay))
- for _, row := range byDay {
- dayDTOs = append(dayDTOs, DayUsageDTO{
- Day: row.Day,
- PromptTokens: row.PromptTokens,
- CompletionTokens: row.CompletionTokens,
- ThinkingTokens: row.ThinkingTokens,
- CostUSD: row.CostUSD,
- })
- }
-
- writeJSON(w, http.StatusOK, AdminUsageResp{
- ByUser: userDTOs,
- ByModel: modelDTOs,
- ByDay: dayDTOs,
- })
+ writeJSON(w, http.StatusOK, AdminUsageResp{Items: items})
}
diff --git a/internal/chat/handler_test.go b/internal/chat/handler_test.go
index 1490790..a4718be 100644
--- a/internal/chat/handler_test.go
+++ b/internal/chat/handler_test.go
@@ -67,7 +67,7 @@ func (f *fakeConvSvc) List(ctx context.Context, userID uuid.UUID, filter Convers
}
return nil, nil
}
-func (f *fakeConvSvc) Get(ctx context.Context, userID, id uuid.UUID) (*Conversation, []Message, error) {
+func (f *fakeConvSvc) Get(ctx context.Context, userID, id uuid.UUID, _ int) (*Conversation, []Message, error) {
if f.getFunc != nil {
return f.getFunc(ctx, userID, id)
}
@@ -95,11 +95,11 @@ func (f *fakeMsgSvc) Stream(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ int64
return ch, nil
}
func (f *fakeMsgSvc) Cancel(_ context.Context, _ uuid.UUID, _ int64) error { return nil }
-func (f *fakeMsgSvc) Retry(_ context.Context, _ uuid.UUID, msgID int64) (int64, error) {
+func (f *fakeMsgSvc) Retry(_ context.Context, _ uuid.UUID, msgID int64) (int64, uuid.UUID, error) {
if f.retryErr != nil {
- return 0, f.retryErr
+ return 0, uuid.Nil, f.retryErr
}
- return msgID + 100, nil
+ return msgID + 100, uuid.Nil, nil
}
func (f *fakeMsgSvc) ListMessages(_ context.Context, _, _ uuid.UUID, _ *int64, _ int) ([]Message, error) {
return nil, nil
diff --git a/internal/chat/service_conversation.go b/internal/chat/service_conversation.go
index dbe7d51..1d5abec 100644
--- a/internal/chat/service_conversation.go
+++ b/internal/chat/service_conversation.go
@@ -84,7 +84,8 @@ func (s *conversationService) Create(ctx context.Context, userID uuid.UUID, in C
}
// Get returns a conversation and its most recent messages, enforcing ownership.
-func (s *conversationService) Get(ctx context.Context, userID, id uuid.UUID) (*Conversation, []Message, error) {
+// limit caps the number of returned messages (defaults to 50 when non-positive).
+func (s *conversationService) Get(ctx context.Context, userID, id uuid.UUID, limit int) (*Conversation, []Message, error) {
c, err := s.repo.GetConversation(ctx, id)
if err != nil {
return nil, nil, err
@@ -92,10 +93,21 @@ func (s *conversationService) Get(ctx context.Context, userID, id uuid.UUID) (*C
if c.UserID != userID {
return nil, nil, errs.New(errs.CodeForbidden, "not conversation owner")
}
- msgs, err := s.repo.ListMessagesByConversation(ctx, id, nil, 50)
+ if limit <= 0 {
+ limit = 50
+ }
+ msgs, err := s.repo.ListMessagesByConversation(ctx, id, nil, limit)
if err != nil {
return nil, nil, err
}
+ // Populate each message's attachments for the user-facing read path.
+ for i := range msgs {
+ atts, err := s.repo.ListAttachmentsForMessage(ctx, msgs[i].ID)
+ if err != nil {
+ return nil, nil, err
+ }
+ msgs[i].Attachments = atts
+ }
return c, msgs, nil
}
diff --git a/internal/chat/service_conversation_test.go b/internal/chat/service_conversation_test.go
index 764d6cc..4d0b4cf 100644
--- a/internal/chat/service_conversation_test.go
+++ b/internal/chat/service_conversation_test.go
@@ -118,6 +118,10 @@ func (r *convFakeRepo) SoftDeleteConversation(_ context.Context, id uuid.UUID) e
return nil
}
+func (r *convFakeRepo) ListAttachmentsForMessage(_ context.Context, _ int64) ([]Attachment, error) {
+ return nil, nil
+}
+
func (r *convFakeRepo) ListMessagesByConversation(_ context.Context, convID uuid.UUID, _ *int64, limit int) ([]Message, error) {
r.mu.Lock()
defer r.mu.Unlock()
@@ -335,7 +339,7 @@ func TestConversationService_Get_HappyPath(t *testing.T) {
{ID: 1, ConversationID: conv.ID, Role: RoleUser, Content: "hello"},
}
- got, msgs, err := svc.Get(context.Background(), userID, conv.ID)
+ got, msgs, err := svc.Get(context.Background(), userID, conv.ID, 50)
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, conv.ID, got.ID)
@@ -350,7 +354,7 @@ func TestConversationService_Get_NotOwner(t *testing.T) {
callerID := uuid.Must(uuid.NewV7())
conv := addConversation(repo, ownerID)
- _, _, err := svc.Get(context.Background(), callerID, conv.ID)
+ _, _, err := svc.Get(context.Background(), callerID, conv.ID, 50)
require.Error(t, err)
var appErr *errs.AppError
require.ErrorAs(t, err, &appErr)
diff --git a/internal/chat/service_message.go b/internal/chat/service_message.go
index 03549d4..b678f81 100644
--- a/internal/chat/service_message.go
+++ b/internal/chat/service_message.go
@@ -161,8 +161,8 @@ func (s *messageService) Send(
TargetType: "message",
TargetID: int64ToString(userMsg.ID),
Metadata: map[string]any{
- "conversation_id": conversationID.String(),
- "attachment_count": len(atts),
+ "conversation_id": conversationID.String(),
+ "attachment_count": len(atts),
},
})
@@ -301,25 +301,25 @@ func (s *messageService) Cancel(ctx context.Context, userID uuid.UUID, messageID
// Retry deletes the failed/cancelled assistant message, inserts a fresh pending
// one, and restarts the streamer.
-func (s *messageService) Retry(ctx context.Context, userID uuid.UUID, messageID int64) (int64, error) {
+func (s *messageService) Retry(ctx context.Context, userID uuid.UUID, messageID int64) (int64, uuid.UUID, error) {
old, err := s.repo.GetMessage(ctx, messageID)
if err != nil {
- return 0, err
+ return 0, uuid.Nil, err
}
if old.Status != StatusError && old.Status != StatusCancelled {
- return 0, errs.New(errs.CodeChatMessageNotPending, "message is not retriable")
+ return 0, uuid.Nil, errs.New(errs.CodeChatMessageNotPending, "message is not retriable")
}
conv, err := s.repo.GetConversation(ctx, old.ConversationID)
if err != nil {
- return 0, err
+ return 0, uuid.Nil, err
}
if conv.UserID != userID {
- return 0, errs.New(errs.CodeForbidden, "not conversation owner")
+ return 0, uuid.Nil, errs.New(errs.CodeForbidden, "not conversation owner")
}
repo, tx, err := s.repo.WithTx(ctx)
if err != nil {
- return 0, err
+ return 0, uuid.Nil, err
}
committed := false
defer func() {
@@ -329,15 +329,15 @@ func (s *messageService) Retry(ctx context.Context, userID uuid.UUID, messageID
}()
if err := repo.DeleteMessage(ctx, messageID); err != nil {
- return 0, err
+ return 0, uuid.Nil, err
}
asstMsg, err := repo.InsertMessage(ctx, conv.ID, RoleAssistant, "", StatusPending)
if err != nil {
- return 0, err
+ return 0, uuid.Nil, err
}
if err := tx.Commit(ctx); err != nil {
- return 0, err
+ return 0, uuid.Nil, err
}
committed = true
@@ -345,20 +345,20 @@ func (s *messageService) Retry(ctx context.Context, userID uuid.UUID, messageID
if err != nil {
e := err.Error()
_ = s.repo.UpdateMessageError(ctx, asstMsg.ID, &e)
- return asstMsg.ID, err
+ return asstMsg.ID, conv.ID, err
}
endpoint, err := s.repo.GetLLMEndpoint(ctx, model.EndpointID)
if err != nil {
e := err.Error()
_ = s.repo.UpdateMessageError(ctx, asstMsg.ID, &e)
- return asstMsg.ID, err
+ return asstMsg.ID, conv.ID, err
}
req, err := s.composeHistory(ctx, conv, model)
if err != nil {
e := err.Error()
_ = s.repo.UpdateMessageError(ctx, asstMsg.ID, &e)
- return asstMsg.ID, err
+ return asstMsg.ID, conv.ID, err
}
params := StreamParams{
@@ -373,7 +373,7 @@ func (s *messageService) Retry(ctx context.Context, userID uuid.UUID, messageID
if err := s.hub.Start(ctx, params); err != nil {
e := err.Error()
_ = s.repo.UpdateMessageError(ctx, asstMsg.ID, &e)
- return asstMsg.ID, err
+ return asstMsg.ID, conv.ID, err
}
// Audit after successful hub start.
@@ -388,7 +388,7 @@ func (s *messageService) Retry(ctx context.Context, userID uuid.UUID, messageID
},
})
- return asstMsg.ID, nil
+ return asstMsg.ID, conv.ID, nil
}
// ListMessages returns paginated messages for a conversation, enforcing ownership.
@@ -408,7 +408,19 @@ func (s *messageService) ListMessages(
if limit == 0 || limit > 200 {
limit = 50
}
- return s.repo.ListMessagesByConversation(ctx, conversationID, beforeID, limit)
+ msgs, err := s.repo.ListMessagesByConversation(ctx, conversationID, beforeID, limit)
+ if err != nil {
+ return nil, err
+ }
+ // Populate each message's attachments for the user-facing read path.
+ for i := range msgs {
+ atts, err := s.repo.ListAttachmentsForMessage(ctx, msgs[i].ID)
+ if err != nil {
+ return nil, err
+ }
+ msgs[i].Attachments = atts
+ }
+ return msgs, nil
}
// composeHistory loads all OK/Pending messages for the conversation, fetches
diff --git a/internal/chat/service_message_test.go b/internal/chat/service_message_test.go
index 154725c..61a6e04 100644
--- a/internal/chat/service_message_test.go
+++ b/internal/chat/service_message_test.go
@@ -408,8 +408,8 @@ func TestMessageService_Send_AttachmentTooLarge_Total(t *testing.T) {
// Use config with smaller maxMsg to make the test reliable.
cfg := defaultMsgConfig()
- cfg.MaxFileBytes = 50 * 1024 * 1024 // 50 MB per file — both pass individually
- cfg.MaxMsgBytes = 40 * 1024 * 1024 // 40 MB total — 60 MB fails
+ cfg.MaxFileBytes = 50 * 1024 * 1024 // 50 MB per file — both pass individually
+ cfg.MaxMsgBytes = 40 * 1024 * 1024 // 40 MB total — 60 MB fails
svc := buildMessageService(repo, hub, cfg)
_, _, err := svc.Send(context.Background(), userID, conv.ID, "hi", []uuid.UUID{a1.ID, a2.ID})
@@ -621,7 +621,7 @@ func TestMessageService_Retry_NotRetriable(t *testing.T) {
repo.addMessage(m)
svc := buildMessageService(repo, hub, defaultMsgConfig())
- _, err := svc.Retry(context.Background(), userID, 55)
+ _, _, err := svc.Retry(context.Background(), userID, 55)
require.Error(t, err)
var ae *errs.AppError
@@ -641,10 +641,11 @@ func TestMessageService_Retry_HappyPath(t *testing.T) {
repo.addMessage(old)
svc := buildMessageService(repo, hub, defaultMsgConfig())
- newID, err := svc.Retry(context.Background(), userID, 10)
+ newID, convID, err := svc.Retry(context.Background(), userID, 10)
require.NoError(t, err)
require.Greater(t, newID, int64(0))
+ require.Equal(t, conv.ID, convID)
// Old message deleted.
repo.mu.Lock()
@@ -681,10 +682,11 @@ func TestMessageService_Retry_Cancelled_HappyPath(t *testing.T) {
repo.addMessage(old)
svc := buildMessageService(repo, hub, defaultMsgConfig())
- newID, err := svc.Retry(context.Background(), userID, 11)
+ newID, convID, err := svc.Retry(context.Background(), userID, 11)
require.NoError(t, err)
require.Greater(t, newID, int64(0))
+ require.Equal(t, conv.ID, convID)
}
// ===== Stream tests =====
diff --git a/internal/chat/sse_test.go b/internal/chat/sse_test.go
index f1a8398..20315c7 100644
--- a/internal/chat/sse_test.go
+++ b/internal/chat/sse_test.go
@@ -53,8 +53,8 @@ func (s *sseMessageService) Stream(_ context.Context, _ uuid.UUID, _ uuid.UUID,
return s.streamCh, nil
}
func (s *sseMessageService) Cancel(_ context.Context, _ uuid.UUID, _ int64) error { return nil }
-func (s *sseMessageService) Retry(_ context.Context, _ uuid.UUID, _ int64) (int64, error) {
- return 0, nil
+func (s *sseMessageService) Retry(_ context.Context, _ uuid.UUID, _ int64) (int64, uuid.UUID, error) {
+ return 0, uuid.Nil, nil
}
func (s *sseMessageService) ListMessages(_ context.Context, _, _ uuid.UUID, _ *int64, _ int) ([]Message, error) {
return nil, nil
diff --git a/web/src/api/chat.ts b/web/src/api/chat.ts
index a51a4f5..48c5e09 100644
--- a/web/src/api/chat.ts
+++ b/web/src/api/chat.ts
@@ -13,7 +13,6 @@ export interface Conversation {
model_id: string
system_prompt: string
title?: string | null
- title_generated_at?: string | null
created_at: string
updated_at: string
}
@@ -24,6 +23,7 @@ export interface Attachment {
mime_type: string
size_bytes: number
sha256: string
+ created_at: string
}
export interface ChatMessage {
@@ -98,15 +98,12 @@ const enc = (v: string) => encodeURIComponent(v)
export const chatApi = {
// ===== Conversations =====
listConversations: (params: ListConversationsParams = {}) =>
- request<{ items: Conversation[] }>(`/api/v1/conversations${buildQuery(params)}`).then(
- (r) => r.items,
- ),
+ requestAll ACP Sessions
+ Global admin view (all users)
+
+
+
+
+
+
+
+ User
+ Branch
+ Status
+ Started
+ Actions
+
+
+
+ {{ s.user_id }}
+ {{ s.branch }}
+
+
+ {{ new Date(s.started_at).toLocaleString() }}
+
+
+
+
+
+ 连接错误:{{ streamError }} +
++ {{ editError }} +
+