267 lines
12 KiB
Go
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
|
|
}
|