chore: commit workspace updates

This commit is contained in:
ddCat
2026-09-14 01:11:56 +08:00
parent 01a2d171f5
commit 9727656b2b
491 changed files with 12826 additions and 72249 deletions

View File

@@ -0,0 +1,229 @@
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
}
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": []string{"canvas_list_node_types", "canvas_get_state", "skill_read_file", "task_get", "model_list", "canvas_apply_ops", "generate_media"}})
})
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") {
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()
}

View File

@@ -14,6 +14,7 @@ func RegisterCanvasAPI(api *gin.RouterGroup, svc *service.Service) {
RegisterAuthRoutes(api, svc)
RegisterAppearanceRoutes(api, svc)
RegisterFeatureAvailabilityRoutes(api, svc)
RegisterAgentRoutes(api, svc)
RegisterResponseInterceptionRoutes(api, svc)
RegisterAdminRoutes(api, svc)
RegisterAdminAnalyticsRoutes(api, svc)
@@ -32,9 +33,7 @@ func RegisterCanvasAPI(api *gin.RouterGroup, svc *service.Service) {
RegisterSystemProxyRoutes(api, svc)
RegisterCustomRelayRoutes(api, svc)
RegisterTaskRoutes(api, svc)
RegisterComfyBridgeRoutes(api, svc)
RegisterRunningHubRoutes(api, svc)
RegisterSessionRoutes(api, svc)
RegisterSkillRoutes(api, svc)
RegisterUserDataRoutes(api, svc)
RegisterChunkedUploadRoutes(api, svc)

View File

@@ -871,7 +871,7 @@ func shortSystemProxyPath(rawPath string) (string, string, bool) {
// as a channel request when a business route returns 404.
func isReservedAPIPathPrefix(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "admin", "ai", "announcements", "assets", "auth", "canvas-projects", "channels", "diagnostics", "features", "files", "model-catalog", "models", "oauth", "plugins", "projects", "public", "resources", "sessions", "settings", "skills", "style-profiles", "tasks", "timeline", "user-data", "voice-profiles", "wallet":
case "admin", "agent", "ai", "announcements", "assets", "auth", "canvas-projects", "channels", "diagnostics", "features", "files", "model-catalog", "models", "oauth", "plugins", "projects", "public", "resources", "sessions", "settings", "skills", "style-profiles", "tasks", "timeline", "user-data", "voice-profiles", "wallet":
return true
default:
return false
@@ -949,7 +949,7 @@ func proxySystemRequestPath(c *gin.Context, svc *service.Service, user *model.Us
if encodedQuery := query.Encode(); encodedQuery != "" {
target += "?" + encodedQuery
}
validatedTarget, err := svc.ValidateChannelOutboundURL(target, channel.AllowLocalChannel, false)
validatedTarget, err := svc.ValidateChannelOutboundURL(target)
if err != nil {
failService(c, err)
return
@@ -1010,7 +1010,7 @@ func proxySystemRequestPath(c *gin.Context, svc *service.Service, user *model.Us
status := model.ApiCallStatusSucceeded
statusCode := 0
errorText := ""
resp, err := svc.OutboundHTTPClientForChannel(35*time.Minute, validatedTarget, channel.AllowLocalChannel).Do(upstreamReq)
resp, err := svc.OutboundHTTPClientForChannel(35*time.Minute, validatedTarget).Do(upstreamReq)
if err != nil {
status = model.ApiCallStatusFailed
errorText = err.Error()

View File

@@ -1,195 +0,0 @@
package handler
import (
"context"
"errors"
"io"
"net/http"
"strconv"
"strings"
"time"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/service"
"github.com/gin-gonic/gin"
)
// Bridge 结果通常包含 base64 媒体;保持和 service 层相同的硬上限,避免请求体无限增长。
const maxComfyBridgeRequestBytes int64 = 64 << 20
// Bridge 心跳会携带本机工作流字段和拓扑,允许与 service 层一致的 4MB 上限。
const maxComfyBridgeCapabilitiesBytes int64 = 4 << 20
type comfyBridgeResultRequest struct {
RequestID string `json:"requestId"`
ID string `json:"id,omitempty"`
Status string `json:"status"`
Result map[string]any `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// RegisterComfyBridgeRoutes 同时注册用户管理接口和 Bridge 专用轮询接口。
// 两套接口使用不同认证方式:用户接口依赖登录 Cookie,Bridge 接口只接受专用 Header Token。
func RegisterComfyBridgeRoutes(r *gin.RouterGroup, svc *service.Service) {
r.POST("/comfy-bridges", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
if err := svc.RequireWorkflowPluginForUser(user.ID, "comfyui-bridge-image"); err != nil {
failService(c, err)
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
var req service.CreateComfyBridgeRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
result, err := svc.CreateComfyBridge(user.ID, req)
if err != nil {
failService(c, err)
return
}
ok(c, result)
})
r.GET("/comfy-bridges", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
if err := svc.RequireWorkflowPluginForUser(user.ID, "comfyui-bridge-image"); err != nil {
failService(c, err)
return
}
bridges, err := svc.ComfyBridges(user.ID)
if err != nil {
fail(c, http.StatusInternalServerError, err)
return
}
ok(c, bridges)
})
revoke := func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
if err := svc.RevokeComfyBridge(user.ID, c.Param("id")); err != nil {
failService(c, err)
return
}
ok(c, gin.H{"revoked": true})
}
r.DELETE("/comfy-bridges/:id", revoke)
r.POST("/comfy-bridges/:id/revoke", revoke)
r.GET("/comfy-bridge/poll", func(c *gin.Context) {
bridge, err := authenticateComfyBridgeRequest(c, svc)
if err != nil {
failService(c, err)
return
}
wait, err := comfyBridgeWait(c.Query("wait"))
if err != nil {
fail(c, http.StatusBadRequest, err)
return
}
request, err := svc.PollComfyBridgeRequest(c.Request.Context(), bridge, wait)
if err != nil {
if errors.Is(err, context.Canceled) {
return
}
failService(c, err)
return
}
ok(c, gin.H{"request": request})
})
r.POST("/comfy-bridge/result", func(c *gin.Context) {
bridge, err := authenticateComfyBridgeRequest(c, svc)
if err != nil {
failService(c, err)
return
}
if c.Request.ContentLength > maxComfyBridgeRequestBytes {
fail(c, http.StatusRequestEntityTooLarge, errors.New("Bridge 结果请求体超过 64MB"))
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxComfyBridgeRequestBytes)
var req comfyBridgeResultRequest
if err := c.ShouldBindJSON(&req); err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
fail(c, http.StatusRequestEntityTooLarge, errors.New("Bridge 结果请求体超过 64MB"))
return
}
fail(c, http.StatusBadRequest, err)
return
}
requestID := strings.TrimSpace(req.RequestID)
if requestID == "" {
requestID = strings.TrimSpace(req.ID)
}
err = svc.CompleteComfyBridgeRequest(bridge.ID, service.ComfyBridgeCompletion{
RequestID: requestID,
Status: req.Status,
Result: req.Result,
Error: req.Error,
})
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"accepted": true})
})
r.POST("/comfy-bridge/heartbeat", func(c *gin.Context) {
bridge, err := authenticateComfyBridgeRequest(c, svc)
if err != nil {
failService(c, err)
return
}
var req struct {
Capabilities map[string]any `json:"capabilities,omitempty"`
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxComfyBridgeCapabilitiesBytes)
if c.Request.ContentLength != 0 {
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
fail(c, http.StatusBadRequest, err)
return
}
}
if err := svc.TouchComfyBridgeHeartbeat(bridge.ID, req.Capabilities); err != nil {
failService(c, err)
return
}
ok(c, gin.H{"ok": true})
})
}
func authenticateComfyBridgeRequest(c *gin.Context, svc *service.Service) (*model.ComfyBridge, error) {
token := strings.TrimSpace(c.GetHeader("X-Canvas-Comfy-Bridge-Token"))
if token == "" {
token = strings.TrimSpace(c.GetHeader("X-Canvas-Bridge-Token"))
}
if token == "" {
authorization := strings.TrimSpace(c.GetHeader("Authorization"))
if len(authorization) >= 7 && strings.EqualFold(authorization[:7], "Bearer ") {
token = strings.TrimSpace(authorization[7:])
}
}
return svc.AuthenticateComfyBridge(token)
}
func comfyBridgeWait(raw string) (time.Duration, error) {
if strings.TrimSpace(raw) == "" {
return 20 * time.Second, nil
}
seconds, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || seconds < 0 || seconds > 60 {
return 0, errors.New("wait 必须是 0 到 60 之间的整数秒")
}
return time.Duration(seconds) * time.Second, nil
}

View File

@@ -18,7 +18,7 @@ import (
const maxCustomRelayErrorResponseBytes int64 = 64 << 10
var customRelayClient = service.CustomRelayHTTPClientForChannel
var customRelayClient = service.CustomRelayHTTPClient
func RegisterCustomRelayRoutes(r *gin.RouterGroup, svc *service.Service) {
r.Any("/ai/custom", func(c *gin.Context) {
@@ -46,21 +46,16 @@ func RegisterCustomRelayRoutes(r *gin.RouterGroup, svc *service.Service) {
return
}
defer release()
proxyCustomRelayRequestWithService(c, policy.Request, svc.DesktopLocalChannelsEnabled(), svc)
proxyCustomRelayRequestWithService(c, policy.Request, svc)
})
}
func proxyCustomRelayRequest(c *gin.Context, policy service.RuntimeRequestPolicy) {
proxyCustomRelayRequestWithCapabilities(c, policy, false)
proxyCustomRelayRequestWithService(c, policy, nil)
}
func proxyCustomRelayRequestWithCapabilities(c *gin.Context, policy service.RuntimeRequestPolicy, desktopLocalChannelsEnabled bool) {
proxyCustomRelayRequestWithService(c, policy, desktopLocalChannelsEnabled, nil)
}
func proxyCustomRelayRequestWithService(c *gin.Context, policy service.RuntimeRequestPolicy, desktopLocalChannelsEnabled bool, svc *service.Service) {
requestedAllowLocal := strings.TrimSpace(c.GetHeader(service.LocalChannelRequestHeader)) == "1"
target, err := service.ValidateCustomRelayChannelURL(c.GetHeader("X-Canvas-Upstream-URL"), c.GetHeader(service.LocalChannelBaseURLHeader), requestedAllowLocal, desktopLocalChannelsEnabled)
func proxyCustomRelayRequestWithService(c *gin.Context, policy service.RuntimeRequestPolicy, svc *service.Service) {
target, err := service.ValidateCustomRelayURL(c.GetHeader("X-Canvas-Upstream-URL"))
if err != nil {
failService(c, err)
return
@@ -127,7 +122,7 @@ func proxyCustomRelayRequestWithService(c *gin.Context, policy service.RuntimeRe
upstreamReq.Header.Set("Authorization", "Bearer "+apiKey)
}
resp, err := customRelayClient(time.Duration(policy.CustomRelayTimeoutMinutes)*time.Minute, target, requestedAllowLocal, desktopLocalChannelsEnabled).Do(upstreamReq)
resp, err := customRelayClient(time.Duration(policy.CustomRelayTimeoutMinutes) * time.Minute).Do(upstreamReq)
if err != nil {
fail(c, http.StatusBadGateway, errors.New(userFacingRelayError(svc, errors.New("自定义渠道上游连接失败"))))
return

View File

@@ -7,7 +7,6 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
@@ -48,7 +47,7 @@ func TestCustomRelayForwardsOpenAIRequestWithoutBrowserHeaders(t *testing.T) {
request.Header.Set("Authorization", "Bearer "+apiKey)
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+"/v1/models")
request.Header.Set("X-Canvas-Upstream-Format", "openai")
markDesktopLocalRelayTestRequest(request, upstream.URL)
prepareRelayTestRequest(t, request)
request.Header.Set(service.CustomRelayHeadersHeader, base64.StdEncoding.EncodeToString([]byte(`[{"name":"User-Agent","value":"Custom Relay Agent"},{"name":"X-Gateway-Tenant","value":"tenant-a"}]`)))
request.Header.Set("Cookie", "browser=session")
request.Header.Set("Origin", "https://canvas.example.com")
@@ -56,7 +55,7 @@ func TestCustomRelayForwardsOpenAIRequestWithoutBrowserHeaders(t *testing.T) {
context, _ := gin.CreateTestContext(response)
context.Request = request
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
proxyCustomRelayRequest(context, defaultCustomRelayTestPolicy())
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
@@ -82,12 +81,12 @@ func TestCustomRelayReturnsVideoContent(t *testing.T) {
request.Header.Set("Authorization", "Bearer test-key")
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+"/v1/videos/task-1/content")
request.Header.Set("X-Canvas-Upstream-Format", "openai")
markDesktopLocalRelayTestRequest(request, upstream.URL)
prepareRelayTestRequest(t, request)
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = request
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
proxyCustomRelayRequest(context, defaultCustomRelayTestPolicy())
if response.Code != http.StatusOK || response.Header().Get("Content-Type") != "video/mp4" || response.Body.String() != "video-content" {
t.Fatalf("status = %d, content-type = %q, body = %q", response.Code, response.Header().Get("Content-Type"), response.Body.String())
}
@@ -115,12 +114,12 @@ func TestCustomRelayConvertsGeminiAuthentication(t *testing.T) {
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+"/v1beta/models/gemini-test:generateContent")
request.Header.Set("X-Canvas-Upstream-Format", "gemini")
markDesktopLocalRelayTestRequest(request, upstream.URL)
prepareRelayTestRequest(t, request)
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = request
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
proxyCustomRelayRequest(context, defaultCustomRelayTestPolicy())
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
@@ -154,12 +153,12 @@ func TestCustomRelayConvertsClaudeAuthentication(t *testing.T) {
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+"/v1/messages")
request.Header.Set("X-Canvas-Upstream-Format", "claude")
markDesktopLocalRelayTestRequest(request, upstream.URL)
prepareRelayTestRequest(t, request)
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = request
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
proxyCustomRelayRequest(context, defaultCustomRelayTestPolicy())
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "claude relay works") {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
@@ -183,7 +182,7 @@ func TestCustomRelayStreamsBeforeUpstreamCompletes(t *testing.T) {
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
context, _ := gin.CreateTestContext(w)
context.Request = r
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
proxyCustomRelayRequest(context, defaultCustomRelayTestPolicy())
}))
defer proxy.Close()
request, _ := http.NewRequest(http.MethodPost, proxy.URL, strings.NewReader(`{"model":"test"}`))
@@ -192,7 +191,7 @@ func TestCustomRelayStreamsBeforeUpstreamCompletes(t *testing.T) {
request.Header.Set("Accept", "text/event-stream")
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+"/v1/responses")
request.Header.Set("X-Canvas-Upstream-Format", "openai")
markDesktopLocalRelayTestRequest(request, upstream.URL)
prepareRelayTestRequest(t, request)
client := &http.Client{Timeout: 3 * time.Second}
response, err := client.Do(request)
if err != nil {
@@ -228,7 +227,7 @@ func TestCustomRelayRejectsOversizedDeclaredBodyBeforeConnecting(t *testing.T) {
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
connected := false
previous := customRelayClient
customRelayClient = func(time.Duration, *url.URL, bool, bool) *http.Client {
customRelayClient = func(time.Duration) *http.Client {
connected = true
return http.DefaultClient
}
@@ -240,12 +239,12 @@ func TestCustomRelayRejectsOversizedDeclaredBodyBeforeConnecting(t *testing.T) {
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Canvas-Upstream-URL", "https://127.0.0.1/v1/responses")
request.Header.Set("X-Canvas-Upstream-Format", "openai")
markDesktopLocalRelayTestRequest(request, "https://127.0.0.1")
prepareRelayTestRequest(t, request)
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = request
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
proxyCustomRelayRequest(context, defaultCustomRelayTestPolicy())
if response.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
@@ -254,134 +253,19 @@ func TestCustomRelayRejectsOversizedDeclaredBodyBeforeConnecting(t *testing.T) {
}
}
func TestDesktopLocalCustomRelayRequiresServerCapabilityAcrossSupportedPaths(t *testing.T) {
gin.SetMode(gin.TestMode)
requests := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if strings.HasSuffix(r.URL.Path, "/content") {
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write([]byte("video-content"))
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"ok":true}`)
}))
defer upstream.Close()
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "false")
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "")
tests := []struct {
name string
method string
path string
contentType string
}{
{name: "models", method: http.MethodGet, path: "/v1/models"},
{name: "text", method: http.MethodPost, path: "/v1/responses", contentType: "application/json"},
{name: "image", method: http.MethodPost, path: "/v1/images/generations", contentType: "application/json"},
{name: "video submit", method: http.MethodPost, path: "/v1/videos", contentType: "application/json"},
{name: "audio", method: http.MethodPost, path: "/v1/audio/speech", contentType: "application/json"},
{name: "video poll", method: http.MethodGet, path: "/v1/videos/task-1"},
{name: "video download", method: http.MethodGet, path: "/v1/videos/task-1/content"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
body := io.Reader(nil)
if test.method == http.MethodPost {
body = strings.NewReader(`{}`)
}
request := httptest.NewRequest(test.method, "/api/ai/custom", body)
request.Header.Set("Authorization", "Bearer test-key")
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+test.path)
request.Header.Set("X-Canvas-Upstream-Format", "openai")
request.Header.Set(service.LocalChannelRequestHeader, "1")
request.Header.Set(service.LocalChannelBaseURLHeader, upstream.URL)
if test.contentType != "" {
request.Header.Set("Content-Type", test.contentType)
}
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = request
before := requests
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), false)
if response.Code == http.StatusOK || requests != before {
t.Fatalf("capability=false status=%d requests=%d->%d", response.Code, before, requests)
}
request = httptest.NewRequest(test.method, "/api/ai/custom", bodyForRelayMethod(test.method))
request.Header.Set("Authorization", "Bearer test-key")
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+test.path)
request.Header.Set("X-Canvas-Upstream-Format", "openai")
request.Header.Set(service.LocalChannelRequestHeader, "1")
request.Header.Set(service.LocalChannelBaseURLHeader, upstream.URL)
if test.contentType != "" {
request.Header.Set("Content-Type", test.contentType)
}
response = httptest.NewRecorder()
context, _ = gin.CreateTestContext(response)
context.Request = request
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
if response.Code != http.StatusOK || requests != before+1 {
t.Fatalf("capability=true status=%d body=%s requests=%d->%d", response.Code, response.Body.String(), before, requests)
}
})
}
}
func TestDesktopLocalCustomRelayDoesNotFollowRedirects(t *testing.T) {
gin.SetMode(gin.TestMode)
reachedTarget := false
target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
reachedTarget = true
}))
defer target.Close()
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Location", target.URL+"/v1/models")
w.WriteHeader(http.StatusFound)
}))
defer source.Close()
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "false")
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "")
request := httptest.NewRequest(http.MethodGet, "/api/ai/custom", nil)
request.Header.Set("Authorization", "Bearer test-key")
request.Header.Set("X-Canvas-Upstream-URL", source.URL+"/v1/models")
request.Header.Set("X-Canvas-Upstream-Format", "openai")
request.Header.Set(service.LocalChannelRequestHeader, "1")
request.Header.Set(service.LocalChannelBaseURLHeader, source.URL)
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = request
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
if response.Code == http.StatusOK {
t.Fatalf("redirect response status=%d body=%s", response.Code, response.Body.String())
}
if reachedTarget {
t.Fatal("desktop local relay redirect target must not be reached")
}
}
func bodyForRelayMethod(method string) io.Reader {
if method == http.MethodPost {
return strings.NewReader(`{}`)
}
return nil
}
func markDesktopLocalRelayTestRequest(request *http.Request, baseURL string) {
request.Header.Set(service.LocalChannelRequestHeader, "1")
request.Header.Set(service.LocalChannelBaseURLHeader, baseURL)
}
func useCustomRelayTestClient(t *testing.T, client *http.Client) {
t.Helper()
previous := customRelayClient
customRelayClient = func(time.Duration, *url.URL, bool, bool) *http.Client { return client }
customRelayClient = func(time.Duration) *http.Client { return client }
t.Cleanup(func() { customRelayClient = previous })
}
func prepareRelayTestRequest(t *testing.T, request *http.Request) {
t.Helper()
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
_ = request
}
func defaultCustomRelayTestPolicy() service.RuntimeRequestPolicy {
return service.RuntimeRequestPolicy{
CustomRelayRequestMB: 32, CustomRelayResponseMB: 32, CustomRelayTimeoutMinutes: 10,

View File

@@ -21,7 +21,158 @@ tags:
- name: resources
- name: tasks
- name: assets
- name: agent
paths:
/agent/capabilities:
get:
tags: [agent]
summary: 读取云端 Agent 能力
responses:
"200":
description: 信封 data 包含 version、permissionModes、contextScopes、skills、writeTools、billing、maxHistoryPairs
/agent/runs:
post:
tags: [agent]
summary: 创建一轮云端 Agent 任务
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CloudAgentRequest"
responses:
"200":
description: 信封 data.run,包含新一轮 id、canvasId、status、permissionMode、model 及时间
"400":
description: 参数、能力、模型或预算不符合要求,未创建任务
"409":
description: 幂等键已用于不同请求
/agent/runs/{id}:
parameters:
- $ref: "#/components/parameters/AgentRunID"
get:
tags: [agent]
summary: 读取当前用户的 Agent 轮次
responses:
"200":
description: 信封 data.run
"404":
description: 轮次不存在或不属于当前用户
/agent/runs/{id}/messages:
parameters:
- $ref: "#/components/parameters/AgentRunID"
post:
tags: [agent]
summary: 从服务端父轮历史创建新轮次
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CloudAgentRequest"
responses:
"200":
description: 信封 data.run(新的 ID)
"409":
description: 父轮仍在执行或幂等键冲突
/agent/runs/{id}/cancel:
parameters:
- $ref: "#/components/parameters/AgentRunID"
post:
tags: [agent]
summary: 取消当前用户的 Agent 轮次
responses:
"200":
description: 信封 data.accepted
/agent/runs/{id}/undo:
parameters:
- $ref: "#/components/parameters/AgentRunID"
post:
tags: [agent]
summary: 撤销当前 Agent 运行产生的最新画布变更
description: 仅撤销仍是最新快照、且未提交生成任务的画布变更;不会取消任务或退款。
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: false
required: [expectedSnapshotHash]
properties:
stepId:
type: string
maxLength: 160
expectedSnapshotHash:
type: string
minLength: 64
maxLength: 64
pattern: '^[0-9a-fA-F]{64}$'
reason:
type: string
maxLength: 2000
responses:
"200":
description: 信封 data.accepted 与当前快照哈希
"400":
description: 请求无效、快照不可撤销或已提交任务不能撤销
"409":
description: 画布已发生后续变化、操作已处理或 stepId 不匹配
/agent/runs/{id}/approvals/{approvalId}/decision:
parameters:
- $ref: "#/components/parameters/AgentRunID"
- name: approvalId
in: path
required: true
schema:
type: string
post:
tags: [agent]
summary: 提交 Agent 工具审批决定
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: false
required: [decision]
properties:
decision:
type: string
enum: [approve, reject]
reason:
type: string
maxLength: 2000
responses:
"200":
description: 信封 data.accepted
"409":
description: 审批不存在、已过期或决定冲突
/agent/runs/{id}/events:
parameters:
- $ref: "#/components/parameters/AgentRunID"
get:
tags: [agent]
summary: Agent 运行事件回放与持续订阅
parameters:
- name: after
in: query
schema:
type: integer
minimum: 0
- name: Last-Event-ID
in: header
schema:
type: integer
minimum: 0
responses:
"200":
description: 首次及 revision 变化时发送紧凑 run_snapshot;无变化时 heartbeat;agent_event.seq 为递增游标,cleanupPending 期间持续发送
content:
text/event-stream:
schema:
type: string
/openapi.yaml:
get:
tags: [meta]
@@ -150,12 +301,74 @@ paths:
"200":
$ref: "#/components/responses/Envelope"
components:
parameters:
AgentRunID:
name: id
in: path
required: true
schema:
type: string
securitySchemes:
sessionCookie:
type: apiKey
in: cookie
name: open_ai_canvas_session
schemas:
CloudAgentRequest:
type: object
additionalProperties: false
required: [canvasId, prompt, permissionMode, contextScope, budget, idempotencyKey]
properties:
canvasId:
type: string
maxLength: 80
prompt:
type: string
description: 非空,最多 16000 字节
logicalModelId:
type: string
model:
type: string
channelId:
type: string
channelModelKey:
type: string
permissionMode:
type: string
enum: [read_only, request_approval, auto]
skillIds:
type: array
maxItems: 8
items:
type: string
contextScope:
type: array
maxItems: 1
items:
type: string
enum: [canvas]
budget:
type: object
additionalProperties: false
required: [maxCredits]
properties:
maxCredits:
type: number
minimum: 0
exclusiveMinimum: true
maximum: 1000000
maxGenerationTasks:
type: integer
minimum: 0
maximum: 8
maxVideoSeconds:
type: integer
minimum: 0
maximum: 120
idempotencyKey:
type: string
minLength: 8
maxLength: 128
Envelope:
type: object
required: [code, msg]

View File

@@ -264,18 +264,27 @@ func RegisterTaskRoutes(r *gin.RouterGroup, svc *service.Service) {
}
func taskTextEventCursor(c *gin.Context) (int64, error) {
raw := c.Query("after")
if raw == "" {
raw = c.GetHeader("Last-Event-ID")
queryRaw, headerRaw := c.Query("after"), c.GetHeader("Last-Event-ID")
var cursor int64
for _, item := range []struct {
name string
raw string
}{
{name: "after", raw: queryRaw},
{name: "Last-Event-ID", raw: headerRaw},
} {
if item.raw == "" {
continue
}
value, err := strconv.ParseInt(item.raw, 10, 64)
if err != nil || value < 0 {
return 0, errors.New("after 或 Last-Event-ID 必须是非负整数")
}
if value > cursor {
cursor = value
}
}
if raw == "" {
return 0, nil
}
after, err := strconv.ParseInt(raw, 10, 64)
if err != nil || after < 0 {
return 0, errors.New("after 或 Last-Event-ID 必须是非负整数")
}
return after, nil
return cursor, nil
}
func streamTaskTextEvents(c *gin.Context, svc *service.Service, userID string, taskID string, after int64, replay *service.TextReplayResult) {
@@ -340,84 +349,3 @@ func writeTaskTextSSE(c *gin.Context, event string, id int64, value any) {
_, _ = fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, data)
c.Writer.Flush()
}
func RegisterSessionRoutes(r *gin.RouterGroup, svc *service.Service) {
createSession := 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, "sessions:"+user.ID, policy.Request.SessionCreatePerMinute, time.Minute) {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<20)
var req service.CreateSessionRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
req.TraceID = TraceID(c)
req.RequestID = RequestID(c)
detail, err := svc.CreateSession(user.ID, req)
if err != nil {
fail(c, http.StatusBadRequest, err)
return
}
ok(c, detail)
}
querySession := func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
detail, err := svc.SessionDetail(user.ID, c.Param("id"))
if err != nil {
fail(c, http.StatusNotFound, err)
return
}
ok(c, detail)
}
uploadFile := 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, "session-files:"+user.ID, policy.Request.SessionFilePerMinute, time.Minute) {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, (policy.Resource.SessionUploadMB<<20)+(1<<20))
file, err := c.FormFile("file")
if err != nil {
fail(c, http.StatusBadRequest, err)
return
}
item, err := svc.StoreUpload(user.ID, c.PostForm("sessionId"), file)
if err != nil {
failService(c, err)
return
}
ok(c, item)
}
downloadResults := func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
detail, err := svc.SessionDetail(user.ID, c.Param("id"))
if err != nil {
fail(c, http.StatusNotFound, err)
return
}
ok(c, detail.Results)
}
r.POST("/sessions", createSession)
r.GET("/sessions/:id", querySession)
r.POST("/files", uploadFile)
r.GET("/sessions/:id/results", downloadResults)
}

View File

@@ -11,6 +11,7 @@ func TestShortSystemProxyPath(t *testing.T) {
}{
{path: "/api/channel-1/chat/completions", channel: "channel-1", provider: "/chat/completions", ok: true},
{path: "/api/channel-1/v1/models", channel: "channel-1", provider: "/v1/models", ok: true},
{path: "/api/agent/runs", ok: false},
{path: "/api/tasks/123", ok: false},
{path: "/api/diagnostics/export", ok: false},
{path: "/api/channel-1", ok: false},