feat(cloud-agent): 云端 Agent - 完善运行时与资源持久化
This commit is contained in:
1 parent
4613cca38c
commit
f4480b129b
40 files changed
+1739
-440
No files matched your search
@@ -331,7 +331,7 @@ func cloudAgentApprovalCallHash(call cloudAgentCall) string {
|
||||
// server revalidates the prepared dependency hash below, which deliberately
|
||||
// ignores layout-only edits such as moving a node.
|
||||
arguments := call.Function.Arguments
|
||||
if call.Function.Name == "generate_media" {
|
||||
if call.Function.Name == "generate_media" || call.Function.Name == "image_layer_split" {
|
||||
var object map[string]any
|
||||
if err := json.Unmarshal([]byte(arguments), &object); err == nil {
|
||||
delete(object, "snapshotHash")
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestCloudAgentMediaReadHashSurvivesMoveBeforeDraft(t *testing.T) {
|
||||
if err := db.Model(canvas).Update("payload_json", string(raw)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, _ := agentMediaRun(t, s, args, "auto")
|
||||
run, _ := agentMediaRun(t, s, args, "request_approval")
|
||||
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func TestCloudAgentMediaApprovalAllowsMovesButRejectsContentChanges(t *testing.T
|
||||
for _, change := range []string{"move", "presentation", "prompt", "resource", "connection", "locked", "task", "unknown_metadata"} {
|
||||
t.Run(change, func(t *testing.T) {
|
||||
s, db, a := agentMediaFixture(t)
|
||||
run, _ := agentMediaRun(t, s, a, "auto")
|
||||
run, _ := agentMediaRun(t, s, a, "request_approval")
|
||||
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -112,7 +112,7 @@ func TestCloudAgentMediaApprovalAllowsMovesButRejectsContentChanges(t *testing.T
|
||||
|
||||
func TestCloudAgentMediaApprovalReusesPreparedInputsAfterModelRetry(t *testing.T) {
|
||||
s, db, a := agentMediaFixture(t)
|
||||
run, _ := agentMediaRun(t, s, a, "auto")
|
||||
run, _ := agentMediaRun(t, s, a, "request_approval")
|
||||
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -170,7 +170,7 @@ func TestCloudAgentImageApprovalEditsAreValidatedAndIdempotent(t *testing.T) {
|
||||
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")
|
||||
run, _ := agentMediaRun(t, s, a, "request_approval")
|
||||
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestCloudAgentCanvasPatchesPersistDraftSubmissionAndAllTerminalStates(t *te
|
||||
for _, status := range []model.TaskStatus{model.TaskStatusSucceeded, model.TaskStatusFailed, model.TaskStatusCancelled} {
|
||||
t.Run(string(status), func(t *testing.T) {
|
||||
s, db, args := agentMediaFixture(t)
|
||||
run, _ := agentMediaRun(t, s, args, "auto")
|
||||
run, _ := agentMediaRun(t, s, args, "request_approval")
|
||||
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -128,7 +128,7 @@ func TestCloudAgentCanvasPatchesPersistDraftSubmissionAndAllTerminalStates(t *te
|
||||
|
||||
func TestCloudAgentMediaImageSourceHasActionableCorrection(t *testing.T) {
|
||||
s, _, args := agentMediaFixture(t)
|
||||
run, state := agentMediaRun(t, s, args, "auto")
|
||||
run, state := agentMediaRun(t, s, args, "request_approval")
|
||||
args.SourceNodeID = "cat"
|
||||
if _, _, err := s.prepareCloudAgentMedia(run, &state, agentMediaCall(args)); err == nil || !strings.Contains(err.Error(), "referenceNodeIds") {
|
||||
t.Fatalf("image source must point to the correct field: %v", err)
|
||||
@@ -226,7 +226,7 @@ func TestCloudAgentCanvasOperationTraceSharesToolCallID(t *testing.T) {
|
||||
|
||||
func TestCloudAgentMissingCanvasStillCheckpointsMediaFailure(t *testing.T) {
|
||||
s, db, args := agentMediaFixture(t)
|
||||
run, _ := agentMediaRun(t, s, args, "auto")
|
||||
run, _ := agentMediaRun(t, s, args, "request_approval")
|
||||
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ func TestCloudAgentMediaAdmissionReportsTaskBinding(t *testing.T) {
|
||||
|
||||
func TestCloudAgentCancellationRecordsSourceOnce(t *testing.T) {
|
||||
s, _, args := agentMediaFixture(t)
|
||||
run, _ := agentMediaRun(t, s, args, "auto")
|
||||
run, _ := agentMediaRun(t, s, args, "request_approval")
|
||||
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -14,10 +14,18 @@ func cloudAgentGenerationSpec(a cloudAgentMediaArgs, refs map[string]any, resolv
|
||||
}
|
||||
options := contract.Options{}
|
||||
if a.Mode == "image" || a.Mode == "video" {
|
||||
options.Size = &a.Size
|
||||
// A blank option means "use the selected model's declared default". Do
|
||||
// not turn absence into an explicit empty value: task admission is where
|
||||
// the model capability contract resolves defaults and validates support.
|
||||
if strings.TrimSpace(a.Size) != "" {
|
||||
options.Size = &a.Size
|
||||
}
|
||||
}
|
||||
if a.Mode == "video" {
|
||||
options.DurationSeconds, options.GenerateAudio = &a.Duration, a.VideoGenerateAudio
|
||||
if a.Duration > 0 {
|
||||
options.DurationSeconds = &a.Duration
|
||||
}
|
||||
options.GenerateAudio = a.VideoGenerateAudio
|
||||
if a.Quality != "" {
|
||||
options.Resolution = &a.Quality
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
@@ -19,8 +20,12 @@ func (s *Service) cloudAgentModelList(intent *ModelRequestIntent) (any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logicalModels, err := s.PublicLogicalModels(intent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := []map[string]any{}
|
||||
for _, m := range catalog.Models {
|
||||
for _, m := range logicalModels {
|
||||
if m.Available && cloudAgentGenerationModeSupported(normalizeCapability(m.Capability)) {
|
||||
items = append(items, map[string]any{"name": m.Name, "capability": m.Capability, "selection": map[string]any{"logicalModelId": m.ID}, "priceLabel": m.PriceLabel, "priceTiers": m.PriceTiers, "options": m.CapabilitySpec, "profiles": m.CapabilityProfiles, "defaults": m.DefaultOptions})
|
||||
}
|
||||
@@ -28,7 +33,7 @@ func (s *Service) cloudAgentModelList(intent *ModelRequestIntent) (any, error) {
|
||||
for _, channel := range catalog.Channels {
|
||||
for _, m := range channel.Models {
|
||||
if m.Available && cloudAgentGenerationModeSupported(normalizeCapability(m.Capability)) {
|
||||
items = append(items, map[string]any{"name": m.DisplayName, "capability": m.Capability, "selection": map[string]any{"channelId": channel.ID, "channelModelKey": m.ModelKey}, "priceLabel": m.PriceLabel, "priceTiers": m.PriceTiers, "options": m.CapabilityConfig})
|
||||
items = append(items, map[string]any{"name": m.DisplayName, "capability": m.Capability, "selection": map[string]any{"channelId": channel.ID, "channelModelKey": m.ModelKey}, "priceLabel": m.PriceLabel, "priceTiers": m.PriceTiers, "options": m.CapabilityConfig, "defaults": m.DefaultOptions})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,6 +114,9 @@ type cloudAgentMediaPlan struct {
|
||||
Args cloudAgentMediaArgs
|
||||
CallID string
|
||||
TransientReferences map[string]cloudAgentTransientReference
|
||||
// Prepared is populated by the auto path after its dry admission. Approval
|
||||
// mode keeps the same admission in state.Approval.Prepared.
|
||||
Prepared *cloudAgentPreparedMedia `json:"-"`
|
||||
}
|
||||
|
||||
// A resumed draft's incoming edges must describe the new approved inputs,
|
||||
@@ -161,7 +169,11 @@ func (s *Service) cloudAgentMediaModelName(a cloudAgentMediaArgs) (string, error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, m := range catalog.Models {
|
||||
logicalModels, err := s.PublicLogicalModels(nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, m := range logicalModels {
|
||||
if a.LogicalModelID != "" && m.ID == a.LogicalModelID && m.Available && normalizeCapability(m.Capability) == normalizeCapability(a.Mode) {
|
||||
return m.Name, nil
|
||||
}
|
||||
@@ -422,17 +434,13 @@ func validateCloudAgentMediaArgs(a cloudAgentMediaArgs, state *cloudAgentRuntime
|
||||
if utf8.RuneCountInString(a.Prompt) > 16000 {
|
||||
return BadAuthRequest(fmt.Sprintf("提示词共%d字符,超过16000字符上限;请先告知用户,不要擅自删改关键内容", utf8.RuneCountInString(a.Prompt)))
|
||||
}
|
||||
if (mode == "image" || mode == "video") && strings.TrimSpace(a.Size) == "" {
|
||||
return BadAuthRequest("请填写模型支持的具体画幅;用户授权默认时沿用参考图比例或目录默认画幅,无需重复询问")
|
||||
}
|
||||
if a.Duration < 0 {
|
||||
return BadAuthRequest("生成时长不能为负数")
|
||||
}
|
||||
switch mode {
|
||||
case "video":
|
||||
if a.Duration == 0 {
|
||||
return BadAuthRequest("视频生成必须明确 durationSeconds")
|
||||
}
|
||||
// 0 means omitted. The selected model's capability defaults are resolved
|
||||
// during task admission; an explicit unsupported value still fails there.
|
||||
case "image", "audio":
|
||||
if a.Duration != 0 {
|
||||
return BadAuthRequest("只有视频生成允许设置 durationSeconds")
|
||||
@@ -540,14 +548,36 @@ func (s *Service) prepareCloudAgentMedia(run *model.CloudAgentExecution, state *
|
||||
if err := decodeCloudAgentJSONObject(call.Function.Arguments, &a); err != nil {
|
||||
return CreateTaskRequest{}, nil, cloudAgentJSONArgumentError(err)
|
||||
}
|
||||
if err := validateCloudAgentModelSelection(call.Function.Arguments, a); err != nil {
|
||||
a.Mode = strings.ToLower(strings.TrimSpace(a.Mode))
|
||||
defaultedModel, err := s.applyCloudAgentProjectDefaultModel(run, state, call.Function.Arguments, &a)
|
||||
if err != nil {
|
||||
return CreateTaskRequest{}, nil, err
|
||||
}
|
||||
selectionArguments := call.Function.Arguments
|
||||
if defaultedModel {
|
||||
selectionFields := map[string]string{}
|
||||
if a.LogicalModelID != "" {
|
||||
selectionFields["logicalModelId"] = a.LogicalModelID
|
||||
} else {
|
||||
selectionFields["channelId"], selectionFields["channelModelKey"] = a.ChannelID, a.ChannelModelKey
|
||||
}
|
||||
encoded, encodeErr := json.Marshal(selectionFields)
|
||||
if encodeErr != nil {
|
||||
return CreateTaskRequest{}, nil, encodeErr
|
||||
}
|
||||
selectionArguments = string(encoded)
|
||||
}
|
||||
if err := validateCloudAgentModelSelection(selectionArguments, a); err != nil {
|
||||
return CreateTaskRequest{}, nil, err
|
||||
}
|
||||
a.Mode = strings.ToLower(strings.TrimSpace(a.Mode))
|
||||
a.DraftRunID = run.ID
|
||||
if state.Approval != nil && state.Approval.Prepared != nil &&
|
||||
state.Approval.CallHash == cloudAgentApprovalCallHash(call) {
|
||||
a.Prepared = state.Approval.Prepared
|
||||
} else if state.AutoPreparedMedia != nil &&
|
||||
state.AutoPreparedCallHash == cloudAgentApprovalCallHash(call) &&
|
||||
time.Now().Before(state.AutoPreparedMedia.Quote.ExpiresAt) {
|
||||
a.Prepared = state.AutoPreparedMedia
|
||||
}
|
||||
if err := s.fillCloudAgentMediaSnapshotHash(run.UserID, state.Request.CanvasID, &a); err != nil {
|
||||
return CreateTaskRequest{}, nil, err
|
||||
@@ -593,7 +623,52 @@ func (s *Service) prepareCloudAgentMedia(run *model.CloudAgentExecution, state *
|
||||
for id, ref := range state.TransientReferences {
|
||||
transientSnapshot[id] = ref
|
||||
}
|
||||
return CreateTaskRequest{ProjectID: state.Request.CanvasID, Type: "canvas_" + a.Mode, Operation: operation, Prompt: a.Prompt, LogicalModelID: a.LogicalModelID, Model: a.ChannelModelKey, Input: input}, &cloudAgentMediaPlan{Args: a, CallID: call.ID, TransientReferences: transientSnapshot}, nil
|
||||
return CreateTaskRequest{ProjectID: state.Request.CanvasID, Type: "canvas_" + a.Mode, Operation: operation, Prompt: a.Prompt, LogicalModelID: a.LogicalModelID, Model: a.ChannelModelKey, Input: input}, &cloudAgentMediaPlan{Args: a, CallID: call.ID, TransientReferences: transientSnapshot, Prepared: a.Prepared}, nil
|
||||
}
|
||||
|
||||
// applyCloudAgentResolvedMediaDefaults makes the result of the dry admission
|
||||
// explicit in both the plan and the request that will be submitted. The
|
||||
// regular task admission already owns default resolution; this helper only
|
||||
// mirrors that resolved contract back to the Agent state so auto execution does
|
||||
// not need to fail once for size/duration and then ask the model to repair it.
|
||||
func applyCloudAgentResolvedMediaDefaults(req *CreateTaskRequest, plan *cloudAgentMediaPlan, task *model.Task) error {
|
||||
if req == nil || plan == nil || task == nil {
|
||||
return BadAuthRequest("媒体生成准入结果无效,未提交生成任务")
|
||||
}
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil {
|
||||
return err
|
||||
}
|
||||
config, ok := input["config"].(map[string]any)
|
||||
if !ok {
|
||||
return BadAuthRequest("媒体生成未解析出模型配置,未提交生成任务")
|
||||
}
|
||||
|
||||
plan.Args.Size = stringValue(config["size"])
|
||||
if plan.Args.Mode == "image" {
|
||||
plan.Args.Quality = stringValue(config["quality"])
|
||||
}
|
||||
if plan.Args.Mode == "video" {
|
||||
plan.Args.Quality = stringValue(config["vquality"])
|
||||
if raw := stringValue(config["videoSeconds"]); raw != "" {
|
||||
seconds, err := strconv.Atoi(raw)
|
||||
if err != nil || seconds < 0 {
|
||||
return BadAuthRequest("模型目录返回的默认视频时长无效,未提交生成任务")
|
||||
}
|
||||
plan.Args.Duration = seconds
|
||||
}
|
||||
if raw := stringValue(config["videoGenerateAudio"]); raw != "" {
|
||||
audio, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return BadAuthRequest("模型目录返回的默认音频参数无效,未提交生成任务")
|
||||
}
|
||||
plan.Args.VideoGenerateAudio = &audio
|
||||
}
|
||||
}
|
||||
plan.Args.Prepared = nil
|
||||
req.Input = input
|
||||
req.Prompt = stringValue(input["prompt"])
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveCloudAgentDocument(repo *repository.Repository, canvas *model.CanvasProject, doc map[string]any, policy RuntimePolicySetting) error {
|
||||
|
||||
@@ -83,8 +83,19 @@ func agentMediaRun(t *testing.T, s *Service, a cloudAgentMediaArgs, permission s
|
||||
func approveAgentMediaDraft(t *testing.T, s *Service, runID string) {
|
||||
t.Helper()
|
||||
run, err := s.CloudAgentRun("user", runID)
|
||||
if err != nil || run.Approval == nil || run.Status != "waiting_approval" {
|
||||
t.Fatalf("media must wait for approval even in auto mode: %v", err)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if run.Status != "waiting_approval" || run.Approval == nil {
|
||||
stored, loadErr := s.repo.CloudAgent("user", runID)
|
||||
state, decodeErr := cloudAgentDecode(stored)
|
||||
if loadErr != nil {
|
||||
decodeErr = loadErr
|
||||
}
|
||||
if decodeErr != nil || state.MediaTaskID == "" {
|
||||
t.Fatalf("media generation was neither approved nor auto-submitted: status=%s task=%s err=%v", run.Status, state.MediaTaskID, decodeErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := s.DecideCloudAgentApproval("user", runID, run.Approval.ID, "approve", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -196,7 +207,6 @@ func TestCloudAgentMediaReferenceAndSnapshotGuards(t *testing.T) {
|
||||
{"existing node", func(v *cloudAgentMediaArgs) { v.NodeID = "cat" }},
|
||||
{"mixed selection", func(v *cloudAgentMediaArgs) { v.LogicalModelID = "made-up" }},
|
||||
{"duration budget", func(v *cloudAgentMediaArgs) { v.Duration = 25 }},
|
||||
{"missing aspect ratio", func(v *cloudAgentMediaArgs) { v.Size = "" }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
bad := a
|
||||
@@ -234,7 +244,7 @@ func TestCloudAgentImageCreatesReferencedNode(t *testing.T) {
|
||||
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")
|
||||
run, _ := agentMediaRun(t, s, a, "request_approval")
|
||||
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -399,7 +409,7 @@ func TestCloudAgentMediaFillsMissingSnapshotHash(t *testing.T) {
|
||||
|
||||
func TestCloudAgentMediaFailedTaskUpdatesNode(t *testing.T) {
|
||||
s, db, a := agentMediaFixture(t)
|
||||
run, _ := agentMediaRun(t, s, a, "auto")
|
||||
run, _ := agentMediaRun(t, s, a, "request_approval")
|
||||
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -545,7 +555,7 @@ func TestCloudAgentMediaDraftReuseLifecycle(t *testing.T) {
|
||||
|
||||
func TestCloudAgentMediaPreviousDraftRequiresNewApproval(t *testing.T) {
|
||||
s, db, a := agentMediaFixture(t)
|
||||
run, state := agentMediaRun(t, s, a, "auto")
|
||||
run, state := agentMediaRun(t, s, a, "request_approval")
|
||||
if err := s.advanceCloudAgentTool(run, &state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -564,7 +574,7 @@ func TestCloudAgentMediaPreviousDraftRequiresNewApproval(t *testing.T) {
|
||||
}
|
||||
a.SnapshotHash = cloudAgentCanvasHash(doc)
|
||||
a.ReferenceNodeIDs = []string{"hero"}
|
||||
next, nextState := agentMediaRun(t, s, a, "auto", "resume-draft-next-turn")
|
||||
next, nextState := agentMediaRun(t, s, a, "request_approval", "resume-draft-next-turn")
|
||||
if next.ID == run.ID {
|
||||
t.Fatal("expected a distinct run")
|
||||
}
|
||||
@@ -605,74 +615,130 @@ func TestCloudAgentMediaPreviousDraftRequiresNewApproval(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudAgentAutoMediaDraftRequiresExplicitApproval(t *testing.T) {
|
||||
for _, decision := range []string{"approve", "reject", "cancel"} {
|
||||
t.Run(decision, func(t *testing.T) {
|
||||
s, db, a := agentMediaFixture(t)
|
||||
run, _ := agentMediaRun(t, s, a, "auto")
|
||||
var ordersBefore int64
|
||||
db.Model(&model.BillingOrder{}).Count(&ordersBefore)
|
||||
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, _ = s.repo.CloudAgent("user", run.ID)
|
||||
state, _ := cloudAgentDecode(run)
|
||||
if run.Status != "waiting_approval" || state.Approval == nil || state.Approval.ModelName != "视频测试" || state.MediaTaskID != "" {
|
||||
t.Fatal("auto mode bypassed approval or omitted selected model")
|
||||
}
|
||||
canvas, _ := s.repo.CanvasProjectForUser("user", "agent-canvas")
|
||||
doc, _ := creationDocument(canvas.PayloadJSON)
|
||||
nodes, _ := creationObjects(doc["nodes"])
|
||||
meta, _ := nodes[a.NodeID]["metadata"].(map[string]any)
|
||||
if meta["status"] != "idle" || meta["taskId"] != nil || meta["size"] != "9:16" || meta["agentDraftRunId"] != run.ID || len(creationMaps(doc["connections"])) != 3 {
|
||||
t.Fatalf("missing uncharged draft with references: %+v", meta)
|
||||
}
|
||||
// Re-entering the tool while awaiting a decision must remain a no-op.
|
||||
if err := s.advanceCloudAgentTool(run, &state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count, orders int64
|
||||
db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&count)
|
||||
db.Model(&model.BillingOrder{}).Count(&orders)
|
||||
if count != 0 || orders != ordersBefore {
|
||||
t.Fatal("draft created a task or charge")
|
||||
}
|
||||
// Another run may not take over this draft, even with the latest snapshot.
|
||||
foreign := a
|
||||
foreign.DraftRunID, foreign.SnapshotHash = "another-run", cloudAgentCanvasHash(doc)
|
||||
if _, _, _, err := cloudAgentMediaDocument(s.repo, "user", "agent-canvas", foreign); err == nil {
|
||||
t.Fatal("foreign draft overwrite accepted")
|
||||
}
|
||||
if decision == "cancel" {
|
||||
if err := s.CancelCloudAgent(context.Background(), "user", run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
// A local focus/zoom autosave cannot change the approved content.
|
||||
doc["viewport"], doc["updatedAt"] = map[string]any{"x": -900, "y": -500, "k": 0.8}, "later"
|
||||
raw, _ := json.Marshal(doc)
|
||||
if err := db.Model(&model.CanvasProject{}).Where("id = ?", "agent-canvas").Update("payload_json", string(raw)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range 2 {
|
||||
if err := s.DecideCloudAgentApproval("user", run.ID, state.Approval.ID, decision, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&count)
|
||||
db.Model(&model.BillingOrder{}).Count(&orders)
|
||||
if decision == "approve" {
|
||||
if count != 1 {
|
||||
t.Fatal("explicit approval did not create exactly one task")
|
||||
}
|
||||
} else if count != 0 || orders != ordersBefore {
|
||||
t.Fatal("reject/cancel submitted generation")
|
||||
}
|
||||
})
|
||||
func TestCloudAgentAutoMediaSubmitsWithoutApproval(t *testing.T) {
|
||||
s, db, a := agentMediaFixture(t)
|
||||
// Omitted size and duration should be filled by the selected model's catalog
|
||||
// defaults during admission instead of producing a repair turn.
|
||||
a.Size = ""
|
||||
a.Duration = 0
|
||||
run, _ := agentMediaRun(t, s, a, "auto")
|
||||
var ordersBefore int64
|
||||
if err := db.Model(&model.BillingOrder{}).Count(&ordersBefore).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, _ = s.repo.CloudAgent("user", run.ID)
|
||||
state, err := cloudAgentDecode(run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if run.Status == "waiting_approval" || state.Approval != nil {
|
||||
t.Fatalf("auto mode unexpectedly requested approval: status=%s approval=%+v", run.Status, state.Approval)
|
||||
}
|
||||
if state.MediaTaskID == "" {
|
||||
t.Fatalf("auto mode did not submit a media task: %+v", state.Events)
|
||||
}
|
||||
for _, event := range state.Events {
|
||||
if event.Type == "approval_requested" {
|
||||
t.Fatal("auto mode emitted an approval event")
|
||||
}
|
||||
}
|
||||
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.Size == "" || input.Config.VideoSeconds == "" {
|
||||
t.Fatalf("model defaults were not resolved: %+v", input.Config)
|
||||
}
|
||||
canvas, _ := s.repo.CanvasProjectForUser("user", "agent-canvas")
|
||||
doc, _ := creationDocument(canvas.PayloadJSON)
|
||||
nodes, _ := creationObjects(doc["nodes"])
|
||||
meta, _ := nodes[a.NodeID]["metadata"].(map[string]any)
|
||||
if meta["status"] != "loading" || meta["taskId"] != task.ID || meta["agentDraftRunId"] != nil {
|
||||
t.Fatalf("auto submission did not finalize the draft: %+v", meta)
|
||||
}
|
||||
var tasks, orders int64
|
||||
if err := db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&tasks).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&model.BillingOrder{}).Count(&orders).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tasks != 1 || orders != ordersBefore+1 {
|
||||
t.Fatalf("auto submission created unexpected records: tasks=%d orders=%d before=%d", tasks, orders, ordersBefore)
|
||||
}
|
||||
// Re-entering a submitted call is idempotent and must not reserve/submit a
|
||||
// second generation.
|
||||
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&tasks).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tasks != 1 {
|
||||
t.Fatalf("re-entering auto media duplicated task: %d", tasks)
|
||||
}
|
||||
|
||||
// Admission failures are still non-billed tool failures and do not create an
|
||||
// approval or a draft task.
|
||||
invalidService, invalidDB, invalidArgs := agentMediaFixture(t)
|
||||
invalidArgs.ChannelModelKey = "removed-model"
|
||||
invalidRun, _ := agentMediaRun(t, invalidService, invalidArgs, "auto")
|
||||
var invalidOrdersBefore int64
|
||||
if err := invalidDB.Model(&model.BillingOrder{}).Count(&invalidOrdersBefore).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := invalidService.advanceCloudAgentByID("user", invalidRun.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
invalidRun, _ = invalidService.repo.CloudAgent("user", invalidRun.ID)
|
||||
invalidState, err := cloudAgentDecode(invalidRun)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var invalidTasks, invalidOrders int64
|
||||
if err := invalidDB.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&invalidTasks).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := invalidDB.Model(&model.BillingOrder{}).Count(&invalidOrders).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if invalidTasks != 0 || invalidOrders != invalidOrdersBefore || invalidState.Approval != nil {
|
||||
t.Fatalf("invalid auto admission created side effects: tasks=%d orders=%d before=%d approval=%+v", invalidTasks, invalidOrders, invalidOrdersBefore, invalidState.Approval)
|
||||
}
|
||||
|
||||
approvalService, approvalDB, approvalArgs := agentMediaFixture(t)
|
||||
approvalRun, _ := agentMediaRun(t, approvalService, approvalArgs, "request_approval")
|
||||
var approvalOrdersBefore int64
|
||||
if err := approvalDB.Model(&model.BillingOrder{}).Count(&approvalOrdersBefore).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := approvalService.advanceCloudAgentByID("user", approvalRun.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approvalRun, _ = approvalService.repo.CloudAgent("user", approvalRun.ID)
|
||||
approvalState, err := cloudAgentDecode(approvalRun)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if approvalRun.Status != "waiting_approval" || approvalState.Approval == nil {
|
||||
t.Fatalf("request_approval stopped bypassing approval: status=%s approval=%+v", approvalRun.Status, approvalState.Approval)
|
||||
}
|
||||
var approvalTasks, approvalOrders int64
|
||||
if err := approvalDB.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&approvalTasks).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := approvalDB.Model(&model.BillingOrder{}).Count(&approvalOrders).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if approvalTasks != 0 || approvalOrders != approvalOrdersBefore {
|
||||
t.Fatalf("request_approval submitted before decision: tasks=%d orders=%d before=%d", approvalTasks, approvalOrders, approvalOrdersBefore)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -692,7 +758,7 @@ func TestCloudAgentMediaPromptCountsUnicodeCharacters(t *testing.T) {
|
||||
|
||||
func TestCloudAgentMediaCancellationUpdatesNode(t *testing.T) {
|
||||
s, _, a := agentMediaFixture(t)
|
||||
run, _ := agentMediaRun(t, s, a, "auto")
|
||||
run, _ := agentMediaRun(t, s, a, "request_approval")
|
||||
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ func TestCloudAgentMediaCleanupUsesPersistedTaskTarget(t *testing.T) {
|
||||
for _, missingTarget := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "known target", true: "unknown target"}[missingTarget], func(t *testing.T) {
|
||||
s, db, args := agentMediaFixture(t)
|
||||
run, _ := agentMediaRun(t, s, args, "auto")
|
||||
run, _ := agentMediaRun(t, s, args, "request_approval")
|
||||
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -81,7 +81,7 @@ func TestCloudAgentMediaCompletionKeepsGenerationAndWritebackFailures(t *testing
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
s, db, args := agentMediaFixture(t)
|
||||
run, _ := agentMediaRun(t, s, args, "auto")
|
||||
run, _ := agentMediaRun(t, s, args, "request_approval")
|
||||
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,104 @@ package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
// Keep cross-field validation server-side rather than adding provider-specific
|
||||
// root schema combinators. The same contract is advertised by both media tools.
|
||||
const cloudAgentModelSelectionDescription = "模型选择必填:将 model_list 返回的一种 selection 展开到顶层参数,必须提供非空 logicalModelId,或同时提供非空 channelId 和 channelModelKey,二者互斥。未使用的选择字段省略或传空字符串,不得传 null 或仅含空白的字符串。缺失、混用或不完整均在提交前拒绝,不会自动选择或切换模型。"
|
||||
const cloudAgentModelSelectionDescription = "模型选择:复制 model_list 的 selection 到顶层。完全省略模型字段时,仅使用当前项目对应能力的可用默认模型。显式选择必须提供 logicalModelId,或同时提供 channelId 与 channelModelKey,二者互斥。未使用字段省略或传空字符串,不得传 null/空白;无可用默认、混用或不完整均拒绝,不会随机选模。"
|
||||
|
||||
// applyCloudAgentProjectDefaultModel only fills a selection when the Agent did
|
||||
// not send any model-selection field at all. An explicitly empty, partial, or
|
||||
// null selection remains invalid so a malformed tool call cannot silently turn
|
||||
// into a different model.
|
||||
func (s *Service) applyCloudAgentProjectDefaultModel(run *model.CloudAgentExecution, state *cloudAgentRuntime, raw string, a *cloudAgentMediaArgs) (bool, error) {
|
||||
if s == nil || s.repo == nil || run == nil || state == nil || a == nil || cloudAgentModelSelectionProvided(raw) {
|
||||
return false, nil
|
||||
}
|
||||
a.Mode = strings.ToLower(strings.TrimSpace(a.Mode))
|
||||
if a.Mode != "image" && a.Mode != "video" || strings.TrimSpace(state.Request.CanvasID) == "" {
|
||||
return false, nil
|
||||
}
|
||||
canvas, err := s.repo.CanvasProjectForUser(run.UserID, state.Request.CanvasID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if strings.TrimSpace(canvas.ProjectID) == "" {
|
||||
return false, nil
|
||||
}
|
||||
project, err := s.repo.ProjectForUser(run.UserID, canvas.ProjectID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defaultModel := strings.TrimSpace(project.DefaultImageModel)
|
||||
if a.Mode == "video" {
|
||||
defaultModel = strings.TrimSpace(project.DefaultVideoModel)
|
||||
}
|
||||
if defaultModel == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if channelID, channelModelKey, ok := splitCloudAgentChannelModel(defaultModel); ok {
|
||||
catalog, catalogErr := s.ModelCatalog(nil)
|
||||
if catalogErr != nil {
|
||||
return false, catalogErr
|
||||
}
|
||||
for _, channel := range catalog.Channels {
|
||||
if channel.ID != channelID {
|
||||
continue
|
||||
}
|
||||
for _, channelModel := range channel.Models {
|
||||
if channelModel.ModelKey == channelModelKey && channelModel.Available && normalizeCapability(channelModel.Capability) == a.Mode {
|
||||
a.ChannelID, a.ChannelModelKey = channelID, channelModelKey
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, fmt.Errorf("项目默认%s模型不可用或能力不匹配,请在项目设置中重新选择模型", cloudAgentModeLabel(a.Mode))
|
||||
}
|
||||
|
||||
logicalModels, err := s.PublicLogicalModels(nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, logicalModel := range logicalModels {
|
||||
if logicalModel.ID == defaultModel && logicalModel.Available && normalizeCapability(logicalModel.Capability) == a.Mode {
|
||||
a.LogicalModelID = defaultModel
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, fmt.Errorf("项目默认%s模型不可用或能力不匹配,请在项目设置中重新选择模型", cloudAgentModeLabel(a.Mode))
|
||||
}
|
||||
|
||||
func cloudAgentModelSelectionProvided(raw string) bool {
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(raw), &fields); err != nil || fields == nil {
|
||||
return true
|
||||
}
|
||||
for _, field := range []string{"logicalModelId", "channelId", "channelModelKey"} {
|
||||
if _, ok := fields[field]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func splitCloudAgentChannelModel(value string) (string, string, bool) {
|
||||
channelID, channelModelKey, ok := strings.Cut(strings.TrimSpace(value), "::")
|
||||
channelID, channelModelKey = strings.TrimSpace(channelID), strings.TrimSpace(channelModelKey)
|
||||
return channelID, channelModelKey, ok && channelID != "" && channelModelKey != ""
|
||||
}
|
||||
|
||||
func cloudAgentModeLabel(mode string) string {
|
||||
if mode == "video" {
|
||||
return "视频"
|
||||
}
|
||||
return "图片"
|
||||
}
|
||||
|
||||
func validateCloudAgentModelSelection(raw string, a cloudAgentMediaArgs) error {
|
||||
// Go's JSON decoder accepts null for string fields; the tool contract does
|
||||
@@ -33,7 +125,7 @@ func validateCloudAgentModelSelection(raw string, a cloudAgentMediaArgs) error {
|
||||
case a.LogicalModelID != "":
|
||||
return nil
|
||||
case a.ChannelID == "" && a.ChannelModelKey == "":
|
||||
return cloudAgentFieldError("logicalModelId", "required", "缺少模型选择:必须复制 model_list 的 logicalModelId 或完整的 channelId/channelModelKey,不会自动选择模型")
|
||||
return cloudAgentFieldError("logicalModelId", "required", "缺少模型选择:请复制 model_list 的 logicalModelId 或完整的 channelId/channelModelKey;若项目未配置当前能力的可用默认模型,不能自动猜测")
|
||||
case a.ChannelID == "":
|
||||
return cloudAgentFieldError("channelId", "required", "系统渠道模型选择不完整:缺少 channelId,请复制 model_list 的完整 selection")
|
||||
case a.ChannelModelKey == "":
|
||||
|
||||
@@ -5,10 +5,23 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
func cloudAgentProjectForDefaultModel(t *testing.T, db *gorm.DB, modelRef string) {
|
||||
t.Helper()
|
||||
project := &model.Project{ID: "agent-default-project", UserID: "user", Name: "Agent 默认模型项目", Type: "short-drama", Status: model.ProjectStatusActive, DefaultImageModel: modelRef, DefaultVideoModel: modelRef}
|
||||
if err := db.Create(project).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&model.CanvasProject{}).Where("id = ? AND user_id = ?", "agent-canvas", "user").Update("project_id", project.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudAgentModelSelectionRejectsBeforeCanvasRead(t *testing.T) {
|
||||
for _, tc := range []struct{ name, args, field, issue string }{
|
||||
{"missing", `{}`, "logicalModelId", "required"},
|
||||
@@ -62,6 +75,54 @@ func TestCloudAgentModelSelectionAcceptsOnlyCompleteSelections(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudAgentProjectDefaultChannelModelFillsOnlyOmittedSelection(t *testing.T) {
|
||||
s, db, _ := agentMediaFixture(t)
|
||||
cloudAgentProjectForDefaultModel(t, db, "channel::seedance-test")
|
||||
run := &model.CloudAgentExecution{ID: "run-default-channel", UserID: "user"}
|
||||
state := &cloudAgentRuntime{Request: CloudAgentRequest{CanvasID: "agent-canvas"}}
|
||||
args := cloudAgentMediaArgs{Mode: "video"}
|
||||
used, err := s.applyCloudAgentProjectDefaultModel(run, state, `{"mode":"video"}`, &args)
|
||||
if err != nil || !used || args.ChannelID != "channel" || args.ChannelModelKey != "seedance-test" || args.LogicalModelID != "" {
|
||||
t.Fatalf("default channel selection = used:%v args:%+v err:%v", used, args, err)
|
||||
}
|
||||
|
||||
args = cloudAgentMediaArgs{Mode: "video"}
|
||||
used, err = s.applyCloudAgentProjectDefaultModel(run, state, `{"mode":"video","channelId":"","channelModelKey":""}`, &args)
|
||||
if err != nil || used {
|
||||
t.Fatalf("explicit empty selection was silently defaulted: used:%v args:%+v err:%v", used, args, err)
|
||||
}
|
||||
if err := validateCloudAgentModelSelection(`{"mode":"video","channelId":"","channelModelKey":""}`, args); err == nil {
|
||||
t.Fatal("explicit empty selection unexpectedly passed validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudAgentProjectDefaultLogicalModelFillsWhenAvailable(t *testing.T) {
|
||||
s, db, _ := agentMediaFixture(t)
|
||||
cloudAgentProjectForDefaultModel(t, db, "logical-image")
|
||||
s.routeCatalogTTL = time.Hour
|
||||
imageSpec := CapabilitySpec{Version: 1, Capability: "image"}
|
||||
s.routeCatalog = &routeCatalogSnapshot{
|
||||
LoadedAt: time.Now(), Ordered: []string{"logical-image"}, Models: map[string]cachedLogicalModel{
|
||||
"logical-image": {
|
||||
Model: model.LogicalModel{ID: "logical-image", Name: "默认逻辑图片", Capability: "image", Enabled: true, PricePolicy: "unified"},
|
||||
ProductSpec: imageSpec,
|
||||
Routes: []cachedLogicalRoute{{
|
||||
Route: model.LogicalModelRoute{ID: "logical-image-route", Enabled: true, Weight: 1},
|
||||
CapabilitySpec: imageSpec,
|
||||
ChannelModel: model.ChannelModel{ID: "video-cm", ChannelID: "channel", ModelKey: "seedance-test", Capability: "image"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
run := &model.CloudAgentExecution{ID: "run-default-logical", UserID: "user"}
|
||||
state := &cloudAgentRuntime{Request: CloudAgentRequest{CanvasID: "agent-canvas"}}
|
||||
args := cloudAgentMediaArgs{Mode: "image"}
|
||||
used, err := s.applyCloudAgentProjectDefaultModel(run, state, `{"mode":"image"}`, &args)
|
||||
if err != nil || !used || args.LogicalModelID != "logical-image" || args.ChannelID != "" || args.ChannelModelKey != "" {
|
||||
t.Fatalf("default logical selection = used:%v args:%+v err:%v", used, args, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudAgentMediaMalformedJSONKeepsStrictFeedback(t *testing.T) {
|
||||
for _, raw := range []string{`{"unknown":"private-sentinel"}`, `{"channelId":3}`, `{} {}`, `null`} {
|
||||
call := cloudAgentCall{}
|
||||
|
||||
@@ -85,31 +85,36 @@ type cloudAgentApproval struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
type cloudAgentRuntime struct {
|
||||
RuntimeRunID string `json:"-"`
|
||||
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"`
|
||||
Profile cloudAgentProfileSnapshot `json:"profile"`
|
||||
ProfileReads map[string]bool `json:"profileReads,omitempty"`
|
||||
ToolReadResults map[string]cloudAgentCachedToolResult `json:"toolReadResults,omitempty"`
|
||||
ToolReadReplays map[string]int `json:"toolReadReplays,omitempty"`
|
||||
readCacheExecution bool `json:"-"`
|
||||
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"`
|
||||
ToolRepairs map[string]cloudAgentToolRepair `json:"toolRepairs,omitempty"`
|
||||
Approval *cloudAgentApproval `json:"approval,omitempty"`
|
||||
RuntimeRunID string `json:"-"`
|
||||
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"`
|
||||
Profile cloudAgentProfileSnapshot `json:"profile"`
|
||||
ProfileReads map[string]bool `json:"profileReads,omitempty"`
|
||||
ToolReadResults map[string]cloudAgentCachedToolResult `json:"toolReadResults,omitempty"`
|
||||
ToolReadReplays map[string]int `json:"toolReadReplays,omitempty"`
|
||||
readCacheExecution bool `json:"-"`
|
||||
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"`
|
||||
ToolRepairs map[string]cloudAgentToolRepair `json:"toolRepairs,omitempty"`
|
||||
Approval *cloudAgentApproval `json:"approval,omitempty"`
|
||||
// AutoPreparedMedia is the durable admission checkpoint for permissionMode=auto.
|
||||
// It prevents a worker restart between dry admission and billed submission from
|
||||
// re-running admission or creating the draft node a second time.
|
||||
AutoPreparedMedia *cloudAgentPreparedMedia `json:"autoPreparedMedia,omitempty"`
|
||||
AutoPreparedCallHash string `json:"autoPreparedCallHash,omitempty"`
|
||||
Decisions map[string]string `json:"decisions"`
|
||||
DecisionSettings map[string]string `json:"decisionSettings,omitempty"`
|
||||
DecisionPreparedHashes map[string]string `json:"decisionPreparedHashes,omitempty"`
|
||||
@@ -436,10 +441,22 @@ func validateCloudAgentRuntime(run *model.CloudAgentExecution, state *cloudAgent
|
||||
return errors.New("Agent runtime active task is not in task history")
|
||||
}
|
||||
if state.MediaTaskID != "" {
|
||||
if !cloudAgentContainsString(state.TaskIDs, state.MediaTaskID) || state.CallIndex >= len(state.Calls) || state.Calls[state.CallIndex].Function.Name != "generate_media" {
|
||||
if !cloudAgentContainsString(state.TaskIDs, state.MediaTaskID) || state.CallIndex >= len(state.Calls) || (state.Calls[state.CallIndex].Function.Name != "generate_media" && state.Calls[state.CallIndex].Function.Name != "image_layer_split") {
|
||||
return errors.New("Agent runtime media task is not attached to current call")
|
||||
}
|
||||
}
|
||||
if state.AutoPreparedMedia != nil {
|
||||
if state.AutoPreparedCallHash == "" || state.CallIndex < 0 || state.CallIndex >= len(state.Calls) {
|
||||
return errors.New("Agent runtime auto media preparation is invalid")
|
||||
}
|
||||
current := state.Calls[state.CallIndex]
|
||||
if current.Function.Name != "generate_media" && current.Function.Name != "image_layer_split" {
|
||||
return errors.New("Agent runtime auto media preparation is not attached to a media call")
|
||||
}
|
||||
if state.AutoPreparedCallHash != cloudAgentApprovalCallHash(current) {
|
||||
return errors.New("Agent runtime auto media preparation does not match current call")
|
||||
}
|
||||
}
|
||||
if state.Decisions == nil || state.Events == nil {
|
||||
return errors.New("Agent runtime maps are missing")
|
||||
}
|
||||
@@ -714,7 +731,14 @@ func (s *Service) cloudAgentExecutionOutput(task *model.Task, initial cloudAgent
|
||||
return nil, err
|
||||
}
|
||||
for _, order := range orders {
|
||||
out.SpentCredits += float64(order.AmountMicrocredits) / float64(CreditScale)
|
||||
// AmountMicrocredits is the reservation/quote, not necessarily the amount
|
||||
// finally charged. A run can reserve 100000 microcredits and settle at
|
||||
// 100000 microcredits (= 0.1 credits), or be refunded altogether. Never
|
||||
// expose the reservation as spend, otherwise the Agent claims a charge
|
||||
// that did not happen and budget/usage copy diverges from billing.
|
||||
if order.Status == model.BillingStatusSettled {
|
||||
out.SpentCredits += float64(order.ActualAmountMicrocredits) / float64(CreditScale)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1520,7 +1544,8 @@ func (s *Service) advanceCloudAgentTool(run *model.CloudAgentExecution, state *c
|
||||
return s.terminateCloudAgent(run, "审批内容与待执行操作不一致,本轮已停止")
|
||||
}
|
||||
allowed := cloudAgentToolAllowed(state.Request, call.Function.Name)
|
||||
if allowed && cloudAgentWrite(call.Function.Name) && (state.Request.PermissionMode == "request_approval" || call.Function.Name == "generate_media" || call.Function.Name == "image_layer_split") && state.Approval == nil {
|
||||
mediaTool := call.Function.Name == "generate_media" || call.Function.Name == "image_layer_split"
|
||||
if allowed && cloudAgentWrite(call.Function.Name) && (state.Request.PermissionMode == "request_approval" || mediaTool) && state.Approval == nil {
|
||||
var plan *cloudAgentMediaPlan
|
||||
var modelName string
|
||||
var mediaRequest CreateTaskRequest
|
||||
@@ -1536,20 +1561,99 @@ func (s *Service) advanceCloudAgentTool(run *model.CloudAgentExecution, state *c
|
||||
if err != nil {
|
||||
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
|
||||
}
|
||||
// Dry admission validates the selected model and prompt limits without a task or charge.
|
||||
mediaPreparation = &creationTaskPreparation{}
|
||||
req.creationPrepare = mediaPreparation
|
||||
preparedTask, err = s.CreateTask(run.UserID, req)
|
||||
if err != nil {
|
||||
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
|
||||
mediaRequest, plan = req, prepared
|
||||
// A durable auto checkpoint already contains the exact admitted input and
|
||||
// quote. Reusing it avoids a second dry admission after a worker restart.
|
||||
if plan.Prepared == nil {
|
||||
// Dry admission validates the selected model and prompt limits without a task or charge.
|
||||
mediaPreparation = &creationTaskPreparation{}
|
||||
req.creationPrepare = mediaPreparation
|
||||
preparedTask, err = s.CreateTask(run.UserID, req)
|
||||
if err != nil {
|
||||
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
|
||||
}
|
||||
// Resolve model-owned defaults once during the dry admission. Both the
|
||||
// eventual task and the canvas draft must use this exact resolved input;
|
||||
// otherwise auto mode reports a false tool failure for omitted size or
|
||||
// duration and makes the model spend another turn repairing its own call.
|
||||
if err := applyCloudAgentResolvedMediaDefaults(&req, prepared, preparedTask); err != nil {
|
||||
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
|
||||
}
|
||||
mediaRequest, plan = req, prepared
|
||||
}
|
||||
mediaRequest = req
|
||||
plan = prepared
|
||||
modelName, err = s.cloudAgentMediaModelName(plan.Args)
|
||||
if err != nil {
|
||||
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
|
||||
}
|
||||
}
|
||||
if plan != nil && state.Request.PermissionMode == "auto" {
|
||||
if plan.Prepared != nil {
|
||||
// The draft and quote were already checkpointed. Reuse them after a
|
||||
// worker restart instead of dry-admitting and mutating the canvas again.
|
||||
latest, err := s.repo.CloudAgent(run.UserID, run.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fresh, err := cloudAgentDecode(latest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.enqueueCloudAgentTask(latest, &fresh, mediaRequest, plan)
|
||||
}
|
||||
// Auto media is a direct, server-admitted write. Prepare the draft and
|
||||
// its immutable quote in one checkpoint transaction, then release the
|
||||
// lock before enqueueCloudAgentTask performs the billed submission.
|
||||
s.storageMu.Lock()
|
||||
err := s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error {
|
||||
if err := createCloudAgentMediaNode(repo, run.UserID, state.Request.CanvasID, plan, nil, policy, cloudAgentCanvasEventRecorder(run.ID, state)); err != nil {
|
||||
return err
|
||||
}
|
||||
canvas, err := repo.CanvasProjectForUser(run.UserID, state.Request.CanvasID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
doc, err := creationDocument(canvas.PayloadJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plan.Args.SnapshotHash = cloudAgentMediaContentHash(doc)
|
||||
raw, err := json.Marshal(plan.Args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
call.Function.Arguments = string(raw)
|
||||
state.Calls[state.CallIndex] = call
|
||||
preparedMedia, err := prepareCloudAgentMediaApproval(repo, run.UserID, doc, plan, mediaRequest, preparedTask, mediaPreparation.Order)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plan.Prepared = preparedMedia
|
||||
state.AutoPreparedMedia = preparedMedia
|
||||
state.AutoPreparedCallHash = cloudAgentApprovalCallHash(call)
|
||||
return cloudAgentSave(current, state)
|
||||
})
|
||||
s.storageMu.Unlock()
|
||||
if err != nil {
|
||||
latest, readErr := s.repo.CloudAgent(run.UserID, run.ID)
|
||||
if readErr != nil || latest.Revision != run.Revision {
|
||||
return err
|
||||
}
|
||||
fresh, decodeErr := cloudAgentDecode(latest)
|
||||
if decodeErr != nil {
|
||||
return decodeErr
|
||||
}
|
||||
return s.cloudAgentMediaError(latest, &fresh, "admission", false, false, err)
|
||||
}
|
||||
latest, err := s.repo.CloudAgent(run.UserID, run.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fresh, err := cloudAgentDecode(latest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.enqueueCloudAgentTask(latest, &fresh, mediaRequest, plan)
|
||||
}
|
||||
s.storageMu.Lock()
|
||||
defer s.storageMu.Unlock()
|
||||
return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error {
|
||||
@@ -1785,11 +1889,17 @@ func (s *Service) enqueueCloudAgentTask(run *model.CloudAgentExecution, state *c
|
||||
var prepared *cloudAgentPreparedMedia
|
||||
approvalID, generationID := "", ""
|
||||
if media != nil {
|
||||
if state.Approval == nil || state.Approval.Prepared == nil {
|
||||
prepared = media.Prepared
|
||||
if prepared == nil && state.Approval != nil {
|
||||
prepared = state.Approval.Prepared
|
||||
if state.Approval != nil {
|
||||
approvalID = state.Approval.ID
|
||||
}
|
||||
}
|
||||
if prepared == nil {
|
||||
return s.cloudAgentMediaError(run, state, "admission", false, false, creationConflict("缺少已批准的生成准备态,请重新申请审批;未提交任务"))
|
||||
}
|
||||
prepared = state.Approval.Prepared
|
||||
approvalID, generationID = state.Approval.ID, prepared.GenerationID
|
||||
generationID = prepared.GenerationID
|
||||
refs, refErr := cloudAgentPreparedReferences(s.repo, run.UserID, prepared)
|
||||
if refErr != nil {
|
||||
return s.cloudAgentMediaError(run, state, "admission", false, false, refErr)
|
||||
@@ -1890,16 +2000,39 @@ func (s *Service) enqueueCloudAgentTask(run *model.CloudAgentExecution, state *c
|
||||
return err
|
||||
}
|
||||
if media != nil && prepared != nil {
|
||||
if err := repo.TransferCloudAgentResourceLeases(run.UserID, approvalID, "task:"+task.ID, task.ID, prepared.Quote.ExpiresAt); err != nil {
|
||||
return err
|
||||
if approvalID != "" {
|
||||
if err := repo.TransferCloudAgentResourceLeases(run.UserID, approvalID, "task:"+task.ID, task.ID, prepared.Quote.ExpiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Auto execution has no approval owner to transfer from. Attach the
|
||||
// resource lease directly to the billed task before it can run.
|
||||
ids := make([]string, 0, len(prepared.ResourceSignatures))
|
||||
for id := range prepared.ResourceSignatures {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := repo.UpsertCloudAgentResourceLeases(run.UserID, run.ID, "task:"+task.ID, ids, prepared.Quote.ExpiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if media != nil {
|
||||
// The prepared auto admission is single-use. Once the billed task and
|
||||
// draft node are committed, a later retry must observe the task state,
|
||||
// not reuse the old quote or create a second task.
|
||||
state.AutoPreparedMedia = nil
|
||||
state.AutoPreparedCallHash = ""
|
||||
}
|
||||
state.TaskIDs = append(state.TaskIDs, task.ID)
|
||||
if media != nil {
|
||||
state.MediaTaskID = task.ID
|
||||
state.Generations++
|
||||
state.VideoSeconds += media.Args.Duration
|
||||
state.event(run.ID, "generation_task_created", map[string]any{"toolName": "generate_media", "taskId": task.ID, "nodeId": media.Args.NodeID, "title": media.Args.Title, "mode": media.Args.Mode, "canvasId": state.Request.CanvasID, "referenceNodeIds": media.Args.ReferenceNodeIDs, "text": "媒体节点与引用连线已创建,生成任务已提交"})
|
||||
toolName := "generate_media"
|
||||
if state.CallIndex >= 0 && state.CallIndex < len(state.Calls) && state.Calls[state.CallIndex].Function.Name != "" {
|
||||
toolName = state.Calls[state.CallIndex].Function.Name
|
||||
}
|
||||
state.event(run.ID, "generation_task_created", map[string]any{"toolName": toolName, "taskId": task.ID, "nodeId": media.Args.NodeID, "title": media.Args.Title, "mode": media.Args.Mode, "canvasId": state.Request.CanvasID, "referenceNodeIds": media.Args.ReferenceNodeIDs, "text": "媒体节点与引用连线已创建,生成任务已提交"})
|
||||
} else {
|
||||
state.ActiveTaskID = task.ID
|
||||
// 压缩调用不是本轮的一步:压完还要用压缩后的上下文继续步进,步数不该被它占掉。
|
||||
@@ -1954,6 +2087,10 @@ func (s *Service) cloudAgentMediaError(run *model.CloudAgentExecution, state *cl
|
||||
return cloudAgentSave(current, state)
|
||||
}
|
||||
cloudAgentRecordToolResult(current, state, state.Calls[state.CallIndex], map[string]any{"phase": phase, "taskSubmitted": submitted}, err)
|
||||
// Any admission failure advances the call into the repair path. Do not let
|
||||
// a prepared quote from the failed attempt leak into the corrected call.
|
||||
state.AutoPreparedMedia = nil
|
||||
state.AutoPreparedCallHash = ""
|
||||
if submitted {
|
||||
state.MediaTaskID = ""
|
||||
}
|
||||
|
||||
@@ -53,9 +53,29 @@ type cloudAgentTaskBillingFacts struct {
|
||||
ReservedAmountMicrocredits int64 `json:"reservedAmountMicrocredits,omitempty"`
|
||||
ActualAmountMicrocredits int64 `json:"actualAmountMicrocredits,omitempty"`
|
||||
RefundedAmountMicrocredits int64 `json:"refundedAmountMicrocredits,omitempty"`
|
||||
ReservedCredits float64 `json:"reservedCredits"`
|
||||
ActualCredits float64 `json:"actualCredits"`
|
||||
RefundedCredits float64 `json:"refundedCredits"`
|
||||
ChargeState string `json:"chargeState"`
|
||||
ChargeSummary string `json:"chargeSummary"`
|
||||
StateAvailable bool `json:"stateAvailable"`
|
||||
}
|
||||
|
||||
func cloudAgentChargeState(status model.BillingStatus) (string, string) {
|
||||
switch status {
|
||||
case model.BillingStatusSettled:
|
||||
return "settled", "已结算,实际扣费以 actualCredits 为准"
|
||||
case model.BillingStatusRefunded:
|
||||
return "refunded", "已退款,本次不计入已消费"
|
||||
case model.BillingStatusReserved, model.BillingStatusRunning:
|
||||
return "pending", "仅预授权/执行中,尚未确认实际扣费"
|
||||
case model.BillingStatusUncertain:
|
||||
return "uncertain", "扣费状态待确认,不得称为已扣费"
|
||||
default:
|
||||
return "unknown", "扣费状态未知,不得称为已扣费"
|
||||
}
|
||||
}
|
||||
|
||||
func cloudAgentTaskDiagnostic(repo *repository.Repository, task *model.Task) map[string]any {
|
||||
if task == nil {
|
||||
return map[string]any{"taskStatus": "unavailable", "taskSubmitted": false, "submissionOutcome": "unavailable"}
|
||||
@@ -98,6 +118,10 @@ func cloudAgentTaskDiagnostic(repo *repository.Repository, task *model.Task) map
|
||||
facts.Billing.ReservedAmountMicrocredits = order.ReservedAmountMicrocredits
|
||||
facts.Billing.ActualAmountMicrocredits = order.ActualAmountMicrocredits
|
||||
facts.Billing.RefundedAmountMicrocredits = order.RefundedAmountMicrocredits
|
||||
facts.Billing.ReservedCredits = float64(order.ReservedAmountMicrocredits) / float64(CreditScale)
|
||||
facts.Billing.ActualCredits = float64(order.ActualAmountMicrocredits) / float64(CreditScale)
|
||||
facts.Billing.RefundedCredits = float64(order.RefundedAmountMicrocredits) / float64(CreditScale)
|
||||
facts.Billing.ChargeState, facts.Billing.ChargeSummary = cloudAgentChargeState(order.Status)
|
||||
facts.Billing.StateAvailable = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,3 +83,33 @@ func TestCloudAgentTaskDiagnosticRejectsMismatchedBillingOrderFacts(t *testing.T
|
||||
t.Fatalf("cross-owner billing state leaked: %#v", billing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudAgentTaskDiagnosticSeparatesReservationFromActualCharge(t *testing.T) {
|
||||
s, db, _, _ := creationTestService(t)
|
||||
task := &model.Task{
|
||||
ID: "task-settled", UserID: "user", ProjectID: "canvas", Status: model.TaskStatusSucceeded,
|
||||
BillingOrderID: "order-settled", InputJSON: `{}`,
|
||||
}
|
||||
order := &model.BillingOrder{
|
||||
ID: "order-settled", UserID: "user", TaskID: task.ID, Status: model.BillingStatusSettled,
|
||||
ReservedAmountMicrocredits: 100_000, ActualAmountMicrocredits: 100_000,
|
||||
}
|
||||
if err := db.Create(task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
facts := cloudAgentTaskDiagnostic(s.repo, task)
|
||||
billing, ok := facts["billing"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("billing facts = %#v", facts["billing"])
|
||||
}
|
||||
if billing["reservedAmountMicrocredits"] != float64(100_000) || billing["actualAmountMicrocredits"] != float64(100_000) {
|
||||
t.Fatalf("microcredit facts = %#v", billing)
|
||||
}
|
||||
if billing["reservedCredits"] != 0.1 || billing["actualCredits"] != 0.1 || billing["chargeState"] != "settled" {
|
||||
t.Fatalf("credit facts = %#v", billing)
|
||||
}
|
||||
}
|
||||
@@ -509,7 +509,7 @@ func compileCloudAgentTools(req CloudAgentRequest, includeProfileTool bool) []ma
|
||||
"topic", "category", "situation")
|
||||
}
|
||||
if req.PermissionMode != "read_only" && len(req.ContextScope) > 0 {
|
||||
add("image_layer_split", "将图片按用户指定对象拆分为独立透明图层。参数与 generate_media 的图片生成参数一致,但 mode 固定为 image;这是生成型操作,必须进入现有媒体审批与计费链路,不能直接执行。", map[string]any{
|
||||
add("image_layer_split", "将图片按用户指定对象拆分为独立透明图层。参数与 generate_media 的图片生成参数一致,但 mode 固定为 image;request_approval 进入界面独立审批,auto 由服务端准入成功后直接提交,不会再次弹出模型生成审批。", map[string]any{
|
||||
"prompt": str("需要拆分的对象与透明背景要求"), "logicalModelId": str("selection.logicalModelId"), "channelId": str("selection.channelId"), "channelModelKey": str("selection.channelModelKey"),
|
||||
"quality": str("模型支持的质量档位"), "snapshotHash": str("最近画布读取返回的 mediaSnapshotHash,可省略"), "nodeId": str("新的结果节点ID"), "title": str("结果节点名称"), "referenceNodeIds": map[string]any{"type": "array", "maxItems": 16, "items": str("源图片节点ID")},
|
||||
}, "prompt", "nodeId", "title", "referenceNodeIds")
|
||||
@@ -582,7 +582,7 @@ func compileCloudAgentTools(req CloudAgentRequest, includeProfileTool bool) []ma
|
||||
}, "snapshotHash")
|
||||
}
|
||||
if req.PermissionMode != "read_only" && len(req.ContextScope) > 0 {
|
||||
add("generate_media", "提交媒体生成:先准备草稿、引用和独立审批,再提交收费任务(auto也需审批)。创建节点/改提示词/连线用 canvas_apply_ops。先读画布和模型目录并遵守其能力。已有任务或产物的节点不可覆盖;状态用 generation/task_get。sourceNodeId 为文本输入,referenceNodeIds 为媒体输入,referenceTransientIds 仅接受标注工具返回值,不接受URL。已提交失败要告知用户,重试须用户明确要求并重新审批。", map[string]any{
|
||||
add("generate_media", "提交媒体生成:先准备草稿和引用;request_approval 需要用户确认,auto 在服务端完成模型、能力、价格、预算和资源校验后直接提交,不会再次弹出模型生成审批。auto 只在准入成功后提交。创建节点/改提示词/连线用 canvas_apply_ops。先读画布和模型目录并遵守其能力。已有任务或产物的节点不可覆盖;状态用 generation/task_get。sourceNodeId 为文本输入,referenceNodeIds 为媒体输入,referenceTransientIds 仅接受标注工具返回值,不接受URL。已提交失败要告知用户,重试须用户明确要求并重新审批。", map[string]any{
|
||||
"mode": map[string]any{"type": "string", "enum": cloudAgentGenerationModeNames()}, "prompt": str("完整生成提示词;引用素材时在对应描述中使用 @图片1、@视频1、@音频1,各类型按 referenceNodeIds 中出现顺序独立编号,文本来源不占媒体编号。服务端会为遗漏的已选素材补齐引用标签,不推断素材用途"),
|
||||
"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": "是否生成音频,仅视频可用"},
|
||||
|
||||
@@ -46,6 +46,7 @@ type PublicChannelModel struct {
|
||||
Capability string `json:"capability"`
|
||||
Protocol model.ChannelInterfaceType `json:"protocol"`
|
||||
CapabilityConfig map[string]any `json:"capabilityConfig,omitempty"`
|
||||
DefaultOptions map[string]any `json:"defaultOptions,omitempty"`
|
||||
PriceTiers []PublicChannelModelPriceTier `json:"priceTiers"`
|
||||
PricingMode string `json:"pricingMode"`
|
||||
DisplayPrice *int64 `json:"displayPrice,omitempty"`
|
||||
@@ -181,6 +182,17 @@ func (s *Service) sanitizeChannelModel(cm *model.ChannelModel) (PublicChannelMod
|
||||
return PublicChannelModel{}, fmt.Errorf("投影渠道模型能力配置失败:%w", err)
|
||||
}
|
||||
}
|
||||
var defaultOptions map[string]any
|
||||
if normalized != nil {
|
||||
spec, specErr := CapabilitySpecFromModelCapabilityConfig(normalized, cm.Capability)
|
||||
if specErr != nil {
|
||||
return PublicChannelModel{}, fmt.Errorf("投影渠道模型默认参数失败:%w", specErr)
|
||||
}
|
||||
defaultOptions, err = channelModelDefaultOptions(*cm, spec)
|
||||
if err != nil {
|
||||
return PublicChannelModel{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return PublicChannelModel{
|
||||
ID: cm.ID,
|
||||
@@ -194,6 +206,7 @@ func (s *Service) sanitizeChannelModel(cm *model.ChannelModel) (PublicChannelMod
|
||||
Capability: cm.Capability,
|
||||
Protocol: cm.Protocol,
|
||||
CapabilityConfig: capabilityConfig,
|
||||
DefaultOptions: defaultOptions,
|
||||
PriceTiers: publicTiers,
|
||||
PricingMode: pricingMode,
|
||||
DisplayPrice: displayPrice,
|
||||
|
||||
@@ -503,10 +503,13 @@ func newProjectUnit(projectID string, req CreateProjectUnitRequest, position int
|
||||
if kind != model.ProjectUnitKindChapter && kind != model.ProjectUnitKindEpisode {
|
||||
return model.ProjectUnit{}, BadAuthRequest("不支持的项目单元类型")
|
||||
}
|
||||
title := strings.TrimSpace(req.Title)
|
||||
title := model.NormalizeProjectUnitTitle(req.Title)
|
||||
if title == "" {
|
||||
return model.ProjectUnit{}, BadAuthRequest("章节标题不能为空")
|
||||
}
|
||||
if !model.ValidProjectUnitTitle(title) {
|
||||
return model.ProjectUnit{}, BadAuthRequest("章节标题不能超过 240 个字符")
|
||||
}
|
||||
if position < 0 {
|
||||
position = 0
|
||||
}
|
||||
@@ -524,7 +527,10 @@ func (s *Service) UpdateProjectUnit(userID string, projectID string, unitID stri
|
||||
return model.ProjectUnit{}, err
|
||||
}
|
||||
sourceChanged := unit.SourceText != req.SourceText
|
||||
if title := strings.TrimSpace(req.Title); title != "" {
|
||||
if title := model.NormalizeProjectUnitTitle(req.Title); title != "" {
|
||||
if !model.ValidProjectUnitTitle(title) {
|
||||
return model.ProjectUnit{}, BadAuthRequest("章节标题不能超过 240 个字符")
|
||||
}
|
||||
unit.Title = title
|
||||
}
|
||||
unit.SourceText = req.SourceText
|
||||
|
||||
Reference in new issue
Block a user