267 lines
12 KiB
Go
267 lines
12 KiB
Go
package app
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"unicode/utf8"
|
||
|
||
"yingce/backend/internal/canvas/layout"
|
||
"yingce/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
|
||
}
|