feat: 新增执行记录实体与文件上传能力

This commit is contained in:
2026-08-18 09:47:51 +08:00
parent 155a5cad0c
commit a939c45508
67 changed files with 7341 additions and 4001 deletions
@@ -0,0 +1,309 @@
package flow
import (
"ai-agent/workflow/consts/node"
"ai-agent/workflow/service/flow/processor"
"ai-agent/workflow/service/flow/processor/builtin/split_shots_pipeline"
"context"
"encoding/json"
"fmt"
"strings"
"ai-agent/gateway"
flowDto "ai-agent/workflow/model/dto/flow"
"ai-agent/workflow/service/flow/processor/builtin/split_shots_pipeline/pipeline"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
// 脚本转写节点默认系统提示词(字段与 domain.Shot 的 JSON tag 对齐,结构化输出与 content 兜底两条路径一致)
const defaultScriptTranscribeSystemPrompt = `你是短剧分镜脚本师。请根据提供的文案/视频分析结果,把内容拆分为连续的分镜镜头脚本。
每个镜头输出一个 JSON 对象,字段固定为:
- index:镜头序号(数字)
- startTime:开始时间,格式 MM:SS
- endTime:结束时间,格式 MM:SS
- event:事件描述/动作描写
- narration:旁白/画外音,没有则省略
- dialogue:角色开口说的主台词,没有则省略
- ambientSound:环境音,没有则省略
- cameraMovement:运镜描述
- shotSize:景别
- characters:出演人物名列表(字符串数组)
- scene:场景名
- props:道具名列表(字符串数组)
时间码需前后衔接、覆盖整个内容时长。直接输出 JSON 数组,不要输出其他文字。`
// ScriptTranscribeLambda 脚本转写节点:
// 把节点输入(文案/视频分析结果,经 valueSource 解析)通过大模型转写为固定结构的 []domain.Shot
// 产出为 [{"shots": [...]}],供视频生成节点的 ModelRequestParams.shots 引用。
func ScriptTranscribeLambda(ctx context.Context, input any) (any, error) {
nodeInput, ok := input.(*flowDto.NodeExecutionInput)
if !ok {
return nil, fmt.Errorf("入参类型错误")
}
n := new([]node.NodePresetField)
err := gconv.Structs(nodeInput.Config.OutputConfig, n)
if err != nil {
return nil, err
}
var totalDuration int
var modelId int64
for _, item := range *n {
switch item.Field {
case "totalDuration":
totalDuration = gconv.Int(item.Value)
case "modelId":
modelId = gconv.Int64(item.Value)
}
}
// 1. 解析 valueSource 引用,填充节点输入
ProcessValueSourceRecursive(nodeInput.Config.ModelConfig.ModelRequestParams, nodeInput.Global)
// 2. 构建系统提示词 + 用户输入
systemPrompt := nodeInput.Config.Prompt
if systemPrompt == "" {
systemPrompt = defaultScriptTranscribeSystemPrompt
}
// 参考素材名单注入:转写模型只从名单选名,保证镜头里的角色/场景/道具名与参考素材精确一致
// (名字绑定/类别推断都依赖名字对上)
refsName, refsItem := pipeline.ExtractRefs(nodeInput.Config.ModelConfig.ModelRequestParams)
if len(refsName) > 0 {
systemPrompt += "\n\n参考素材名单:" + strings.Join(refsName, "、") +
"\n约束:镜头里的 characters/scene/props 必须原样使用名单中的名字,不得改写、不得加修饰(如“主角小明”)、不得造新名;名单外的名字按原文输出。"
}
if totalDuration > 0 {
systemPrompt += fmt.Sprintf("\n\n视频总时长 %d 秒(MM:SS 为 %s):所有镜头的时间码需前后衔接并完整覆盖该总时长,最后一镜的 endTime 对齐到总时长。", totalDuration, formatSecondsToMMSS(totalDuration))
}
// 单镜头时长上限约束:按视频模型推导单段最大时长注入转写提示词,从源头避免超长镜头
//SplitOversized 仍是机械兜底);推导失败仅降级跳过约束注入,不影响转写主流程。
if maxSeg, _, err := split_shots_pipeline.SegmentBounds(ctx, modelId); err != nil {
g.Log().Warningf(ctx, "获取视频模型单段时长约束失败,跳过单镜时长约束注入: %v", err)
} else {
systemPrompt += shotDurationConstraintPrompt(maxSeg)
}
info, err := gateway.GetModelInfoById(ctx, &gateway.GetModelInfoByIdReq{ModelId: nodeInput.Config.ModelConfig.ModelId})
if err != nil {
return nil, err
}
params := map[string]any{
"system_prompt": systemPrompt,
}
// 结构化输出:chat 模型映射配置了 response_formatStructuredOutput)即走原生 json_schema 保证结构;
// 否则模型 content 直接出 JSON,靠容错解析兜底
val, ok := info.ModelManage.RequestBusinessFieldMapping["response_format"]
if ok {
if !g.IsEmpty(val) {
params["response_format"] = pipeline.ShotsStructuredFormat()
}
}
modelInfo, err := gateway.GetModelInfoById(ctx, &gateway.GetModelInfoByIdReq{ModelId: nodeInput.Config.ModelConfig.ModelId})
if err != nil {
return "", fmt.Errorf("获取模型配置失败: %w", err)
}
result, err := gateway.ModelCallResult(ctx, nodeInput.Config.ModelConfig.ModelId, modelInfo.ModelManage.ResponseType, nodeInput.Global.SessionId, nodeInput.Config.ModelConfig.ModelRequestParams, params)
if err != nil {
return nil, err
}
var content string
for _, v := range gconv.Map(result.Content) {
content += v.(string)
}
// 4. 解析镜头数组(兼容英文键结构化输出与中文键 content 兜底)
shots, err := unmarshalShots(content)
if err != nil {
return nil, err
}
// 5. 产出固定结构 {"shots": [...]}
var arr []any
if b, err := json.Marshal(shots); err == nil {
_ = json.Unmarshal(b, &arr)
}
args := split_shots_pipeline.SplitShotsInput{
ModelID: modelId,
Shots: shots,
TotalDuration: totalDuration,
FlatRefs: refsItem,
Seed: nodeInput.Global.ExecutionId % 1000000,
NegativePrompt: nodeInput.Config.NegativePrompt,
}
data, err := processor.Call(ctx, "split_shots_pipeline", gconv.Map(args))
if err != nil {
return nil, err
}
nodeInput.Config.OutputResult = gconv.Maps(data)
return nodeInput, nil
}
// unmarshalShots 解析镜头数组 JSON,容忍 markdown 代码围栏、json_schema 结构化输出的 {"shots":[...]} 包装,
// 以及模型按中文键输出(时间码/事件/台词旁白/景别/运镜/出演角色/场景/道具)的容错映射。
func unmarshalShots(s string) ([]pipeline.Shot, error) {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "```") {
s = strings.TrimPrefix(s, "```json")
s = strings.TrimPrefix(s, "```")
s = strings.TrimSuffix(s, "```")
s = strings.TrimSpace(s)
}
var raw []map[string]any
if err := json.Unmarshal([]byte(s), &raw); err == nil && len(raw) > 0 {
return parseShots(raw)
}
var wrapped struct {
Shots []map[string]any `json:"shots"`
}
if err := json.Unmarshal([]byte(s), &wrapped); err == nil && len(wrapped.Shots) > 0 {
return parseShots(wrapped.Shots)
}
return nil, fmt.Errorf("解析分镜脚本失败: %v", s)
}
// parseShots 把原始镜头对象数组归一为 domain.Shot,跳过没有内容字段的镜头。
func parseShots(raw []map[string]any) ([]pipeline.Shot, error) {
shots := make([]pipeline.Shot, 0, len(raw))
for i, m := range raw {
shot := shotFromMap(m)
if shot.Index == 0 {
shot.Index = i + 1
}
if isEmptyShot(shot) {
continue
}
shots = append(shots, shot)
}
if len(shots) == 0 {
return nil, fmt.Errorf("解析分镜脚本失败: 镜头内容为空")
}
return shots, nil
}
// isEmptyShot 镜头是否没有可用内容(仅有时间码/序号,或字段名对不上导致全空)。
func isEmptyShot(s pipeline.Shot) bool {
return s.Event == "" && s.Dialogue == "" && s.Narration == "" && s.AmbientSound == "" &&
s.CameraMovement == "" && s.ShotSize == "" && s.Scene == "" &&
len(s.Characters) == 0 && len(s.Props) == 0
}
// shotFromMap 把单个镜头对象映射为 domain.Shot,兼容英文键(结构化输出)与中文键(提示词兜底输出)。
func shotFromMap(m map[string]any) pipeline.Shot {
get := func(keys ...string) string {
for _, k := range keys {
switch v := m[k].(type) {
case string:
if t := strings.TrimSpace(v); t != "" {
return t
}
case float64:
return gconv.String(v)
}
}
return ""
}
getSlice := func(keys ...string) []string {
for _, k := range keys {
switch v := m[k].(type) {
case []any:
var out []string
for _, e := range v {
if t, ok := e.(string); ok && strings.TrimSpace(t) != "" {
out = append(out, strings.TrimSpace(t))
}
}
if len(out) > 0 {
return out
}
case string:
if out := splitList(v); len(out) > 0 {
return out
}
}
}
return nil
}
shot := pipeline.Shot{
Index: gconv.Int(m["index"]),
StartTime: get("startTime", "开始时间"),
EndTime: get("endTime", "结束时间"),
Event: get("event", "事件"),
Dialogue: get("dialogue", "台词"),
Narration: get("narration", "旁白"),
AmbientSound: get("ambientSound", "环境音"),
CameraMovement: get("cameraMovement", "运镜"),
ShotSize: get("shotSize", "景别"),
Scene: get("scene", "场景"),
Characters: getSlice("characters", "出演角色"),
Props: getSlice("props", "道具"),
}
if shot.StartTime == "" && shot.EndTime == "" {
shot.StartTime, shot.EndTime = splitTimeRange(get("时间码"))
}
if shot.Dialogue == "" && shot.Narration == "" {
shot.Dialogue, shot.Narration = splitDialogueNarration(get("台词/旁白"))
}
return shot
}
// formatSecondsToMMSS 秒转 MM:SS 时间码。
func formatSecondsToMMSS(sec int) string {
if sec < 0 {
sec = 0
}
return fmt.Sprintf("%02d:%02d", sec/60, sec%60)
}
// splitTimeRange 解析时间码 "MM:SS-MM:SS"(兼容 "—"/"~"/"到" 等分隔,或单个时间点)。
func splitTimeRange(s string) (start, end string) {
if s == "" {
return "", ""
}
normalized := strings.NewReplacer("—", "-", "", "-", "", "-", "~", "-", "到", "-", "至", "-").Replace(s)
parts := strings.Split(normalized, "-")
start = strings.TrimSpace(parts[0])
if len(parts) > 1 {
end = strings.TrimSpace(parts[1])
} else {
end = start
}
return start, end
}
// splitDialogueNarration 把"台词/旁白"合字段拆成 dialogue 与 narration
// 以"旁白"/"画外音"开头的内容归为旁白,其余视为角色开口的主台词。
func splitDialogueNarration(s string) (dialogue, narration string) {
s = strings.TrimSpace(s)
switch {
case strings.HasPrefix(s, "旁白"):
return "", strings.TrimSpace(strings.TrimLeft(strings.TrimPrefix(s, "旁白"), ":"))
case strings.HasPrefix(s, "画外音"):
return "", strings.TrimSpace(strings.TrimLeft(strings.TrimPrefix(s, "画外音"), ":"))
default:
return s, ""
}
}
// splitList 按常见分隔符拆分人名/道具列表(兼容中英文顿号、逗号、分号、"和""及"等)。
func splitList(s string) []string {
repl := strings.NewReplacer("、", "|", "", "|", ",", "|", "", "|", ";", "|", "和", "|", "及", "|", "&", "|", "/", "|", " ", "|")
var out []string
for _, p := range strings.Split(repl.Replace(strings.TrimSpace(s)), "|") {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return out
}
// shotDurationConstraintPrompt 生成"单个镜头时长不超过 maxSeg 秒"的转写约束提示词片段;maxSeg<=0 返回空串。
func shotDurationConstraintPrompt(maxSeg int) string {
if maxSeg <= 0 {
return ""
}
return fmt.Sprintf("\n\n单个镜头时长不超过 %d 秒:每镜的 startTime 与 endTime 之差必须 ≤ %d 秒。", maxSeg, maxSeg)
}