You've already forked agentic-coding-workflow
改动
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+41
-15
@@ -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,
|
||||
|
||||
+96
-62
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =====
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user