diff --git a/backend/internal/app/cloud_agent.go b/backend/internal/app/cloud_agent.go index 199194449..4815ab398 100644 --- a/backend/internal/app/cloud_agent.go +++ b/backend/internal/app/cloud_agent.go @@ -373,9 +373,6 @@ func (s *Service) CreateCloudAgentRun(userID string, req CloudAgentRequest, pare history = append(history, providerTextMessage{Role: "user", Content: parent.Prompt}, providerTextMessage{Role: "assistant", Content: text}) } // Bound prompt growth without silently injecting a huge canvas or transcript. - if len(history) > 16 { - return nil, BadAuthRequest("对话已达到 8 轮上下文上限,请新建对话") - } encodedHistory, err := json.Marshal(history) if err != nil { return nil, err diff --git a/backend/internal/app/cloud_agent_approval_settings_test.go b/backend/internal/app/cloud_agent_approval_settings_test.go new file mode 100644 index 000000000..8ee3b1c7f --- /dev/null +++ b/backend/internal/app/cloud_agent_approval_settings_test.go @@ -0,0 +1,231 @@ +package app + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestCloudAgentMediaReadHashSurvivesMoveBeforeDraft(t *testing.T) { + s, db, args := agentMediaFixture(t) + canvas, _ := s.repo.CanvasProjectForUser("user", "agent-canvas") + doc, _ := creationDocument(canvas.PayloadJSON) + view, err := cloudAgentCanvasState(s.repo, "user", doc, 0, nil, 0) + if err != nil { + t.Fatal(err) + } + args.SnapshotHash = view.(map[string]any)["mediaSnapshotHash"].(string) + nodes, _ := creationObjects(doc["nodes"]) + nodes["cat"]["position"] = map[string]any{"x": 1600, "y": 500} + raw, _ := json.Marshal(doc) + if err := db.Model(canvas).Update("payload_json", string(raw)).Error; err != nil { + t.Fatal(err) + } + run, _ := agentMediaRun(t, s, args, "auto") + if err := s.advanceCloudAgentByID("user", run.ID); err != nil { + t.Fatal(err) + } + pending, err := s.CloudAgentRun("user", run.ID) + if err != nil || pending.Status != "waiting_approval" { + t.Fatalf("movement prevented draft creation: %v %+v", err, pending) + } + approveAgentMediaDraft(t, s, run.ID) +} + +func TestCloudAgentMediaApprovalAllowsMovesButRejectsContentChanges(t *testing.T) { + for _, change := range []string{"move", "prompt", "resource", "connection", "locked"} { + t.Run(change, func(t *testing.T) { + s, db, a := agentMediaFixture(t) + run, _ := agentMediaRun(t, s, a, "auto") + 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 || state.Approval == nil { + t.Fatalf("missing approval: %v", err) + } + canvas, _ := s.repo.CanvasProjectForUser("user", "agent-canvas") + doc, _ := creationDocument(canvas.PayloadJSON) + nodes, _ := creationObjects(doc["nodes"]) + fullHash := cloudAgentCanvasHash(doc) + switch change { + case "move": + nodes["cat"]["position"] = map[string]any{"x": 1234, "y": 567} + nodes[a.NodeID]["position"] = map[string]any{"x": 2000, "y": 900} + case "prompt": + nodes[a.NodeID]["metadata"].(map[string]any)["composerContent"] = "用户修改的提示词" + case "resource": + nodes["cat"]["metadata"].(map[string]any)["storageKey"] = "resource:ref-two" + case "connection": + doc["connections"] = []any{} + case "locked": + nodes[a.NodeID]["metadata"].(map[string]any)["locked"] = true + } + if fullHash == cloudAgentCanvasHash(doc) { + t.Fatal("full mutation/undo hash must still detect changes") + } + raw, _ := json.Marshal(doc) + if err := db.Model(canvas).Update("payload_json", string(raw)).Error; err != nil { + t.Fatal(err) + } + if err := s.DecideCloudAgentApproval("user", run.ID, state.Approval.ID, "approve", ""); err != nil { + t.Fatal(err) + } + if err := s.advanceCloudAgentByID("user", run.ID); err != nil { + t.Fatal(err) + } + var count int64 + db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&count) + if change != "move" { + if count != 0 { + t.Fatal("content change submitted generation") + } + return + } + if count != 1 { + t.Fatal("layout-only change blocked generation") + } + canvas, _ = s.repo.CanvasProjectForUser("user", "agent-canvas") + doc, _ = creationDocument(canvas.PayloadJSON) + nodes, _ = creationObjects(doc["nodes"]) + if nodes["cat"]["position"].(map[string]any)["x"] != float64(1234) || nodes[a.NodeID]["position"].(map[string]any)["x"] != float64(2000) { + t.Fatal("generation overwrote user's positions") + } + }) + } +} + +func TestCloudAgentImageApprovalEditsAreValidatedAndIdempotent(t *testing.T) { + s, db, a := agentMediaFixture(t) + capability := DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceGrokImage), "grok-image") + for _, key := range []string{"grok-image", "grok-image-alternative"} { + for _, row := range []any{ + &model.ChannelModel{ID: key, ChannelID: "channel", ModelKey: key, DisplayName: key, Capability: "image", Protocol: model.ChannelInterfaceGrokImage, CapabilityConfigJSON: mustEncodeModelCapabilityConfig(t, capability), BillingMode: "fixed_request", PriceConfigured: true, Enabled: true}, + &model.ChannelModelPriceTier{ID: key + "-tier", ChannelModelID: key, SelectorKey: "{}", SelectorJSON: "{}", BillingMode: "fixed_request", UnitPriceMicrocredits: 1, PriceConfigured: true, Enabled: true}, + } { + if err := db.Create(row).Error; err != nil { + t.Fatal(err) + } + } + } + a.Mode, a.ChannelModelKey, a.Duration, a.VideoGenerateAudio = "image", "grok-image", 0, nil + a.Size, a.Quality, a.NodeID = "1:1", "2k", "image-shot-1" + a.ReferenceNodeIDs = []string{"cat"} + run, _ := agentMediaRun(t, s, a, "auto") + 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 || state.Approval == nil { + t.Fatalf("missing approval: %v", err) + } + id := state.Approval.ID + settings := CloudAgentMediaSettings{ChannelID: "channel", ChannelModelKey: "grok-image-alternative", Size: "16:9", Quality: "1k"} + for _, invalid := range []CloudAgentMediaSettings{ + {ChannelID: "channel", ChannelModelKey: "missing", Size: "16:9", Quality: "1k"}, + {ChannelID: "channel", ChannelModelKey: "text-test", Size: "16:9", Quality: "1k"}, + {LogicalModelID: "mixed", ChannelID: "channel", ChannelModelKey: settings.ChannelModelKey, Size: "16:9"}, + {ChannelID: "channel", ChannelModelKey: settings.ChannelModelKey, Size: "invalid-size", Quality: "1k"}, + {ChannelID: "channel", ChannelModelKey: settings.ChannelModelKey, Size: "16:9", Quality: "invalid-quality"}, + } { + if err := s.DecideCloudAgentApproval("user", run.ID, id, "approve", "", &invalid); err == nil { + t.Fatalf("invalid settings accepted: %+v", invalid) + } + } + if err := s.DecideCloudAgentApproval("other", run.ID, id, "approve", "", &settings); err == nil { + t.Fatal("cross-user approval accepted") + } + pending, _ := s.CloudAgentRun("user", run.ID) + if pending.Status != "waiting_approval" { + t.Fatal("invalid settings consumed approval") + } + var before int64 + db.Model(&model.BillingOrder{}).Count(&before) + for range 2 { + if err := s.DecideCloudAgentApproval("user", run.ID, id, "approve", "", &settings); err != nil { + t.Fatal(err) + } + } + var orders int64 + db.Model(&model.BillingOrder{}).Count(&orders) + if orders != before { + t.Fatal("editing approval charged before task submission") + } + changed := settings + changed.Size = "1:1" + if err := s.DecideCloudAgentApproval("user", run.ID, id, "approve", "", &changed); err == nil { + t.Fatal("conflicting retry accepted") + } + 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 || state.MediaTaskID == "" { + t.Fatalf("edited approval did not submit: %v %+v", err, state.Events) + } + task, err := s.repo.TaskForUser("user", state.MediaTaskID) + if err != nil { + t.Fatal(err) + } + var input canvasGenerationInput + if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil { + t.Fatal(err) + } + if input.Config.Model != settings.ChannelModelKey || input.Config.Size != settings.Size || input.Config.Quality != settings.Quality || task.Prompt != a.Prompt || len(input.ReferenceImages) != 1 { + t.Fatalf("approved inputs were lost: %+v", input.Config) + } + var count int64 + db.Model(&model.Task{}).Where("type = ?", "canvas_image").Count(&count) + if count != 1 { + t.Fatalf("expected exactly one image task, got %d", count) + } +} + +func TestCloudAgentConversationContinuesBeyondEightRounds(t *testing.T) { + s, db, _, _ := creationTestService(t) + if err := db.Create(&model.CanvasProject{ID: "agent-canvas", UserID: "user", PayloadJSON: `{"nodes":[]}`}).Error; err != nil { + t.Fatal(err) + } + parent := "" + for round := 0; round < 11; round++ { + req := agentTestRequest() + req.IdempotencyKey = fmt.Sprintf("long-conversation-%d", round) + run, err := s.CreateCloudAgentRun("user", req, parent) + if err != nil { + t.Fatalf("round %d failed: %v", round+1, err) + } + execution, _ := s.repo.CloudAgent("user", run.ID) + state, err := cloudAgentDecode(execution) + if err != nil || len(state.TextHistory) != round*2 { + t.Fatalf("history was lost at round %d: %v", round, err) + } + if err := db.Model(&model.Task{}).Where("id = ?", run.ID).Updates(map[string]any{"status": model.TaskStatusSucceeded, "result_json": `{"text":"继续创作"}`}).Error; err != nil { + t.Fatal(err) + } + if err := s.advanceCloudAgentByID("user", run.ID); err != nil { + t.Fatal(err) + } + parent = run.ID + } + // Removing the turn cap must not remove the existing context-size bound. + run, _ := s.repo.CloudAgent("user", parent) + state, _ := cloudAgentDecode(run) + for i := range state.TextHistory { + state.TextHistory[i].Content = strings.Repeat("x", 4000) + } + raw, _ := json.Marshal(state) + if err := db.Model(run).Update("state_json", string(raw)).Error; err != nil { + t.Fatal(err) + } + req := agentTestRequest() + req.IdempotencyKey = "long-conversation-byte-bound" + if _, err := s.CreateCloudAgentRun("user", req, parent); err == nil || !strings.Contains(err.Error(), "64KB") { + t.Fatalf("context byte bound removed: %v", err) + } +} diff --git a/backend/internal/app/cloud_agent_canvas_state.go b/backend/internal/app/cloud_agent_canvas_state.go index 4b22a2ab3..d1f4c5e19 100644 --- a/backend/internal/app/cloud_agent_canvas_state.go +++ b/backend/internal/app/cloud_agent_canvas_state.go @@ -38,6 +38,28 @@ func cloudAgentCanvasHash(doc map[string]any) string { return creationHash(content) } +// Generation does not depend on node positions. Keep the full canvas hash for +// mutations and undo, which must still detect layout edits before restoring data. +func cloudAgentMediaContentHash(doc map[string]any) string { + content := make(map[string]any, len(doc)) + for key, value := range doc { + content[key] = value + } + nodes := creationMaps(doc["nodes"]) + projected := make([]map[string]any, 0, len(nodes)) + for _, node := range nodes { + item := make(map[string]any, len(node)) + for key, value := range node { + if key != "position" { + item[key] = value + } + } + projected = append(projected, item) + } + content["nodes"] = projected + return cloudAgentCanvasHash(content) +} + func cloudAgentCanvasState(repo *repository.Repository, userID string, doc map[string]any, offset int, ids []string, storyboardOffset int) (any, error) { if offset < 0 || storyboardOffset < 0 || len(ids) > 8 { return nil, BadAuthRequest("画布读取分页参数无效") @@ -152,7 +174,7 @@ func cloudAgentCanvasState(repo *repository.Repository, userID string, doc map[s edges = append(edges, map[string]any{"id": edge["id"], "fromNodeId": edge["fromNodeId"], "toNodeId": edge["toNodeId"]}) } } - return map[string]any{"snapshotHash": cloudAgentCanvasHash(doc), "nodes": nodes, "connections": edges, "totalNodes": len(all), "nextOffset": next, "hasMore": next > 0}, nil + return map[string]any{"snapshotHash": cloudAgentCanvasHash(doc), "mediaSnapshotHash": cloudAgentMediaContentHash(doc), "nodes": nodes, "connections": edges, "totalNodes": len(all), "nextOffset": next, "hasMore": next > 0}, nil } func cloudAgentSafeNumber(value any) (any, bool) { diff --git a/backend/internal/app/cloud_agent_media.go b/backend/internal/app/cloud_agent_media.go index 886d06f9f..f91508aef 100644 --- a/backend/internal/app/cloud_agent_media.go +++ b/backend/internal/app/cloud_agent_media.go @@ -213,7 +213,8 @@ func cloudAgentMediaDocument(repo *repository.Repository, userID, canvasID strin if err != nil { return nil, nil, nil, err } - if args.SnapshotHash == "" || cloudAgentCanvasHash(doc) != args.SnapshotHash { + unchanged := args.SnapshotHash != "" && (cloudAgentCanvasHash(doc) == args.SnapshotHash || cloudAgentMediaContentHash(doc) == args.SnapshotHash) + if !unchanged { return nil, nil, nil, creationConflict("画布已变化,请重新读取画布并重新审批;未提交生成任务") } nodes, err := creationObjects(doc["nodes"]) diff --git a/backend/internal/app/cloud_agent_media_approval.go b/backend/internal/app/cloud_agent_media_approval.go new file mode 100644 index 000000000..dbcc8d1c9 --- /dev/null +++ b/backend/internal/app/cloud_agent_media_approval.go @@ -0,0 +1,78 @@ +package app + +import ( + "encoding/json" + "strings" + + "infinite-canvas/backend/internal/model" +) + +// Only user-editable image options cross the approval boundary. Targets, +// references, prompt, snapshot and generation mode remain server-authored. +type CloudAgentMediaSettings struct { + LogicalModelID string `json:"logicalModelId,omitempty"` + ChannelID string `json:"channelId,omitempty"` + ChannelModelKey string `json:"channelModelKey,omitempty"` + Size string `json:"size"` + Quality string `json:"quality"` +} + +func (s *Service) updateCloudAgentMediaApproval(run *model.CloudAgentExecution, state *cloudAgentRuntime, settings CloudAgentMediaSettings) error { + call := state.Approval.Call + if call.Function.Name != "generate_media" { + return BadAuthRequest("当前审批不是图片生成,不能修改生成参数") + } + var args cloudAgentMediaArgs + if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil { + return err + } + if args.Mode != "image" { + return BadAuthRequest("仅图片生成审批支持修改模型、画幅和质量") + } + args.LogicalModelID, args.ChannelID, args.ChannelModelKey = settings.LogicalModelID, settings.ChannelID, settings.ChannelModelKey + args.Size, args.Quality = settings.Size, settings.Quality + raw, err := json.Marshal(args) + if err != nil { + return err + } + call.Function.Arguments = string(raw) + req, plan, err := s.prepareCloudAgentMedia(run, state, call) + if err != nil { + return err + } + // Dry admission uses the same model, ownership and option checks as task + // submission, without reserving credits or creating a generation task. + req.creationPrepare = &creationTaskPreparation{} + task, err := s.CreateTask(run.UserID, req) + if err != nil { + return err + } + var resolved struct { + Config map[string]any `json:"config"` + } + if err := json.Unmarshal([]byte(task.InputJSON), &resolved); err != nil { + return err + } + if err := validateCloudAgentResolvedMediaOptions(req.Input["config"].(map[string]any), resolved.Config); err != nil { + return err + } + name, err := s.cloudAgentMediaModelName(args) + if err != nil { + return err + } + state.Calls[state.CallIndex] = call + state.Approval.Call = call + state.Approval.CallHash = cloudAgentApprovalCallHash(call) + state.Approval.ModelName = name + state.Approval.Preview = cloudAgentMediaApprovalPreview(plan, name) + return nil +} + +func validateCloudAgentResolvedMediaOptions(requested, resolved map[string]any) error { + for _, key := range []string{"size", "videoSeconds", "vquality", "quality", "count", "videoGenerateAudio"} { + if value := stringValue(requested[key]); value != "" && !strings.EqualFold(value, stringValue(resolved[key])) { + return creationConflict("模型解析后的生成规格与审批参数不同,请重新读取目录并审批") + } + } + return nil +} diff --git a/backend/internal/app/cloud_agent_policy.go b/backend/internal/app/cloud_agent_policy.go index 7e8d2faaa..e0134c5bd 100644 --- a/backend/internal/app/cloud_agent_policy.go +++ b/backend/internal/app/cloud_agent_policy.go @@ -96,7 +96,7 @@ func compileCloudAgentPolicies(req CloudAgentRequest, skills []cloudAgentSkill, b.WriteString(media.Text) b.WriteString("\n\n") b.WriteString("本轮执行上下文(仅供行为编排,不改变服务端权限):\n") - b.WriteString("- 本轮最多 8 次模型调用,每次最多 8 个工具。\n") + b.WriteString("- 模型调用不设固定轮数,由累计积分预算和运行状态控制;每次响应最多 8 个工具。生成任务数和视频秒数预算为 0 时表示该项不限。\n") b.WriteString("- 当前权限模式:") b.WriteString(req.PermissionMode) b.WriteString("。只读模式只能读取分析,不能修改或生成媒体。\n") diff --git a/backend/internal/app/cloud_agent_runtime.go b/backend/internal/app/cloud_agent_runtime.go index f854e8601..44f8642d0 100644 --- a/backend/internal/app/cloud_agent_runtime.go +++ b/backend/internal/app/cloud_agent_runtime.go @@ -46,29 +46,30 @@ type cloudAgentApproval struct { Reason string `json:"reason,omitempty"` } type cloudAgentRuntime struct { - Request CloudAgentRequest `json:"request"` - Policy cloudAgentPolicySnapshot `json:"policy"` - ParentID string `json:"parentId,omitempty"` - Fingerprint string `json:"fingerprint,omitempty"` - CreativeAnchor cloudAgentCreativeAnchor `json:"creativeAnchor,omitempty"` - TextHistory []providerTextMessage `json:"textHistory,omitempty"` - Skills []cloudAgentSkill `json:"skills"` - SkillReads map[string]bool `json:"skillReads,omitempty"` - Profile cloudAgentProfileSnapshot `json:"profile"` - ProfileReads map[string]bool `json:"profileReads,omitempty"` - Canonical canonicalAgentRequest `json:"canonical"` - ActiveTaskID string `json:"activeTaskId"` - ActiveTextDraft string `json:"activeTextDraft,omitempty"` - MediaTaskID string `json:"mediaTaskId,omitempty"` - TaskIDs []string `json:"taskIds"` - Step int `json:"step"` - Generations int `json:"generations"` - VideoSeconds int `json:"videoSeconds"` - Calls []cloudAgentCall `json:"calls"` - CallIndex int `json:"callIndex"` - Approval *cloudAgentApproval `json:"approval,omitempty"` - Decisions map[string]string `json:"decisions"` - Events []CloudAgentEvent `json:"events"` + Request CloudAgentRequest `json:"request"` + Policy cloudAgentPolicySnapshot `json:"policy"` + ParentID string `json:"parentId,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + CreativeAnchor cloudAgentCreativeAnchor `json:"creativeAnchor,omitempty"` + TextHistory []providerTextMessage `json:"textHistory,omitempty"` + Skills []cloudAgentSkill `json:"skills"` + SkillReads map[string]bool `json:"skillReads,omitempty"` + Profile cloudAgentProfileSnapshot `json:"profile"` + ProfileReads map[string]bool `json:"profileReads,omitempty"` + Canonical canonicalAgentRequest `json:"canonical"` + ActiveTaskID string `json:"activeTaskId"` + ActiveTextDraft string `json:"activeTextDraft,omitempty"` + MediaTaskID string `json:"mediaTaskId,omitempty"` + TaskIDs []string `json:"taskIds"` + Step int `json:"step"` + Generations int `json:"generations"` + VideoSeconds int `json:"videoSeconds"` + Calls []cloudAgentCall `json:"calls"` + CallIndex int `json:"callIndex"` + Approval *cloudAgentApproval `json:"approval,omitempty"` + Decisions map[string]string `json:"decisions"` + DecisionSettings map[string]string `json:"decisionSettings,omitempty"` + Events []CloudAgentEvent `json:"events"` } func (s *Service) ensureCloudAgentExecution(task *model.Task, initial cloudAgentState) error { @@ -842,7 +843,7 @@ func (s *Service) advanceCloudAgentTool(run *model.CloudAgentExecution, state *c if err != nil { return err } - plan.Args.SnapshotHash = cloudAgentCanvasHash(doc) + plan.Args.SnapshotHash = cloudAgentMediaContentHash(doc) raw, err := json.Marshal(plan.Args) if err != nil { return err @@ -978,10 +979,8 @@ func (s *Service) enqueueCloudAgentTask(run *model.CloudAgentExecution, state *c if media != nil { requested, _ := req.Input["config"].(map[string]any) resolved, _ := input["config"].(map[string]any) - for _, key := range []string{"size", "videoSeconds", "vquality", "quality", "count", "videoGenerateAudio"} { - if value := stringValue(requested[key]); value != "" && !strings.EqualFold(value, stringValue(resolved[key])) { - return s.cloudAgentMediaError(run, state, "admission", false, false, creationConflict("模型解析后的生成规格与审批参数不同,请重新读取目录并审批")) - } + if err := validateCloudAgentResolvedMediaOptions(requested, resolved); err != nil { + return s.cloudAgentMediaError(run, state, "admission", false, false, err) } } if err = s.protectTaskSecrets(input); err != nil { @@ -1138,7 +1137,17 @@ func (s *Service) advanceCloudAgentMedia(run *model.CloudAgentExecution, state * return s.enqueueCloudAgentTask(run, state, req, plan) } -func (s *Service) DecideCloudAgentApproval(userID, id, approvalID, decision, reason string) error { +func (s *Service) DecideCloudAgentApproval(userID, id, approvalID, decision, reason string, mediaSettings ...*CloudAgentMediaSettings) error { + var settings *CloudAgentMediaSettings + if len(mediaSettings) > 1 { + return BadAuthRequest("只能提交一组生成参数") + } + if len(mediaSettings) == 1 { + settings = mediaSettings[0] + } + if settings != nil && decision != "approve" { + return BadAuthRequest("仅批准生成时可修改生成参数") + } if decision != "approve" && decision != "reject" { return BadAuthRequest("无效审批决定") } @@ -1157,7 +1166,7 @@ func (s *Service) DecideCloudAgentApproval(userID, id, approvalID, decision, rea return err } if previous, ok := state.Decisions[approvalID]; ok { - if previous == decision { + if previous == decision && (settings == nil || state.DecisionSettings[approvalID] == creationHash(settings)) { return nil } return creationConflict("该审批已有不同决定") @@ -1165,10 +1174,21 @@ func (s *Service) DecideCloudAgentApproval(userID, id, approvalID, decision, rea if run.Status != "waiting_approval" || state.Approval == nil || state.Approval.ID != approvalID { return creationConflict("审批不存在或已过期") } + if settings != nil { + if err := s.updateCloudAgentMediaApproval(run, &state, *settings); err != nil { + return err + } + } return s.repo.MutateCloudAgent(userID, id, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { state.Approval.Decision = decision state.Approval.Reason = reason state.Decisions[approvalID] = decision + if settings != nil { + if state.DecisionSettings == nil { + state.DecisionSettings = map[string]string{} + } + state.DecisionSettings[approvalID] = creationHash(settings) + } if decision == "reject" { // Rejection is a user control-plane decision, not a failed tool // invocation. Make it terminal before the scheduler can advance the @@ -1186,7 +1206,7 @@ func (s *Service) DecideCloudAgentApproval(userID, id, approvalID, decision, rea return cloudAgentSave(current, &state) } current.Status = "running" - state.event(id, "approval_decided", map[string]any{"approvalId": approvalID, "decision": decision}) + state.event(id, "approval_decided", map[string]any{"approvalId": approvalID, "decision": decision, "arguments": json.RawMessage(state.Approval.Call.Function.Arguments), "preview": state.Approval.Preview, "modelName": state.Approval.ModelName}) return cloudAgentSave(current, &state) }) } diff --git a/backend/internal/app/cloud_agent_tools.go b/backend/internal/app/cloud_agent_tools.go index 4415c44f4..7dab9f5e4 100644 --- a/backend/internal/app/cloud_agent_tools.go +++ b/backend/internal/app/cloud_agent_tools.go @@ -173,7 +173,7 @@ func cloudAgentTools(req CloudAgentRequest) []map[string]any { "mode": map[string]any{"type": "string", "enum": cloudAgentGenerationModeNames()}, "prompt": str("完整生成提示词"), "logicalModelId": str("selection.logicalModelId;与channelId/channelModelKey互斥"), "channelId": str("selection.channelId"), "channelModelKey": str("selection.channelModelKey"), "durationSeconds": map[string]any{"type": "integer", "minimum": 0}, "size": str("模型支持的画幅,例如9:16"), "quality": str("目录支持的分辨率或质量"), "videoGenerateAudio": map[string]any{"type": "boolean", "description": "是否生成音频,仅视频可用"}, - "snapshotHash": str("最近canvas_get_state的snapshotHash"), "nodeId": str("可续用的未提交媒体草稿ID;无草稿时才使用新唯一ID"), "title": str("媒体节点名称"), "sourceNodeId": str("仅文本/镜头提示词节点ID;无文本来源则留空,绝不能填图片/视频/音频ID"), "referenceNodeIds": map[string]any{"type": "array", "maxItems": 16, "items": str("当前画布媒体参考节点ID;参考图只放此处,保持引用顺序")}, + "snapshotHash": str("使用最近 canvas_get_state 返回的 mediaSnapshotHash;媒体生成忽略纯节点移动,但仍校验内容与引用变化"), "nodeId": str("可续用的未提交媒体草稿ID;无草稿时才使用新唯一ID"), "title": str("媒体节点名称"), "sourceNodeId": str("仅文本/镜头提示词节点ID;无文本来源则留空,绝不能填图片/视频/音频ID"), "referenceNodeIds": map[string]any{"type": "array", "maxItems": 16, "items": str("当前画布媒体参考节点ID;参考图只放此处,保持引用顺序")}, }, "mode", "prompt", "snapshotHash", "nodeId", "title", "referenceNodeIds") } return tools diff --git a/backend/internal/handler/agent.go b/backend/internal/handler/agent.go index 5e60b5dfa..303c32cc8 100644 --- a/backend/internal/handler/agent.go +++ b/backend/internal/handler/agent.go @@ -22,7 +22,7 @@ func RegisterAgentRoutes(r *gin.RouterGroup, svc *service.Service) { 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}) + ok(c, gin.H{"version": 2, "permissionModes": []string{"read_only", "request_approval", "auto"}, "contextScopes": []string{"canvas"}, "skills": true, "writeTools": true, "billing": "fixed_request", "maxHistoryPairs": 0, "maxHistoryBytes": 64000, "maxSteps": 0, "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 @@ -204,8 +204,9 @@ func RegisterAgentRoutes(r *gin.RouterGroup, svc *service.Service) { } c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 4096) var req struct { - Decision string `json:"decision"` - Reason string `json:"reason"` + Decision string `json:"decision"` + Reason string `json:"reason"` + MediaSettings *service.CloudAgentMediaSettings `json:"mediaSettings,omitempty"` } decoder := json.NewDecoder(c.Request.Body) decoder.DisallowUnknownFields() @@ -225,7 +226,7 @@ func RegisterAgentRoutes(r *gin.RouterGroup, svc *service.Service) { 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 { + if err := svc.DecideCloudAgentApproval(user.ID, c.Param("id"), c.Param("approvalId"), req.Decision, req.Reason, req.MediaSettings); err != nil { failService(c, err) return } diff --git a/backend/internal/handler/openapi.yaml b/backend/internal/handler/openapi.yaml index 752ec8df8..91bf1e20d 100644 --- a/backend/internal/handler/openapi.yaml +++ b/backend/internal/handler/openapi.yaml @@ -40,7 +40,7 @@ paths: summary: 读取云端 Agent 能力 responses: "200": - description: 信封 data 包含 version、permissionModes、contextScopes、skills、writeTools、billing、maxHistoryPairs、capabilitySetVersion、capabilitySetHash、nodeTypes、tools + description: 信封 data 包含 version、permissionModes、contextScopes、skills、writeTools、billing、maxHistoryPairs、maxSteps、maxHistoryBytes、capabilitySetVersion、capabilitySetHash、nodeTypes、tools;maxHistoryPairs 和 maxSteps 为 0 表示无固定轮数上限,历史仍受 maxHistoryBytes 限制 /agent/profile: get: tags: [agent] @@ -192,6 +192,22 @@ paths: reason: type: string maxLength: 2000 + mediaSettings: + type: object + additionalProperties: false + description: 仅 approve 图片生成审批可传;替换本次模型、画幅和质量,重新强校验,不修改提示词、引用或目标。logicalModelId 与 channelId + channelModelKey 二选一。 + required: [size, quality] + properties: + logicalModelId: + type: string + channelId: + type: string + channelModelKey: + type: string + size: + type: string + quality: + type: string responses: "200": description: 信封 data.accepted diff --git a/backend/internal/service/aliases_types.go b/backend/internal/service/aliases_types.go index 294468bb6..41ac86d3c 100644 --- a/backend/internal/service/aliases_types.go +++ b/backend/internal/service/aliases_types.go @@ -4,6 +4,7 @@ import "infinite-canvas/backend/internal/app" type ( CloudAgentRequest = app.CloudAgentRequest + CloudAgentMediaSettings = app.CloudAgentMediaSettings CloudAgentCapabilitySet = app.CloudAgentCapabilitySet AgentProfileRequest = app.AgentProfileRequest AgentProfileView = app.AgentProfileView diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 790ade0aa..8bd3b05fb 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -17,11 +17,12 @@ description: 信封、错误码与 OpenAPI ## 云端 Agent -所有 `/agent` 路径都需要登录 Cookie。`GET /agent/capabilities` 返回当前版本(2)、权限模式、上下文范围、技能和工具清单。Agent 运行由服务端任务 Worker 驱动,浏览器断开不会停止运行。 +所有 `/agent` 路径都需要登录 Cookie。`GET /agent/capabilities` 返回当前版本(2)、权限模式、上下文范围、技能和工具清单;`maxHistoryPairs: 0`、`maxSteps: 0` 表示不设固定对话 / 模型调用轮数,历史仍受 `maxHistoryBytes: 64000` 约束。Agent 运行由服务端任务 Worker 驱动,浏览器断开不会停止运行。 - `POST /agent/runs` 创建一轮;`POST /agent/runs/:id/messages` 以上一轮为父节点创建新的不可变轮次,两者返回 `{ run }`,追加后必须使用新的 `run.id` 订阅。 - 请求字段:`canvasId`、`prompt`、受管 `logicalModelId` 或 `channelId` + `channelModelKey`、`permissionMode: "read_only" | "request_approval" | "auto"`、`reasoningMode: "off" | "auto" | "deep"`、`profileRevision`、`skillIds`、`contextScope: [] | ["canvas"]`、`budget: { maxCredits, maxGenerationTasks, maxVideoSeconds }`、`idempotencyKey`。不接受密钥、自定义 URL、客户端历史或未知字段。 -- 同用户同幂等键及相同请求返回同一运行;不同请求复用键返回 409。失败后重试同一提交应保留键。上一轮未完成时禁止追加;已完成、失败、取消或已拒绝的终态轮次作为不可变历史读取,即使其冻结的策略或节点能力版本早于当前服务,也可用于创建采用当前执行合同的新一轮,父运行不会被改写。旧版合同下仍活动或待审批的运行不得恢复执行。历史最多携带 8 对消息及 64KB JSON,超限提示新建对话;历史结构损坏返回明确 409,不投影为通用 500。 +- 同用户同幂等键及相同请求返回同一运行;不同请求复用键返回 409。失败后重试同一提交应保留键。上一轮未完成时禁止追加;已完成、失败、取消或已拒绝的终态轮次作为不可变历史读取,即使其冻结的策略或节点能力版本早于当前服务,也可用于创建采用当前执行合同的新一轮,父运行不会被改写。旧版合同下仍活动或待审批的运行不得恢复执行。历史不设固定消息对数,JSON 最大 64000 字节,超限提示新建对话;历史结构损坏返回明确 409,不投影为通用 500。 +- `POST /agent/runs/:id/approvals/:approvalId/decision` 的 `{ decision, reason? }` 可在批准图片生成时附带 `mediaSettings: { logicalModelId? | channelId? + channelModelKey?, size, quality }`。仅替换本次模型选择、画幅和质量,不允许修改目标、提示词、引用、模式或快照。服务端先无收费预检,失败保留待审批状态;成功原子更新审批调用、哈希及预览,提交任务时再次校验内容、资源归属、模型能力、价格与累计预算。相同参数重复批准幂等,不同参数复用已决定审批返回 409。 - 审批拒绝是独立终态 `rejected`,表示本次操作未执行;不会生成 `tool_failed`、继续调用模型、写入画布、提交生成任务或扣费。拒绝后的草稿按既有草稿策略保留,用户可在下一轮说明修改方向。 - 创建和追加消息可选择 `reasoningMode`,默认 `off`;`auto`/`deep` 为服务端行为策略与模型推理配置的选择,不等于允许暴露内部思维链。供应商能力由后端校验;声明式 Agent 渠道不支持时明确报错。推理可能增加耗时和费用。 - Cloud Agent 与首页创造的规划请求使用自动工具选择。OpenAI Chat Completion 兼容协议会把显式 `auto` 归一化为省略 `tool_choice`,沿用协议默认的自动选择语义;显式开启思考模式时还会在首个上游请求前移除 `required` 或指定函数对象。非思考模式仍保留艺术点评、提示词优化等结构化任务的强制函数选择。若上游隐式开启思考而本地未声明,仍按 `required` → `auto` → 省略字段做兼容重试。 @@ -36,7 +37,7 @@ description: 信封、错误码与 OpenAPI - `generate_media` 支持图片 / 视频 / 音频:必填 `mode`、`prompt`、`snapshotHash`、`nodeId`(可续用草稿,无草稿时才新建)、`title`、有序 `referenceNodeIds`(无参考传空数组),再加一种模型选择;图片 / 视频要求 `size`,视频要求正数 `durationSeconds`,图片 / 音频时长为 0。可传来源节点 `sourceNodeId`、`quality`、视频 `videoGenerateAudio`。`sourceNodeId` 仅接文本 / 镜头提示词节点;参考图片、视频、音频只放 `referenceNodeIds`,无文本来源时留空且不重复传入。引用从当前画布节点的账号资源解析,校验归属、类型、就绪状态和模型能力,不接受任意 URL。图片生成仅接图片参考;视频可按模型能力接图片、视频、音频参考;音频生成当前仅支持文本输入。 - 参数选择与收费授权分开:用户明确说“随便 / 你决定 / 按默认”时,Agent 可从当前目录选择能力及预算匹配的模型,优先用户最近明确使用的可用模型,其次价格清楚的低成本选项;支持时沿用参考图原始比例,否则选目录默认,时长用目录默认。未要求音频且模型不支持时用默片。用户接受已推荐方案后不再要求特定确认口令;仅真正缺失或冲突的阻塞项一次性询问。本轮已有目录结果应复用。以上是 Agent 的参数选择策略,服务端仍强校验具体规格,不提供绕过预算 / 能力校验的默认兜底,也不绕过独立收费审批。 - 媒体参数先进行无任务、无预留账单的准入校验,再在同一事务创建草稿节点、来源 / 资产连线及待审批检查点;`approval_requested` 和运行中的 `approval` 携带目录真实 `modelName`。用户批准后才在事务中绑定任务、创建预留账单并更新检查点;拒绝或取消保留未生成草稿,不提交媒体任务。`nodeId` 可续用同类型、未锁定、idle、无任务且无媒体结果的空白占位节点或当前运行草稿;跨轮 Agent 草稿还要求原运行属于同一用户和画布、已结束且清理完成。续用必须重新读取快照并重新审批,不能复用旧审批、覆盖已有任务或成品,不能通过循环新建节点绕过限制。画布读取的 `generationDraft` 提示未提交、原运行状态、是否仍在清理及仍需审批;写入仍重新强校验。已明确审批的规格不得被模型默认值静默替换。媒体终态回写原节点;已删除或已换任务的节点不自动复活,结果仍可从任务中心查看。 -- Agent 的 `snapshotHash` 忽略顶层 `viewport`、`updatedAt`,避免平移 / 聚焦自动保存使审批失效;节点、连线、标题等内容改动仍需重新读取并审批,数据库写入仍使用完整快照 CAS。客户端刷新同样区分视口和真实内容编辑,并保留本地视口。 +- Agent 的 `snapshotHash` 忽略顶层 `viewport`、`updatedAt`。`canvas_get_state` 另返回 `mediaSnapshotHash`,供 `generate_media.snapshotHash` 使用,并额外忽略节点 `position`;创建生成草稿后的审批也固定该内容哈希。因此移动图片或草稿不会使生成审批失效,执行时保留最新坐标;节点内容、素材、锁定状态和连线变化仍需重新读取并审批。画布修改与撤销继续使用包含位置的 `snapshotHash`,数据库写入仍使用完整快照 CAS。客户端刷新保留本地视口。 - `read_only` 只允许读取;`request_approval` 对写画布和媒体生成暂停为 `waiting_approval`;`auto` 可在预算内执行已授权画布操作,但图片 / 视频生成始终独立审批,不能绕过。`maxGenerationTasks` / `maxVideoSeconds` 为非负整数,0 表示该项不设业务上限,正数按本轮累计执行;不再固定 8 次模型调用、8 个生成任务或 120 秒视频。积分上限仍必填且大于 0,实际报价在任务创建与预留积分前校验,时长必须符合所选模型的实际能力。失败事件保留具体错误和已有任务的 `taskId`、`nodeId`、`status`;媒体结果的 `phase` 区分 `admission` 与 `completion`,`taskSubmitted` 表达是否已提交。模型应报告错误,不能自行删减提示词或自动重试收费生成。 - 必须有当前用户已保存的画布。首轮摘要最多取前 80 个节点;`canvas_get_state` 可按 `offset` 每页读取 40 个节点、资产可引用状态及相关连线,按 `nodeIds` 精读最多 8 个节点(正文每字段最多 16000 字符),通过 `hasMore` / `nextOffset` 和字段 `Truncated` 判断是否完整。分镜表包含镜头、角色节点与资产绑定;摘要每页最多 20 行,精读每次 1 行,可用 `storyboardOffset` 翻页;单行叙事文本累计最多 24000 字符,角色 / 绑定各最多 16 项。上下文不含媒体 URL、任意元数据或未同步修改;节点名称不等于图像视觉识别。普通任务重试接口不能重试 Agent 任务。 - `GET /agent/runs/:id` 返回运行信息;`POST /agent/runs/:id/cancel` 取消当前运行及活动子任务;`POST /agent/runs/:id/approvals/:approvalId/decision` 接受 `{"decision":"approve"|"reject","reason":"..."}`,审批决定幂等且服务端校验归属。 diff --git a/web/src/components/canvas/canvas-agent-image-approval-settings.tsx b/web/src/components/canvas/canvas-agent-image-approval-settings.tsx new file mode 100644 index 000000000..208332bff --- /dev/null +++ b/web/src/components/canvas/canvas-agent-image-approval-settings.tsx @@ -0,0 +1,51 @@ +import { useMemo } from "react"; +import { ImageSettingsPanel } from "@/components/image-settings-panel"; +import { ModelPicker } from "@/components/model-picker"; +import type { CanvasTheme } from "@/lib/canvas-theme"; +import { agentApprovalModel, agentApprovalModelSelection, type AgentImageApproval } from "@/lib/canvas/agent-media-approval"; +import { modelCapabilityConfigFor, normalizeImageValue } from "@/lib/model-capabilities"; +import { resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection"; +import type { AgentMediaSettings } from "@/services/api/agent"; +import { PUBLIC_MODEL_CATALOG_ID, useEffectiveConfig } from "@/stores/use-config-store"; + +export function CanvasAgentImageApprovalSettings({ initial, value, onChange, theme, disabled }: { + initial: AgentImageApproval; + value?: AgentMediaSettings; + onChange: (value: AgentMediaSettings) => void; + theme: CanvasTheme; + disabled: boolean; +}) { + const effective = useEffectiveConfig(); + const config = useMemo(() => ({ ...effective, channels: effective.channels.filter((channel) => channel.scope === "system" || channel.id === PUBLIC_MODEL_CATALOG_ID) }), [effective]); + const settings = value || initial; + const model = agentApprovalModel(config, settings); + const requirements: ModelRequirements = { + capability: "image", + input: { textCount: 1, imageCount: initial.referenceNodeIds.length, videoCount: 0, audioCount: 0, characterCount: 0 }, + imageSize: settings.size, + options: { size: settings.size, ...(settings.quality ? { quality: settings.quality } : {}), count: 1 }, + }; + const changeModel = (next: string) => { + if (disabled) return; + const profile = modelCapabilityConfigFor(config, next).image; + if (!profile) return; + const normalized = normalizeImageValue(profile, settings); + onChange({ ...agentApprovalModelSelection(config, next), size: normalized.size, quality: profile.quality.supported ? normalized.quality : "" }); + }; + const changeOption = (key: "quality" | "size" | "transparentBackground" | "count", next: string) => { + if (disabled || (key !== "size" && key !== "quality")) return; + const options = { size: settings.size, quality: settings.quality, [key]: next }; + const selected = resolveCompatibleModel(config, model, { ...requirements, imageSize: options.size, options: { ...options, count: 1 } }); + if (!selected) return; + onChange({ ...agentApprovalModelSelection(config, selected), ...options }); + }; + return
; +} diff --git a/web/src/components/canvas/canvas-cloud-agent-panel.tsx b/web/src/components/canvas/canvas-cloud-agent-panel.tsx index 48d4c83af..297c1dd17 100644 --- a/web/src/components/canvas/canvas-cloud-agent-panel.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-panel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type Dispatch, type SetStateAction } from "react"; import { Button, Dropdown, Input } from "antd"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { ArrowLeft, Bot, Check, ChevronRight, CircleDot, Clock3, Download, History, LoaderCircle, MessageSquarePlus, Settings2, ShieldCheck, Trash2, Sparkles, X } from "lucide-react"; @@ -8,16 +8,21 @@ import { markdownPlainText } from "@/lib/markdown-plain-text"; import { nanoid } from "nanoid"; import { ModelPicker } from "@/components/model-picker"; +import { FluidOrb } from "@/components/ui/fluid-orb"; +import { cn } from "@/lib/utils"; import { modelCapabilityConfigFor } from "@/lib/model-capabilities"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme"; import { agentErrorPresentation, agentSubmissionErrorTitle } from "@/lib/canvas/agent-error-presentation"; import { cancelAgentRun, getAgentCapabilities, getAgentProfile, getAgentRun, createAgentRun, decideAgentApproval, sendAgentMessage, subscribeAgentEvents, updateAgentProfile, type AgentEvent, type AgentPermissionMode, type AgentProfileScope, type AgentProfileView, type AgentReasoningMode, type AgentRun } from "@/services/api/agent"; import { agentApprovalPresentation } from "@/lib/canvas/agent-approval-presentation"; +import { agentApprovalMatchesSettings, agentImageApproval } from "@/lib/canvas/agent-media-approval"; +import type { AgentMediaSettings } from "@/services/api/agent"; +import { CanvasAgentImageApprovalSettings } from "./canvas-agent-image-approval-settings"; import { addSkill, listAddedSkills, listSkills, type Skill, type SkillCategory } from "@/services/api/skills"; import { clearCloudAgentPendingSubmission, cloudAgentConversationTitle, loadCloudAgentConversations, loadCloudAgentPendingSubmission, saveCloudAgentConversations, saveCloudAgentPendingSubmission, type CloudAgentConversation, type CloudAgentPendingSubmission } from "@/services/cloud-agent-conversations"; import { logicalModelIDForConfig, modelOptionName, resolveModelRequestConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; -import { useThemeStore } from "@/stores/use-theme-store"; +import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; import { applyAgentCanvasPatches, refreshCanvasAfterAgent, saveRemoteUserDataNow } from "@/services/user-data-sync"; import { createAgentCanvasSync } from "@/services/agent-canvas-sync"; import { buildSkillMentionReferences, resolveSkillMentions } from "@/services/skill-runtime"; @@ -32,7 +37,7 @@ type ApprovalState = { approvalId: string; detail: Record{action.description}
{action.items.length ? (