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, - ), + request(`/api/v1/conversations${buildQuery(params)}`), createConversation: (body: CreateConversationBody) => request('/api/v1/conversations', { method: 'POST', body }), - getConversation: (id: string, opts: { before?: number; limit?: number } = {}) => + getConversation: (id: string, opts: { limit?: number } = {}) => request( `/api/v1/conversations/${enc(id)}${buildQuery({ - before: opts.before, limit: opts.limit ?? 50, })}`, ), @@ -127,6 +124,16 @@ export const chatApi = { method: 'POST', body, }), + listMessages: ( + id: string, + params: { before_id?: number; limit?: number } = {}, + ) => + request( + `/api/v1/conversations/${enc(id)}/messages${buildQuery({ + before_id: params.before_id, + limit: params.limit, + })}`, + ), cancelMessage: (messageId: number) => request(`/api/v1/messages/${messageId}/cancel`, { method: 'POST' }), retryMessage: (messageId: number) => @@ -140,8 +147,7 @@ export const chatApi = { }, // ===== Templates ===== - listTemplates: () => - request<{ items: PromptTemplate[] }>('/api/v1/prompt-templates').then((r) => r.items), + listTemplates: () => request('/api/v1/prompt-templates'), createTemplate: (body: { name: string; content: string; scope: TemplateScope }) => request('/api/v1/prompt-templates', { method: 'POST', body }), updateTemplate: (id: string, body: { name?: string; content?: string }) => @@ -151,7 +157,5 @@ export const chatApi = { // ===== Personal usage ===== myDailyUsage: (from: string, to: string) => - request<{ items: UsageDailyItem[] }>( - `/api/v1/users/me/usage${buildQuery({ from, to })}`, - ).then((r) => r.items), + request(`/api/v1/users/me/usage${buildQuery({ from, to })}`), } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3e3b2dd..edb0e1d 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -11,13 +11,16 @@ interface RequestOptions { let authTokenProvider: () => string | null = () => null let onUnauthorized: () => void = () => {} +let onForbidden: () => void = () => {} export function configure(opts: { tokenProvider: () => string | null onUnauthorized: () => void + onForbidden?: () => void }) { authTokenProvider = opts.tokenProvider onUnauthorized = opts.onUnauthorized + if (opts.onForbidden) onForbidden = opts.onForbidden } export async function request(path: string, opts: RequestOptions = {}): Promise { @@ -55,6 +58,8 @@ export async function request(path: string, opts: RequestOptions = {}): Promi if (resp.status === 401) { onUnauthorized() + } else if (resp.status === 403) { + onForbidden() } if (resp.status === 204) { diff --git a/web/src/api/llmAdmin.ts b/web/src/api/llmAdmin.ts index 1851851..2772d61 100644 --- a/web/src/api/llmAdmin.ts +++ b/web/src/api/llmAdmin.ts @@ -7,6 +7,7 @@ export interface LLMEndpoint { provider: LLMProvider display_name: string base_url: string + created_by: string created_at: string updated_at: string } @@ -81,8 +82,7 @@ const enc = (v: string) => encodeURIComponent(v) export const llmAdminApi = { // ===== Endpoints ===== - listEndpoints: () => - request<{ items: LLMEndpoint[] }>('/api/v1/admin/llm-endpoints').then((r) => r.items), + listEndpoints: () => request('/api/v1/admin/llm-endpoints'), createEndpoint: (body: CreateEndpointBody) => request('/api/v1/admin/llm-endpoints', { method: 'POST', body }), updateEndpoint: (id: string, body: UpdateEndpointBody) => @@ -94,9 +94,7 @@ export const llmAdminApi = { // ===== Models ===== listModels: (onlyEnabled = false) => - request<{ items: LLMModel[] }>( - `/api/v1/admin/llm-models${buildQuery({ only_enabled: onlyEnabled })}`, - ).then((r) => r.items), + request(`/api/v1/admin/llm-models${buildQuery({ only_enabled: onlyEnabled })}`), createModel: (body: CreateModelBody) => request('/api/v1/admin/llm-models', { method: 'POST', body }), updateModel: (id: string, body: UpdateModelBody) => @@ -108,5 +106,5 @@ export const llmAdminApi = { adminUsage: (from: string, to: string, groupBy: AdminUsageGroupBy) => request<{ items: AdminUsageItem[] }>( `/api/v1/admin/usage${buildQuery({ from, to, group_by: groupBy })}`, - ), + ).then((r) => r.items), } diff --git a/web/src/api/models.ts b/web/src/api/models.ts new file mode 100644 index 0000000..9ec1c22 --- /dev/null +++ b/web/src/api/models.ts @@ -0,0 +1,7 @@ +import { buildQuery, request } from './client' +import type { LLMModel } from './llmAdmin' + +export const modelsApi = { + list: (onlyEnabled = true) => + request('/api/v1/models' + buildQuery({ only_enabled: onlyEnabled })), +} diff --git a/web/src/api/projects.ts b/web/src/api/projects.ts index 35ffb3f..4ef0fe0 100644 --- a/web/src/api/projects.ts +++ b/web/src/api/projects.ts @@ -162,6 +162,7 @@ export const projectsApi = { description?: string requirement_number?: number assignee_id?: string + workspace_id?: string }, ) => request(`/api/v1/projects/${enc(slug)}/issues`, { method: 'POST', body }), getIssue: (slug: string, number: number) => diff --git a/web/src/api/runs.ts b/web/src/api/runs.ts index dbaca61..6641caf 100644 --- a/web/src/api/runs.ts +++ b/web/src/api/runs.ts @@ -63,8 +63,6 @@ export const runsApi = { restart: (id: string) => request(`/api/v1/run-profiles/${enc(id)}/restart`, { method: 'POST' }), status: (id: string) => request(`/api/v1/run-profiles/${enc(id)}/status`), - logs: (id: string, since = 0) => - request(`/api/v1/run-profiles/${enc(id)}/logs?since=${since}`), } // openRunLogsWS 打开到日志流的原生 WebSocket。浏览器 WS 不支持自定义 header, diff --git a/web/src/components/layout/NavBar.vue b/web/src/components/layout/NavBar.vue index 9a8df11..f9e3945 100644 --- a/web/src/components/layout/NavBar.vue +++ b/web/src/components/layout/NavBar.vue @@ -69,6 +69,12 @@ > MCP Tokens + + ACP 会话 + events: Ref pendingPermissions: Ref + errorMsg: Ref prompt: (text: string, agentSessionID: string) => void cancel: (agentSessionID: string) => void reconnect: () => void @@ -49,6 +50,7 @@ export function useAcpStream(sessionId: string): UseAcpStreamReturn { const sessionStatus = ref('starting') const events = ref([]) const pendingPermissions = ref([]) + const errorMsg = ref(null) const lastEventID = ref(0) function upsertPermission(p: PermissionRequest) { @@ -118,6 +120,13 @@ export function useAcpStream(sessionId: string): UseAcpStreamReturn { return } + // 客户端错误帧(bad json / 无权限 / 方法不允许 / 转发失败)——单独提示,不进 events 流。 + if (obj.kind === 'client_error') { + const msg = typeof obj.message === 'string' ? obj.message : 'client error' + errorMsg.value = msg + return + } + // Initial state event from server if (obj.kind === 'state') { const s = (parsed as ServerStateEvent).status @@ -209,5 +218,5 @@ export function useAcpStream(sessionId: string): UseAcpStreamReturn { connect() onScopeDispose(close) - return { status, sessionStatus, events, pendingPermissions, prompt, cancel, reconnect, close } + return { status, sessionStatus, events, pendingPermissions, errorMsg, prompt, cancel, reconnect, close } } diff --git a/web/src/composables/useRunStream.ts b/web/src/composables/useRunStream.ts index 070216e..e40b6bb 100644 --- a/web/src/composables/useRunStream.ts +++ b/web/src/composables/useRunStream.ts @@ -11,6 +11,7 @@ export type StreamStatus = 'idle' | 'connecting' | 'streaming' | 'closed' | 'err export interface UseRunStreamReturn { status: Ref runStatus: Ref + exitCode: Ref logs: Ref reconnect: () => void close: () => void @@ -24,11 +25,13 @@ interface WireFrame { text?: string ts?: string status?: RunState + exit_code?: number } export function useRunStream(profileId: string): UseRunStreamReturn { const status = ref('idle') const runStatus = ref('stopped') + const exitCode = ref(null) const logs = ref([]) const lastLogID = ref(0) @@ -62,6 +65,7 @@ export function useRunStream(profileId: string): UseRunStreamReturn { if (parsed.kind === 'state') { if (parsed.status) runStatus.value = parsed.status + exitCode.value = parsed.exit_code ?? null return } if (parsed.kind === 'slow_consumer_disconnect') { @@ -124,5 +128,5 @@ export function useRunStream(profileId: string): UseRunStreamReturn { connect() onScopeDispose(close) - return { status, runStatus, logs, reconnect, close, clear } + return { status, runStatus, exitCode, logs, reconnect, close, clear } } diff --git a/web/src/main.ts b/web/src/main.ts index f432751..aff326e 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -3,6 +3,7 @@ import { createPinia } from 'pinia' import App from './App.vue' import router, { bindAuthToApi } from './router' +import { useAuthStore } from './stores/auth' import './styles/tailwind.css' import './styles/tokens.css' @@ -12,3 +13,9 @@ app.use(createPinia()) app.use(router) bindAuthToApi() app.mount('#app') + +// 启动后异步校验本地会话(不阻塞首屏渲染): +// 若 token 仍有效则回填最新用户信息;遇到 401 则登出。 +useAuthStore() + .validateSession() + .catch(() => {}) diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 0cfa45d..a203fc8 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -154,6 +154,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/admin/MCPTokenAdminView.vue'), meta: { requiresAuth: true, requiresAdmin: true }, }, + { + path: '/admin/acp-sessions', + name: 'admin-acp-sessions', + component: () => import('@/views/acp/AcpAdminSessionsView.vue'), + meta: { requiresAdmin: true }, + }, { path: '/admin/acp/agent-kinds', component: () => import('@/views/admin/AgentKindAdminListView.vue'), @@ -195,13 +201,16 @@ export default router // 把 auth store 注入 api client(在 router 模块顶层晚于 pinia 安装可能未就绪,所以用懒访问) export function bindAuthToApi() { const auth = useAuthStore() + const redirectToLogin = () => { + auth.clearSession() + const current = router.currentRoute.value + const query = current.name === 'login' ? undefined : { next: current.fullPath } + router.replace({ name: 'login', query }).catch(() => {}) + } configure({ tokenProvider: () => auth.token, - onUnauthorized: () => { - auth.clearSession() - const current = router.currentRoute.value - const query = current.name === 'login' ? undefined : { next: current.fullPath } - router.replace({ name: 'login', query }).catch(() => {}) - }, + onUnauthorized: redirectToLogin, + // 403:会话仍有效但权限不足(如被降级/禁用),同样登出并回登录页。 + onForbidden: redirectToLogin, }) } diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index 2ea507d..f2bd731 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -1,5 +1,7 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' +import { authApi } from '@/api/auth' +import { ApiException } from '@/api/types' export interface User { id: string @@ -69,5 +71,42 @@ export const useAuthStore = defineStore('auth', () => { safeRemove('user') } - return { token, user, isAuthenticated, isAdmin, setSession, clearSession } + // setUser 仅更新当前用户信息(不改动 token),用于服务端回填最新资料。 + function setUser(u: User) { + user.value = u + safeWrite('user', JSON.stringify(u)) + } + + // refreshUser 调用 /auth/me 拉取最新用户信息并同步进 store。 + // 调用方负责处理异常(如需)。 + async function refreshUser(): Promise { + const u = await authApi.me() + setUser(u) + } + + // validateSession 在应用启动时校验本地 token 是否仍然有效: + // 有 token 才校验;成功则同步最新用户信息,遇到 401 则登出。 + async function validateSession(): Promise { + if (!token.value) return + try { + await refreshUser() + } catch (e: unknown) { + if (e instanceof ApiException && e.status === 401) { + clearSession() + } + // 其它错误(网络等)保留本地会话,避免误登出。 + } + } + + return { + token, + user, + isAuthenticated, + isAdmin, + setSession, + clearSession, + setUser, + refreshUser, + validateSession, + } }) diff --git a/web/src/stores/chat.ts b/web/src/stores/chat.ts index eb469b1..0b01ba1 100644 --- a/web/src/stores/chat.ts +++ b/web/src/stores/chat.ts @@ -51,7 +51,8 @@ export const useChatStore = defineStore('chat', () => { detachStream() const detail = await chatApi.getConversation(id) currentConversation.value = detail.conversation - messages.value = detail.messages + // 后端按 id DESC 返回(最新在前);视图按升序渲染(最新在底部),故排为升序。 + messages.value = [...detail.messages].sort((a, b) => a.id - b.id) const pending = detail.messages.find((m) => m.status === 'pending') if (pending) { streamingMessageId.value = pending.id @@ -59,6 +60,23 @@ export const useChatStore = defineStore('chat', () => { } } + async function loadOlderMessages() { + const conv = currentConversation.value + if (!conv) return + if (messages.value.length === 0) return + const beforeId = messages.value.reduce( + (min, m) => (m.id < min ? m.id : min), + messages.value[0].id, + ) + const older = await chatApi.listMessages(conv.id, { before_id: beforeId }) + if (older.length === 0) return + const existingIds = new Set(messages.value.map((m) => m.id)) + const fresh = older.filter((m) => !existingIds.has(m.id)) + if (fresh.length === 0) return + // 合并后统一按 id 升序,避免后端 DESC 批次与现有升序列表错位。 + messages.value = [...fresh, ...messages.value].sort((a, b) => a.id - b.id) + } + async function sendMessage(content: string, attachmentIds: string[] = []) { if (!currentConversation.value) return if (streamingMessageId.value !== null) return @@ -211,6 +229,7 @@ export const useChatStore = defineStore('chat', () => { isStreaming, loadConversations, openConversation, + loadOlderMessages, closeCurrent, sendMessage, cancelMessage, diff --git a/web/src/stores/notifications.ts b/web/src/stores/notifications.ts index 45dd276..563f8fc 100644 --- a/web/src/stores/notifications.ts +++ b/web/src/stores/notifications.ts @@ -10,6 +10,7 @@ export interface Notification { link?: string read_at?: string created_at: string + metadata?: Record } export const useNotificationsStore = defineStore('notifications', () => { diff --git a/web/src/views/acp/AcpAdminSessionsView.vue b/web/src/views/acp/AcpAdminSessionsView.vue new file mode 100644 index 0000000..8885799 --- /dev/null +++ b/web/src/views/acp/AcpAdminSessionsView.vue @@ -0,0 +1,67 @@ + + + diff --git a/web/src/views/acp/AcpSessionDetailView.vue b/web/src/views/acp/AcpSessionDetailView.vue index fa0c5ee..b25d1c8 100644 --- a/web/src/views/acp/AcpSessionDetailView.vue +++ b/web/src/views/acp/AcpSessionDetailView.vue @@ -26,6 +26,8 @@ const events = computed(() => stream.value?.events.value ?? []) const pendingPermissions = computed( () => stream.value?.pendingPermissions.value ?? [], ) +// 后端通过 WS 推送的 client_error 帧(如越权 prompt / 方法不允许 / 转发失败)。 +const streamError = computed(() => stream.value?.errorMsg.value ?? null) // 仅 session owner(或 admin)可决策;后端同样强制。 const canDecide = computed( () => auth.user?.is_admin === true || store.currentSession?.user_id === auth.user?.id, @@ -178,6 +180,11 @@ async function openCommit() { {{ errorMsg }}

