This commit is contained in:
2026-06-10 07:55:16 +08:00
parent 821eca9f0c
commit aaac7e9d98
31 changed files with 548 additions and 149 deletions
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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})
}
+4 -4
View File
@@ -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
+14 -2
View File
@@ -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
}
+6 -2
View File
@@ -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)
+27 -15
View File
@@ -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
+5 -3
View File
@@ -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 =====
+2 -2
View File
@@ -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
View File
@@ -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 })}`),
}
+5
View File
@@ -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) {
+4 -6
View File
@@ -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),
}
+7
View File
@@ -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 })),
}
+1
View File
@@ -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) =>
-2
View File
@@ -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,
+6
View File
@@ -69,6 +69,12 @@
>
MCP Tokens
</RouterLink>
<RouterLink
:to="{ name: 'admin-acp-sessions' }"
class="text-sm hover:underline"
>
ACP 会话
</RouterLink>
</template>
</nav>
<span
+10 -1
View File
@@ -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 }
}
+5 -1
View File
@@ -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 }
}
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+1
View File
@@ -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"
+1 -2
View File
@@ -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)
}
+4
View File
@@ -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 || '操作失败')
+3 -2
View File
@@ -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(() =>
+112 -2
View File
@@ -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'