Files
ttstd-infinite-canvas/backend/internal/handler/agent.go

405 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
"yingce/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": 10, "maxHistoryBytes": 64000, "maxSteps": 0, "tools": service.CloudAgentSupportedToolNames(), "capabilitySetVersion": capabilities.Version, "capabilitySetHash": capabilities.Hash, "nodeTypes": capabilities.Nodes})
})
// Skill usage is derived from the caller's own journal receipts, so it stays
// read-only and never exposes another user's runs.
r.GET("/agent/skills/usage", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
view, err := svc.CloudAgentSkillUsage(user.ID)
if err != nil {
failService(c, err)
return
}
ok(c, view)
})
// 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.POST("/agent/runs/:id/interjections", 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
}
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, 64<<10)
var req struct {
Text string `json:"text"`
MessageID string `json:"messageId"`
}
decoder := json.NewDecoder(c.Request.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&req); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
if req.Text != "" && (!utf8.ValidString(req.Text) || utf8.RuneCountInString(req.Text) > 4000 || strings.ContainsRune(req.Text, 0)) {
fail(c, http.StatusBadRequest, errors.New("text 无效"))
return
}
if req.MessageID != "" && (!utf8.ValidString(req.MessageID) || utf8.RuneCountInString(req.MessageID) > 160) {
fail(c, http.StatusBadRequest, errors.New("messageId 无效"))
return
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
fail(c, http.StatusBadRequest, errors.New("请求必须只包含一个 JSON 对象"))
return
}
pending, err := svc.InterjectCloudAgent(user.ID, c.Param("id"), req.MessageID, req.Text)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"accepted": true, "pending": pending})
})
r.GET("/agent/runs/:id", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
// 运行详情只返回一页事件,分页参数越界或非法一律拒绝:静默夹取会让客户端
// 以为拿到的是它请求的那一页,从而把缺口当成"没有更多记录"。
options, err := agentRunViewOptions(c)
if err != nil {
fail(c, http.StatusBadRequest, err)
return
}
run, err := svc.CloudAgentRun(user.ID, c.Param("id"), options)
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"`
MediaSettings *service.CloudAgentMediaSettings `json:"mediaSettings,omitempty"`
}
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, req.MediaSettings); 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
}
// 游标就是事件 seq:先解析它,才能只把 after 之后的增量交给视图,
// 断线重连不必重新载入前面已经发过的事件。
after, err := taskTextEventCursor(c)
if err != nil {
fail(c, 400, err)
return
}
options := service.CloudAgentRunViewOptions{SinceSeq: int(after)}
run, err := svc.CloudAgentRun(user.ID, c.Param("id"), options)
if err != nil {
failService(c, 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, options)
}
}
})
}
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()
}
// agentRunEventQueryLimit 是 eventLimit 允许的最大值,与 app 侧的增量上限一致:
// 请求更大的页没有意义(单次响应体积),直接拒绝比静默截断更容易被发现。
const agentRunEventQueryLimit = 500
func agentRunViewOptions(c *gin.Context) (service.CloudAgentRunViewOptions, error) {
options := service.CloudAgentRunViewOptions{}
for _, item := range []struct {
name string
value *int
min int
max int
}{
{name: "sinceSeq", value: &options.SinceSeq, min: 0, max: math.MaxInt32},
{name: "eventLimit", value: &options.EventLimit, min: 1, max: agentRunEventQueryLimit},
} {
raw := strings.TrimSpace(c.Query(item.name))
if raw == "" {
continue
}
parsed, err := strconv.Atoi(raw)
if err != nil || parsed < item.min || parsed > item.max {
return options, fmt.Errorf("%s 必须是 %d–%d 之间的整数", item.name, item.min, item.max)
}
*item.value = parsed
}
return options, nil
}