Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_character_create.go
T

267 lines
12 KiB
Go

package app
import (
"fmt"
"strings"
"unicode/utf8"
"infinite-canvas/backend/internal/canvas/layout"
"infinite-canvas/backend/internal/repository"
)
const (
cloudAgentCharacterNodeWidth = 264.0
cloudAgentCharacterNodeHeight = 352.0
)
// cloudAgentCharacterDefinitionArgs 是 Agent 可写的角色设定字段;未声明的字段会被拒绝。
type cloudAgentCharacterDefinitionArgs struct {
Role string `json:"role"`
Aliases []string `json:"aliases"`
Appearance string `json:"appearance"`
Physique string `json:"physique"`
Clothing string `json:"clothing"`
Personality string `json:"personality"`
Props string `json:"props"`
ConsistencyPrompt string `json:"consistencyPrompt"`
MultiViewPrompt string `json:"multiViewPrompt"`
VoiceLanguage string `json:"voiceLanguage"`
VoiceAge string `json:"voiceAge"`
VoiceTimbre string `json:"voiceTimbre"`
}
type cloudAgentCharacterCreateArgs struct {
NodeID string `json:"nodeId"`
Name string `json:"name"`
ImageNodeID string `json:"imageNodeId"`
AudioNodeID string `json:"audioNodeId"`
Definition cloudAgentCharacterDefinitionArgs `json:"definition"`
X *float64 `json:"x"`
Y *float64 `json:"y"`
}
type cloudAgentCharacterCreatePlan struct {
Args cloudAgentCharacterCreateArgs
Definition map[string]any
ImageResourceID string
AudioResourceID string
VoiceName string
Position layout.Position
Preview cloudAgentApprovalPreview
}
// 设定字段只在描述里列出键名以控制工具 schema 体积;解码时 DisallowUnknownFields 会拒绝未声明的键。
func cloudAgentCharacterCreateSchema() map[string]any {
str := func(description string) map[string]any {
return map[string]any{"type": "string", "description": description}
}
return map[string]any{
"nodeId": str("新节点ID"), "name": str("角色名"), "imageNodeId": str("就绪的形象图片节点ID"), "audioNodeId": str("可选,就绪的声音音频节点ID"),
"definition": map[string]any{"type": "object", "description": "可选设定:role/appearance/physique/clothing/personality/props/consistencyPrompt/multiViewPrompt/voiceLanguage/voiceAge/voiceTimbre 为字符串,aliases 为字符串数组"},
"x": map[string]any{"type": "number"}, "y": map[string]any{"type": "number"},
}
}
func cloudAgentCharacterText(value string, limit int) (string, error) {
value = strings.TrimSpace(value)
if utf8.RuneCountInString(value) > limit {
return "", BadAuthRequest(fmt.Sprintf("角色设定单项不能超过 %d 字", limit))
}
return value, nil
}
func (d cloudAgentCharacterDefinitionArgs) normalized() (map[string]any, error) {
result := map[string]any{}
for key, value := range map[string]string{
"role": d.Role, "appearance": d.Appearance, "physique": d.Physique, "clothing": d.Clothing,
"personality": d.Personality, "props": d.Props, "consistencyPrompt": d.ConsistencyPrompt,
"multiViewPrompt": d.MultiViewPrompt, "voiceLanguage": d.VoiceLanguage, "voiceAge": d.VoiceAge, "voiceTimbre": d.VoiceTimbre,
} {
text, err := cloudAgentCharacterText(value, 4000)
if err != nil {
return nil, err
}
if text != "" {
result[key] = text
}
}
if len(d.Aliases) > 16 {
return nil, BadAuthRequest("角色别名最多 16 个")
}
aliases := []string{}
for _, alias := range d.Aliases {
text, err := cloudAgentCharacterText(alias, 100)
if err != nil {
return nil, err
}
if text != "" {
aliases = append(aliases, text)
}
}
result["aliases"] = aliases
return result, nil
}
// cloudAgentCharacterMediaResource 从画布节点取已上传且就绪的账号资源;只接受真实节点,不接受 URL。
func cloudAgentCharacterMediaResource(repo *repository.Repository, userID string, nodes map[string]map[string]any, nodeID, kind, label string) (string, map[string]any, error) {
node := nodes[nodeID]
if node == nil || stringValue(node["type"]) != kind {
return "", nil, BadAuthRequest(label + "节点不在当前画布或类型不对")
}
meta, _ := node["metadata"].(map[string]any)
key := stringValue(meta["storageKey"])
if !strings.HasPrefix(key, "resource:") {
return "", nil, BadAuthRequest(label + "尚未保存到账号资源库,不能用于角色卡")
}
resource, err := repo.ResourceForUser(userID, strings.TrimPrefix(key, "resource:"))
if err != nil || resource == nil || resource.Kind != kind || resource.Status != "ready" {
return "", nil, BadAuthRequest(label + "资源不存在、未就绪或类型不匹配")
}
if kind == "audio" && !isSupportedVoiceSampleMimeType(resource.MimeType) {
return "", nil, BadAuthRequest("声音音频格式不支持:请使用 MP3、WAV、M4A/AAC、FLAC、OGG/Opus 或 WebM")
}
return resource.ID, node, nil
}
func prepareCloudAgentCharacterCreate(repo *repository.Repository, userID, canvasID string, call cloudAgentCall) (*cloudAgentCharacterCreatePlan, error) {
var args cloudAgentCharacterCreateArgs
if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil {
return nil, &cloudAgentArgumentError{BadAuthRequest("创建角色卡参数无效:仅允许 nodeId、name、imageNodeId、audioNodeId、definition、x、y")}
}
if err := validateCloudAgentID(args.NodeID, "角色卡节点ID", 80); err != nil {
return nil, &cloudAgentArgumentError{err}
}
name, err := cloudAgentCharacterText(args.Name, 80)
if err != nil || name == "" {
return nil, &cloudAgentArgumentError{BadAuthRequest("角色名不能为空且不超过 80 字")}
}
if args.ImageNodeID == "" {
return nil, &cloudAgentArgumentError{BadAuthRequest("创建角色卡需要 imageNodeId:先有一张已就绪的形象/三视图图片节点")}
}
definition, err := args.Definition.normalized()
if err != nil {
return nil, &cloudAgentArgumentError{err}
}
canvas, err := repo.CanvasProjectForUser(userID, canvasID)
if err != nil {
return nil, err
}
doc, err := creationDocument(canvas.PayloadJSON)
if err != nil {
return nil, err
}
nodes, err := creationObjects(doc["nodes"])
if err != nil {
return nil, err
}
if nodes[args.NodeID] != nil {
return nil, &cloudAgentArgumentError{BadAuthRequest("角色卡节点ID已存在,请换一个新ID")}
}
plan := &cloudAgentCharacterCreatePlan{Args: args, Definition: definition}
plan.Args.Name = name
imageID, imageNode, err := cloudAgentCharacterMediaResource(repo, userID, nodes, args.ImageNodeID, "image", "形象图片")
if err != nil {
return nil, err
}
plan.ImageResourceID = imageID
details := []string{"形象:《" + cloudAgentApprovalNodeTitle(imageNode, "图片") + "》"}
if args.AudioNodeID != "" {
audioID, audioNode, err := cloudAgentCharacterMediaResource(repo, userID, nodes, args.AudioNodeID, "audio", "声音音频")
if err != nil {
return nil, err
}
plan.AudioResourceID = audioID
plan.VoiceName = truncateRunes(strings.TrimSpace(stringValue(audioNode["title"])), 160)
details = append(details, "声音:《"+cloudAgentApprovalNodeTitle(audioNode, "音频")+"》")
}
if len(definition) > 1 {
details = append(details, fmt.Sprintf("设定 %d 项", len(definition)-1))
}
// 显式坐标按原样使用;否则落在形象图片右侧的空位,不叠在已有节点上。
if args.X != nil && args.Y != nil {
plan.Position = layout.Position{X: *args.X, Y: *args.Y}
} else {
occupied := cloudAgentLayoutNodes(doc)
var preferred *layout.Position
if image, ok := findLayoutNode(occupied, args.ImageNodeID); ok {
preferred = &layout.Position{X: image.X + image.Width + layout.PlacementGap, Y: image.Y}
}
plan.Position = layout.FreeSlot(cloudAgentCharacterNodeWidth, cloudAgentCharacterNodeHeight, "text", occupied, preferred, layout.PlacementGap)
}
withAudio := ""
if plan.AudioResourceID != "" {
withAudio = "和音频"
}
plan.Preview = cloudAgentApprovalPreview{
Kind: "canvas_mutation", Title: "确认创建角色卡",
Description: fmt.Sprintf("Agent 准备把画布上的图片%s打包为角色卡《%s》:写入账号角色库并在画布放置角色卡节点。批准后才会执行。", withAudio, name),
Items: []cloudAgentApprovalPreviewItem{{Operation: "create_character", NodeID: args.NodeID, NodeTitle: name, NodeType: "character", NodeTypeLabel: "角色卡", Details: details, Summary: fmt.Sprintf("创建角色卡《%s》", name)}},
}
return plan, nil
}
func cloudAgentCharacterReferencePrompt(name string, definition map[string]any) string {
parts := []string{"【角色卡:" + name + "】"}
for _, key := range []string{"role", "appearance", "physique", "clothing", "personality", "props", "consistencyPrompt"} {
if value := strings.TrimSpace(stringValue(definition[key])); value != "" {
parts = append(parts, value)
}
}
return strings.Join(parts, "\n")
}
// applyCloudAgentCharacterCreate 在 Agent 检查点事务里建角色资产并放置角色卡节点;任一步失败整体回滚。
func applyCloudAgentCharacterCreate(repo *repository.Repository, userID, canvasID string, call cloudAgentCall, policy RuntimePolicySetting, recorder ...cloudAgentMutationRecorder) (any, error) {
plan, err := prepareCloudAgentCharacterCreate(repo, userID, canvasID, call)
if err != nil {
return nil, err
}
created, err := (&Service{repo: repo}).CreateCharacter(userID, CreateCharacterRequest{
Name: plan.Args.Name, Definition: plan.Definition, ImageResourceID: plan.ImageResourceID,
AudioResourceID: plan.AudioResourceID, VoiceName: plan.VoiceName,
})
if err != nil {
return nil, err
}
canvas, err := repo.CanvasProjectForUser(userID, canvasID)
if err != nil {
return nil, err
}
doc, err := creationDocument(canvas.PayloadJSON)
if err != nil {
return nil, err
}
beforeJSON, beforeHash := canvas.PayloadJSON, cloudAgentCanvasHash(doc)
card := created.Character
meta := map[string]any{
"workflowKind": "character", "characterAssetId": created.Asset.ID, "assetId": created.Asset.ID,
"characterVersionId": card.VersionID, "characterVersionPolicy": "current",
"characterName": plan.Args.Name, "characterPrompt": cloudAgentCharacterReferencePrompt(plan.Args.Name, plan.Definition),
"characterAliases": plan.Definition["aliases"], "characterDefinition": plan.Definition,
"characterVisualStatus": card.VisualStatus, "characterVoiceStatus": card.VoiceStatus,
"status": "success", "fontSize": float64(14),
}
if card.Voice != nil {
meta["characterVoiceName"] = card.Voice.Profile.Name
}
node := map[string]any{
"id": plan.Args.NodeID, "type": "text", "title": plan.Args.Name,
"position": map[string]any{"x": plan.Position.X, "y": plan.Position.Y},
"width": cloudAgentCharacterNodeWidth, "height": cloudAgentCharacterNodeHeight, "metadata": meta,
}
doc["nodes"] = append(creationMaps(doc["nodes"]), node)
if err := saveCloudAgentDocument(repo, canvas, doc, policy); err != nil {
return nil, err
}
if len(recorder) > 0 && recorder[0] != nil {
if err := recorder[0](repo, cloudAgentMutationInput{UserID: userID, CanvasID: canvasID, StepID: call.ID, Operation: call.Function.Name, BeforeSnapshotHash: beforeHash, AfterSnapshotHash: cloudAgentCanvasHash(doc), BeforeJSON: beforeJSON, Preview: &plan.Preview}); err != nil {
return nil, err
}
}
return map[string]any{
"canvasId": canvasID, "nodeId": plan.Args.NodeID, "characterAssetId": created.Asset.ID, "versionId": card.VersionID,
"visualStatus": card.VisualStatus, "voiceStatus": card.VoiceStatus, "snapshotHash": cloudAgentCanvasHash(doc),
"summary": plan.Preview.Items[0].Summary,
}, nil
}