Files
ai-agent/workflow/service/flow/lambda_node_util.go
T
19904408334 cef837a35c feat: 支持工作流断点续跑并拆分错误信息存储
- 新增同会话+同工作流最近执行失败且参数一致时断点续跑逻辑
- exec_workflow/exec_chat 新增 error 字段存储原始错误,error_message 仅存友好提示
- 新增 UpdateExecChatReq 与 exec_chat_dao Update 方法
- 新增 GetLatestBySessionAndFlow 查询最近执行记录
- 修正 ListDates 分组与排序 SQL 表达式
- 新增 pipeline 配置结构,删除旧设计文档
2026-08-21 09:54:06 +08:00

381 lines
11 KiB
Go
Raw 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"
"context"
"fmt"
"regexp"
"strings"
"sync"
commonHttp "gitea.redpowerfuture.com/red-future/common/http"
"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"
)
// 全局等待任务回调的工具
var (
asyncMu sync.Mutex
asyncTasks = make(map[string]chan any)
)
// Wait 阻塞等待回调结果
// 调用后会一直卡住,直到 Notify 唤醒 或 超时/取消
func Wait(ctx context.Context, taskId string) (any, error) {
asyncMu.Lock()
ch := make(chan any, 1)
asyncTasks[taskId] = ch
asyncMu.Unlock()
defer close(ch)
for {
select {
case result := <-ch:
return result, nil
case <-ctx.Done():
asyncMu.Lock()
delete(asyncTasks, taskId)
asyncMu.Unlock()
return nil, ctx.Err()
}
}
}
// Notify 回调时调用,唤醒等待的任务
func Notify(taskId string, result any) {
asyncMu.Lock()
defer asyncMu.Unlock()
ch, exist := asyncTasks[taskId]
if !exist {
return
}
ch <- result
delete(asyncTasks, taskId)
}
// 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) ([]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)
}
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
// 异步模型 msgTopic 由 gateway.ModelCallResult 在为空时自动生成(唯一、带业务标识),调用方无需管理
responseParams, err := gateway.ModelCallResult(ctx, 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(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 = make(map[string]string)
if r := g.RequestFromCtx(ctx); r != nil {
for k, v := range r.Request.Header {
if len(v) > 0 {
headers[k] = v[0]
}
}
}
}
// 构建请求参数
ProcessValueSourceRecursive(body, nodeInput.Global)
// 递归剥掉 {type, value/attrs} 包裹层,只保留 key/value
wrapper := 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 = 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 = 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 := utils.GetFileAddressPrefix(ctx)
if err != nil {
g.Log().Warningf(ctx, "获取文件前缀失败,保持原路径: %v", err)
return
}
for k, v := range body {
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 utils.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
}
}
// BuildSubtitles 核心工具:单个sentence生成多条subtitle
func BuildSubtitles(sents *[]flowDto.Sentence) ([]flowDto.Subtitle, error) {
var subtitles []flowDto.Subtitle
for _, sent := range *sents {
// 1. 先按标点把文本拆成多个片段(保留标点)
segList := splitTextByPunct(sent.Text)
if len(segList) == 0 {
continue
}
wordIdx := 0
allWords := sent.Words
// 2. 遍历每个文本片段,匹配对应的Words
for _, seg := range segList {
// 去除文本片段的标点,方便和Word.Word拼接内容匹配
segClean := strings.ReplaceAll(seg, "", "")
segClean = strings.ReplaceAll(segClean, "。", "")
segClean = strings.ReplaceAll(segClean, "", "")
segClean = strings.ReplaceAll(segClean, "", "")
segClean = strings.ReplaceAll(segClean, "", "")
var collectWords []flowDto.Word
var currentText strings.Builder
// 收集Word直到拼接内容覆盖当前分段
for wordIdx < len(allWords) {
word := allWords[wordIdx]
currentText.WriteString(word.Word)
collectWords = append(collectWords, word)
wordIdx++
// 当拼接的文本包含当前分段的纯文本时,停止收集
if strings.Contains(currentText.String(), segClean) {
break
}
}
if len(collectWords) == 0 {
continue
}
// 3. 生成字幕(时间戳取首尾Word的时间)
sub := flowDto.Subtitle{
Start: collectWords[0].StartTime,
End: collectWords[len(collectWords)-1].EndTime,
Text: segClean,
}
subtitles = append(subtitles, sub)
}
}
return subtitles, nil
}
// splitTextByPunct 按中文标点分割句子,同时保留标点在分段内
// 例如:"这个叫高血压调理方,注意是根源调理不是临时缓解,"
// 会变成:["这个叫高血压调理方,", "注意是根源调理不是临时缓解,"]
func splitTextByPunct(raw string) []string {
// 匹配中文标点并保留在文本中,按标点位置切分
re := regexp.MustCompile(`[,。;!?]`)
// 先找到所有标点的位置
indexes := re.FindAllStringIndex(raw, -1)
if len(indexes) == 0 {
return []string{raw}
}
var res []string
prev := 0
for _, idx := range indexes {
end := idx[1] // 标点的结束位置
seg := raw[prev:end]
res = append(res, seg)
prev = end
}
// 处理最后一段没有标点的文本
if prev < len(raw) {
res = append(res, raw[prev:])
}
return res
}