- 新增计费模块:执行开始建单、终态结算/取消/失败处理,支持按条/按秒/按token计费 - 新增执行生命周期跟踪:优雅关停时取消运行中执行并等待落库 - 新增异步任务等待/通知机制(Wait/Notify) - 重构执行记录落库与进度上报,统一失败分类与重试语义 - 重命名文件:async_task.go→async.go、flow_checkpoint_store.go→exec_checkpoint.go、flow_graph_util.go→exec_record.go - 更新 .gitignore 与数据库密码配置
260 lines
8.4 KiB
Go
260 lines
8.4 KiB
Go
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, 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=null(HTTP 仍 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, 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
|
||
}
|
||
}
|