+ +

+ 连接错误:{{ streamError }} +

+ {}) + } await reload() } catch (e: unknown) { msg.error((e as Error).message || '操作失败') diff --git a/web/src/views/chat/ChatDetailView.vue b/web/src/views/chat/ChatDetailView.vue index 165dce5..569bd7f 100644 --- a/web/src/views/chat/ChatDetailView.vue +++ b/web/src/views/chat/ChatDetailView.vue @@ -108,7 +108,8 @@ import { useChatStore } from '@/stores/chat' import MessageBubble from './components/MessageBubble.vue' import AttachmentUploader from './components/AttachmentUploader.vue' import ConversationCreateModal from './ConversationCreateModal.vue' -import { llmAdminApi, type LLMModel, type ModelCapabilities } from '@/api/llmAdmin' +import { modelsApi } from '@/api/models' +import type { LLMModel, ModelCapabilities } from '@/api/llmAdmin' const chat = useChatStore() const route = useRoute() @@ -142,7 +143,7 @@ const currentModelCaps = computed(() => { }) onMounted(async () => { - allModels.value = await llmAdminApi.listModels(true) + allModels.value = await modelsApi.list(true) await chat.loadConversations({ project_id: projectId.value }) const cid = route.params.conversationId if (typeof cid === 'string' && cid !== '') { diff --git a/web/src/views/chat/components/ModelSelector.vue b/web/src/views/chat/components/ModelSelector.vue index 1751b38..6faa174 100644 --- a/web/src/views/chat/components/ModelSelector.vue +++ b/web/src/views/chat/components/ModelSelector.vue @@ -19,7 +19,8 @@ import { computed, onMounted, ref } from 'vue' import { NSelect } from 'naive-ui' -import { llmAdminApi, type LLMModel } from '@/api/llmAdmin' +import { modelsApi } from '@/api/models' +import type { LLMModel } from '@/api/llmAdmin' const props = defineProps<{ modelValue?: string }>() const emit = defineEmits<{ 'update:modelValue': [v: string] }>() @@ -27,7 +28,7 @@ const emit = defineEmits<{ 'update:modelValue': [v: string] }>() const models = ref([]) onMounted(async () => { - models.value = await llmAdminApi.listModels(true) + models.value = await modelsApi.list(true) }) const options = computed(() => diff --git a/web/src/views/projects/ProjectHomeView.vue b/web/src/views/projects/ProjectHomeView.vue index 7b3a3bd..ba5829f 100644 --- a/web/src/views/projects/ProjectHomeView.vue +++ b/web/src/views/projects/ProjectHomeView.vue @@ -29,6 +29,9 @@ {{ store.current.slug }}

+ + 编辑 + @@ -63,15 +66,79 @@ 项目不存在或无权访问。 + + + + + + + + + + + + + + 私有 + + + 内部 + + + +
+ + 取消 + + + 保存 + +
+
+

+ {{ editError }} +

+