305 lines
9.3 KiB
Go
305 lines
9.3 KiB
Go
package handler
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"infinite-canvas/backend/internal/service"
|
||
)
|
||
|
||
// Agent orchestration is durable; the browser stream never drives execution.
|
||
func RegisterAgentRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||
r.GET("/agent/capabilities", func(c *gin.Context) {
|
||
if _, err := currentUser(c, svc); err != nil {
|
||
failService(c, err)
|
||
return
|
||
}
|
||
capabilities := service.CloudAgentCapabilitySetInfo()
|
||
ok(c, gin.H{"version": 2, "permissionModes": []string{"read_only", "request_approval", "auto"}, "contextScopes": []string{"canvas"}, "skills": true, "writeTools": true, "billing": "fixed_request", "maxHistoryPairs": 8, "maxSteps": 8, "tools": service.CloudAgentSupportedToolNames(), "capabilitySetVersion": capabilities.Version, "capabilitySetHash": capabilities.Hash, "nodeTypes": capabilities.Nodes})
|
||
})
|
||
// Profiles are durable preference data, not an authorization surface. The
|
||
// service validates scope ownership and the compiler injects the effective
|
||
// layers only after the code-level policy has been fixed.
|
||
r.GET("/agent/profile", func(c *gin.Context) {
|
||
user, err := currentUser(c, svc)
|
||
if err != nil {
|
||
failService(c, err)
|
||
return
|
||
}
|
||
scope := strings.TrimSpace(c.Query("scope"))
|
||
projectID := strings.TrimSpace(c.Query("projectId"))
|
||
canvasID := strings.TrimSpace(c.Query("canvasId"))
|
||
if scope == "" {
|
||
switch {
|
||
case canvasID != "":
|
||
scope = "canvas"
|
||
case projectID != "":
|
||
scope = "project"
|
||
default:
|
||
scope = "user"
|
||
}
|
||
}
|
||
switch scope {
|
||
case "user":
|
||
if projectID != "" || canvasID != "" {
|
||
fail(c, http.StatusBadRequest, errors.New("用户偏好不能带项目或画布 ID"))
|
||
return
|
||
}
|
||
case "project":
|
||
if projectID == "" || canvasID != "" {
|
||
fail(c, http.StatusBadRequest, errors.New("项目偏好需要 projectId,且不能带 canvasId"))
|
||
return
|
||
}
|
||
case "canvas":
|
||
if canvasID == "" {
|
||
fail(c, http.StatusBadRequest, errors.New("画布偏好需要 canvasId"))
|
||
return
|
||
}
|
||
default:
|
||
fail(c, http.StatusBadRequest, errors.New("无效的 Agent 偏好作用域"))
|
||
return
|
||
}
|
||
view, err := svc.CloudAgentProfileForScope(user.ID, projectID, canvasID)
|
||
if err != nil {
|
||
failService(c, err)
|
||
return
|
||
}
|
||
ok(c, view)
|
||
})
|
||
r.PATCH("/agent/profile", func(c *gin.Context) {
|
||
user, err := currentUser(c, svc)
|
||
if err != nil {
|
||
failService(c, err)
|
||
return
|
||
}
|
||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
|
||
var req service.AgentProfileRequest
|
||
decoder := json.NewDecoder(c.Request.Body)
|
||
decoder.DisallowUnknownFields()
|
||
if err := decoder.Decode(&req); err != nil {
|
||
fail(c, http.StatusBadRequest, err)
|
||
return
|
||
}
|
||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||
fail(c, http.StatusBadRequest, errors.New("请求必须只包含一个 JSON 对象"))
|
||
return
|
||
}
|
||
view, err := svc.UpdateCloudAgentProfile(user.ID, req)
|
||
if err != nil {
|
||
failService(c, err)
|
||
return
|
||
}
|
||
ok(c, view)
|
||
})
|
||
create := func(c *gin.Context) {
|
||
user, err := currentUser(c, svc)
|
||
if err != nil {
|
||
failService(c, err)
|
||
return
|
||
}
|
||
policy, available := loadRuntimePolicy(c, svc)
|
||
if !available || !enforceRateLimit(c, "tasks:"+user.ID, policy.Request.TaskCreatePerMinute, time.Minute) {
|
||
return
|
||
}
|
||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 128<<10)
|
||
var req service.CloudAgentRequest
|
||
decoder := json.NewDecoder(c.Request.Body)
|
||
decoder.DisallowUnknownFields()
|
||
if err := decoder.Decode(&req); err != nil {
|
||
fail(c, http.StatusBadRequest, err)
|
||
return
|
||
}
|
||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||
fail(c, http.StatusBadRequest, errors.New("请求必须只包含一个 JSON 对象"))
|
||
return
|
||
}
|
||
run, err := svc.CreateCloudAgentRun(user.ID, req, c.Param("id"))
|
||
if err != nil {
|
||
failService(c, err)
|
||
return
|
||
}
|
||
ok(c, gin.H{"run": run})
|
||
}
|
||
r.POST("/agent/runs", create)
|
||
// Each additional message starts a new immutable turn and returns its ID.
|
||
r.POST("/agent/runs/:id/messages", create)
|
||
r.GET("/agent/runs/:id", func(c *gin.Context) {
|
||
user, err := currentUser(c, svc)
|
||
if err != nil {
|
||
failService(c, err)
|
||
return
|
||
}
|
||
run, err := svc.CloudAgentRun(user.ID, c.Param("id"))
|
||
if err != nil {
|
||
failService(c, err)
|
||
return
|
||
}
|
||
ok(c, gin.H{"run": run})
|
||
})
|
||
r.POST("/agent/runs/:id/cancel", func(c *gin.Context) {
|
||
user, err := currentUser(c, svc)
|
||
if err != nil {
|
||
failService(c, err)
|
||
return
|
||
}
|
||
if err = svc.CancelCloudAgent(c.Request.Context(), user.ID, c.Param("id")); err != nil {
|
||
failService(c, err)
|
||
return
|
||
}
|
||
ok(c, gin.H{"accepted": true})
|
||
})
|
||
r.POST("/agent/runs/:id/undo", func(c *gin.Context) {
|
||
user, err := currentUser(c, svc)
|
||
if err != nil {
|
||
failService(c, err)
|
||
return
|
||
}
|
||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 4096)
|
||
var req struct {
|
||
StepID string `json:"stepId"`
|
||
ExpectedSnapshotHash string `json:"expectedSnapshotHash"`
|
||
Reason string `json:"reason"`
|
||
}
|
||
decoder := json.NewDecoder(c.Request.Body)
|
||
decoder.DisallowUnknownFields()
|
||
if err := decoder.Decode(&req); err != nil {
|
||
fail(c, http.StatusBadRequest, err)
|
||
return
|
||
}
|
||
if req.StepID != "" && (!utf8.ValidString(req.StepID) || strings.TrimSpace(req.StepID) != req.StepID || utf8.RuneCountInString(req.StepID) > 160) {
|
||
fail(c, http.StatusBadRequest, errors.New("stepId 无效"))
|
||
return
|
||
}
|
||
if req.Reason != "" && (!utf8.ValidString(req.Reason) || utf8.RuneCountInString(req.Reason) > 2000 || strings.ContainsAny(req.Reason, "\x00\r\n")) {
|
||
fail(c, http.StatusBadRequest, errors.New("reason 无效"))
|
||
return
|
||
}
|
||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||
fail(c, http.StatusBadRequest, errors.New("请求必须只包含一个 JSON 对象"))
|
||
return
|
||
}
|
||
result, err := svc.UndoCloudAgentCanvas(user.ID, c.Param("id"), req.StepID, req.ExpectedSnapshotHash, req.Reason)
|
||
if err != nil {
|
||
failService(c, err)
|
||
return
|
||
}
|
||
ok(c, result)
|
||
})
|
||
r.POST("/agent/runs/:id/approvals/:approvalId/decision", func(c *gin.Context) {
|
||
user, err := currentUser(c, svc)
|
||
if err != nil {
|
||
failService(c, err)
|
||
return
|
||
}
|
||
if _, err = svc.CloudAgentRun(user.ID, c.Param("id")); err != nil {
|
||
failService(c, err)
|
||
return
|
||
}
|
||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 4096)
|
||
var req struct {
|
||
Decision string `json:"decision"`
|
||
Reason string `json:"reason"`
|
||
}
|
||
decoder := json.NewDecoder(c.Request.Body)
|
||
decoder.DisallowUnknownFields()
|
||
if err := decoder.Decode(&req); err != nil {
|
||
fail(c, http.StatusBadRequest, err)
|
||
return
|
||
}
|
||
if req.Decision != "approve" && req.Decision != "reject" {
|
||
fail(c, http.StatusBadRequest, errors.New("decision 无效"))
|
||
return
|
||
}
|
||
if req.Reason != "" && (!utf8.ValidString(req.Reason) || utf8.RuneCountInString(req.Reason) > 2000 || strings.ContainsAny(req.Reason, "\x00\r\n")) {
|
||
fail(c, http.StatusBadRequest, errors.New("reason 无效"))
|
||
return
|
||
}
|
||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||
fail(c, http.StatusBadRequest, errors.New("请求必须只包含一个 JSON 对象"))
|
||
return
|
||
}
|
||
if err := svc.DecideCloudAgentApproval(user.ID, c.Param("id"), c.Param("approvalId"), req.Decision, req.Reason); err != nil {
|
||
failService(c, err)
|
||
return
|
||
}
|
||
ok(c, gin.H{"accepted": true})
|
||
})
|
||
r.GET("/agent/runs/:id/events", func(c *gin.Context) {
|
||
user, err := currentUser(c, svc)
|
||
if err != nil {
|
||
failService(c, err)
|
||
return
|
||
}
|
||
run, err := svc.CloudAgentRun(user.ID, c.Param("id"))
|
||
if err != nil {
|
||
failService(c, err)
|
||
return
|
||
}
|
||
after, err := taskTextEventCursor(c)
|
||
if err != nil {
|
||
fail(c, 400, err)
|
||
return
|
||
}
|
||
c.Header("Content-Type", "text/event-stream")
|
||
c.Header("Cache-Control", "no-cache")
|
||
c.Header("X-Accel-Buffering", "no")
|
||
ticker := time.NewTicker(time.Second)
|
||
defer ticker.Stop()
|
||
revision := run.Revision
|
||
lastWrite := time.Now()
|
||
for {
|
||
if err != nil {
|
||
c.SSEvent("error", gin.H{"message": "Agent 状态读取失败"})
|
||
c.Writer.Flush()
|
||
return
|
||
}
|
||
if run != nil {
|
||
for _, event := range run.Events {
|
||
if int64(event.Seq) > after {
|
||
writeAgentSSE(c, "agent_event", int64(event.Seq), event)
|
||
after = int64(event.Seq)
|
||
}
|
||
}
|
||
// Snapshots are state observations, not replayable Agent events; they
|
||
// intentionally have no SSE id and therefore never advance the cursor.
|
||
snapshot := *run
|
||
snapshot.Events = nil // Events were sent once above, never in every snapshot too.
|
||
writeAgentSSE(c, "run_snapshot", 0, &snapshot)
|
||
revision, lastWrite = run.Revision, time.Now()
|
||
if !run.CleanupPending && (run.Status == "completed" || run.Status == "failed" || run.Status == "cancelled" || run.Status == "rejected") {
|
||
return
|
||
}
|
||
} else if time.Since(lastWrite) >= 15*time.Second {
|
||
_, _ = fmt.Fprint(c.Writer, ": heartbeat\n\n")
|
||
c.Writer.Flush()
|
||
lastWrite = time.Now()
|
||
}
|
||
select {
|
||
case <-c.Request.Context().Done():
|
||
return
|
||
case <-ticker.C:
|
||
run, err = svc.CloudAgentRunIfChanged(user.ID, c.Param("id"), revision)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
func writeAgentSSE(c *gin.Context, event string, id int64, value any) {
|
||
data, err := json.Marshal(value)
|
||
if err != nil {
|
||
return
|
||
}
|
||
if id > 0 {
|
||
_, _ = fmt.Fprintf(c.Writer, "id: %d\n", id)
|
||
}
|
||
_, _ = fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, data)
|
||
c.Writer.Flush()
|
||
}
|