fix(canvas): 画布智能体 - 严格校验模型选择并修正任务与 DNS 错误诊断

This commit is contained in:
ddCat
2026-09-20 12:47:07 +08:00
parent 8d94bde1f4
commit 0ea9f7d94b
17 changed files with 407 additions and 13 deletions

View File

@@ -26,7 +26,7 @@ type cloudAgentFieldArgumentError struct {
func (e *cloudAgentFieldArgumentError) Unwrap() error { return e.error }
func cloudAgentCanvasFieldError(field, issue, message string) error {
func cloudAgentFieldError(field, issue, message string) error {
return &cloudAgentFieldArgumentError{
error: &cloudAgentArgumentError{BadAuthRequest(message)}, Field: field, Issue: issue,
}
@@ -45,10 +45,10 @@ func decodeCloudAgentCanvasArgs(raw string) (agentCanvasArgs, error) {
return args, cloudAgentJSONArgumentError(err)
}
if envelope.SnapshotHash == "" {
return args, cloudAgentCanvasFieldError("snapshotHash", "required", "画布参数 snapshotHash 不能为空,请先读取画布")
return args, cloudAgentFieldError("snapshotHash", "required", "画布参数 snapshotHash 不能为空,请先读取画布")
}
if len(envelope.Ops) < 1 || len(envelope.Ops) > 20 {
return args, cloudAgentCanvasFieldError("ops", "item_count", "画布参数 ops 必须包含1到20项操作")
return args, cloudAgentFieldError("ops", "item_count", "画布参数 ops 必须包含1到20项操作")
}
args.SnapshotHash = envelope.SnapshotHash
for i, rawOp := range envelope.Ops {
@@ -74,7 +74,7 @@ func decodeCloudAgentCanvasArgs(raw string) (agentCanvasArgs, error) {
}
}
}
return args, cloudAgentCanvasFieldError(field, issue, fmt.Sprintf("画布参数 %s 无效:%s", field, cloudAgentSafeToolError(cloudAgentJSONArgumentError(err))))
return args, cloudAgentFieldError(field, issue, fmt.Sprintf("画布参数 %s 无效:%s", field, cloudAgentSafeToolError(cloudAgentJSONArgumentError(err))))
}
required := ""
switch {
@@ -93,7 +93,7 @@ func decodeCloudAgentCanvasArgs(raw string) (agentCanvasArgs, error) {
}
if required != "" {
field := path + "." + required
return args, cloudAgentCanvasFieldError(field, "required", fmt.Sprintf("画布参数 %s 不能为空", field))
return args, cloudAgentFieldError(field, "required", fmt.Sprintf("画布参数 %s 不能为空", field))
}
args.Ops = append(args.Ops, op)
}

View File

@@ -538,7 +538,10 @@ func (s *Service) fillCloudAgentMediaSnapshotHash(userID, canvasID string, a *cl
func (s *Service) prepareCloudAgentMedia(run *model.CloudAgentExecution, state *cloudAgentRuntime, call cloudAgentCall) (CreateTaskRequest, *cloudAgentMediaPlan, error) {
var a cloudAgentMediaArgs
if err := decodeCloudAgentJSONObject(call.Function.Arguments, &a); err != nil {
return CreateTaskRequest{}, nil, BadAuthRequest("生成参数必须是只含支持字段的单个JSON对象")
return CreateTaskRequest{}, nil, cloudAgentJSONArgumentError(err)
}
if err := validateCloudAgentModelSelection(call.Function.Arguments, a); err != nil {
return CreateTaskRequest{}, nil, err
}
a.Mode = strings.ToLower(strings.TrimSpace(a.Mode))
a.DraftRunID = run.ID
@@ -551,9 +554,6 @@ func (s *Service) prepareCloudAgentMedia(run *model.CloudAgentExecution, state *
if err := validateCloudAgentMediaArgs(a, state); err != nil {
return CreateTaskRequest{}, nil, err
}
if (a.LogicalModelID == "" && (a.ChannelID == "" || a.ChannelModelKey == "")) || (a.LogicalModelID != "" && (a.ChannelID != "" || a.ChannelModelKey != "")) {
return CreateTaskRequest{}, nil, BadAuthRequest("请复制 model_list 的 selection:逻辑模型或系统渠道二选一,不得混用")
}
_, _, refs, err := cloudAgentMediaDocument(s.repo, run.UserID, state.Request.CanvasID, a, state.TransientReferences)
if err != nil {
return CreateTaskRequest{}, nil, err

View File

@@ -0,0 +1,44 @@
package app
import (
"encoding/json"
"strings"
)
// Keep cross-field validation server-side rather than adding provider-specific
// root schema combinators. The same contract is advertised by both media tools.
const cloudAgentModelSelectionDescription = "模型选择必填:将 model_list 返回的一种 selection 展开到顶层参数,必须提供非空 logicalModelId,或同时提供非空 channelId 和 channelModelKey,二者互斥。未使用的选择字段省略或传空字符串,不得传 null 或仅含空白的字符串。缺失、混用或不完整均在提交前拒绝,不会自动选择或切换模型。"
func validateCloudAgentModelSelection(raw string, a cloudAgentMediaArgs) error {
// Go's JSON decoder accepts null for string fields; the tool contract does
// not. Check presence/type before interpreting the decoded selection.
var fields map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &fields); err != nil {
return cloudAgentJSONArgumentError(err)
}
for _, field := range []string{"logicalModelId", "channelId", "channelModelKey"} {
if value, exists := fields[field]; exists {
var text *string
if err := json.Unmarshal(value, &text); err != nil || text == nil {
return cloudAgentFieldError(field, "type_mismatch", "模型选择字段必须是字符串,不能为 null")
}
if *text != "" && strings.TrimSpace(*text) == "" {
return cloudAgentFieldError(field, "invalid_value", "模型选择字段不能仅包含空白字符")
}
}
}
switch {
case a.LogicalModelID != "" && (a.ChannelID != "" || a.ChannelModelKey != ""):
return cloudAgentFieldError("logicalModelId", "mutually_exclusive", "模型选择冲突:logicalModelId 与 channelId/channelModelKey 不得混用,请复制 model_list 的一种 selection")
case a.LogicalModelID != "":
return nil
case a.ChannelID == "" && a.ChannelModelKey == "":
return cloudAgentFieldError("logicalModelId", "required", "缺少模型选择:必须复制 model_list 的 logicalModelId 或完整的 channelId/channelModelKey,不会自动选择模型")
case a.ChannelID == "":
return cloudAgentFieldError("channelId", "required", "系统渠道模型选择不完整:缺少 channelId,请复制 model_list 的完整 selection")
case a.ChannelModelKey == "":
return cloudAgentFieldError("channelModelKey", "required", "系统渠道模型选择不完整:缺少 channelModelKey,请复制 model_list 的完整 selection")
default:
return nil
}
}

View File

@@ -0,0 +1,148 @@
package app
import (
"encoding/json"
"errors"
"strings"
"testing"
"infinite-canvas/backend/internal/model"
)
func TestCloudAgentModelSelectionRejectsBeforeCanvasRead(t *testing.T) {
for _, tc := range []struct{ name, args, field, issue string }{
{"missing", `{}`, "logicalModelId", "required"},
{"empty", `{"logicalModelId":"","channelId":"","channelModelKey":""}`, "logicalModelId", "required"},
{"missing channel", `{"channelModelKey":"image"}`, "channelId", "required"},
{"missing key", `{"channelId":"channel"}`, "channelModelKey", "required"},
{"mixed", `{"logicalModelId":"model","channelId":"channel","channelModelKey":"image"}`, "logicalModelId", "mutually_exclusive"},
{"partial mixed", `{"logicalModelId":"model","channelModelKey":"image"}`, "logicalModelId", "mutually_exclusive"},
{"whitespace", `{"channelId":" \t","channelModelKey":"image"}`, "channelId", "invalid_value"},
{"null", `{"logicalModelId":null,"channelId":"channel","channelModelKey":"image"}`, "logicalModelId", "type_mismatch"},
} {
t.Run(tc.name, func(t *testing.T) {
call := cloudAgentCall{ID: "invalid"}
call.Function.Name, call.Function.Arguments = "generate_media", tc.args
// No service/repository: rejection must precede any read or side effect.
var s *Service
_, _, err := s.prepareCloudAgentMedia(nil, nil, call)
var fieldErr *cloudAgentFieldArgumentError
if !errors.As(err, &fieldErr) || fieldErr.Field != tc.field || fieldErr.Issue != tc.issue {
t.Fatalf("unexpected validation: %#v", err)
}
req := agentTestRequest()
req.PermissionMode = "auto"
state := &cloudAgentRuntime{Canonical: canonicalAgentRequest{Tools: cloudAgentTools(req)}}
cloudAgentToolResult("run", state, call, map[string]any{"phase": "admission", "taskSubmitted": false}, err)
var result map[string]any
if err := json.Unmarshal([]byte(state.Canonical.Messages[0]["content"].(string)), &result); err != nil {
t.Fatal(err)
}
if result["field"] != tc.field || result["issue"] != tc.issue || result["reason"] != "invalid_tool_arguments" || result["parameters"] == nil || result["taskSubmitted"] != false {
t.Fatalf("missing strict repair feedback: %#v", result)
}
})
}
}
func TestCloudAgentModelSelectionAcceptsOnlyCompleteSelections(t *testing.T) {
for _, raw := range []string{
`{"logicalModelId":"model"}`,
`{"logicalModelId":"model","channelId":"","channelModelKey":""}`,
`{"channelId":"channel","channelModelKey":"image"}`,
`{"logicalModelId":"","channelId":"channel","channelModelKey":"image"}`,
} {
var args cloudAgentMediaArgs
if err := decodeCloudAgentJSONObject(raw, &args); err != nil {
t.Fatal(err)
}
if err := validateCloudAgentModelSelection(raw, args); err != nil {
t.Fatalf("valid selection rejected: %v", err)
}
}
}
func TestCloudAgentMediaMalformedJSONKeepsStrictFeedback(t *testing.T) {
for _, raw := range []string{`{"unknown":"private-sentinel"}`, `{"channelId":3}`, `{} {}`, `null`} {
call := cloudAgentCall{}
call.Function.Arguments = raw
var s *Service
_, _, err := s.prepareCloudAgentMedia(nil, nil, call)
var argumentErr *cloudAgentArgumentError
if !errors.As(err, &argumentErr) || strings.Contains(err.Error(), "private-sentinel") {
t.Fatalf("malformed arguments lost safe schema error: %v", err)
}
}
}
func TestCloudAgentMediaToolsDeclareExclusiveModelSelection(t *testing.T) {
count := 0
req := agentTestRequest()
req.PermissionMode = "auto"
for _, tool := range cloudAgentTools(req) {
function := tool["function"].(map[string]any)
if function["name"] != "generate_media" && function["name"] != "image_layer_split" {
continue
}
count++
parameters := function["parameters"].(map[string]any)
if !strings.Contains(function["description"].(string), cloudAgentModelSelectionDescription) || parameters["additionalProperties"] != false {
t.Fatalf("incomplete model selection schema: %#v", parameters)
}
props := parameters["properties"].(map[string]any)
for _, field := range []string{"logicalModelId", "channelId", "channelModelKey"} {
property := props[field].(map[string]any)
if property["type"] != "string" {
t.Fatalf("model field contract drift: %s %#v", field, property)
}
}
}
if count != 2 {
t.Fatalf("missing media tools: %d", count)
}
}
func TestCloudAgentMissingModelCreatesNoDraftApprovalOrTask(t *testing.T) {
s, db, args := agentMediaFixture(t)
args.ChannelID, args.ChannelModelKey = "", ""
run, _ := agentMediaRun(t, s, args, "auto")
canvas, _ := s.repo.CanvasProjectForUser("user", "agent-canvas")
var before int64
if err := db.Model(&model.Task{}).Count(&before).Error; err != nil {
t.Fatal(err)
}
var billingBefore int64
if err := db.Model(&model.BillingOrder{}).Count(&billingBefore).Error; err != nil {
t.Fatal(err)
}
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}
run, _ = s.repo.CloudAgent("user", run.ID)
state, err := cloudAgentDecode(run)
if err != nil {
t.Fatal(err)
}
afterCanvas, _ := s.repo.CanvasProjectForUser("user", "agent-canvas")
var after int64
if err := db.Model(&model.Task{}).Count(&after).Error; err != nil {
t.Fatal(err)
}
var billingAfter int64
if err := db.Model(&model.BillingOrder{}).Count(&billingAfter).Error; err != nil {
t.Fatal(err)
}
if before != after || billingBefore != billingAfter || canvas.PayloadJSON != afterCanvas.PayloadJSON || state.Approval != nil || state.MediaTaskID != "" {
t.Fatal("invalid selection created a task, draft, approval or billing order")
}
found := false
for _, event := range state.Events {
if event.Type == "tool_failed" {
result, _ := event.Payload["result"].(map[string]any)
found = result["reason"] == "invalid_tool_arguments" && result["taskSubmitted"] == false
}
}
if !found {
t.Fatal("admission failure was silently hidden")
}
}

View File

@@ -847,6 +847,9 @@ func failCloudAgentAdmission(current *model.CloudAgentExecution, state *cloudAge
// Only expose known failure categories; raw provider errors can contain URLs and credentials.
func cloudAgentModelFailure(task *model.Task) (string, string) {
detail, reason := "模型任务未成功", "model_task_failed"
if diagnostic := taskExecutionDiagnostic(task); diagnostic != nil && diagnostic.Code == string(ReasonUpstreamDNSFailed) {
return "模型服务域名解析失败,请检查渠道域名和后端 DNS 配置;本轮已停止。请在任务中心检查模型任务 " + task.ID, string(ReasonUpstreamDNSFailed)
}
raw := strings.ToLower(task.Error)
switch {
case strings.Contains(raw, "connection reset by peer"):

View File

@@ -120,7 +120,11 @@ func compileCloudAgentTools(req CloudAgentRequest, includeProfileTool bool) []ma
if required == nil {
required = []string{}
}
tools = append(tools, map[string]any{"type": "function", "function": map[string]any{"name": name, "description": description, "parameters": map[string]any{"type": "object", "properties": properties, "required": required, "additionalProperties": false}}})
parameters := map[string]any{"type": "object", "properties": properties, "required": required, "additionalProperties": false}
if name == "generate_media" || name == "image_layer_split" {
description += " " + cloudAgentModelSelectionDescription
}
tools = append(tools, map[string]any{"type": "function", "function": map[string]any{"name": name, "description": description, "parameters": parameters}})
}
str := func(description string) map[string]any {
return map[string]any{"type": "string", "description": description}

View File

@@ -35,6 +35,7 @@ const (
ReasonTimeout = kernel.ReasonTimeout
ReasonInternal = kernel.ReasonInternal
ReasonBadGateway = kernel.ReasonBadGateway
ReasonUpstreamDNSFailed = kernel.ReasonUpstreamDNSFailed
)
func ReasonForStatus(status int) ErrorReason {

View File

@@ -84,7 +84,13 @@ func taskExecutionDiagnostic(task *model.Task) *model.TaskExecutionDiagnostic {
if json.Unmarshal([]byte(task.ExecutionDiagnosticJSON), &diagnostic) != nil {
return nil
}
diagnostic.SafeMessage = safeTaskDiagnosticMessage(diagnostic.SafeMessage)
// Execution success and writeback failure are separate facts. A successful
// task has no execution error; writeback errors remain in WritebackMessage.
if task.Status == model.TaskStatusSucceeded {
diagnostic.SafeMessage = ""
} else if diagnostic.SafeMessage != "" || task.Status == model.TaskStatusFailed || task.Status == model.TaskStatusCancelled {
diagnostic.SafeMessage = safeTaskDiagnosticMessage(diagnostic.SafeMessage)
}
return &diagnostic
}

View File

@@ -0,0 +1,97 @@
package app
import (
"encoding/json"
"errors"
"net"
"net/url"
"strings"
"testing"
"infinite-canvas/backend/internal/model"
)
func TestTaskDiagnosticKeepsExecutionAndWritebackFailuresSeparate(t *testing.T) {
s, db, _ := agentMediaFixture(t)
for _, tc := range []struct {
name, outcome, storedMessage, wantError string
status model.TaskStatus
writebackErr error
}{
{"success", "succeeded", "", "", model.TaskStatusSucceeded, nil},
{"success stale message", "succeeded", "任务未完成,请在任务中心查看诊断", "", model.TaskStatusSucceeded, nil},
{"writeback failed", "failed", "", "", model.TaskStatusSucceeded, BadAuthRequest("结果无法回写画布")},
{"execution failed", "succeeded", "上游拒绝请求", "上游拒绝请求", model.TaskStatusFailed, nil},
{"failed without detail", "succeeded", "", "任务未完成,请在任务中心查看诊断", model.TaskStatusFailed, nil},
{"cancelled", "succeeded", "任务已取消", "任务已取消", model.TaskStatusCancelled, nil},
{"sensitive", "succeeded", "https://private-sentinel.invalid?token=private-sentinel", "任务未完成,请在任务中心查看诊断", model.TaskStatusFailed, nil},
} {
t.Run(tc.name, func(t *testing.T) {
diagnostic := model.TaskExecutionDiagnostic{Code: "task_terminal", Phase: "completion", SafeMessage: tc.storedMessage}
raw, err := json.Marshal(diagnostic)
if err != nil {
t.Fatal(err)
}
task := &model.Task{ID: tc.name, UserID: "user", ProjectID: "agent-canvas", Status: tc.status, ExecutionDiagnosticJSON: string(raw)}
recordTaskWritebackDiagnostic(task, "node", tc.outcome, "", tc.writebackErr)
if err := db.Create(task).Error; err != nil {
t.Fatal(err)
}
call := cloudAgentCall{ID: "task-query"}
call.Function.Name, call.Function.Arguments = "task_get", `{"taskId":"`+task.ID+`"}`
result, err := cloudAgentReadTool(s.repo, "user", &cloudAgentRuntime{Request: agentTestRequest()}, call)
if err != nil {
t.Fatal(err)
}
facts := result.(map[string]any)
if tc.wantError == "" {
if _, exists := facts["error"]; exists {
t.Fatalf("successful task contains error: %#v", facts)
}
} else if facts["error"] != tc.wantError {
t.Fatalf("failure detail lost: %#v", facts)
}
if facts["writebackOutcome"] != tc.outcome || (tc.writebackErr != nil && facts["writebackError"] != "结果无法回写画布") {
t.Fatalf("writeback facts lost: %#v", facts)
}
})
}
}
func TestTaskDiagnosticSuccessWithoutStoredDiagnosticHasNoError(t *testing.T) {
task := &model.Task{ID: "success", Status: model.TaskStatusSucceeded}
recordTaskWritebackDiagnostic(task, "node", "succeeded", "", nil)
for range 2 {
facts := cloudAgentTaskDiagnostic(nil, task)
if _, exists := facts["error"]; exists {
t.Fatalf("successful writeback fabricated an error: %#v", facts)
}
recordTaskWritebackDiagnostic(task, "node", "succeeded", "", nil)
}
}
func TestCloudAgentDNSDiagnosticSurvivesTransportWrapping(t *testing.T) {
dnsCause := &net.DNSError{Err: "private-sentinel resolver failure", Name: "private-sentinel.invalid", IsTimeout: true}
dnsErr := WrapAppError(CodeBadGateway, "外部服务域名解析失败,请检查渠道域名和后端 DNS 配置", dnsCause)
dnsErr.Reason = ReasonUpstreamDNSFailed
transportErr := &url.Error{Op: "Post", URL: "https://private-sentinel.invalid?token=private-sentinel", Err: dnsErr}
for _, cause := range []error{mapOutboundError(dnsErr), transportErr} {
if !errors.Is(cause, dnsCause) {
t.Fatal("internal DNS cause lost")
}
task := &model.Task{ID: "dns-task", Status: model.TaskStatusFailed, Error: providerUserFacingErrorMessage(cause)}
recordTaskDiagnostic(task, "execution", "provider_execution_failed", "unknown", "reconcile_submission", cause)
facts := cloudAgentTaskDiagnostic(nil, task)
if facts["diagnosticCode"] != string(ReasonUpstreamDNSFailed) || facts["submissionOutcome"] != "unknown" || facts["retryClass"] != "reconcile_submission" {
t.Fatalf("DNS classification or submission safety changed: %#v", facts)
}
message, reason := cloudAgentModelFailure(task)
if reason != string(ReasonUpstreamDNSFailed) || !strings.Contains(message, "域名解析失败") || !strings.Contains(message, task.ID) {
t.Fatalf("DNS cause hidden in generic failure: %s %s", reason, message)
}
raw, _ := json.Marshal(facts)
if strings.Contains(string(raw)+message, "private-sentinel") {
t.Fatal("raw DNS/transport details leaked")
}
}
}

View File

@@ -1203,6 +1203,7 @@ components:
- timeout
- internal
- bad_gateway
- upstream_dns_failed
- model_capability_not_supported
- model_price_not_configured
- model_route_unavailable

View File

@@ -5,6 +5,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
@@ -40,6 +41,20 @@ func TestFailServiceProjectsAppError(t *testing.T) {
}
}
func TestFailServiceProjectsDNSFailureWithoutTransportDetails(t *testing.T) {
recorder, context := responseTestContext()
err := service.WrapAppError(http.StatusBadGateway, "外部服务域名解析失败,请检查渠道域名和后端 DNS 配置", errors.New("private-sentinel resolver failure"))
err.Reason = service.ReasonUpstreamDNSFailed
failService(context, &url.Error{Op: "Post", URL: "https://private-sentinel.invalid?token=private-sentinel", Err: err})
response := decodeFailureEnvelope(t, recorder)
if recorder.Code != http.StatusBadGateway || response.Code != service.CodeBadGateway || response.Reason != string(service.ReasonUpstreamDNSFailed) || response.Msg != err.Message {
t.Fatalf("DNS response contract lost: %d %#v", recorder.Code, response)
}
if strings.Contains(recorder.Body.String(), "private-sentinel") {
t.Fatal("raw transport details leaked into HTTP response")
}
}
func TestFailServiceQuotaExceeded(t *testing.T) {
recorder, context := responseTestContext()
failService(context, service.QuotaExceeded("账号素材数量已达到 100 个上限"))

View File

@@ -37,6 +37,7 @@ const (
ReasonTimeout ErrorReason = "timeout"
ReasonInternal ErrorReason = "internal"
ReasonBadGateway ErrorReason = "bad_gateway"
ReasonUpstreamDNSFailed ErrorReason = "upstream_dns_failed"
)
func ReasonForStatus(status int) ErrorReason {

View File

@@ -0,0 +1,52 @@
package outbound
import (
"context"
"errors"
"net"
"net/http"
"strings"
"testing"
"infinite-canvas/backend/internal/kernel"
)
func TestOutboundDNSFailurePreservesCauseWithoutExposingHost(t *testing.T) {
previous := net.DefaultResolver
net.DefaultResolver = &net.Resolver{PreferGo: true, Dial: func(context.Context, string, string) (net.Conn, error) {
return nil, errors.New("private-sentinel DNS transport unavailable")
}}
t.Cleanup(func() { net.DefaultResolver = previous })
for _, validate := range []func(string) error{ValidateOutboundHost, validateCustomRelayHost} {
err := validate("private-sentinel.invalid")
var appErr *kernel.AppError
var dnsErr *net.DNSError
if !errors.As(err, &appErr) || appErr.Status != http.StatusBadGateway || appErr.Reason != kernel.ReasonUpstreamDNSFailed || appErr.Retryable {
t.Fatalf("incorrect DNS failure classification: %#v", err)
}
if !errors.As(err, &dnsErr) {
t.Fatal("DNS cause was discarded")
}
if strings.Contains(err.Error(), "private-sentinel") {
t.Fatal("DNS details leaked into safe message")
}
}
// Invalid destinations remain policy failures, never DNS/network failures.
for _, host := range []string{"", "localhost", "127.0.0.1", "10.0.0.1"} {
_, err := resolveOutboundHostWithPolicy(context.Background(), host, false)
var badRequest *BadRequestError
if !errors.As(err, &badRequest) {
t.Fatalf("SSRF validation weakened for %q: %v", host, err)
}
}
}
func TestOutboundDNSCancellationKeepsCancellationSemantics(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := resolveOutboundHostWithPolicy(ctx, "cancelled.invalid", false)
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation became a DNS failure: %v", err)
}
}

View File

@@ -14,6 +14,8 @@ import (
"strings"
"time"
"infinite-canvas/backend/internal/kernel"
"golang.org/x/net/http/httpproxy"
)
@@ -341,10 +343,17 @@ func resolveOutboundHostWithPolicy(ctx context.Context, host string, allowPrivat
}
addresses, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if err != nil {
return nil, BadAuthRequest("外部服务域名解析失败")
if ctx.Err() != nil {
return nil, ctx.Err()
}
failure := kernel.WrapAppError(http.StatusBadGateway, "外部服务域名解析失败,请检查渠道域名和后端 DNS 配置", err)
failure.Reason = kernel.ReasonUpstreamDNSFailed
return nil, failure
}
if len(addresses) == 0 {
return nil, BadAuthRequest("外部服务域名没有可用地址")
failure := kernel.NewAppError(http.StatusBadGateway, "外部服务域名没有可用地址,请检查渠道域名和后端 DNS 配置")
failure.Reason = kernel.ReasonUpstreamDNSFailed
return nil, failure
}
if !allowPrivateHost {
for _, ip := range addresses {

View File

@@ -65,6 +65,7 @@ const (
PluginScopeSystem = app.PluginScopeSystem
PluginScopeUser = app.PluginScopeUser
ReasonBadGateway = app.ReasonBadGateway
ReasonUpstreamDNSFailed = app.ReasonUpstreamDNSFailed
ReasonConflict = app.ReasonConflict
ReasonFailedPrecondition = app.ReasonFailedPrecondition
ReasonForbidden = app.ReasonForbidden

View File

@@ -9,6 +9,8 @@ description: 信封、错误码与 OpenAPI
机器可读规范:运行中的 `GET /api/openapi.yaml`,源文件 `backend/internal/handler/openapi.yaml`。前端 TypeScript 仍走 `web/src/services/api` 的 `http`,不从 OpenAPI 生成客户端。
出站模型服务域名解析失败返回 HTTP / `code: 502`、`reason: upstream_dns_failed`,不归类为用户参数错误。公开消息不包含上游域名、DNS 服务器或请求 URL;原始解析错误仅保留在内部错误链。此分类不会自动重试、切换渠道或放宽 SSRF 校验;上下文取消仍按取消处理。
## 站点皮肤主按钮
`GET /public/appearance` 返回当前 `activeSkin`;管理员通过 `GET/PUT /admin/settings/appearance` 读取、保存完整主题库。外观合同版本 8 新增 `tokens.buttons.light/dark`,字段为 `mode`(`solid|gradient`)、整数 `angle`(0–360)、HEX 色值 `start/end`、`hoverStart/hoverEnd`、`activeStart/activeEnd`、`foreground`。纯色模式使用既有主操作颜色,渐变仅用于主按钮。非法模式、角度、色值或修改锁定经典主题均拒绝保存;整块缺失的旧配置按主题身份补齐。此变更不增加数据表迁移。
@@ -132,6 +134,8 @@ Token 报价的 `estimated` 为 `true`,`quantity` 是预留 Token 数。参考
- `generate_media.prompt` 使用 `@图片1`、`@视频1`、`@音频1` 引用素材,各类型按 `referenceNodeIds` 的出现顺序独立编号,文本来源不占媒体编号。已有标签保留;遗漏的画布素材以“素材名称:@标签”补入 `【资产参考】`,不推断素材用途。节点入边同步排列,草稿、审批和实际任务保存同一份带标签的提示词。未绑定的媒体编号和补齐后超过16000字符的提示词会在提交前被拒绝,模型自身的提示词长度限制仍由任务准入校验。临时标注图没有持久画布节点,不自动添加无法解析的编辑器标签;历史节点不自动改写。
- Agent 的 `snapshotHash` 忽略顶层 `viewport`、`updatedAt`。`canvas_get_state` 另返回 `mediaSnapshotHash`,供 `generate_media.snapshotHash` 使用:仅比较节点与连线,节点忽略 `position`、`width`、`height`、`createdAt`、`updatedAt`,保留全部业务字段及未知 metadata。生成草稿审批也固定该哈希,移动、调整显示尺寸、自动补时间戳和保存聊天记录不会使生成审批失效,执行保留最新展示信息;提示词、素材、锁定状态、任务绑定和连线变化仍需重新读取并审批。画布修改与撤销继续使用完整内容哈希,数据库写入保持完整快照 CAS。发布前已按旧算法生成的待审批内容可能要求重新读取和审批,不自动替换旧快照重提任务。
- `canvas_get_state.nodes[].outputReference` 的 `ready` / `issue` 仅表示该节点产物能否作为其他生成的参考,不诊断本节点的生成输入。`generation` 返回当前画布、当前用户所属任务的 `taskId`、`taskStatus` 和安全的失败 / 取消 `error`;无任务绑定为 `not_submitted`,无法验证归属或读取失败为 `unavailable`。节点无法作为草稿提交时返回 `submitBlockedReason` / `submitBlockedIssue`,与写路径共用节点准入规则;未被阻止不等于已获授权,草稿所属运行和审批仍在提交时校验。`task_get` 同样返回安全错误详情。`generate_media` 准入失败可返回 `snapshot_conflict`、`task_bound`、`target_locked` 等结构化 `reason` 和 `nodeId`,不自动选择新节点或重新生成。
- `task_get` / `generation` 的执行状态与回写状态分开:`succeeded` 不返回执行 `error`;回写失败仍通过 `writebackOutcome` / `writebackError` 暴露,不能因生成成功而隐藏。失败 / 取消任务保留脱敏错误,缺少详情不能伪装成成功。Agent 文本步骤的持久诊断为 `upstream_dns_failed` 时,`run_failed.reason` 同步该分类并明确提示检查渠道域名及后端 DNS,不自动重跑。
- `generate_media` / `image_layer_split` 必须将 `model_list.selection` 展开到顶层参数:提供非空 `logicalModelId`,或完整的非空 `channelId` + `channelModelKey`,不得混用;未使用的字段可省略或传空字符串,不能传 `null` 或仅含空白的字符串。后端在读取画布、创建草稿、审批与计费前校验选择结构;返回 `reason: invalid_tool_arguments`、`field`、`issue` 和本轮工具 `parameters`,区分 `required`、`mutually_exclusive`、`type_mismatch`、`invalid_value`。合法结构不代表模型可用,仍须通过目录、能力、权限和价格校验;不猜测模型、不补全选择、不切换渠道,也不吞掉未知字段。
- `canvas_apply_ops` 创建节点、编辑提示词或连线不会提交生成任务,也不会改变已提交任务的输入;收费生成独立使用 `generate_media`。操作参数错误按数组项返回 `field`(例如 `ops[0].snapshotHash`)和 `issue`(例如 `unexpected_field`、`type_mismatch`、`required`),并附本轮工具 schema;不把未知字段误报成缺少其他必填字段,不回显任意参数键和值。
- `read_only` 只允许读取;`request_approval` 对写画布和媒体生成暂停为 `waiting_approval`;`auto` 可在预算内执行已授权画布操作,但图片 / 视频生成始终独立审批,不能绕过。`maxGenerationTasks` / `maxVideoSeconds` 为非负整数,0 表示该项不设业务上限,正数按本轮累计执行;不再固定 8 次模型调用、8 个生成任务或 120 秒视频。积分上限仍必填且大于 0,实际报价在任务创建与预留积分前校验,时长必须符合所选模型的实际能力。失败事件保留具体错误和已有任务的 `taskId`、`nodeId`、`status`;媒体结果的 `phase` 区分 `admission` 与 `completion`,`taskSubmitted` 表达是否已提交。已提交的收费生成失败作为工具错误交回模型,整轮保持可继续,不得自动重试收费生成;只有生成结果无法回写画布时才把整轮标为 `failed`。模型应报告错误,不能自行删减提示词。
- 必须有当前用户已保存的画布。首轮摘要最多取前 80 个节点,并按约 60KB 预算截断;装不下的节点记入 `omittedNodes`,不因摘要过大整轮拒绝。`canvas_get_state` 可按 `offset` 每页读取 40 个节点、资产可引用状态及相关连线,按 `nodeIds` 精读最多 8 个节点(正文每字段最多 16000 字符),通过 `hasMore` / `nextOffset` 和字段 `Truncated` 判断是否完整。分镜表包含镜头、角色节点与资产绑定;摘要每页最多 20 行,精读每次 1 行,可用 `storyboardOffset` 翻页;单行叙事文本累计最多 24000 字符,角色 / 绑定各最多 16 项。上下文不含媒体 URL、任意元数据或未同步修改;节点名称不等于图像视觉识别。普通任务重试接口不能重试 Agent 任务。

View File

@@ -5,6 +5,14 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
# 待测试
## Agent 任务诊断与模型选择强校验
- 已修复:成功任务不再由空错误文本生成“任务未完成”;执行失败与回写失败独立保留。模型选择缺失、不完整、混用、`null` 和空白值在读画布及产生草稿 / 审批 / 收费任务前拒绝,返回字段级修正信息;未知字段继续拒绝,不自动选择模型或重放收费任务。
- 已修复:出站 DNS 失败使用 `502 / upstream_dns_failed`,保留内部原因链且不公开请求地址;Agent 按持久诊断展示 DNS 原因,不修改代理、解析器、私网放行或自动重试策略。
- [x] 本地专项回归:Agent 参数与执行流程、任务诊断、出站 DNS / 取消 / SSRF、HTTP 错误脱敏通过;缺失模型选择不会新增任务、账单、审批或画布草稿。
- [ ] 全量回归发现 `TestDeleteGeneratedAssetTaskReferences/failed_output` 查询 `resource_deletion_jobs` 时遇到 SQLite 表锁;该用例单独复跑通过,未修改资源删除逻辑或增加重试,仍需排查并发稳定性。
- [ ] 部署后检查真实模型渠道域名及后端 DNS;在真实 Agent 会话中验证失败提示、纠正参数后重新审批,以及成功任务与回写失败的分离展示。本地回归不等于部署 DNS 已恢复,也不替代真实供应商与浏览器验收。
## 画布 Agent 入口拖动
- [x] 右下角 Agent 图标支持鼠标和触屏拖动,使用指针捕获与 6px 阈值区分点击和拖动;拖动结束不打开面板,中断或失去捕获时结束手势,不传播到画布。