This commit is contained in:
2026-06-22 08:55:57 +08:00
parent dbb87823e8
commit 6ade6e8fa9
325 changed files with 41131 additions and 855 deletions
+329 -25
View File
@@ -6,6 +6,7 @@ package acp
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"sort"
"strconv"
@@ -83,12 +84,27 @@ func (h *Handler) Mount(r chi.Router) {
r.Get("/{id}/events", h.getEvents)
r.Get("/{id}/ws", h.sessionWS)
r.Get("/{id}/permissions", h.listSessionPermissions)
r.Get("/{id}/usage", h.getSessionUsage)
// run-history 单会话事件时间线(owner 或 admin)。
r.Get("/{id}/timeline", h.getSessionTimeline)
})
// run-history dashboard 汇总(owner scope;admin 可全量)。
r.Group(func(r chi.Router) {
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
r.Get("/api/v1/acp/dashboard", h.getDashboard)
})
r.Route("/api/v1/acp/permissions", func(r chi.Router) {
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
// 跨会话审批 inbox:调用者所有会话的待审权限请求(HITL)。
r.Get("/", h.listPendingPermissions)
r.Post("/{reqID}/approve", h.approvePermission)
r.Post("/{reqID}/deny", h.denyPermission)
})
// Admin ACP 用量聚合(镜像 chat adminUsage);service 层 requireAdmin。
r.Group(func(r chi.Router) {
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
r.Get("/api/v1/acp/usage", h.adminAcpUsage)
})
}
// listSessionPermissions 返回某会话的待审权限请求(owner 或 admin)。
@@ -115,6 +131,25 @@ func (h *Handler) listSessionPermissions(w http.ResponseWriter, r *http.Request)
httpx.WriteJSON(w, http.StatusOK, out)
}
// listPendingPermissions 返回调用者跨所有会话的待审权限请求(HITL inbox)。
func (h *Handler) listPendingPermissions(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
reqs, err := h.permSvc.ListPendingForUser(r.Context(), c)
if err != nil {
writeErr(w, r, err)
return
}
out := make([]PermissionRequestDTO, 0, len(reqs))
for _, p := range reqs {
out = append(out, permissionRequestToDTO(p))
}
httpx.WriteJSON(w, http.StatusOK, out)
}
type approvePermissionReq struct {
OptionID string `json:"option_id"`
}
@@ -205,17 +240,26 @@ func (h *Handler) createAgentKind(w http.ResponseWriter, r *http.Request) {
if req.Enabled != nil {
enabled = *req.Enabled
}
modelID, perr := parseOptionalUUID(req.ModelID)
if perr != nil {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad model_id"))
return
}
in := CreateAgentKindInput{
Name: strings.TrimSpace(req.Name),
DisplayName: req.DisplayName,
Description: req.Description,
BinaryPath: strings.TrimSpace(req.BinaryPath),
Args: req.Args,
Env: req.Env,
Enabled: enabled,
ToolAllowlist: req.ToolAllowlist,
ClientType: ClientType(req.ClientType),
MCPServers: req.MCPServers,
Name: strings.TrimSpace(req.Name),
DisplayName: req.DisplayName,
Description: req.Description,
BinaryPath: strings.TrimSpace(req.BinaryPath),
Args: req.Args,
Env: req.Env,
Enabled: enabled,
ToolAllowlist: req.ToolAllowlist,
ClientType: ClientType(req.ClientType),
MCPServers: req.MCPServers,
ModelID: modelID,
MaxCostUSD: req.MaxCostUSD,
MaxTokens: req.MaxTokens,
MaxWallClockSeconds: req.MaxWallClockSeconds,
}
out, err := h.akSvc.Create(r.Context(), c, in)
if err != nil {
@@ -278,6 +322,35 @@ func (h *Handler) updateAgentKind(w http.ResponseWriter, r *http.Request) {
ct := ClientType(*req.ClientType)
in.ClientType = &ct
}
if req.ModelID != nil {
in.SetModelID = true
if *req.ModelID == "" {
in.ModelID = nil // 显式清空
} else {
mid, merr := uuid.Parse(*req.ModelID)
if merr != nil {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad model_id"))
return
}
in.ModelID = &mid
}
}
if req.MaxCostUSD != nil {
in.SetMaxCostUSD = true
in.MaxCostUSD = positiveOrNil(req.MaxCostUSD)
}
if req.MaxTokens != nil {
in.SetMaxTokens = true
if *req.MaxTokens > 0 {
in.MaxTokens = req.MaxTokens
}
}
if req.MaxWallClockSeconds != nil {
in.SetMaxWallClock = true
if *req.MaxWallClockSeconds > 0 {
in.MaxWallClockSeconds = req.MaxWallClockSeconds
}
}
out, err := h.akSvc.Update(r.Context(), c, id, in)
if err != nil {
writeErr(w, r, err)
@@ -457,6 +530,123 @@ func (h *Handler) getSession(w http.ResponseWriter, r *http.Request) {
httpx.WriteJSON(w, http.StatusOK, sessionToDTO(s))
}
// getSessionUsage 返回会话累计成本/token + 最近账目(owner 或 admin)。
func (h *Handler) getSessionUsage(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
id, perr := uuid.Parse(chi.URLParam(r, "id"))
if perr != nil {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
return
}
// 复用 sessSvc.Get 做 owner/admin 访问校验(NotFound/Forbidden 由其返回)。
s, err := h.sessSvc.Get(r.Context(), c, id)
if err != nil {
writeErr(w, r, err)
return
}
const recentLimit = 50
ledger, err := h.repo.ListSessionUsage(r.Context(), id, recentLimit)
if err != nil {
writeErr(w, r, err)
return
}
recent := make([]SessionUsageDTO, 0, len(ledger))
for _, rec := range ledger {
recent = append(recent, sessionUsageToDTO(rec))
}
httpx.WriteJSON(w, http.StatusOK, SessionUsageResponse{
SessionID: s.ID.String(),
PromptTokens: s.PromptTokens,
CompletionTokens: s.CompletionTokens,
ThinkingTokens: s.ThinkingTokens,
TotalCostUSD: s.TotalCostUSD,
BudgetMaxCostUSD: s.BudgetMaxCostUSD,
BudgetMaxTokens: s.BudgetMaxTokens,
Recent: recent,
})
}
// adminAcpUsage 返回 ACP 用量聚合(admin only),group=user|model|day,镜像 chat。
func (h *Handler) adminAcpUsage(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
if !c.IsAdmin {
writeErr(w, r, errs.New(errs.CodeForbidden, "admin only"))
return
}
q := r.URL.Query()
to := time.Now().UTC()
from := to.AddDate(0, 0, -30)
if v := q.Get("from"); v != "" {
if t, e := time.Parse(time.RFC3339, v); e == nil {
from = t
}
}
if v := q.Get("to"); v != "" {
if t, e := time.Parse(time.RFC3339, v); e == nil {
to = t
}
}
groupBy := q.Get("group")
if groupBy == "" {
groupBy = q.Get("group_by")
}
if groupBy == "" {
groupBy = "day"
}
items := make([]AcpUsageItem, 0)
switch groupBy {
case "user":
rows, rerr := h.repo.SummarizeAcpUsageByUser(r.Context(), from, to)
if rerr != nil {
writeErr(w, r, rerr)
return
}
for _, row := range rows {
items = append(items, AcpUsageItem{
Key: row.UserID.String(), PromptTokens: row.PromptTokens,
CompletionTokens: row.CompletionTokens, ThinkingTokens: row.ThinkingTokens, CostUSD: row.CostUSD,
})
}
case "model":
rows, rerr := h.repo.SummarizeAcpUsageByModel(r.Context(), from, to)
if rerr != nil {
writeErr(w, r, rerr)
return
}
for _, row := range rows {
items = append(items, AcpUsageItem{
Key: row.ModelID.String(), PromptTokens: row.PromptTokens,
CompletionTokens: row.CompletionTokens, ThinkingTokens: row.ThinkingTokens, CostUSD: row.CostUSD,
})
}
case "day":
rows, rerr := h.repo.SummarizeAcpUsageByDay(r.Context(), from, to)
if rerr != nil {
writeErr(w, r, rerr)
return
}
for _, row := range rows {
items = append(items, AcpUsageItem{
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"))
return
}
httpx.WriteJSON(w, http.StatusOK, AcpUsageResponse{Items: items})
}
func (h *Handler) terminateSession(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
@@ -519,6 +709,80 @@ func (h *Handler) getEvents(w http.ResponseWriter, r *http.Request) {
httpx.WriteJSON(w, http.StatusOK, out)
}
// getDashboard 返回 run-history 汇总(owner scope;admin 全量)。可选 query:
// project_id / requirement_id / from / to(RFC3339)。
func (h *Handler) getDashboard(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
q := r.URL.Query()
var in DashboardInput
if v := q.Get("project_id"); v != "" {
id, perr := uuid.Parse(v)
if perr != nil {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad project_id"))
return
}
in.ProjectID = &id
}
if v := q.Get("requirement_id"); v != "" {
id, perr := uuid.Parse(v)
if perr != nil {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad requirement_id"))
return
}
in.RequirementID = &id
}
if v := q.Get("from"); v != "" {
t, terr := time.Parse(time.RFC3339, v)
if terr != nil {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad from"))
return
}
in.From = t
}
if v := q.Get("to"); v != "" {
t, terr := time.Parse(time.RFC3339, v)
if terr != nil {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad to"))
return
}
in.To = t
}
res, err := h.sessSvc.Dashboard(r.Context(), c, in)
if err != nil {
writeErr(w, r, err)
return
}
httpx.WriteJSON(w, http.StatusOK, dashboardToResponse(res))
}
// getSessionTimeline 返回单会话事件时间线(owner 或 admin)。
func (h *Handler) getSessionTimeline(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
id, perr := uuid.Parse(chi.URLParam(r, "id"))
if perr != nil {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
return
}
buckets, err := h.sessSvc.Timeline(r.Context(), c, id)
if err != nil {
writeErr(w, r, err)
return
}
out := make([]SessionTimelineDTO, 0, len(buckets))
for _, b := range buckets {
out = append(out, sessionTimelineToDTO(b))
}
httpx.WriteJSON(w, http.StatusOK, out)
}
// ===== WebSocket =====
func (h *Handler) sessionWS(w http.ResponseWriter, r *http.Request) {
@@ -675,6 +939,17 @@ func (h *Handler) sessionWS(w http.ResponseWriter, r *http.Request) {
})
continue
}
// 回合相关联:用户从前端发起 session/prompt 时也登记回合,使其完成时
// 走与 auto-prompt 相同的检测路径。client 自生成的字符串 id 作为相关联键。
if m.Method == "session/prompt" {
if tr := relay.Tracker(); tr != nil {
if pid, ok := m.IDString(); ok && pid != "" {
if _, err := tr.StartTurn(ctx, pid); err != nil {
slog.Warn("acp.ws.start_turn", "session_id", sess.ID, "err", err.Error())
}
}
}
}
if err := relay.SendClient(m); err != nil {
_ = wsjson.Write(ctx, conn, map[string]any{
"kind": "client_error",
@@ -702,22 +977,51 @@ func (h *Handler) agentKindToAdminDTO(k *AgentKind) AgentKindAdminDTO {
if servers, err := decryptMCPServers(h.enc, k.EncryptedMCPServers); err == nil {
mcpServers = servers
}
return AgentKindAdminDTO{
ID: k.ID,
Name: k.Name,
DisplayName: k.DisplayName,
Description: k.Description,
BinaryPath: k.BinaryPath,
Args: append([]string(nil), k.Args...),
EnvKeys: envKeys,
Enabled: k.Enabled,
ToolAllowlist: append([]string(nil), k.ToolAllowlist...),
ClientType: string(k.ClientType),
MCPServers: mcpServers,
CreatedBy: k.CreatedBy,
CreatedAt: k.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
UpdatedAt: k.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
var modelID *string
if k.ModelID != nil {
v := k.ModelID.String()
modelID = &v
}
return AgentKindAdminDTO{
ID: k.ID,
Name: k.Name,
DisplayName: k.DisplayName,
Description: k.Description,
BinaryPath: k.BinaryPath,
Args: append([]string(nil), k.Args...),
EnvKeys: envKeys,
Enabled: k.Enabled,
ToolAllowlist: append([]string(nil), k.ToolAllowlist...),
ClientType: string(k.ClientType),
MCPServers: mcpServers,
ModelID: modelID,
MaxCostUSD: k.MaxCostUSD,
MaxTokens: k.MaxTokens,
MaxWallClockSeconds: k.MaxWallClockSeconds,
CreatedBy: k.CreatedBy,
CreatedAt: k.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
UpdatedAt: k.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
}
}
// parseOptionalUUID 解析一个可选的字符串 UUID。nil/空串 → (nil, nil);非法 → 错误。
func parseOptionalUUID(s *string) (*uuid.UUID, error) {
if s == nil || *s == "" {
return nil, nil
}
id, err := uuid.Parse(*s)
if err != nil {
return nil, err
}
return &id, nil
}
// positiveOrNil 返回正数指针,否则 nil(用于"值 <=0 视为清空"语义)。
func positiveOrNil(f *float64) *float64 {
if f == nil || *f <= 0 {
return nil
}
return f
}
func agentKindToPublicDTO(k *AgentKind) AgentKindPublicDTO {