feat(agent): Agent 生成审批 - 支持改模型参数、忽略位置漂移并取消 8 轮硬限制
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
231
backend/internal/app/cloud_agent_approval_settings_test.go
Normal file
231
backend/internal/app/cloud_agent_approval_settings_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"])
|
||||
|
||||
78
backend/internal/app/cloud_agent_media_approval.go
Normal file
78
backend/internal/app/cloud_agent_media_approval.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":"..."}`,审批决定幂等且服务端校验归属。
|
||||
|
||||
@@ -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 <fieldset disabled={disabled} className="min-w-0 space-y-3 border-0 p-0" data-canvas-no-zoom data-canvas-wheel-scroll aria-label="图片生成设置">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs" style={{ color: theme.node.muted }}>生成模型</div>
|
||||
<ModelPicker config={config} capability="image" value={model} onChange={changeModel} requirements={requirements} fullWidth showOptionPrices placeholder="选择生成模型" />
|
||||
</div>
|
||||
{model ? <ImageSettingsPanel config={{ ...config, model, imageModel: model, size: settings.size, quality: settings.quality, count: "1" }} onConfigChange={changeOption} theme={theme} showTitle={false} showCount={false} showTransparent={false} className="min-w-0 space-y-3" /> : <p className="text-xs" style={{ color: theme.node.muted }}>当前模型不在可选目录中,可重新选择;提交时将重新校验模型与规格。</p>}
|
||||
<p className="text-xs" style={{ color: theme.node.text }}>本次规格:{settings.size}{settings.quality ? ` · ${settings.quality}` : ""}</p>
|
||||
<p className="text-xs" style={{ color: theme.node.muted }}>修改仅用于本次生成,费用按最终模型和规格计算。</p>
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -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<string, unknown>; reas
|
||||
type AgentPanelView = "chat" | "history" | "settings";
|
||||
|
||||
export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, references, open, onOpen, onCollapse, onFocusNode }: CloudAgentPanelProps) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const theme = canvasThemes[useActiveTheme()];
|
||||
const config = useEffectiveConfig();
|
||||
const updateConfig = useConfigStore((state) => state.updateConfig);
|
||||
const reducedMotion = useReducedMotion();
|
||||
@@ -436,7 +441,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
|
||||
}
|
||||
};
|
||||
|
||||
const submitApproval = async (decision: "approve" | "reject") => {
|
||||
const submitApproval = async (decision: "approve" | "reject", mediaSettings?: AgentMediaSettings) => {
|
||||
if (!run || !approval || connectionStatus !== "connected" || approvalRequestRef.current === approval.approvalId) return;
|
||||
const runId = run.id;
|
||||
const approvalId = approval.approvalId;
|
||||
@@ -444,7 +449,9 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
|
||||
approvalRequestRef.current = approvalId;
|
||||
setApprovalSubmitting(true);
|
||||
try {
|
||||
await decideAgentApproval(runId, approvalId, decision, approval.reason, AbortSignal.timeout(15_000));
|
||||
if (decision === "approve") await saveRemoteUserDataNow();
|
||||
if (currentScope.current !== scope) return;
|
||||
await decideAgentApproval(runId, approvalId, decision, approval.reason, AbortSignal.timeout(15_000), mediaSettings);
|
||||
if (currentScope.current === scope) {
|
||||
setApproval((current) => current?.approvalId === approvalId ? null : current);
|
||||
setRun((current) => current?.id === runId && current.status === "waiting_approval" && (!current.approval || current.approval.approvalId === approvalId) ? { ...current, status: decision === "reject" ? "rejected" : "running", approval: undefined } : current);
|
||||
@@ -455,7 +462,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
|
||||
// asking the user to retry. The backend treats identical decisions idempotently.
|
||||
try {
|
||||
const snapshot = await getAgentRun(runId, AbortSignal.timeout(5_000));
|
||||
const decided = snapshot.run.events?.some((event) => event.type === "approval_decided" && event.payload.approvalId === approvalId && event.payload.decision === decision);
|
||||
const decided = snapshot.run.events?.some((event) => event.type === "approval_decided" && event.payload.approvalId === approvalId && event.payload.decision === decision && (!mediaSettings || agentApprovalMatchesSettings(event.payload.arguments, mediaSettings)));
|
||||
if (decided) {
|
||||
setApproval((current) => current?.approvalId === approvalId ? null : current);
|
||||
setRun(snapshot.run);
|
||||
@@ -666,7 +673,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
|
||||
nodeCount={nodeCount}
|
||||
approvalSubmitting={approvalSubmitting || connectionStatus !== "connected"}
|
||||
onApprovalReasonChange={(reason) => setApproval((current) => (current ? { ...current, reason } : current))}
|
||||
onApprove={() => void submitApproval("approve")}
|
||||
onApprove={(settings) => void submitApproval("approve", settings)}
|
||||
onReject={() => void submitApproval("reject")}
|
||||
/>
|
||||
<AgentChatComposer
|
||||
@@ -736,16 +743,18 @@ function AgentLauncher({ theme, statusColor, approvalPending, reducedMotion, onO
|
||||
type="button"
|
||||
aria-label="打开云端 Agent"
|
||||
title={approvalPending ? "Agent 等待你的审批" : "打开 Agent 助手"}
|
||||
className="canvas-agent-launcher fixed bottom-5 right-5 z-[var(--z-modal-overlay)] flex h-12 items-center gap-2.5 rounded-2xl px-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/35"
|
||||
style={{ color: theme.node.text, background: theme.node.panel, border: `1px solid ${theme.toolbar.border}`, boxShadow: `0 8px 28px ${theme.spatial.shadow}` }}
|
||||
className="canvas-agent-launcher fixed bottom-5 right-5 z-[var(--z-modal-overlay)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/35"
|
||||
style={{ color: theme.node.text, "--canvas-agent-launcher-shadow": theme.spatial.shadow } as CSSProperties}
|
||||
data-canvas-no-zoom
|
||||
onClick={onOpen}
|
||||
whileHover={reducedMotion ? undefined : { y: -2 }}
|
||||
whileTap={reducedMotion ? undefined : { scale: 0.98 }}
|
||||
whileHover={reducedMotion ? undefined : { y: -3, scale: 1.035 }}
|
||||
whileTap={reducedMotion ? undefined : { scale: 0.96 }}
|
||||
transition={{ duration: reducedMotion ? 0 : 0.18 }}
|
||||
>
|
||||
<Bot className="size-5" />
|
||||
<span className="text-sm font-semibold">Agent</span>
|
||||
{approvalPending ? <span className="text-xs" style={{ color: statusColor }}>待审批</span> : null}
|
||||
<FluidOrb size={62} color="#7164f6" />
|
||||
<span className="canvas-agent-launcher-label">Agent</span>
|
||||
<span className={cn("canvas-agent-launcher-status", approvalPending && "is-pending")} style={{ "--canvas-agent-status-color": statusColor } as CSSProperties} />
|
||||
{approvalPending ? <span className="canvas-agent-launcher-badge">待审批</span> : null}
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
@@ -843,7 +852,7 @@ function AgentConversation({
|
||||
nodeCount: number;
|
||||
onFocusNode?: (nodeId: string) => void;
|
||||
onApprovalReasonChange: (reason: string) => void;
|
||||
onApprove: () => void;
|
||||
onApprove: (settings?: AgentMediaSettings) => void;
|
||||
onReject: () => void;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
@@ -989,8 +998,10 @@ function reasoningMenuItems(mode: AgentReasoningMode, onChange: (value: AgentRea
|
||||
}));
|
||||
}
|
||||
|
||||
function ApprovalCard({ approval, theme, submitting, onFocusNode, onReasonChange, onApprove, onReject }: { approval: ApprovalState; theme: CanvasTheme; submitting: boolean; onFocusNode?: (nodeId: string) => void; onReasonChange: (value: string) => void; onApprove: () => void; onReject: () => void }) {
|
||||
function ApprovalCard({ approval, theme, submitting, onFocusNode, onReasonChange, onApprove, onReject }: { approval: ApprovalState; theme: CanvasTheme; submitting: boolean; onFocusNode?: (nodeId: string) => void; onReasonChange: (value: string) => void; onApprove: (settings?: AgentMediaSettings) => void; onReject: () => void }) {
|
||||
const [showReason, setShowReason] = useState(Boolean(approval.reason));
|
||||
const [mediaSettings, setMediaSettings] = useState<AgentMediaSettings>();
|
||||
const imageApproval = agentImageApproval(approval.detail);
|
||||
const action = agentApprovalPresentation(approval.detail);
|
||||
return (
|
||||
<section className="canvas-agent-approval-card" aria-label={action.title}>
|
||||
@@ -1002,16 +1013,17 @@ function ApprovalCard({ approval, theme, submitting, onFocusNode, onReasonChange
|
||||
<p className="canvas-agent-approval-description" style={{ color: theme.node.muted }}>{action.description}</p>
|
||||
{action.items.length ? (
|
||||
<div className="canvas-agent-approval-items" aria-label="涉及节点">
|
||||
{action.items.map((item, index) => <ApprovalPreviewItemView key={`${item.operation}-${item.nodeId || item.nodeTitle || index}-${index}`} item={item} theme={theme} onFocusNode={onFocusNode} />)}
|
||||
{action.items.map((item, index) => <ApprovalPreviewItemView key={`${item.operation}-${item.nodeId || item.nodeTitle || index}-${index}`} item={imageApproval ? { ...item, details: item.details?.filter((detail) => !/^(模型|画幅|质量)[::]/.test(detail)) } : item} theme={theme} onFocusNode={onFocusNode} />)}
|
||||
</div>
|
||||
) : <div className="canvas-agent-approval-empty" style={{ color: theme.node.muted }}>无法确认具体目标,继续前请重新读取画布。</div>}
|
||||
{imageApproval ? <CanvasAgentImageApprovalSettings initial={imageApproval} value={mediaSettings} onChange={setMediaSettings} theme={theme} disabled={submitting} /> : null}
|
||||
<button type="button" className="canvas-agent-approval-reason-toggle" aria-expanded={showReason} onClick={() => setShowReason((value) => !value)} disabled={submitting}>
|
||||
{showReason ? "收起拒绝理由" : "填写拒绝理由(可选)"}
|
||||
</button>
|
||||
{showReason ? <Input.TextArea className="canvas-agent-approval-reason" value={approval.reason} onChange={(event) => onReasonChange(event.target.value)} placeholder="告诉 Agent 为什么暂不执行" autoSize={{ minRows: 2, maxRows: 3 }} maxLength={2000} disabled={submitting} /> : null}
|
||||
<div className="canvas-agent-approval-actions">
|
||||
<button type="button" className="canvas-agent-approval-reject" disabled={submitting} onClick={onReject}>暂不执行</button>
|
||||
<button type="button" className="canvas-agent-approval-approve" disabled={submitting} onClick={onApprove}>
|
||||
<button type="button" className="canvas-agent-approval-approve" disabled={submitting} onClick={() => onApprove(mediaSettings)}>
|
||||
{submitting ? <LoaderCircle className="size-4 animate-spin motion-reduce:animate-none" aria-hidden="true" /> : <Check className="size-4" aria-hidden="true" />}
|
||||
{submitting ? "正在提交" : "同意执行"}
|
||||
</button>
|
||||
|
||||
@@ -274,6 +274,86 @@
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.canvas-agent-launcher {
|
||||
display: grid;
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
padding: 7px;
|
||||
box-shadow: 0 16px 38px color-mix(in srgb, var(--canvas-agent-launcher-shadow) 64%, transparent);
|
||||
cursor: pointer;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.canvas-agent-launcher::before {
|
||||
position: absolute;
|
||||
inset: 3px;
|
||||
border: 1px solid color-mix(in srgb, currentColor 10%, transparent);
|
||||
border-radius: inherit;
|
||||
background: color-mix(in srgb, var(--background) 82%, transparent);
|
||||
box-shadow: 0 5px 18px color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
content: "";
|
||||
backdrop-filter: blur(12px);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.canvas-agent-launcher-label {
|
||||
position: absolute;
|
||||
bottom: -17px;
|
||||
left: 50%;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--background) 92%, transparent);
|
||||
color: color-mix(in srgb, var(--foreground) 72%, transparent);
|
||||
font-size: 10px;
|
||||
font-weight: 680;
|
||||
line-height: 1;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.canvas-agent-launcher-status {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 7px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 2px solid var(--background);
|
||||
border-radius: 50%;
|
||||
background: #57b786;
|
||||
box-shadow: 0 2px 7px rgba(23, 90, 61, .28);
|
||||
}
|
||||
|
||||
.canvas-agent-launcher-status.is-pending {
|
||||
background: var(--canvas-agent-status-color, #df9b47);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--canvas-agent-status-color, #df9b47) 20%, transparent);
|
||||
}
|
||||
|
||||
.canvas-agent-launcher-badge {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: 62px;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--background) 94%, transparent);
|
||||
padding: 5px 8px;
|
||||
color: var(--foreground);
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 8px 22px color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.canvas-agent-launcher { transition: none !important; }
|
||||
}
|
||||
|
||||
.agent-composer-send-hint {
|
||||
color: color-mix(in srgb, currentColor 52%, transparent);
|
||||
font-size: var(--fs-tiny);
|
||||
|
||||
44
web/src/components/ui/fluid-orb.css
Normal file
44
web/src/components/ui/fluid-orb.css
Normal file
@@ -0,0 +1,44 @@
|
||||
/* Rare UI Fluid Orb inspired launcher: isolated from canvas wheel/pointer gestures. */
|
||||
.fluid-orb {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--fluid-orb-color, #7164f6) 18%, white);
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(circle at 34% 20%, rgba(255, 255, 255, .96) 0 9%, rgba(255, 255, 255, .48) 18%, transparent 42%),
|
||||
var(--fluid-orb-color, #7164f6);
|
||||
box-shadow:
|
||||
0 14px 32px color-mix(in srgb, var(--fluid-orb-color, #7164f6) 27%, transparent),
|
||||
inset 0 1px 1px rgba(255, 255, 255, .82);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.fluid-orb::after {
|
||||
position: absolute;
|
||||
inset: 5%;
|
||||
border: 1px solid rgba(255, 255, 255, .28);
|
||||
border-radius: inherit;
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fluid-orb canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fluid-orb-fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
radial-gradient(circle at 30% 22%, #fff 0 9%, transparent 27%),
|
||||
radial-gradient(circle at 60% 78%, color-mix(in srgb, var(--fluid-orb-color, #7164f6) 82%, white), transparent 60%),
|
||||
linear-gradient(145deg, color-mix(in srgb, var(--fluid-orb-color, #7164f6) 70%, #b7e8ff), var(--fluid-orb-color, #7164f6));
|
||||
}
|
||||
|
||||
|
||||
@media (prefers-reduced-motion: reduce) { .fluid-orb { transition: none !important; } }
|
||||
209
web/src/components/ui/fluid-orb.tsx
Normal file
209
web/src/components/ui/fluid-orb.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import "./fluid-orb.css";
|
||||
|
||||
type FluidOrbProps = {
|
||||
size?: number;
|
||||
color?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const VERTEX_SHADER = `
|
||||
attribute vec2 a_position;
|
||||
void main() {
|
||||
gl_Position = vec4(a_position, 0.0, 1.0);
|
||||
}`;
|
||||
|
||||
const FRAGMENT_SHADER = `
|
||||
precision highp float;
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_time;
|
||||
uniform vec3 u_color;
|
||||
|
||||
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
|
||||
vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
|
||||
vec4 permute(vec4 x) { return mod289(((x * 34.0) + 1.0) * x); }
|
||||
vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
|
||||
|
||||
float snoise(vec3 v) {
|
||||
const vec2 C = vec2(1.0 / 6.0, 1.0 / 3.0);
|
||||
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
|
||||
vec3 i = floor(v + dot(v, C.yyy));
|
||||
vec3 x0 = v - i + dot(i, C.xxx);
|
||||
vec3 g = step(x0.yzx, x0.xyz);
|
||||
vec3 l = 1.0 - g;
|
||||
vec3 i1 = min(g.xyz, l.zxy);
|
||||
vec3 i2 = max(g.xyz, l.zxy);
|
||||
vec3 x1 = x0 - i1 + C.xxx;
|
||||
vec3 x2 = x0 - i2 + C.yyy;
|
||||
vec3 x3 = x0 - D.yyy;
|
||||
i = mod289(i);
|
||||
vec4 p = permute(permute(permute(
|
||||
i.z + vec4(0.0, i1.z, i2.z, 1.0))
|
||||
+ i.y + vec4(0.0, i1.y, i2.y, 1.0))
|
||||
+ i.x + vec4(0.0, i1.x, i2.x, 1.0));
|
||||
float n_ = 0.142857142857;
|
||||
vec3 ns = n_ * D.wyz - D.xzx;
|
||||
vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
|
||||
vec4 x_ = floor(j * ns.z);
|
||||
vec4 y_ = floor(j - 7.0 * x_);
|
||||
vec4 x = x_ * ns.x + ns.yyyy;
|
||||
vec4 y = y_ * ns.x + ns.yyyy;
|
||||
vec4 h = 1.0 - abs(x) - abs(y);
|
||||
vec4 b0 = vec4(x.xy, y.xy);
|
||||
vec4 b1 = vec4(x.zw, y.zw);
|
||||
vec4 s0 = floor(b0) * 2.0 + 1.0;
|
||||
vec4 s1 = floor(b1) * 2.0 + 1.0;
|
||||
vec4 sh = -step(h, vec4(0.0));
|
||||
vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
|
||||
vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
|
||||
vec3 p0 = vec3(a0.xy, h.x);
|
||||
vec3 p1 = vec3(a0.zw, h.y);
|
||||
vec3 p2 = vec3(a1.xy, h.z);
|
||||
vec3 p3 = vec3(a1.zw, h.w);
|
||||
vec4 norm = taylorInvSqrt(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)));
|
||||
p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
|
||||
vec4 m = max(0.6 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
|
||||
m *= m;
|
||||
return 42.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
|
||||
}
|
||||
|
||||
float fbm(vec3 p) {
|
||||
float value = 0.0;
|
||||
float amplitude = 0.5;
|
||||
for (int i = 0; i < 5; i++) {
|
||||
value += amplitude * snoise(p);
|
||||
p *= 2.0;
|
||||
amplitude *= 0.5;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
|
||||
vec2 p = uv * 2.0 - 1.0;
|
||||
p.x *= u_resolution.x / u_resolution.y;
|
||||
float r = length(p);
|
||||
if (r > 1.0) discard;
|
||||
float y = uv.y;
|
||||
float t = u_time * 0.18;
|
||||
float n1 = fbm(vec3(p * 1.28, t));
|
||||
float n2 = fbm(vec3(p * 2.35 + n1 * 0.55, t * 1.22));
|
||||
float shape = smoothstep(1.0, 0.16, r + n2 * 0.2);
|
||||
float topGlow = smoothstep(0.16, 0.92, y);
|
||||
vec3 white = vec3(1.0);
|
||||
vec3 color = mix(u_color * 0.66, u_color * 1.17, n1 * 0.5 + 0.5);
|
||||
color = mix(color, white, topGlow * 0.96);
|
||||
color += vec3(0.11, 0.06, 0.17) * max(n2, 0.0) * (1.0 - topGlow);
|
||||
float rim = smoothstep(0.78, 0.99, r);
|
||||
color = mix(color, white, rim * 0.2);
|
||||
gl_FragColor = vec4(color, shape);
|
||||
}`;
|
||||
|
||||
export function FluidOrb({ size = 56, color = "#6d5dfc", className }: FluidOrbProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [fallback, setFallback] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const gl = canvas.getContext("webgl", { alpha: true, antialias: true, premultipliedAlpha: true });
|
||||
if (!gl) {
|
||||
setFallback(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const compile = (type: number, source: string) => {
|
||||
const shader = gl.createShader(type);
|
||||
if (!shader) throw new Error("无法创建 Fluid Orb shader");
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
const detail = gl.getShaderInfoLog(shader) || "未知 shader 错误";
|
||||
gl.deleteShader(shader);
|
||||
throw new Error(detail);
|
||||
}
|
||||
return shader;
|
||||
};
|
||||
|
||||
let frame = 0;
|
||||
let removeMotionListener = () => {};
|
||||
let program: WebGLProgram | null = null;
|
||||
let vertexShader: WebGLShader | null = null;
|
||||
let fragmentShader: WebGLShader | null = null;
|
||||
let buffer: WebGLBuffer | null = null;
|
||||
try {
|
||||
vertexShader = compile(gl.VERTEX_SHADER, VERTEX_SHADER);
|
||||
fragmentShader = compile(gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
|
||||
program = gl.createProgram();
|
||||
if (!program) throw new Error("无法创建 Fluid Orb program");
|
||||
gl.attachShader(program, vertexShader);
|
||||
gl.attachShader(program, fragmentShader);
|
||||
gl.linkProgram(program);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(program) || "Fluid Orb link 失败");
|
||||
gl.useProgram(program);
|
||||
|
||||
buffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
|
||||
const position = gl.getAttribLocation(program, "a_position");
|
||||
gl.enableVertexAttribArray(position);
|
||||
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
const resolution = gl.getUniformLocation(program, "u_resolution");
|
||||
const time = gl.getUniformLocation(program, "u_time");
|
||||
const colorUniform = gl.getUniformLocation(program, "u_color");
|
||||
const [red, green, blue] = hexToRgb(color);
|
||||
gl.uniform3f(colorUniform, red / 255, green / 255, blue / 255);
|
||||
|
||||
const motionPreference = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const startedAt = performance.now();
|
||||
const render = (now: number) => {
|
||||
const ratio = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const width = Math.max(1, Math.round(canvas.clientWidth * ratio));
|
||||
const height = Math.max(1, Math.round(canvas.clientHeight * ratio));
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
gl.viewport(0, 0, width, height);
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
gl.uniform2f(resolution, width, height);
|
||||
gl.uniform1f(time, motionPreference.matches ? 0.8 : (now - startedAt) / 1000);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
if (!motionPreference.matches) frame = window.requestAnimationFrame(render);
|
||||
};
|
||||
const updateMotion = () => { window.cancelAnimationFrame(frame); frame = window.requestAnimationFrame(render); };
|
||||
motionPreference.addEventListener("change", updateMotion);
|
||||
removeMotionListener = () => motionPreference.removeEventListener("change", updateMotion);
|
||||
frame = window.requestAnimationFrame(render);
|
||||
} catch (error) {
|
||||
console.warn("Fluid Orb WebGL 初始化失败,已切换为 CSS 降级效果", error);
|
||||
setFallback(true);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
removeMotionListener();
|
||||
if (buffer) gl.deleteBuffer(buffer);
|
||||
if (program) gl.deleteProgram(program);
|
||||
if (vertexShader) gl.deleteShader(vertexShader);
|
||||
if (fragmentShader) gl.deleteShader(fragmentShader);
|
||||
};
|
||||
}, [color]);
|
||||
|
||||
return <span className={cn("fluid-orb", fallback && "is-fallback", className)} style={{ width: size, height: size, "--fluid-orb-color": color } as CSSProperties} aria-hidden="true">
|
||||
<canvas ref={canvasRef} />
|
||||
{fallback ? <span className="fluid-orb-fallback" /> : null}
|
||||
</span>;
|
||||
}
|
||||
|
||||
function hexToRgb(value: string): [number, number, number] {
|
||||
const normalized = value.trim().replace(/^#/, "");
|
||||
const expanded = normalized.length === 3 ? normalized.split("").map((item) => item + item).join("") : normalized;
|
||||
const parsed = Number.parseInt(expanded, 16);
|
||||
if (!Number.isFinite(parsed) || expanded.length !== 6) return [109, 93, 252];
|
||||
return [(parsed >> 16) & 255, (parsed >> 8) & 255, parsed & 255];
|
||||
}
|
||||
45
web/src/lib/canvas/agent-media-approval.ts
Normal file
45
web/src/lib/canvas/agent-media-approval.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { AgentMediaSettings } from "@/services/api/agent";
|
||||
import { logicalModelIDForConfig, modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
export type AgentImageApproval = AgentMediaSettings & { prompt: string; referenceNodeIds: string[] };
|
||||
|
||||
export function agentImageApproval(detail: Record<string, unknown>): AgentImageApproval | null {
|
||||
const call = detail.call as { function?: { name?: string; arguments?: unknown } } | undefined;
|
||||
if ((call?.function?.name || detail.toolName) !== "generate_media") return null;
|
||||
const raw = call?.function?.arguments ?? detail.arguments;
|
||||
try {
|
||||
const args = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
if (!args || args.mode !== "image" || typeof args.size !== "string" || typeof args.prompt !== "string") return null;
|
||||
return {
|
||||
logicalModelId: typeof args.logicalModelId === "string" ? args.logicalModelId : "",
|
||||
channelId: typeof args.channelId === "string" ? args.channelId : "",
|
||||
channelModelKey: typeof args.channelModelKey === "string" ? args.channelModelKey : "",
|
||||
size: args.size,
|
||||
quality: typeof args.quality === "string" ? args.quality : "",
|
||||
prompt: args.prompt,
|
||||
referenceNodeIds: Array.isArray(args.referenceNodeIds) ? args.referenceNodeIds.filter((id: unknown): id is string => typeof id === "string") : [],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function agentApprovalModel(config: AiConfig, settings: AgentMediaSettings): string {
|
||||
return selectableModelsByCapability(config, "image").find((model) => {
|
||||
if (settings.logicalModelId) return logicalModelIDForConfig({ ...config, model }) === settings.logicalModelId;
|
||||
return resolveModelChannel(config, model).id === settings.channelId && modelOptionName(model) === settings.channelModelKey;
|
||||
}) || "";
|
||||
}
|
||||
|
||||
export function agentApprovalMatchesSettings(argumentsValue: unknown, settings: AgentMediaSettings): boolean {
|
||||
const approved = agentImageApproval({ toolName: "generate_media", arguments: argumentsValue });
|
||||
return Boolean(approved && (["logicalModelId", "channelId", "channelModelKey", "size", "quality"] as const).every((key) => (approved[key] || "") === (settings[key] || "")));
|
||||
}
|
||||
|
||||
export function agentApprovalModelSelection(config: AiConfig, model: string): Pick<AgentMediaSettings, "logicalModelId" | "channelId" | "channelModelKey"> {
|
||||
const logicalModelId = logicalModelIDForConfig({ ...config, model });
|
||||
if (logicalModelId) return { logicalModelId };
|
||||
const channel = resolveModelChannel(config, model);
|
||||
if (channel.scope !== "system") throw new Error("Agent 生成仅支持平台模型");
|
||||
return { channelId: channel.id, channelModelKey: modelOptionName(model) };
|
||||
}
|
||||
@@ -3,6 +3,13 @@ import { consumeTaskTextStream, createTaskTextStreamParser } from "@/services/ap
|
||||
|
||||
export type AgentPermissionMode = "read_only" | "auto" | "request_approval";
|
||||
export type AgentReasoningMode = "off" | "auto" | "deep";
|
||||
export type AgentMediaSettings = {
|
||||
logicalModelId?: string;
|
||||
channelId?: string;
|
||||
channelModelKey?: string;
|
||||
size: string;
|
||||
quality: string;
|
||||
};
|
||||
export type AgentProfileScope = "user" | "project" | "canvas";
|
||||
|
||||
export type AgentProfileLayer = {
|
||||
@@ -167,8 +174,8 @@ export function undoAgentCanvasRun(runId: string, input: { stepId?: string; expe
|
||||
return http.post<{ accepted: boolean; snapshotHash: string }>(`/agent/runs/${encodeURIComponent(runId)}/undo`, input, { signal });
|
||||
}
|
||||
|
||||
export async function decideAgentApproval(runId: string, approvalId: string, decision: "approve" | "reject", reason?: string, signal?: AbortSignal) {
|
||||
return http.post<{ accepted: boolean }>(`/agent/runs/${encodeURIComponent(runId)}/approvals/${encodeURIComponent(approvalId)}/decision`, { decision, reason: reason?.trim() || undefined }, { signal });
|
||||
export async function decideAgentApproval(runId: string, approvalId: string, decision: "approve" | "reject", reason?: string, signal?: AbortSignal, mediaSettings?: AgentMediaSettings) {
|
||||
return http.post<{ accepted: boolean }>(`/agent/runs/${encodeURIComponent(runId)}/approvals/${encodeURIComponent(approvalId)}/decision`, { decision, reason: reason?.trim() || undefined, ...(mediaSettings ? { mediaSettings } : {}) }, { signal });
|
||||
}
|
||||
|
||||
export function subscribeAgentEvents(runId: string, onEvent: (event: AgentEvent) => void, options: { after?: number; onError?: (error: unknown) => void; onConnectionChange?: (status: "connecting" | "connected" | "reconnecting" | "disconnected") => void; timeoutMs?: number } = {}) {
|
||||
|
||||
@@ -65,6 +65,14 @@ function observe(options: { timeoutMs?: number; after?: number } = {}) {
|
||||
}
|
||||
|
||||
describe("Agent SSE recovery", () => {
|
||||
it("sends the user's final image settings with the approval and keeps cancellation", async () => {
|
||||
const requests: unknown[][] = [];
|
||||
transport.http.post = async (...args: unknown[]) => { requests.push(args); return { accepted: true }; };
|
||||
const signal = new AbortController().signal;
|
||||
const mediaSettings = { logicalModelId: "chosen-image", size: "1536x1024", quality: "high" };
|
||||
await api.decideAgentApproval("run/id", "approval/id", "approve", "", signal, mediaSettings);
|
||||
expect(requests).toEqual([["/agent/runs/run%2Fid/approvals/approval%2Fid/decision", { decision: "approve", reason: undefined, mediaSettings }, { signal }]]);
|
||||
});
|
||||
it.each([401, 403, 404, 422])("stops immediately on definitive %s", async (status) => {
|
||||
let requests = 0;
|
||||
globalThis.fetch = (async () => { requests++; return new Response("", { status }); }) as typeof fetch;
|
||||
|
||||
40
web/test/agent-media-approval.test.ts
Normal file
40
web/test/agent-media-approval.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { agentApprovalMatchesSettings, agentApprovalModel, agentApprovalModelSelection, agentImageApproval } from "../src/lib/canvas/agent-media-approval";
|
||||
import { createModelChannel, defaultConfig, encodeChannelModel } from "../src/stores/use-config-store";
|
||||
|
||||
describe("image generation approval settings", () => {
|
||||
const args = { mode: "image", prompt: "保持参考图产品外观", size: "1024x1024", quality: "high", channelId: "platform", channelModelKey: "gpt-image-2", referenceNodeIds: ["product"] };
|
||||
it("reads both SSE approval requests and restored run snapshots", () => {
|
||||
const event = agentImageApproval({ toolName: "generate_media", arguments: args });
|
||||
const restored = agentImageApproval({ call: { function: { name: "generate_media", arguments: JSON.stringify(args) } } });
|
||||
expect(event).toEqual(restored);
|
||||
expect(restored?.referenceNodeIds).toEqual(["product"]);
|
||||
expect(restored?.quality).toBe("high");
|
||||
});
|
||||
it("does not enable image settings for other tools or invalid arguments", () => {
|
||||
expect(agentImageApproval({ toolName: "canvas_apply_ops", arguments: args })).toBeNull();
|
||||
expect(agentImageApproval({ toolName: "generate_media", arguments: { ...args, mode: "video" } })).toBeNull();
|
||||
expect(agentImageApproval({ toolName: "generate_media", arguments: "{" })).toBeNull();
|
||||
});
|
||||
it("does not treat another tab's different approval settings as a successful retry", () => {
|
||||
expect(agentApprovalMatchesSettings(args, args)).toBe(true);
|
||||
expect(agentApprovalMatchesSettings(args, { ...args, quality: "low" })).toBe(false);
|
||||
expect(agentApprovalMatchesSettings(args, { logicalModelId: "other", size: args.size, quality: args.quality })).toBe(false);
|
||||
});
|
||||
it("switches between logical and channel models without retaining the previous selector", () => {
|
||||
const cost = { model: "gpt-image-2", capability: "image" as const, billingMode: "fixed_request" as const, unitPriceMicrocredits: 1, priceConfigured: true };
|
||||
const config = { ...defaultConfig, channels: [
|
||||
createModelChannel({ id: "platform", scope: "system", models: [cost.model], modelCosts: [cost] }),
|
||||
createModelChannel({ id: "managed", scope: "system", models: [cost.model], modelCosts: [{ ...cost, logicalModelId: "logical-image" }] }),
|
||||
createModelChannel({ id: "personal", scope: "custom", models: [cost.model], modelCosts: [cost] }),
|
||||
] };
|
||||
const logical = agentApprovalModelSelection(config, encodeChannelModel("managed", cost.model));
|
||||
expect(logical).toEqual({ logicalModelId: "logical-image" });
|
||||
expect(agentApprovalModel(config, { ...logical, size: args.size, quality: args.quality })).toBe(encodeChannelModel("managed", cost.model));
|
||||
const channel = agentApprovalModelSelection(config, encodeChannelModel("platform", cost.model));
|
||||
expect(channel).toEqual({ channelId: "platform", channelModelKey: cost.model });
|
||||
expect(agentApprovalModel(config, { ...channel, size: args.size, quality: args.quality })).toBe(encodeChannelModel("platform", cost.model));
|
||||
expect(() => agentApprovalModelSelection(config, encodeChannelModel("personal", cost.model))).toThrow("平台模型");
|
||||
expect(agentApprovalModel(config, { logicalModelId: "removed", size: args.size, quality: args.quality })).toBe("");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user