Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_media.go
2026-09-14 01:11:56 +08:00

563 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"encoding/json"
"fmt"
"strings"
"time"
"unicode/utf8"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
)
const cloudAgentMediaPolicy = "媒体生成先区分参数选择与收费授权。用户明确指定的模型、画幅、时长、音频必须遵守;用户说‘随便’‘你决定’‘按默认’时允许安全默认,不要求四项逐个确认:从当前目录选择支持所需参考方式、参数且预算可覆盖的模型,优先用户最近明确使用且仍可用的模型,否则选择价格清楚的低成本可用项;画幅优先匹配参考图原始宽高比且必须在模型支持范围内,否则用目录默认;时长用目录默认,不编造;模型不支持音频且用户未要求声音时直接用默片。用户对上一条明确推荐方案说‘可以’‘开始吧’,即接受该方案的参数,不再要求回复特定确认口令。只有引用对象不明确、用户硬性要求与能力冲突、无有效默认值或预算不足时,一次性询问真正缺少的事项。选择后简短说明实际参数和默认来源,并立即创建草稿交给审批卡,不再添加文字确认轮次。generate_media 创建草稿及连线不等于收费授权;所有模式都必须等待界面独立审批,‘随便’‘开始’和默认参数都不能跳过审批。已提交的收费任务失败不得自动重提、换模型或盲重试。"
// The Agent uses the same public catalog as the composer, never a second routing policy.
func (s *Service) cloudAgentModelList() (any, error) {
catalog, err := s.ModelCatalog(nil)
if err != nil {
return nil, err
}
items := []map[string]any{}
generationModes := cloudAgentGenerationModes()
for _, m := range catalog.Models {
if m.Available && generationModes[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})
}
}
for _, channel := range catalog.Channels {
for _, m := range channel.Models {
if m.Available && generationModes[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})
}
}
}
return map[string]any{"source": catalog.Source, "models": items}, nil
}
type cloudAgentMediaArgs struct {
DraftRunID string `json:"-"`
Mode string `json:"mode"`
Prompt string `json:"prompt"`
LogicalModelID string `json:"logicalModelId"`
ChannelID string `json:"channelId"`
ChannelModelKey string `json:"channelModelKey"`
Duration int `json:"durationSeconds"`
Size string `json:"size"`
Quality string `json:"quality"`
VideoGenerateAudio *bool `json:"videoGenerateAudio"`
SnapshotHash string `json:"snapshotHash"`
NodeID string `json:"nodeId"`
Title string `json:"title"`
SourceNodeID string `json:"sourceNodeId"`
ReferenceNodeIDs []string `json:"referenceNodeIds"`
}
type cloudAgentMediaPlan struct {
Args cloudAgentMediaArgs
CallID string
}
func (s *Service) cloudAgentMediaModelName(a cloudAgentMediaArgs) (string, error) {
if !cloudAgentGenerationModeSupported(a.Mode) {
return "", BadAuthRequest("生成模式当前不受 Agent 支持")
}
catalog, err := s.ModelCatalog(nil)
if err != nil {
return "", err
}
for _, m := range catalog.Models {
if a.LogicalModelID != "" && m.ID == a.LogicalModelID && m.Available && normalizeCapability(m.Capability) == normalizeCapability(a.Mode) {
return m.Name, nil
}
}
for _, channel := range catalog.Channels {
if channel.ID != a.ChannelID {
continue
}
for _, m := range channel.Models {
if m.ModelKey == a.ChannelModelKey && m.Available && normalizeCapability(m.Capability) == normalizeCapability(a.Mode) {
return m.DisplayName, nil
}
}
}
return "", BadAuthRequest("模型目录已变化,请重新读取目录并询问用户选择模型")
}
func cloudAgentReference(repo *repository.Repository, userID string, node map[string]any) (map[string]any, error) {
kind := stringValue(node["type"])
if kind != "image" && kind != "video" && kind != "audio" {
return nil, BadAuthRequest("引用节点必须是图片、视频或音频")
}
meta, _ := node["metadata"].(map[string]any)
key := stringValue(meta["storageKey"])
if !strings.HasPrefix(key, "resource:") {
return nil, BadAuthRequest("参考资产尚未保存到账号资源库,请先上传;不能用外部地址代替")
}
resource, err := repo.ResourceForUser(userID, strings.TrimPrefix(key, "resource:"))
if err != nil {
return nil, BadAuthRequest("参考资产不存在或不属于当前用户")
}
if resource.Status != "ready" || !strings.HasPrefix(resource.MimeType, kind+"/") {
return nil, BadAuthRequest("参考资产尚未就绪或媒体类型不匹配")
}
return map[string]any{"id": node["id"], "name": node["title"], "storageKey": key, "type": resource.MimeType, "mimeType": resource.MimeType, "bytes": resource.Size, "width": resource.Width, "height": resource.Height, "durationMs": resource.DurationMs}, nil
}
func cloudAgentMediaDocument(repo *repository.Repository, userID, canvasID string, args cloudAgentMediaArgs) (*model.CanvasProject, map[string]any, map[string]any, error) {
canvas, err := repo.CanvasProjectForUser(userID, canvasID)
if err != nil {
return nil, nil, nil, err
}
doc, err := creationDocument(canvas.PayloadJSON)
if err != nil {
return nil, nil, nil, err
}
if args.SnapshotHash == "" || cloudAgentCanvasHash(doc) != args.SnapshotHash {
return nil, nil, nil, creationConflict("画布已变化,请重新读取画布并重新审批;未提交生成任务")
}
nodes, err := creationObjects(doc["nodes"])
if err != nil {
return nil, nil, nil, err
}
existing := nodes[args.NodeID]
existingMeta, _ := existing["metadata"].(map[string]any)
ownedDraft := existing != nil && args.DraftRunID != "" && stringValue(existingMeta["agentDraftRunId"]) == args.DraftRunID && stringValue(existingMeta["taskId"]) == "" && stringValue(existing["type"]) == args.Mode
if err := validateCloudAgentID(args.NodeID, "生成节点 ID", 80); err != nil {
return nil, nil, nil, err
}
if existing != nil && !ownedDraft {
return nil, nil, nil, BadAuthRequest("生成节点必须使用新的唯一 nodeId")
}
if args.SourceNodeID != "" && nodes[args.SourceNodeID] == nil {
return nil, nil, nil, BadAuthRequest("来源镜头节点不在当前画布")
}
if source := nodes[args.SourceNodeID]; source != nil && stringValue(source["type"]) != "text" {
return nil, nil, nil, BadAuthRequest("sourceNodeId 仅接受文本/镜头提示词节点;图片、视频、音频请放入 referenceNodeIds,并将 sourceNodeId 留空,不要重复传入")
}
// Keep the media entry point subject to the same graph admission policy as
// canvas_apply_ops. The old implementation only checked that references
// existed, which allowed invalid edges (for example frame -> image) to be
// smuggled in through generate_media.
prospectiveConnections := creationMaps(doc["connections"])
target := existing
if target == nil {
target = map[string]any{"id": args.NodeID, "type": args.Mode}
}
prospectiveNodes := make([]map[string]any, 0, len(nodes)+1)
for _, node := range nodes {
prospectiveNodes = append(prospectiveNodes, node)
}
if existing == nil {
prospectiveNodes = append(prospectiveNodes, target)
}
prospectiveSources := append(append([]string{}, args.ReferenceNodeIDs...), args.SourceNodeID)
prospectiveSeen := map[string]bool{}
for _, sourceID := range prospectiveSources {
if sourceID == "" || prospectiveSeen[sourceID] {
continue
}
prospectiveSeen[sourceID] = true
alreadyConnected := false
for _, edge := range prospectiveConnections {
if stringValue(edge["fromNodeId"]) == sourceID && stringValue(edge["toNodeId"]) == args.NodeID {
alreadyConnected = true
break
}
}
if alreadyConnected {
continue
}
if err := validateCloudAgentConnection(prospectiveNodes, sourceID, args.NodeID, prospectiveConnections); err != nil {
return nil, nil, nil, err
}
prospectiveConnections = append(prospectiveConnections, map[string]any{"fromNodeId": sourceID, "toNodeId": args.NodeID})
}
refs := map[string]any{}
seen := map[string]bool{}
for _, id := range args.ReferenceNodeIDs {
if id == "" || seen[id] || nodes[id] == nil {
return nil, nil, nil, BadAuthRequest("参考节点不存在或重复")
}
seen[id] = true
ref, e := cloudAgentReference(repo, userID, nodes[id])
if e != nil {
return nil, nil, nil, e
}
key := map[string]string{"image": "referenceImages", "video": "referenceVideos", "audio": "referenceAudios"}[stringValue(nodes[id]["type"])]
list, _ := refs[key].([]any)
refs[key] = append(list, ref)
}
return canvas, doc, refs, nil
}
func validateCloudAgentMediaArgs(a cloudAgentMediaArgs, state *cloudAgentRuntime) error {
mode := strings.ToLower(strings.TrimSpace(a.Mode))
if mode != "image" && mode != "video" && mode != "audio" {
return BadAuthRequest("生成模式只能是 image、video 或 audio")
}
a.Mode = mode
if a.SnapshotHash == "" {
return BadAuthRequest("缺少画布快照,请先读取当前画布")
}
if len(a.SnapshotHash) != 64 {
return BadAuthRequest("画布快照无效,请重新读取当前画布")
}
for _, r := range a.SnapshotHash {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
return BadAuthRequest("画布快照无效,请重新读取当前画布")
}
}
if err := validateCloudAgentID(a.NodeID, "生成节点 ID", 80); err != nil {
return err
}
if a.SourceNodeID != "" {
if err := validateCloudAgentID(a.SourceNodeID, "来源节点 ID", 80); err != nil {
return err
}
}
for _, id := range a.ReferenceNodeIDs {
if err := validateCloudAgentID(id, "参考节点 ID", 80); err != nil {
return err
}
}
if strings.TrimSpace(a.Prompt) == "" {
return BadAuthRequest("生成提示词不能为空")
}
if strings.TrimSpace(a.Title) == "" || utf8.RuneCountInString(a.Title) > 240 {
return BadAuthRequest("生成节点标题不能为空且不能超过 240 个字符")
}
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 || a.Duration > 120 {
return BadAuthRequest("生成时长必须在 0 到 120 秒之间")
}
switch mode {
case "video":
if a.Duration == 0 {
return BadAuthRequest("视频生成必须明确 durationSeconds")
}
case "image", "audio":
if a.Duration != 0 {
return BadAuthRequest("只有视频生成允许设置 durationSeconds")
}
if a.VideoGenerateAudio != nil {
return BadAuthRequest("videoGenerateAudio 仅适用于视频生成")
}
}
if len(a.ReferenceNodeIDs) > 16 {
return BadAuthRequest("参考节点最多 16 个")
}
if a.SourceNodeID != "" {
for _, id := range a.ReferenceNodeIDs {
if id == a.SourceNodeID {
return BadAuthRequest("sourceNodeId 与 referenceNodeIds 不能重复;参考图片、视频、音频请仅保留在 referenceNodeIds 并清空 sourceNodeId,文本镜头节点则仅放 sourceNodeId")
}
}
}
if state == nil {
return BadAuthRequest("Agent 状态无效")
}
if state.Generations >= state.Request.Budget.MaxGenerationTasks {
return BadAuthRequest("已达到本轮媒体生成次数上限")
}
if mode == "video" && state.VideoSeconds > state.Request.Budget.MaxVideoSeconds-a.Duration {
return BadAuthRequest("已超过本轮视频时长预算")
}
return nil
}
func validateCloudAgentMediaReferences(mode string, refs map[string]any) error {
imageCount := lenAnySlice(refs["referenceImages"])
videoCount := lenAnySlice(refs["referenceVideos"])
audioCount := lenAnySlice(refs["referenceAudios"])
switch mode {
case "image":
if videoCount > 0 || audioCount > 0 {
return BadAuthRequest("图片生成仅支持图片参考资产")
}
case "audio":
if imageCount > 0 || videoCount > 0 || audioCount > 0 {
return BadAuthRequest("当前音频生成只支持文本输入,暂不支持媒体参考资产")
}
case "video":
// Video reference admission is completed against the selected model's
// capability contract by CreateTask. Do not guess a provider operation here.
}
return nil
}
func lenAnySlice(value any) int {
switch items := value.(type) {
case []any:
return len(items)
case []map[string]any:
return len(items)
default:
return 0
}
}
func cloudAgentMediaOperation(mode string, refs map[string]any) string {
switch mode {
case "video":
if lenAnySlice(refs["referenceVideos"]) > 0 {
return "reference_to_video"
}
if lenAnySlice(refs["referenceAudios"]) > 0 {
return "audio_to_video"
}
if lenAnySlice(refs["referenceImages"]) > 0 {
return "image_to_video"
}
case "image":
if lenAnySlice(refs["referenceImages"]) > 0 {
return "image_to_image"
}
}
// Audio is currently text-to-audio only. Keep this explicit so a future
// provider-specific reference operation cannot be introduced accidentally.
return "text_to_" + mode
}
func (s *Service) prepareCloudAgentMedia(run *model.CloudAgentExecution, state *cloudAgentRuntime, call cloudAgentCall) (CreateTaskRequest, *cloudAgentMediaPlan, error) {
var a cloudAgentMediaArgs
if err := decodeCloudAgentJSONObject(call.Function.Arguments, &a); err != nil {
return CreateTaskRequest{}, nil, BadAuthRequest("生成参数必须是只含支持字段的单个JSON对象")
}
a.Mode = strings.ToLower(strings.TrimSpace(a.Mode))
a.DraftRunID = run.ID
if err := validateCloudAgentMediaArgs(a, state); err != nil {
return CreateTaskRequest{}, nil, err
}
if (a.LogicalModelID == "" && (a.ChannelID == "" || a.ChannelModelKey == "")) || (a.LogicalModelID != "" && (a.ChannelID != "" || a.ChannelModelKey != "")) {
return CreateTaskRequest{}, nil, BadAuthRequest("请复制 model_list 的 selection:逻辑模型或系统渠道二选一,不得混用")
}
_, _, refs, err := cloudAgentMediaDocument(s.repo, run.UserID, state.Request.CanvasID, a)
if err != nil {
return CreateTaskRequest{}, nil, err
}
config := map[string]any{"count": "1"}
if a.ChannelID != "" {
config["channelId"], config["channelModelKey"], config["model"] = a.ChannelID, a.ChannelModelKey, a.ChannelModelKey
}
if a.Mode == "video" {
config["videoSeconds"] = fmt.Sprint(a.Duration)
}
if a.Size != "" {
config["size"] = a.Size
}
if a.Quality != "" {
if a.Mode == "video" {
config["vquality"] = a.Quality
} else {
config["quality"] = a.Quality
}
}
if a.VideoGenerateAudio != nil {
config["videoGenerateAudio"] = fmt.Sprint(*a.VideoGenerateAudio)
}
input := refs
input["mode"], input["prompt"], input["config"] = a.Mode, a.Prompt, config
if err := validateCloudAgentMediaReferences(a.Mode, refs); err != nil {
return CreateTaskRequest{}, nil, err
}
operation := cloudAgentMediaOperation(a.Mode, refs)
metadata := map[string]any{"nodeId": a.NodeID, "source": "cloud_agent"}
if a.Mode == "video" {
metadata["videoEditOperation"] = operation
}
input["metadata"] = metadata
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}, nil
}
func saveCloudAgentDocument(repo *repository.Repository, canvas *model.CanvasProject, doc map[string]any, policy RuntimePolicySetting) error {
doc["updatedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
raw, err := json.Marshal(doc)
if err != nil {
return err
}
if len(raw) > 8<<20 {
return BadAuthRequest("画布大小超限")
}
usage, err := repo.UserStorageUsage(canvas.UserID)
if err != nil {
return err
}
if err = validateStructuredStorageQuotaWithPolicy(usage, "canvas", false, int64(len(raw)-len(canvas.PayloadJSON)), policy.Resource); err != nil {
return err
}
before := canvas.PayloadJSON
canvas.PayloadJSON = string(raw)
return repo.CompareSaveCreationCanvas(canvas, before)
}
// Called inside the same transaction as the task, charge reservation and Agent checkpoint.
func createCloudAgentMediaNode(repo *repository.Repository, userID, canvasID string, plan *cloudAgentMediaPlan, task *model.Task, policy RuntimePolicySetting, recorder ...cloudAgentMutationRecorder) error {
a := plan.Args
canvas, doc, _, err := cloudAgentMediaDocument(repo, userID, canvasID, a)
if err != nil {
return err
}
beforeJSON := canvas.PayloadJSON
beforeHash := cloudAgentCanvasHash(doc)
nodes := creationMaps(doc["nodes"])
x, y := 80.0, 80.0
for _, node := range nodes {
position, _ := node["position"].(map[string]any)
nx, _ := position["x"].(float64)
width, _ := node["width"].(float64)
if nx+width+80 > x {
x = nx + width + 80
}
if stringValue(node["id"]) == a.SourceNodeID {
y, _ = position["y"].(float64)
}
}
meta := map[string]any{"status": "idle", "agentDraftRunId": a.DraftRunID, "prompt": a.Prompt, "composerContent": a.Prompt, "referenceNodeIds": a.ReferenceNodeIDs}
if a.Size != "" && (a.Mode == "image" || a.Mode == "video") {
meta["size"] = a.Size
}
if a.Mode == "video" {
meta["videoSeconds"] = fmt.Sprint(a.Duration)
}
if a.Quality != "" {
key := "quality"
if a.Mode == "video" {
key = "vquality"
}
meta[key] = a.Quality
}
if a.VideoGenerateAudio != nil {
meta["videoGenerateAudio"] = fmt.Sprint(*a.VideoGenerateAudio)
}
var input struct {
Config map[string]any `json:"config"`
}
if task != nil {
meta["status"], meta["taskId"], meta["taskStatus"] = "loading", task.ID, "queued"
delete(meta, "agentDraftRunId")
if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil {
return err
}
}
// Persist public selectors and requested options only, never provider credentials.
for _, key := range []string{"size", "quality", "vquality", "videoSeconds", "videoGenerateAudio"} {
if value, ok := input.Config[key]; ok {
meta[key] = value
}
}
if a.Mode != "video" {
delete(meta, "videoSeconds")
delete(meta, "videoGenerateAudio")
}
if a.LogicalModelID != "" {
meta["logicalModelId"] = a.LogicalModelID
} else {
meta["channelId"], meta["channelModelKey"], meta["model"] = a.ChannelID, a.ChannelModelKey, a.ChannelModelKey
}
node := creationAddedNode(CreationCanvasOp{Type: "add_node", ID: a.NodeID, NodeType: a.Mode, Title: a.Title, X: &x, Y: &y, Metadata: meta})
if a.Size == "9:16" {
node["width"], node["height"] = float64(360), float64(640)
}
replaced := false
for _, existing := range nodes {
if stringValue(existing["id"]) == a.NodeID {
existing["metadata"], existing["title"] = meta, a.Title
replaced = true
break
}
}
if !replaced {
nodes = append(nodes, node)
}
doc["nodes"] = nodes
edges := creationMaps(doc["connections"])
seen := map[string]bool{}
for _, edge := range edges {
if stringValue(edge["toNodeId"]) == a.NodeID {
seen[stringValue(edge["fromNodeId"])] = true
}
}
for _, id := range append(append([]string{}, a.ReferenceNodeIDs...), a.SourceNodeID) {
if id == "" || seen[id] {
continue
}
seen[id] = true
edges = append(edges, map[string]any{"id": "agent-" + newID(), "fromNodeId": id, "toNodeId": a.NodeID})
}
doc["connections"] = edges
if err := saveCloudAgentDocument(repo, canvas, doc, policy); err != nil {
return err
}
if len(recorder) > 0 && recorder[0] != nil {
stepID := plan.CallID
if stepID == "" {
stepID = a.NodeID
}
operation := "generate_media_draft"
if task != nil {
operation = "generate_media_submit"
}
return recorder[0](repo, cloudAgentMutationInput{
RunID: a.DraftRunID,
UserID: userID,
CanvasID: canvasID,
StepID: stepID,
Operation: operation,
BeforeSnapshotHash: beforeHash,
AfterSnapshotHash: cloudAgentCanvasHash(doc),
BeforeJSON: beforeJSON,
HasSubmittedTask: task != nil,
})
}
return nil
}
func completeCloudAgentMediaNode(repo *repository.Repository, userID, canvasID string, task *model.Task, policy RuntimePolicySetting) (string, error) {
canvas, err := repo.CanvasProjectForUser(userID, canvasID)
if err != nil {
return "", err
}
doc, err := creationDocument(canvas.PayloadJSON)
if err != nil {
return "", err
}
for _, node := range creationMaps(doc["nodes"]) {
meta, _ := node["metadata"].(map[string]any)
if stringValue(meta["taskId"]) != task.ID {
continue
}
meta["taskStatus"] = string(task.Status)
meta["status"] = "error"
meta["errorDetails"] = "媒体任务" + string(task.Status) + ":" + cloudAgentSafeMediaTaskError(task)
if task.Status == model.TaskStatusSucceeded {
id, _ := taskOutputResource(task.ResultJSON, task.Type)
resource, e := repo.ResourceForUser(userID, id)
if e != nil || resource.Status != "ready" || !strings.HasPrefix(resource.MimeType, stringValue(node["type"])+"/") {
meta["status"] = "error"
meta["errorDetails"] = "生成结果没有可用的账号资源,未写入媒体地址"
if saveErr := saveCloudAgentDocument(repo, canvas, doc, policy); saveErr != nil {
return stringValue(node["id"]), saveErr
}
return stringValue(node["id"]), BadAuthRequest("生成结果没有可用的账号资源,未写入媒体地址")
}
meta["content"], meta["storageKey"], meta["status"] = resourceFileURL(id), "resource:"+id, "success"
meta["naturalWidth"], meta["naturalHeight"] = resource.Width, resource.Height
if resource.Width > 0 && resource.Height > 0 {
if width, ok := node["width"].(float64); ok && width > 0 {
node["height"] = width * float64(resource.Height) / float64(resource.Width)
}
}
delete(meta, "errorDetails")
}
return stringValue(node["id"]), saveCloudAgentDocument(repo, canvas, doc, policy)
}
return "", creationConflict("生成节点已删除或已绑定其他任务;结果仍保留在任务中心,未重建节点")
}