Files
19904408334 73f296731c refactor(workflow): 隔离逻辑运行键为 node_group_id
将 checkpoint、异步任务与段结果缓存的唯一键从 execution_id 改为 node_group_id,区分换参重跑与续跑语义;恢复/续跑复用执行记录的组标识,换参重跑则换新组并软删旧组残留,消除软删墓碑导致同键重存失效的问题。
2026-09-04 17:36:46 +08:00

260 lines
8.5 KiB
Go
Raw Permalink 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 flow
import (
"ai-agent/gateway"
"ai-agent/workflow/consts/model"
"ai-agent/workflow/consts/node"
flowDto "ai-agent/workflow/model/dto/flow"
"ai-agent/workflow/service/flow/values"
"context"
"fmt"
"strings"
commonHttp "gitea.redpowerfuture.com/red-future/common/http"
"gitea.redpowerfuture.com/red-future/common/oss"
"gitea.redpowerfuture.com/red-future/common/utils"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/util/gconv"
"github.com/google/uuid"
)
// ModelCallResultLambda 调用模型并返回输出内容列表,同时回传本次调用的 token/费用(*gateway.ModelCallRes
// 与是否推理模型(供 ModelLambda 决定分批结果是否拼接),供调用方(ModelLambda)累计写入节点执行记录
// token_info,最后由汇总节点聚合到 exec_workflow。
func ModelCallResultLambda(ctx context.Context, nodeGroupId string, modelId int64, sessionId string, modelRequestParams map[string]any, prompt string, execId int64, nodeId string, segIdx int) ([]map[string]any, *gateway.ModelCallRes, bool, error) {
modelInfo, err := gateway.GetModelInfoById(ctx, &gateway.GetModelInfoByIdReq{ModelId: modelId})
if err != nil {
return nil, nil, false, fmt.Errorf("获取模型配置失败: %w", err)
}
// model-gateway 对不存在的模型返回 modelManage=nullHTTP 仍 200),零值 struct 里 ResponseType 是 nil 指针,
// 直接传入 ModelCallResult 会在 *responseType 处 panic,这里提前报业务错误
if g.IsEmpty(modelInfo.ModelManage.Id) {
return nil, nil, false, fmt.Errorf("模型配置不存在: modelId=%d", modelId)
}
businessParams := make(map[string]any)
if !g.IsEmpty(prompt) {
if modelInfo.ModelManage.ModelType != nil && *modelInfo.ModelManage.ModelType == model.TypeVideo {
businessParams["user_prompt"] = prompt
} else if modelInfo.ModelManage.ModelType != nil && *modelInfo.ModelManage.ModelType == model.TypeInference {
businessParams["system_prompt"] = prompt
}
}
// 推理模型:分批调用结果需拼接为单个字段,模型类型仅网关配置携带,此处顺带判断
isInference := modelInfo.ModelManage.ModelType != nil && *modelInfo.ModelManage.ModelType == model.TypeInference
// 统一异步入口:提交落库 flow_async_task,崩溃恢复重订阅 msg_topic 拿回结果(同步模型直接调用,不落库)
responseParams, err := AsyncModelCallWithRecovery(ctx, nodeGroupId, execId, nodeId, segIdx, modelId, modelInfo.ModelManage.ResponseType, sessionId, modelRequestParams, businessParams)
if err != nil {
return nil, nil, false, err
}
if g.IsEmpty(responseParams) {
return nil, nil, false, fmt.Errorf("生成内容为空")
}
outputRes := make([]map[string]any, 0)
for key, val := range responseParams.Content {
outputRes = append(outputRes, map[string]any{
key: val,
})
}
return outputRes, responseParams, isInference, nil
}
func HttpCallResultLambda(ctx context.Context, nodeInput *flowDto.NodeExecutionInput) ([]map[string]any, error) {
var method, url, responseType, callbackUrl string
var headers map[string]string
var body map[string]any
var responseMapping map[string]any
n := new([]node.NodePresetField)
err := gconv.Structs(nodeInput.Config.OutputConfig, n)
if err != nil {
return nil, err
}
for _, item := range *n {
switch item.Field {
case "method":
method = gconv.String(item.Value)
case "url":
url = gconv.String(item.Value)
case "headers":
headers = gconv.MapStrStr(item.Value)
case "body":
body = gconv.Map(item.Value)
case "response":
// 先剥掉 {type, value/attrs} 包裹层,得到干净的输出结构模板
responseMapping = gconv.Map(values.UnwrapSchemaWrapper(gconv.Map(item.Value)))
case "responseType":
responseType = gconv.String(item.Value)
if responseType == "callback" {
callbackUrl = item.Options[0].Config[0].Value
}
}
}
if method == "" {
return nil, fmt.Errorf("method为空")
}
if url == "" {
return nil, fmt.Errorf("url为空")
}
if headers == nil {
headers = utils.HeadersFromCtx(ctx)
}
// 构建请求参数
values.ProcessValueSourceRecursive(body, nodeInput.Global)
// 递归剥掉 {type, value/attrs} 包裹层,只保留 key/value
wrapper := values.UnwrapSchemaWrapper(body)
newBody := gconv.Map(wrapper)
// body 值若为 MinIO 裸路径(模型网关转存 OSS 后返回,无 http 前缀),
// 补上前缀供目标 HTTP 服务直接下载文件
addFilePathPrefix(ctx, url, newBody)
// 1. 自己生成唯一 taskId(不用前端给)
taskId := "my_task_" + uuid.New().String() // 自己生成唯一ID
if responseType == "callback" {
newBody[callbackUrl] = utils.GetCallbackURL(ctx, "/httpNodeCallback?task_id="+taskId)
}
// ====================== 核心改动 ======================
// 1. 定义一个空map接收原始HTTP返回结果
var rawHttpResult map[string]any
// 2. 发送请求(不变)
if method == "GET" {
err = commonHttp.Get(ctx, url, headers, &rawHttpResult, newBody)
} else if method == "POST" {
err = commonHttp.Post(ctx, url, headers, &rawHttpResult, newBody)
} else if method == "PUT" {
err = commonHttp.Put(ctx, url, headers, &rawHttpResult, newBody)
} else if method == "DELETE" {
err = commonHttp.Delete(ctx, url, headers, &rawHttpResult, newBody)
} else {
return nil, fmt.Errorf("method 不支持")
}
if err != nil {
return nil, err
}
var e = ""
finalResult := make(map[string]any)
if responseType == "sync" {
httpResultJson := gconv.String(rawHttpResult)
// 按 responseMapping 定义的结构,从 http 返回结果中拷贝对应字段
finalResult = values.MapResultByTemplate(responseMapping, rawHttpResult)
e = httpResultJson
}
if responseType == "callback" {
var waitResult any
waitResult, err = Wait(ctx, taskId)
if err != nil {
return nil, err
}
request, ok := waitResult.(*ghttp.Request)
if !ok {
return nil, fmt.Errorf("入参类型错误")
}
bodyStr := request.GetBodyString()
// 按 responseMapping 定义的结构,从回调结果中拷贝对应字段
finalResult = values.MapResultByTemplate(responseMapping, gconv.Map(bodyStr))
e = bodyStr
}
if responseType == "pull" {
return nil, fmt.Errorf("pull 暂不支持")
}
if g.IsEmpty(finalResult) {
return nil, fmt.Errorf("http请求异常,返回结果为空:%v", e)
}
outputRes := make([]map[string]any, 0)
for i, item := range finalResult {
if nodeInput.Config.IsSaveFile {
outputRes = append(outputRes, map[string]any{
fmt.Sprintf("http_file_url:%v", i): item,
})
}
outputRes = append(outputRes, map[string]any{
fmt.Sprintf("%v", i): item,
})
}
return outputRes, nil
}
// addFilePathPrefix 递归把 body 中的 MinIO 裸路径(模型网关转存 OSS 后返回,无 http 前缀)补上文件前缀,
// 供目标 HTTP 服务直接下载文件;已是完整 URL 的值保持不变
func addFilePathPrefix(ctx context.Context, url string, body map[string]any) {
prefix, err := oss.GetFileAddressPrefix(ctx)
if err != nil {
g.Log().Warningf(ctx, "获取文件前缀失败,保持原路径: %v", err)
return
}
for k, v := range body {
if k == "templates" && g.IsEmpty(v) {
delete(body, k)
continue
}
body[k] = prependFilePathPrefix(prefix, v)
}
// template/template 模板接口要求 video_urls 为数组:标量值包装为单元素数组
if strings.Contains(url, "template/template") {
if v, ok := body["video_urls"]; ok {
body["video_urls"] = toVideoURLsArray(v)
}
if v, ok := body["subtitles"]; ok {
a := new([]flowDto.Sentence)
err = gconv.Structs(v, a)
v, err = BuildSubtitles(a)
body["subtitles"] = v
}
}
}
// toVideoURLsArray 把标量 video_urls 包装为数组;已是数组/切片则原样保留
func toVideoURLsArray(v any) any {
switch val := v.(type) {
case string:
if val == "" {
return []string{}
}
return []string{val}
case []string, []any:
return val
default:
return v
}
}
// prependFilePathPrefix 对单个值加前缀,递归处理嵌套 map/切片
func prependFilePathPrefix(prefix string, v any) any {
switch val := v.(type) {
case string:
if oss.IsOSSPath(val) {
return prefix + val
}
return val
case map[string]any:
for k, item := range val {
val[k] = prependFilePathPrefix(prefix, item)
}
return val
case []any:
for i, item := range val {
val[i] = prependFilePathPrefix(prefix, item)
}
return val
case []map[string]any:
for _, m := range val {
for k, item := range m {
m[k] = prependFilePathPrefix(prefix, item)
}
}
return val
default:
return val
}
}