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)
|
||||
}
|
||||
|
||||
|
||||
+29
-3
@@ -115,6 +115,7 @@ type MessageDTO struct {
|
||||
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"`
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+76
-42
@@ -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 {
|
||||
@@ -727,56 +755,62 @@ func (h *Handler) adminUsage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
byUser, err := h.usageSvc.SummarizeByUser(r.Context(), from, to)
|
||||
groupBy := q.Get("group_by")
|
||||
if groupBy == "" {
|
||||
groupBy = "day"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
byModel, err := h.usageSvc.SummarizeByModel(r.Context(), from, to)
|
||||
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
|
||||
}
|
||||
byDay, err := h.usageSvc.SummarizeByDay(r.Context(), from, to)
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+15
-11
@@ -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<Conversation[]>(`/api/v1/conversations${buildQuery(params)}`),
|
||||
createConversation: (body: CreateConversationBody) =>
|
||||
request<Conversation>('/api/v1/conversations', { method: 'POST', body }),
|
||||
getConversation: (id: string, opts: { before?: number; limit?: number } = {}) =>
|
||||
getConversation: (id: string, opts: { limit?: number } = {}) =>
|
||||
request<ConversationDetail>(
|
||||
`/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<ChatMessage[]>(
|
||||
`/api/v1/conversations/${enc(id)}/messages${buildQuery({
|
||||
before_id: params.before_id,
|
||||
limit: params.limit,
|
||||
})}`,
|
||||
),
|
||||
cancelMessage: (messageId: number) =>
|
||||
request<void>(`/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<PromptTemplate[]>('/api/v1/prompt-templates'),
|
||||
createTemplate: (body: { name: string; content: string; scope: TemplateScope }) =>
|
||||
request<PromptTemplate>('/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<UsageDailyItem[]>(`/api/v1/users/me/usage${buildQuery({ from, to })}`),
|
||||
}
|
||||
|
||||
@@ -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<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
@@ -55,6 +58,8 @@ export async function request<T>(path: string, opts: RequestOptions = {}): Promi
|
||||
|
||||
if (resp.status === 401) {
|
||||
onUnauthorized()
|
||||
} else if (resp.status === 403) {
|
||||
onForbidden()
|
||||
}
|
||||
|
||||
if (resp.status === 204) {
|
||||
|
||||
@@ -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<LLMEndpoint[]>('/api/v1/admin/llm-endpoints'),
|
||||
createEndpoint: (body: CreateEndpointBody) =>
|
||||
request<LLMEndpoint>('/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<LLMModel[]>(`/api/v1/admin/llm-models${buildQuery({ only_enabled: onlyEnabled })}`),
|
||||
createModel: (body: CreateModelBody) =>
|
||||
request<LLMModel>('/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),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { buildQuery, request } from './client'
|
||||
import type { LLMModel } from './llmAdmin'
|
||||
|
||||
export const modelsApi = {
|
||||
list: (onlyEnabled = true) =>
|
||||
request<LLMModel[]>('/api/v1/models' + buildQuery({ only_enabled: onlyEnabled })),
|
||||
}
|
||||
@@ -162,6 +162,7 @@ export const projectsApi = {
|
||||
description?: string
|
||||
requirement_number?: number
|
||||
assignee_id?: string
|
||||
workspace_id?: string
|
||||
},
|
||||
) => request<Issue>(`/api/v1/projects/${enc(slug)}/issues`, { method: 'POST', body }),
|
||||
getIssue: (slug: string, number: number) =>
|
||||
|
||||
@@ -63,8 +63,6 @@ export const runsApi = {
|
||||
restart: (id: string) =>
|
||||
request<RunStatus>(`/api/v1/run-profiles/${enc(id)}/restart`, { method: 'POST' }),
|
||||
status: (id: string) => request<RunStatus>(`/api/v1/run-profiles/${enc(id)}/status`),
|
||||
logs: (id: string, since = 0) =>
|
||||
request<LogLine[]>(`/api/v1/run-profiles/${enc(id)}/logs?since=${since}`),
|
||||
}
|
||||
|
||||
// openRunLogsWS 打开到日志流的原生 WebSocket。浏览器 WS 不支持自定义 header,
|
||||
|
||||
@@ -69,6 +69,12 @@
|
||||
>
|
||||
MCP Tokens
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
:to="{ name: 'admin-acp-sessions' }"
|
||||
class="text-sm hover:underline"
|
||||
>
|
||||
ACP 会话
|
||||
</RouterLink>
|
||||
</template>
|
||||
</nav>
|
||||
<span
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface UseAcpStreamReturn {
|
||||
sessionStatus: Ref<SessionStatus>
|
||||
events: Ref<AcpEvent[]>
|
||||
pendingPermissions: Ref<PermissionRequest[]>
|
||||
errorMsg: Ref<string | null>
|
||||
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<SessionStatus>('starting')
|
||||
const events = ref<AcpEvent[]>([])
|
||||
const pendingPermissions = ref<PermissionRequest[]>([])
|
||||
const errorMsg = ref<string | null>(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 }
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export type StreamStatus = 'idle' | 'connecting' | 'streaming' | 'closed' | 'err
|
||||
export interface UseRunStreamReturn {
|
||||
status: Ref<StreamStatus>
|
||||
runStatus: Ref<RunState>
|
||||
exitCode: Ref<number | null>
|
||||
logs: Ref<LogLine[]>
|
||||
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<StreamStatus>('idle')
|
||||
const runStatus = ref<RunState>('stopped')
|
||||
const exitCode = ref<number | null>(null)
|
||||
const logs = ref<LogLine[]>([])
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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(() => {})
|
||||
|
||||
+13
-4
@@ -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()
|
||||
configure({
|
||||
tokenProvider: () => auth.token,
|
||||
onUnauthorized: () => {
|
||||
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: redirectToLogin,
|
||||
// 403:会话仍有效但权限不足(如被降级/禁用),同样登出并回登录页。
|
||||
onForbidden: redirectToLogin,
|
||||
})
|
||||
}
|
||||
|
||||
+40
-1
@@ -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<void> {
|
||||
const u = await authApi.me()
|
||||
setUser(u)
|
||||
}
|
||||
|
||||
// validateSession 在应用启动时校验本地 token 是否仍然有效:
|
||||
// 有 token 才校验;成功则同步最新用户信息,遇到 401 则登出。
|
||||
async function validateSession(): Promise<void> {
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
||||
+20
-1
@@ -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,
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface Notification {
|
||||
link?: string
|
||||
read_at?: string
|
||||
created_at: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export const useNotificationsStore = defineStore('notifications', () => {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useAcpStore } from '@/stores/acp'
|
||||
import SessionStatusBadge from '@/components/acp/SessionStatusBadge.vue'
|
||||
|
||||
const store = useAcpStore()
|
||||
|
||||
onMounted(() => {
|
||||
// all=true → 管理员视图,列出所有用户的会话。
|
||||
void store.listSessions(true)
|
||||
})
|
||||
|
||||
async function terminate(id: string) {
|
||||
if (!confirm('Terminate this session?')) return
|
||||
try {
|
||||
await store.terminateSession(id)
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'failed')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">All ACP Sessions</h1>
|
||||
<span class="text-sm text-gray-500">Global admin view (all users)</span>
|
||||
</div>
|
||||
|
||||
<div v-if="store.loading" class="text-gray-500">Loading...</div>
|
||||
<div v-else-if="store.error" class="text-red-600">{{ store.error }}</div>
|
||||
<div v-else-if="store.sessions.length === 0" class="text-gray-500">
|
||||
No sessions.
|
||||
</div>
|
||||
|
||||
<table v-else class="w-full text-sm">
|
||||
<thead class="text-left">
|
||||
<tr class="border-b">
|
||||
<th class="px-3 py-2">User</th>
|
||||
<th class="px-3 py-2">Branch</th>
|
||||
<th class="px-3 py-2">Status</th>
|
||||
<th class="px-3 py-2">Started</th>
|
||||
<th class="px-3 py-2 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in store.sessions" :key="s.id" class="border-t hover:bg-gray-50">
|
||||
<td class="px-3 py-2 font-mono text-xs">{{ s.user_id }}</td>
|
||||
<td class="px-3 py-2 font-mono text-xs">{{ s.branch }}</td>
|
||||
<td class="px-3 py-2"><SessionStatusBadge :status="s.status" /></td>
|
||||
<td class="px-3 py-2 text-xs text-gray-500">
|
||||
{{ new Date(s.started_at).toLocaleString() }}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right space-x-2">
|
||||
<button
|
||||
v-if="s.status === 'starting' || s.status === 'running'"
|
||||
class="text-red-600 hover:underline"
|
||||
@click="terminate(s.id)"
|
||||
>
|
||||
Terminate
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -26,6 +26,8 @@ const events = computed<AcpEvent[]>(() => stream.value?.events.value ?? [])
|
||||
const pendingPermissions = computed<PermissionRequest[]>(
|
||||
() => stream.value?.pendingPermissions.value ?? [],
|
||||
)
|
||||
// 后端通过 WS 推送的 client_error 帧(如越权 prompt / 方法不允许 / 转发失败)。
|
||||
const streamError = computed<string | null>(() => 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 }}
|
||||
</p>
|
||||
|
||||
<!-- WS client_error 帧提示 -->
|
||||
<p v-if="streamError" class="px-4 py-2 text-sm text-red-700 bg-red-100">
|
||||
连接错误:{{ streamError }}
|
||||
</p>
|
||||
|
||||
<!-- 待审批工具调用 -->
|
||||
<PermissionApprovalPanel
|
||||
:requests="pendingPermissions"
|
||||
|
||||
@@ -105,8 +105,7 @@ async function reload() {
|
||||
const from = new Date(r[0]).toISOString()
|
||||
const to = new Date(r[1]).toISOString()
|
||||
if (props.adminMode) {
|
||||
const data = await llmAdminApi.adminUsage(from, to, groupBy.value)
|
||||
rows.value = data.items
|
||||
rows.value = await llmAdminApi.adminUsage(from, to, groupBy.value)
|
||||
} else {
|
||||
rows.value = await chatApi.myDailyUsage(from, to)
|
||||
}
|
||||
|
||||
@@ -294,6 +294,10 @@ async function toggleRole(row: AdminUser) {
|
||||
try {
|
||||
await userAdminApi.setRole(row.id, !row.is_admin)
|
||||
msg.success('已更新角色')
|
||||
// 若修改的是当前登录用户,刷新本地会话以同步 isAdmin,避免缓存陈旧。
|
||||
if (row.id === auth.user?.id) {
|
||||
await auth.refreshUser().catch(() => {})
|
||||
}
|
||||
await reload()
|
||||
} catch (e: unknown) {
|
||||
msg.error((e as Error).message || '操作失败')
|
||||
|
||||
@@ -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<ModelCapabilities>(() => {
|
||||
})
|
||||
|
||||
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 !== '') {
|
||||
|
||||
@@ -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<LLMModel[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
models.value = await llmAdminApi.listModels(true)
|
||||
models.value = await modelsApi.list(true)
|
||||
})
|
||||
|
||||
const options = computed(() =>
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
{{ store.current.slug }}
|
||||
</p>
|
||||
</div>
|
||||
<NButton @click="openEdit">
|
||||
编辑
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
@@ -63,15 +66,79 @@
|
||||
项目不存在或无权访问。
|
||||
</div>
|
||||
</NSpin>
|
||||
|
||||
<!-- Edit project modal -->
|
||||
<NModal
|
||||
v-model:show="showEdit"
|
||||
preset="card"
|
||||
title="编辑项目"
|
||||
class="w-full max-w-md"
|
||||
>
|
||||
<NForm @submit.prevent="onEditSubmit">
|
||||
<NFormItem label="名称">
|
||||
<NInput
|
||||
v-model:value="editForm.name"
|
||||
placeholder="My Project"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem label="描述">
|
||||
<NInput
|
||||
v-model:value="editForm.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="(可选)"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem label="可见性">
|
||||
<NRadioGroup v-model:value="editForm.visibility">
|
||||
<NRadio value="private">
|
||||
私有
|
||||
</NRadio>
|
||||
<NRadio value="internal">
|
||||
内部
|
||||
</NRadio>
|
||||
</NRadioGroup>
|
||||
</NFormItem>
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<NButton @click="showEdit = false">
|
||||
取消
|
||||
</NButton>
|
||||
<NButton
|
||||
type="primary"
|
||||
attr-type="submit"
|
||||
:loading="saving"
|
||||
>
|
||||
保存
|
||||
</NButton>
|
||||
</div>
|
||||
</NForm>
|
||||
<p
|
||||
v-if="editError"
|
||||
class="text-red-500 text-sm mt-2"
|
||||
>
|
||||
{{ editError }}
|
||||
</p>
|
||||
</NModal>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { NButton, NTag, NSpin } from 'naive-ui'
|
||||
import {
|
||||
NButton,
|
||||
NTag,
|
||||
NSpin,
|
||||
NModal,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NRadio,
|
||||
NRadioGroup,
|
||||
} from 'naive-ui'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import { useProjectsStore } from '@/stores/projects'
|
||||
import { projectsApi, type Visibility } from '@/api/projects'
|
||||
|
||||
const store = useProjectsStore()
|
||||
const route = useRoute()
|
||||
@@ -81,6 +148,49 @@ const slug = route.params.slug as string
|
||||
|
||||
onMounted(() => store.load(slug))
|
||||
|
||||
// ---- Edit modal ----
|
||||
const showEdit = ref(false)
|
||||
const saving = ref(false)
|
||||
const editError = ref('')
|
||||
const editForm = ref<{ name: string; description: string; visibility: Visibility }>({
|
||||
name: '',
|
||||
description: '',
|
||||
visibility: 'private',
|
||||
})
|
||||
|
||||
function openEdit() {
|
||||
if (!store.current) return
|
||||
editForm.value = {
|
||||
name: store.current.name,
|
||||
description: store.current.description,
|
||||
visibility: store.current.visibility,
|
||||
}
|
||||
editError.value = ''
|
||||
showEdit.value = true
|
||||
}
|
||||
|
||||
async function onEditSubmit() {
|
||||
editError.value = ''
|
||||
if (!editForm.value.name.trim()) {
|
||||
editError.value = '名称不能为空'
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await projectsApi.update(slug, {
|
||||
name: editForm.value.name,
|
||||
description: editForm.value.description,
|
||||
visibility: editForm.value.visibility,
|
||||
})
|
||||
showEdit.value = false
|
||||
await store.load(slug)
|
||||
} catch (e) {
|
||||
editError.value = e instanceof Error ? e.message : '保存失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goRequirements() {
|
||||
router.push({ name: 'requirement-list', params: { slug } })
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
<span>
|
||||
日志 ·
|
||||
<span :class="connClass">{{ connText }}</span>
|
||||
<span
|
||||
v-if="exitText"
|
||||
class="text-gray-400"
|
||||
>
|
||||
· {{ exitText }}
|
||||
</span>
|
||||
</span>
|
||||
<NButton
|
||||
text
|
||||
@@ -45,7 +51,7 @@ import { useRunStream } from '@/composables/useRunStream'
|
||||
|
||||
const props = defineProps<{ profileId: string }>()
|
||||
|
||||
const { status, logs, clear } = useRunStream(props.profileId)
|
||||
const { status, runStatus, exitCode, logs, clear } = useRunStream(props.profileId)
|
||||
|
||||
const scrollRef = ref<InstanceType<typeof NScrollbar> | null>(null)
|
||||
|
||||
@@ -69,6 +75,12 @@ const connText = computed(() => {
|
||||
return '已断开'
|
||||
}
|
||||
})
|
||||
// 进程已停止/崩溃且带退出码时展示,例如「退出码 0」。
|
||||
const exitText = computed(() =>
|
||||
exitCode.value !== null && (runStatus.value === 'stopped' || runStatus.value === 'crashed')
|
||||
? `退出码 ${exitCode.value}`
|
||||
: '',
|
||||
)
|
||||
const connClass = computed(() =>
|
||||
status.value === 'streaming'
|
||||
? 'text-green-400'
|
||||
|
||||
Reference in New Issue
Block a user