You've already forked agentic-coding-workflow
改动
This commit is contained in:
@@ -137,6 +137,7 @@ type Message struct {
|
|||||||
PromptTokens *int
|
PromptTokens *int
|
||||||
CompletionTokens *int
|
CompletionTokens *int
|
||||||
ThinkingTokens *int
|
ThinkingTokens *int
|
||||||
|
Attachments []Attachment // 用户可读路径填充;composeHistory 不依赖此字段
|
||||||
CreatedAt, UpdatedAt time.Time
|
CreatedAt, UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +174,7 @@ type ConversationReader interface {
|
|||||||
type ConversationService interface {
|
type ConversationService interface {
|
||||||
Create(ctx context.Context, userID uuid.UUID, in CreateConversationInput) (*Conversation, error)
|
Create(ctx context.Context, userID uuid.UUID, in CreateConversationInput) (*Conversation, error)
|
||||||
List(ctx context.Context, userID uuid.UUID, filter ConversationFilter) ([]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
|
UpdateTitle(ctx context.Context, userID, id uuid.UUID, title string) error
|
||||||
SoftDelete(ctx context.Context, userID, id uuid.UUID) 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)
|
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)
|
Stream(ctx context.Context, userID, conversationID uuid.UUID, sinceMessageID int64) (<-chan SSEEvent, error)
|
||||||
Cancel(ctx context.Context, userID uuid.UUID, messageID int64) 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)
|
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.
|
// MessageDTO is the JSON representation of a Message.
|
||||||
type MessageDTO struct {
|
type MessageDTO struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
ConversationID uuid.UUID `json:"conversation_id"`
|
ConversationID uuid.UUID `json:"conversation_id"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Thinking *string `json:"thinking,omitempty"`
|
Thinking *string `json:"thinking,omitempty"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
ErrorMessage *string `json:"error_message,omitempty"`
|
ErrorMessage *string `json:"error_message,omitempty"`
|
||||||
PromptTokens *int `json:"prompt_tokens,omitempty"`
|
PromptTokens *int `json:"prompt_tokens,omitempty"`
|
||||||
CompletionTokens *int `json:"completion_tokens,omitempty"`
|
CompletionTokens *int `json:"completion_tokens,omitempty"`
|
||||||
ThinkingTokens *int `json:"thinking_tokens,omitempty"`
|
ThinkingTokens *int `json:"thinking_tokens,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
Attachments []AttachmentDTO `json:"attachments"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AttachmentDTO is the JSON representation of an Attachment.
|
// AttachmentDTO is the JSON representation of an Attachment.
|
||||||
@@ -205,11 +206,18 @@ type ModelUsageDTO struct {
|
|||||||
CostUSD float64 `json:"cost_usd"`
|
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.
|
// AdminUsageResp is the response body for GET /admin/usage.
|
||||||
type AdminUsageResp struct {
|
type AdminUsageResp struct {
|
||||||
ByUser []UserUsageDTO `json:"by_user"`
|
Items []AdminUsageItem `json:"items"`
|
||||||
ByModel []ModelUsageDTO `json:"by_model"`
|
|
||||||
ByDay []DayUsageDTO `json:"by_day"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Conversion helpers =====
|
// ===== Conversion helpers =====
|
||||||
@@ -241,11 +249,29 @@ func toMessageDTO(m *Message) MessageDTO {
|
|||||||
PromptTokens: m.PromptTokens,
|
PromptTokens: m.PromptTokens,
|
||||||
CompletionTokens: m.CompletionTokens,
|
CompletionTokens: m.CompletionTokens,
|
||||||
ThinkingTokens: m.ThinkingTokens,
|
ThinkingTokens: m.ThinkingTokens,
|
||||||
|
Attachments: toAttachmentDTOs(m.Attachments),
|
||||||
CreatedAt: m.CreatedAt,
|
CreatedAt: m.CreatedAt,
|
||||||
UpdatedAt: m.UpdatedAt,
|
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 {
|
func toTemplateDTO(t *PromptTemplate) TemplateDTO {
|
||||||
return TemplateDTO{
|
return TemplateDTO{
|
||||||
ID: t.ID,
|
ID: t.ID,
|
||||||
|
|||||||
+96
-62
@@ -99,6 +99,9 @@ func (h *Handler) Mount(r chi.Router) {
|
|||||||
// User usage.
|
// User usage.
|
||||||
r.Get("/users/me/usage", h.userDailyUsage)
|
r.Get("/users/me/usage", h.userDailyUsage)
|
||||||
|
|
||||||
|
// Public (non-admin) models list for chat composers.
|
||||||
|
r.Get("/models", h.listModelsPublic)
|
||||||
|
|
||||||
// Admin-only routes.
|
// Admin-only routes.
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(h.adminGuard)
|
r.Use(h.adminGuard)
|
||||||
@@ -246,7 +249,13 @@ func (h *Handler) getConversation(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, r, err)
|
writeErr(w, r, err)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeErr(w, r, err)
|
writeErr(w, r, err)
|
||||||
return
|
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"))
|
writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "invalid message id"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
newID, err := h.msgSvc.Retry(r.Context(), uid, msgID)
|
newID, convID, err := h.msgSvc.Retry(r.Context(), uid, msgID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, r, err)
|
writeErr(w, r, err)
|
||||||
return
|
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 =====
|
// ===== Template handlers =====
|
||||||
@@ -641,6 +653,22 @@ func (h *Handler) listModels(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, out)
|
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) {
|
func (h *Handler) createModel(w http.ResponseWriter, r *http.Request) {
|
||||||
var req CreateModelReq
|
var req CreateModelReq
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
m, err := h.epSvc.CreateModel(r.Context(), CreateModelInput{
|
m, err := h.epSvc.CreateModel(r.Context(), CreateModelInput{
|
||||||
EndpointID: req.EndpointID,
|
EndpointID: req.EndpointID,
|
||||||
ModelID: req.ModelID,
|
ModelID: req.ModelID,
|
||||||
DisplayName: req.DisplayName,
|
DisplayName: req.DisplayName,
|
||||||
Capabilities: req.Capabilities,
|
Capabilities: req.Capabilities,
|
||||||
ContextWindow: req.ContextWindow,
|
ContextWindow: req.ContextWindow,
|
||||||
MaxOutputTokens: req.MaxOutputTokens,
|
MaxOutputTokens: req.MaxOutputTokens,
|
||||||
PromptPrice: req.PromptPricePerMillionUSD,
|
PromptPrice: req.PromptPricePerMillionUSD,
|
||||||
CompletionPrice: req.CompletionPricePerMillionUSD,
|
CompletionPrice: req.CompletionPricePerMillionUSD,
|
||||||
ThinkingPrice: req.ThinkingPricePerMillionUSD,
|
ThinkingPrice: req.ThinkingPricePerMillionUSD,
|
||||||
IsTitleGenerator: req.IsTitleGenerator,
|
IsTitleGenerator: req.IsTitleGenerator,
|
||||||
Enabled: req.Enabled,
|
Enabled: req.Enabled,
|
||||||
SortOrder: req.SortOrder,
|
SortOrder: req.SortOrder,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, r, err)
|
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)
|
groupBy := q.Get("group_by")
|
||||||
if err != nil {
|
if groupBy == "" {
|
||||||
writeErr(w, r, err)
|
groupBy = "day"
|
||||||
return
|
|
||||||
}
|
}
|
||||||
byModel, err := h.usageSvc.SummarizeByModel(r.Context(), from, to)
|
|
||||||
if err != nil {
|
items := make([]AdminUsageItem, 0)
|
||||||
writeErr(w, r, err)
|
switch groupBy {
|
||||||
return
|
case "user":
|
||||||
}
|
rows, err := h.usageSvc.SummarizeByUser(r.Context(), from, to)
|
||||||
byDay, err := h.usageSvc.SummarizeByDay(r.Context(), from, to)
|
if err != nil {
|
||||||
if err != nil {
|
writeErr(w, r, err)
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userDTOs := make([]UserUsageDTO, 0, len(byUser))
|
writeJSON(w, http.StatusOK, AdminUsageResp{Items: items})
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func (f *fakeConvSvc) List(ctx context.Context, userID uuid.UUID, filter Convers
|
|||||||
}
|
}
|
||||||
return nil, nil
|
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 {
|
if f.getFunc != nil {
|
||||||
return f.getFunc(ctx, userID, id)
|
return f.getFunc(ctx, userID, id)
|
||||||
}
|
}
|
||||||
@@ -95,11 +95,11 @@ func (f *fakeMsgSvc) Stream(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ int64
|
|||||||
return ch, nil
|
return ch, nil
|
||||||
}
|
}
|
||||||
func (f *fakeMsgSvc) Cancel(_ context.Context, _ uuid.UUID, _ int64) error { return 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 {
|
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) {
|
func (f *fakeMsgSvc) ListMessages(_ context.Context, _, _ uuid.UUID, _ *int64, _ int) ([]Message, error) {
|
||||||
return nil, nil
|
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.
|
// 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)
|
c, err := s.repo.GetConversation(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -92,10 +93,21 @@ func (s *conversationService) Get(ctx context.Context, userID, id uuid.UUID) (*C
|
|||||||
if c.UserID != userID {
|
if c.UserID != userID {
|
||||||
return nil, nil, errs.New(errs.CodeForbidden, "not conversation owner")
|
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 {
|
if err != nil {
|
||||||
return nil, nil, err
|
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
|
return c, msgs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,10 @@ func (r *convFakeRepo) SoftDeleteConversation(_ context.Context, id uuid.UUID) e
|
|||||||
return nil
|
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) {
|
func (r *convFakeRepo) ListMessagesByConversation(_ context.Context, convID uuid.UUID, _ *int64, limit int) ([]Message, error) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
@@ -335,7 +339,7 @@ func TestConversationService_Get_HappyPath(t *testing.T) {
|
|||||||
{ID: 1, ConversationID: conv.ID, Role: RoleUser, Content: "hello"},
|
{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.NoError(t, err)
|
||||||
require.NotNil(t, got)
|
require.NotNil(t, got)
|
||||||
assert.Equal(t, conv.ID, got.ID)
|
assert.Equal(t, conv.ID, got.ID)
|
||||||
@@ -350,7 +354,7 @@ func TestConversationService_Get_NotOwner(t *testing.T) {
|
|||||||
callerID := uuid.Must(uuid.NewV7())
|
callerID := uuid.Must(uuid.NewV7())
|
||||||
conv := addConversation(repo, ownerID)
|
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)
|
require.Error(t, err)
|
||||||
var appErr *errs.AppError
|
var appErr *errs.AppError
|
||||||
require.ErrorAs(t, err, &appErr)
|
require.ErrorAs(t, err, &appErr)
|
||||||
|
|||||||
@@ -161,8 +161,8 @@ func (s *messageService) Send(
|
|||||||
TargetType: "message",
|
TargetType: "message",
|
||||||
TargetID: int64ToString(userMsg.ID),
|
TargetID: int64ToString(userMsg.ID),
|
||||||
Metadata: map[string]any{
|
Metadata: map[string]any{
|
||||||
"conversation_id": conversationID.String(),
|
"conversation_id": conversationID.String(),
|
||||||
"attachment_count": len(atts),
|
"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
|
// Retry deletes the failed/cancelled assistant message, inserts a fresh pending
|
||||||
// one, and restarts the streamer.
|
// 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)
|
old, err := s.repo.GetMessage(ctx, messageID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, uuid.Nil, err
|
||||||
}
|
}
|
||||||
if old.Status != StatusError && old.Status != StatusCancelled {
|
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)
|
conv, err := s.repo.GetConversation(ctx, old.ConversationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, uuid.Nil, err
|
||||||
}
|
}
|
||||||
if conv.UserID != userID {
|
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)
|
repo, tx, err := s.repo.WithTx(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, uuid.Nil, err
|
||||||
}
|
}
|
||||||
committed := false
|
committed := false
|
||||||
defer func() {
|
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 {
|
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)
|
asstMsg, err := repo.InsertMessage(ctx, conv.ID, RoleAssistant, "", StatusPending)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, uuid.Nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
return 0, err
|
return 0, uuid.Nil, err
|
||||||
}
|
}
|
||||||
committed = true
|
committed = true
|
||||||
|
|
||||||
@@ -345,20 +345,20 @@ func (s *messageService) Retry(ctx context.Context, userID uuid.UUID, messageID
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
e := err.Error()
|
e := err.Error()
|
||||||
_ = s.repo.UpdateMessageError(ctx, asstMsg.ID, &e)
|
_ = 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)
|
endpoint, err := s.repo.GetLLMEndpoint(ctx, model.EndpointID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e := err.Error()
|
e := err.Error()
|
||||||
_ = s.repo.UpdateMessageError(ctx, asstMsg.ID, &e)
|
_ = s.repo.UpdateMessageError(ctx, asstMsg.ID, &e)
|
||||||
return asstMsg.ID, err
|
return asstMsg.ID, conv.ID, err
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := s.composeHistory(ctx, conv, model)
|
req, err := s.composeHistory(ctx, conv, model)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e := err.Error()
|
e := err.Error()
|
||||||
_ = s.repo.UpdateMessageError(ctx, asstMsg.ID, &e)
|
_ = s.repo.UpdateMessageError(ctx, asstMsg.ID, &e)
|
||||||
return asstMsg.ID, err
|
return asstMsg.ID, conv.ID, err
|
||||||
}
|
}
|
||||||
|
|
||||||
params := StreamParams{
|
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 {
|
if err := s.hub.Start(ctx, params); err != nil {
|
||||||
e := err.Error()
|
e := err.Error()
|
||||||
_ = s.repo.UpdateMessageError(ctx, asstMsg.ID, &e)
|
_ = s.repo.UpdateMessageError(ctx, asstMsg.ID, &e)
|
||||||
return asstMsg.ID, err
|
return asstMsg.ID, conv.ID, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audit after successful hub start.
|
// 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.
|
// ListMessages returns paginated messages for a conversation, enforcing ownership.
|
||||||
@@ -408,7 +408,19 @@ func (s *messageService) ListMessages(
|
|||||||
if limit == 0 || limit > 200 {
|
if limit == 0 || limit > 200 {
|
||||||
limit = 50
|
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
|
// 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.
|
// Use config with smaller maxMsg to make the test reliable.
|
||||||
cfg := defaultMsgConfig()
|
cfg := defaultMsgConfig()
|
||||||
cfg.MaxFileBytes = 50 * 1024 * 1024 // 50 MB per file — both pass individually
|
cfg.MaxFileBytes = 50 * 1024 * 1024 // 50 MB per file — both pass individually
|
||||||
cfg.MaxMsgBytes = 40 * 1024 * 1024 // 40 MB total — 60 MB fails
|
cfg.MaxMsgBytes = 40 * 1024 * 1024 // 40 MB total — 60 MB fails
|
||||||
|
|
||||||
svc := buildMessageService(repo, hub, cfg)
|
svc := buildMessageService(repo, hub, cfg)
|
||||||
_, _, err := svc.Send(context.Background(), userID, conv.ID, "hi", []uuid.UUID{a1.ID, a2.ID})
|
_, _, 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)
|
repo.addMessage(m)
|
||||||
|
|
||||||
svc := buildMessageService(repo, hub, defaultMsgConfig())
|
svc := buildMessageService(repo, hub, defaultMsgConfig())
|
||||||
_, err := svc.Retry(context.Background(), userID, 55)
|
_, _, err := svc.Retry(context.Background(), userID, 55)
|
||||||
|
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
var ae *errs.AppError
|
var ae *errs.AppError
|
||||||
@@ -641,10 +641,11 @@ func TestMessageService_Retry_HappyPath(t *testing.T) {
|
|||||||
repo.addMessage(old)
|
repo.addMessage(old)
|
||||||
|
|
||||||
svc := buildMessageService(repo, hub, defaultMsgConfig())
|
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.NoError(t, err)
|
||||||
require.Greater(t, newID, int64(0))
|
require.Greater(t, newID, int64(0))
|
||||||
|
require.Equal(t, conv.ID, convID)
|
||||||
|
|
||||||
// Old message deleted.
|
// Old message deleted.
|
||||||
repo.mu.Lock()
|
repo.mu.Lock()
|
||||||
@@ -681,10 +682,11 @@ func TestMessageService_Retry_Cancelled_HappyPath(t *testing.T) {
|
|||||||
repo.addMessage(old)
|
repo.addMessage(old)
|
||||||
|
|
||||||
svc := buildMessageService(repo, hub, defaultMsgConfig())
|
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.NoError(t, err)
|
||||||
require.Greater(t, newID, int64(0))
|
require.Greater(t, newID, int64(0))
|
||||||
|
require.Equal(t, conv.ID, convID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Stream tests =====
|
// ===== Stream tests =====
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ func (s *sseMessageService) Stream(_ context.Context, _ uuid.UUID, _ uuid.UUID,
|
|||||||
return s.streamCh, nil
|
return s.streamCh, nil
|
||||||
}
|
}
|
||||||
func (s *sseMessageService) Cancel(_ context.Context, _ uuid.UUID, _ int64) error { return nil }
|
func (s *sseMessageService) Cancel(_ context.Context, _ uuid.UUID, _ int64) error { return nil }
|
||||||
func (s *sseMessageService) Retry(_ context.Context, _ uuid.UUID, _ int64) (int64, error) {
|
func (s *sseMessageService) Retry(_ context.Context, _ uuid.UUID, _ int64) (int64, uuid.UUID, error) {
|
||||||
return 0, nil
|
return 0, uuid.Nil, nil
|
||||||
}
|
}
|
||||||
func (s *sseMessageService) ListMessages(_ context.Context, _, _ uuid.UUID, _ *int64, _ int) ([]Message, error) {
|
func (s *sseMessageService) ListMessages(_ context.Context, _, _ uuid.UUID, _ *int64, _ int) ([]Message, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
+15
-11
@@ -13,7 +13,6 @@ export interface Conversation {
|
|||||||
model_id: string
|
model_id: string
|
||||||
system_prompt: string
|
system_prompt: string
|
||||||
title?: string | null
|
title?: string | null
|
||||||
title_generated_at?: string | null
|
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@@ -24,6 +23,7 @@ export interface Attachment {
|
|||||||
mime_type: string
|
mime_type: string
|
||||||
size_bytes: number
|
size_bytes: number
|
||||||
sha256: string
|
sha256: string
|
||||||
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
@@ -98,15 +98,12 @@ const enc = (v: string) => encodeURIComponent(v)
|
|||||||
export const chatApi = {
|
export const chatApi = {
|
||||||
// ===== Conversations =====
|
// ===== Conversations =====
|
||||||
listConversations: (params: ListConversationsParams = {}) =>
|
listConversations: (params: ListConversationsParams = {}) =>
|
||||||
request<{ items: Conversation[] }>(`/api/v1/conversations${buildQuery(params)}`).then(
|
request<Conversation[]>(`/api/v1/conversations${buildQuery(params)}`),
|
||||||
(r) => r.items,
|
|
||||||
),
|
|
||||||
createConversation: (body: CreateConversationBody) =>
|
createConversation: (body: CreateConversationBody) =>
|
||||||
request<Conversation>('/api/v1/conversations', { method: 'POST', body }),
|
request<Conversation>('/api/v1/conversations', { method: 'POST', body }),
|
||||||
getConversation: (id: string, opts: { before?: number; limit?: number } = {}) =>
|
getConversation: (id: string, opts: { limit?: number } = {}) =>
|
||||||
request<ConversationDetail>(
|
request<ConversationDetail>(
|
||||||
`/api/v1/conversations/${enc(id)}${buildQuery({
|
`/api/v1/conversations/${enc(id)}${buildQuery({
|
||||||
before: opts.before,
|
|
||||||
limit: opts.limit ?? 50,
|
limit: opts.limit ?? 50,
|
||||||
})}`,
|
})}`,
|
||||||
),
|
),
|
||||||
@@ -127,6 +124,16 @@ export const chatApi = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body,
|
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) =>
|
cancelMessage: (messageId: number) =>
|
||||||
request<void>(`/api/v1/messages/${messageId}/cancel`, { method: 'POST' }),
|
request<void>(`/api/v1/messages/${messageId}/cancel`, { method: 'POST' }),
|
||||||
retryMessage: (messageId: number) =>
|
retryMessage: (messageId: number) =>
|
||||||
@@ -140,8 +147,7 @@ export const chatApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ===== Templates =====
|
// ===== Templates =====
|
||||||
listTemplates: () =>
|
listTemplates: () => request<PromptTemplate[]>('/api/v1/prompt-templates'),
|
||||||
request<{ items: PromptTemplate[] }>('/api/v1/prompt-templates').then((r) => r.items),
|
|
||||||
createTemplate: (body: { name: string; content: string; scope: TemplateScope }) =>
|
createTemplate: (body: { name: string; content: string; scope: TemplateScope }) =>
|
||||||
request<PromptTemplate>('/api/v1/prompt-templates', { method: 'POST', body }),
|
request<PromptTemplate>('/api/v1/prompt-templates', { method: 'POST', body }),
|
||||||
updateTemplate: (id: string, body: { name?: string; content?: string }) =>
|
updateTemplate: (id: string, body: { name?: string; content?: string }) =>
|
||||||
@@ -151,7 +157,5 @@ export const chatApi = {
|
|||||||
|
|
||||||
// ===== Personal usage =====
|
// ===== Personal usage =====
|
||||||
myDailyUsage: (from: string, to: string) =>
|
myDailyUsage: (from: string, to: string) =>
|
||||||
request<{ items: UsageDailyItem[] }>(
|
request<UsageDailyItem[]>(`/api/v1/users/me/usage${buildQuery({ from, to })}`),
|
||||||
`/api/v1/users/me/usage${buildQuery({ from, to })}`,
|
|
||||||
).then((r) => r.items),
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,16 @@ interface RequestOptions {
|
|||||||
|
|
||||||
let authTokenProvider: () => string | null = () => null
|
let authTokenProvider: () => string | null = () => null
|
||||||
let onUnauthorized: () => void = () => {}
|
let onUnauthorized: () => void = () => {}
|
||||||
|
let onForbidden: () => void = () => {}
|
||||||
|
|
||||||
export function configure(opts: {
|
export function configure(opts: {
|
||||||
tokenProvider: () => string | null
|
tokenProvider: () => string | null
|
||||||
onUnauthorized: () => void
|
onUnauthorized: () => void
|
||||||
|
onForbidden?: () => void
|
||||||
}) {
|
}) {
|
||||||
authTokenProvider = opts.tokenProvider
|
authTokenProvider = opts.tokenProvider
|
||||||
onUnauthorized = opts.onUnauthorized
|
onUnauthorized = opts.onUnauthorized
|
||||||
|
if (opts.onForbidden) onForbidden = opts.onForbidden
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
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) {
|
if (resp.status === 401) {
|
||||||
onUnauthorized()
|
onUnauthorized()
|
||||||
|
} else if (resp.status === 403) {
|
||||||
|
onForbidden()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resp.status === 204) {
|
if (resp.status === 204) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface LLMEndpoint {
|
|||||||
provider: LLMProvider
|
provider: LLMProvider
|
||||||
display_name: string
|
display_name: string
|
||||||
base_url: string
|
base_url: string
|
||||||
|
created_by: string
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@@ -81,8 +82,7 @@ const enc = (v: string) => encodeURIComponent(v)
|
|||||||
|
|
||||||
export const llmAdminApi = {
|
export const llmAdminApi = {
|
||||||
// ===== Endpoints =====
|
// ===== Endpoints =====
|
||||||
listEndpoints: () =>
|
listEndpoints: () => request<LLMEndpoint[]>('/api/v1/admin/llm-endpoints'),
|
||||||
request<{ items: LLMEndpoint[] }>('/api/v1/admin/llm-endpoints').then((r) => r.items),
|
|
||||||
createEndpoint: (body: CreateEndpointBody) =>
|
createEndpoint: (body: CreateEndpointBody) =>
|
||||||
request<LLMEndpoint>('/api/v1/admin/llm-endpoints', { method: 'POST', body }),
|
request<LLMEndpoint>('/api/v1/admin/llm-endpoints', { method: 'POST', body }),
|
||||||
updateEndpoint: (id: string, body: UpdateEndpointBody) =>
|
updateEndpoint: (id: string, body: UpdateEndpointBody) =>
|
||||||
@@ -94,9 +94,7 @@ export const llmAdminApi = {
|
|||||||
|
|
||||||
// ===== Models =====
|
// ===== Models =====
|
||||||
listModels: (onlyEnabled = false) =>
|
listModels: (onlyEnabled = false) =>
|
||||||
request<{ items: LLMModel[] }>(
|
request<LLMModel[]>(`/api/v1/admin/llm-models${buildQuery({ only_enabled: onlyEnabled })}`),
|
||||||
`/api/v1/admin/llm-models${buildQuery({ only_enabled: onlyEnabled })}`,
|
|
||||||
).then((r) => r.items),
|
|
||||||
createModel: (body: CreateModelBody) =>
|
createModel: (body: CreateModelBody) =>
|
||||||
request<LLMModel>('/api/v1/admin/llm-models', { method: 'POST', body }),
|
request<LLMModel>('/api/v1/admin/llm-models', { method: 'POST', body }),
|
||||||
updateModel: (id: string, body: UpdateModelBody) =>
|
updateModel: (id: string, body: UpdateModelBody) =>
|
||||||
@@ -108,5 +106,5 @@ export const llmAdminApi = {
|
|||||||
adminUsage: (from: string, to: string, groupBy: AdminUsageGroupBy) =>
|
adminUsage: (from: string, to: string, groupBy: AdminUsageGroupBy) =>
|
||||||
request<{ items: AdminUsageItem[] }>(
|
request<{ items: AdminUsageItem[] }>(
|
||||||
`/api/v1/admin/usage${buildQuery({ from, to, group_by: groupBy })}`,
|
`/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
|
description?: string
|
||||||
requirement_number?: number
|
requirement_number?: number
|
||||||
assignee_id?: string
|
assignee_id?: string
|
||||||
|
workspace_id?: string
|
||||||
},
|
},
|
||||||
) => request<Issue>(`/api/v1/projects/${enc(slug)}/issues`, { method: 'POST', body }),
|
) => request<Issue>(`/api/v1/projects/${enc(slug)}/issues`, { method: 'POST', body }),
|
||||||
getIssue: (slug: string, number: number) =>
|
getIssue: (slug: string, number: number) =>
|
||||||
|
|||||||
@@ -63,8 +63,6 @@ export const runsApi = {
|
|||||||
restart: (id: string) =>
|
restart: (id: string) =>
|
||||||
request<RunStatus>(`/api/v1/run-profiles/${enc(id)}/restart`, { method: 'POST' }),
|
request<RunStatus>(`/api/v1/run-profiles/${enc(id)}/restart`, { method: 'POST' }),
|
||||||
status: (id: string) => request<RunStatus>(`/api/v1/run-profiles/${enc(id)}/status`),
|
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,
|
// openRunLogsWS 打开到日志流的原生 WebSocket。浏览器 WS 不支持自定义 header,
|
||||||
|
|||||||
@@ -69,6 +69,12 @@
|
|||||||
>
|
>
|
||||||
MCP Tokens
|
MCP Tokens
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'admin-acp-sessions' }"
|
||||||
|
class="text-sm hover:underline"
|
||||||
|
>
|
||||||
|
ACP 会话
|
||||||
|
</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface UseAcpStreamReturn {
|
|||||||
sessionStatus: Ref<SessionStatus>
|
sessionStatus: Ref<SessionStatus>
|
||||||
events: Ref<AcpEvent[]>
|
events: Ref<AcpEvent[]>
|
||||||
pendingPermissions: Ref<PermissionRequest[]>
|
pendingPermissions: Ref<PermissionRequest[]>
|
||||||
|
errorMsg: Ref<string | null>
|
||||||
prompt: (text: string, agentSessionID: string) => void
|
prompt: (text: string, agentSessionID: string) => void
|
||||||
cancel: (agentSessionID: string) => void
|
cancel: (agentSessionID: string) => void
|
||||||
reconnect: () => void
|
reconnect: () => void
|
||||||
@@ -49,6 +50,7 @@ export function useAcpStream(sessionId: string): UseAcpStreamReturn {
|
|||||||
const sessionStatus = ref<SessionStatus>('starting')
|
const sessionStatus = ref<SessionStatus>('starting')
|
||||||
const events = ref<AcpEvent[]>([])
|
const events = ref<AcpEvent[]>([])
|
||||||
const pendingPermissions = ref<PermissionRequest[]>([])
|
const pendingPermissions = ref<PermissionRequest[]>([])
|
||||||
|
const errorMsg = ref<string | null>(null)
|
||||||
const lastEventID = ref(0)
|
const lastEventID = ref(0)
|
||||||
|
|
||||||
function upsertPermission(p: PermissionRequest) {
|
function upsertPermission(p: PermissionRequest) {
|
||||||
@@ -118,6 +120,13 @@ export function useAcpStream(sessionId: string): UseAcpStreamReturn {
|
|||||||
return
|
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
|
// Initial state event from server
|
||||||
if (obj.kind === 'state') {
|
if (obj.kind === 'state') {
|
||||||
const s = (parsed as ServerStateEvent).status
|
const s = (parsed as ServerStateEvent).status
|
||||||
@@ -209,5 +218,5 @@ export function useAcpStream(sessionId: string): UseAcpStreamReturn {
|
|||||||
connect()
|
connect()
|
||||||
onScopeDispose(close)
|
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 {
|
export interface UseRunStreamReturn {
|
||||||
status: Ref<StreamStatus>
|
status: Ref<StreamStatus>
|
||||||
runStatus: Ref<RunState>
|
runStatus: Ref<RunState>
|
||||||
|
exitCode: Ref<number | null>
|
||||||
logs: Ref<LogLine[]>
|
logs: Ref<LogLine[]>
|
||||||
reconnect: () => void
|
reconnect: () => void
|
||||||
close: () => void
|
close: () => void
|
||||||
@@ -24,11 +25,13 @@ interface WireFrame {
|
|||||||
text?: string
|
text?: string
|
||||||
ts?: string
|
ts?: string
|
||||||
status?: RunState
|
status?: RunState
|
||||||
|
exit_code?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRunStream(profileId: string): UseRunStreamReturn {
|
export function useRunStream(profileId: string): UseRunStreamReturn {
|
||||||
const status = ref<StreamStatus>('idle')
|
const status = ref<StreamStatus>('idle')
|
||||||
const runStatus = ref<RunState>('stopped')
|
const runStatus = ref<RunState>('stopped')
|
||||||
|
const exitCode = ref<number | null>(null)
|
||||||
const logs = ref<LogLine[]>([])
|
const logs = ref<LogLine[]>([])
|
||||||
const lastLogID = ref(0)
|
const lastLogID = ref(0)
|
||||||
|
|
||||||
@@ -62,6 +65,7 @@ export function useRunStream(profileId: string): UseRunStreamReturn {
|
|||||||
|
|
||||||
if (parsed.kind === 'state') {
|
if (parsed.kind === 'state') {
|
||||||
if (parsed.status) runStatus.value = parsed.status
|
if (parsed.status) runStatus.value = parsed.status
|
||||||
|
exitCode.value = parsed.exit_code ?? null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (parsed.kind === 'slow_consumer_disconnect') {
|
if (parsed.kind === 'slow_consumer_disconnect') {
|
||||||
@@ -124,5 +128,5 @@ export function useRunStream(profileId: string): UseRunStreamReturn {
|
|||||||
connect()
|
connect()
|
||||||
onScopeDispose(close)
|
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 App from './App.vue'
|
||||||
import router, { bindAuthToApi } from './router'
|
import router, { bindAuthToApi } from './router'
|
||||||
|
import { useAuthStore } from './stores/auth'
|
||||||
|
|
||||||
import './styles/tailwind.css'
|
import './styles/tailwind.css'
|
||||||
import './styles/tokens.css'
|
import './styles/tokens.css'
|
||||||
@@ -12,3 +13,9 @@ app.use(createPinia())
|
|||||||
app.use(router)
|
app.use(router)
|
||||||
bindAuthToApi()
|
bindAuthToApi()
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
||||||
|
// 启动后异步校验本地会话(不阻塞首屏渲染):
|
||||||
|
// 若 token 仍有效则回填最新用户信息;遇到 401 则登出。
|
||||||
|
useAuthStore()
|
||||||
|
.validateSession()
|
||||||
|
.catch(() => {})
|
||||||
|
|||||||
+15
-6
@@ -154,6 +154,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/admin/MCPTokenAdminView.vue'),
|
component: () => import('@/views/admin/MCPTokenAdminView.vue'),
|
||||||
meta: { requiresAuth: true, requiresAdmin: true },
|
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',
|
path: '/admin/acp/agent-kinds',
|
||||||
component: () => import('@/views/admin/AgentKindAdminListView.vue'),
|
component: () => import('@/views/admin/AgentKindAdminListView.vue'),
|
||||||
@@ -195,13 +201,16 @@ export default router
|
|||||||
// 把 auth store 注入 api client(在 router 模块顶层晚于 pinia 安装可能未就绪,所以用懒访问)
|
// 把 auth store 注入 api client(在 router 模块顶层晚于 pinia 安装可能未就绪,所以用懒访问)
|
||||||
export function bindAuthToApi() {
|
export function bindAuthToApi() {
|
||||||
const auth = useAuthStore()
|
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({
|
configure({
|
||||||
tokenProvider: () => auth.token,
|
tokenProvider: () => auth.token,
|
||||||
onUnauthorized: () => {
|
onUnauthorized: redirectToLogin,
|
||||||
auth.clearSession()
|
// 403:会话仍有效但权限不足(如被降级/禁用),同样登出并回登录页。
|
||||||
const current = router.currentRoute.value
|
onForbidden: redirectToLogin,
|
||||||
const query = current.name === 'login' ? undefined : { next: current.fullPath }
|
|
||||||
router.replace({ name: 'login', query }).catch(() => {})
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-1
@@ -1,5 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import { authApi } from '@/api/auth'
|
||||||
|
import { ApiException } from '@/api/types'
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string
|
id: string
|
||||||
@@ -69,5 +71,42 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
safeRemove('user')
|
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()
|
detachStream()
|
||||||
const detail = await chatApi.getConversation(id)
|
const detail = await chatApi.getConversation(id)
|
||||||
currentConversation.value = detail.conversation
|
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')
|
const pending = detail.messages.find((m) => m.status === 'pending')
|
||||||
if (pending) {
|
if (pending) {
|
||||||
streamingMessageId.value = pending.id
|
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[] = []) {
|
async function sendMessage(content: string, attachmentIds: string[] = []) {
|
||||||
if (!currentConversation.value) return
|
if (!currentConversation.value) return
|
||||||
if (streamingMessageId.value !== null) return
|
if (streamingMessageId.value !== null) return
|
||||||
@@ -211,6 +229,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
isStreaming,
|
isStreaming,
|
||||||
loadConversations,
|
loadConversations,
|
||||||
openConversation,
|
openConversation,
|
||||||
|
loadOlderMessages,
|
||||||
closeCurrent,
|
closeCurrent,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
cancelMessage,
|
cancelMessage,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface Notification {
|
|||||||
link?: string
|
link?: string
|
||||||
read_at?: string
|
read_at?: string
|
||||||
created_at: string
|
created_at: string
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useNotificationsStore = defineStore('notifications', () => {
|
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[]>(
|
const pendingPermissions = computed<PermissionRequest[]>(
|
||||||
() => stream.value?.pendingPermissions.value ?? [],
|
() => stream.value?.pendingPermissions.value ?? [],
|
||||||
)
|
)
|
||||||
|
// 后端通过 WS 推送的 client_error 帧(如越权 prompt / 方法不允许 / 转发失败)。
|
||||||
|
const streamError = computed<string | null>(() => stream.value?.errorMsg.value ?? null)
|
||||||
// 仅 session owner(或 admin)可决策;后端同样强制。
|
// 仅 session owner(或 admin)可决策;后端同样强制。
|
||||||
const canDecide = computed(
|
const canDecide = computed(
|
||||||
() => auth.user?.is_admin === true || store.currentSession?.user_id === auth.user?.id,
|
() => auth.user?.is_admin === true || store.currentSession?.user_id === auth.user?.id,
|
||||||
@@ -178,6 +180,11 @@ async function openCommit() {
|
|||||||
{{ errorMsg }}
|
{{ errorMsg }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- WS client_error 帧提示 -->
|
||||||
|
<p v-if="streamError" class="px-4 py-2 text-sm text-red-700 bg-red-100">
|
||||||
|
连接错误:{{ streamError }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<!-- 待审批工具调用 -->
|
<!-- 待审批工具调用 -->
|
||||||
<PermissionApprovalPanel
|
<PermissionApprovalPanel
|
||||||
:requests="pendingPermissions"
|
:requests="pendingPermissions"
|
||||||
|
|||||||
@@ -105,8 +105,7 @@ async function reload() {
|
|||||||
const from = new Date(r[0]).toISOString()
|
const from = new Date(r[0]).toISOString()
|
||||||
const to = new Date(r[1]).toISOString()
|
const to = new Date(r[1]).toISOString()
|
||||||
if (props.adminMode) {
|
if (props.adminMode) {
|
||||||
const data = await llmAdminApi.adminUsage(from, to, groupBy.value)
|
rows.value = await llmAdminApi.adminUsage(from, to, groupBy.value)
|
||||||
rows.value = data.items
|
|
||||||
} else {
|
} else {
|
||||||
rows.value = await chatApi.myDailyUsage(from, to)
|
rows.value = await chatApi.myDailyUsage(from, to)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -294,6 +294,10 @@ async function toggleRole(row: AdminUser) {
|
|||||||
try {
|
try {
|
||||||
await userAdminApi.setRole(row.id, !row.is_admin)
|
await userAdminApi.setRole(row.id, !row.is_admin)
|
||||||
msg.success('已更新角色')
|
msg.success('已更新角色')
|
||||||
|
// 若修改的是当前登录用户,刷新本地会话以同步 isAdmin,避免缓存陈旧。
|
||||||
|
if (row.id === auth.user?.id) {
|
||||||
|
await auth.refreshUser().catch(() => {})
|
||||||
|
}
|
||||||
await reload()
|
await reload()
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
msg.error((e as Error).message || '操作失败')
|
msg.error((e as Error).message || '操作失败')
|
||||||
|
|||||||
@@ -108,7 +108,8 @@ import { useChatStore } from '@/stores/chat'
|
|||||||
import MessageBubble from './components/MessageBubble.vue'
|
import MessageBubble from './components/MessageBubble.vue'
|
||||||
import AttachmentUploader from './components/AttachmentUploader.vue'
|
import AttachmentUploader from './components/AttachmentUploader.vue'
|
||||||
import ConversationCreateModal from './ConversationCreateModal.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 chat = useChatStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -142,7 +143,7 @@ const currentModelCaps = computed<ModelCapabilities>(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
allModels.value = await llmAdminApi.listModels(true)
|
allModels.value = await modelsApi.list(true)
|
||||||
await chat.loadConversations({ project_id: projectId.value })
|
await chat.loadConversations({ project_id: projectId.value })
|
||||||
const cid = route.params.conversationId
|
const cid = route.params.conversationId
|
||||||
if (typeof cid === 'string' && cid !== '') {
|
if (typeof cid === 'string' && cid !== '') {
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { NSelect } from 'naive-ui'
|
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 props = defineProps<{ modelValue?: string }>()
|
||||||
const emit = defineEmits<{ 'update:modelValue': [v: string] }>()
|
const emit = defineEmits<{ 'update:modelValue': [v: string] }>()
|
||||||
@@ -27,7 +28,7 @@ const emit = defineEmits<{ 'update:modelValue': [v: string] }>()
|
|||||||
const models = ref<LLMModel[]>([])
|
const models = ref<LLMModel[]>([])
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
models.value = await llmAdminApi.listModels(true)
|
models.value = await modelsApi.list(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
const options = computed(() =>
|
const options = computed(() =>
|
||||||
|
|||||||
@@ -29,6 +29,9 @@
|
|||||||
{{ store.current.slug }}
|
{{ store.current.slug }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<NButton @click="openEdit">
|
||||||
|
编辑
|
||||||
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
@@ -63,15 +66,79 @@
|
|||||||
项目不存在或无权访问。
|
项目不存在或无权访问。
|
||||||
</div>
|
</div>
|
||||||
</NSpin>
|
</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>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
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 AppShell from '@/layouts/AppShell.vue'
|
||||||
import { useProjectsStore } from '@/stores/projects'
|
import { useProjectsStore } from '@/stores/projects'
|
||||||
|
import { projectsApi, type Visibility } from '@/api/projects'
|
||||||
|
|
||||||
const store = useProjectsStore()
|
const store = useProjectsStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -81,6 +148,49 @@ const slug = route.params.slug as string
|
|||||||
|
|
||||||
onMounted(() => store.load(slug))
|
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() {
|
function goRequirements() {
|
||||||
router.push({ name: 'requirement-list', params: { slug } })
|
router.push({ name: 'requirement-list', params: { slug } })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
<span>
|
<span>
|
||||||
日志 ·
|
日志 ·
|
||||||
<span :class="connClass">{{ connText }}</span>
|
<span :class="connClass">{{ connText }}</span>
|
||||||
|
<span
|
||||||
|
v-if="exitText"
|
||||||
|
class="text-gray-400"
|
||||||
|
>
|
||||||
|
· {{ exitText }}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<NButton
|
<NButton
|
||||||
text
|
text
|
||||||
@@ -45,7 +51,7 @@ import { useRunStream } from '@/composables/useRunStream'
|
|||||||
|
|
||||||
const props = defineProps<{ profileId: string }>()
|
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)
|
const scrollRef = ref<InstanceType<typeof NScrollbar> | null>(null)
|
||||||
|
|
||||||
@@ -69,6 +75,12 @@ const connText = computed(() => {
|
|||||||
return '已断开'
|
return '已断开'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
// 进程已停止/崩溃且带退出码时展示,例如「退出码 0」。
|
||||||
|
const exitText = computed(() =>
|
||||||
|
exitCode.value !== null && (runStatus.value === 'stopped' || runStatus.value === 'crashed')
|
||||||
|
? `退出码 ${exitCode.value}`
|
||||||
|
: '',
|
||||||
|
)
|
||||||
const connClass = computed(() =>
|
const connClass = computed(() =>
|
||||||
status.value === 'streaming'
|
status.value === 'streaming'
|
||||||
? 'text-green-400'
|
? 'text-green-400'
|
||||||
|
|||||||
Reference in New Issue
Block a user