feat: 支持工作流断点续跑并拆分错误信息存储

- 新增同会话+同工作流最近执行失败且参数一致时断点续跑逻辑
- exec_workflow/exec_chat 新增 error 字段存储原始错误,error_message 仅存友好提示
- 新增 UpdateExecChatReq 与 exec_chat_dao Update 方法
- 新增 GetLatestBySessionAndFlow 查询最近执行记录
- 修正 ListDates 分组与排序 SQL 表达式
- 新增 pipeline 配置结构,删除旧设计文档
This commit is contained in:
2026-08-21 09:54:06 +08:00
parent a939c45508
commit cef837a35c
34 changed files with 1519 additions and 515 deletions
+177 -8
View File
@@ -2,10 +2,13 @@ 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"
@@ -57,20 +60,31 @@ func Notify(taskId string, result any) {
delete(asyncTasks, taskId)
}
// ModelCallResultLambda 调用模型并返回输出内容列表,同时回传本次调用的 token/费用(*gateway.ModelCallRes
// 供调用方(ModelLambda)累计写入节点执行记录 token_info,最后由汇总节点聚合到 exec_workflow。
func ModelCallResultLambda(ctx context.Context, modelId int64, sessionId string, modelRequestParams map[string]any) ([]map[string]any, *gateway.ModelCallRes, error) {
// 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, fmt.Errorf("获取模型配置失败: %w", err)
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, nil)
responseParams, err := gateway.ModelCallResult(ctx, modelId, modelInfo.ModelManage.ResponseType, sessionId, modelRequestParams, businessParams)
if err != nil {
return nil, nil, err
return nil, nil, false, err
}
if g.IsEmpty(responseParams) {
return nil, nil, fmt.Errorf("生成内容为空")
return nil, nil, false, fmt.Errorf("生成内容为空")
}
outputRes := make([]map[string]any, 0)
for key, val := range responseParams.Content {
@@ -78,7 +92,7 @@ func ModelCallResultLambda(ctx context.Context, modelId int64, sessionId string,
key: val,
})
}
return outputRes, responseParams, nil
return outputRes, responseParams, isInference, nil
}
func HttpCallResultLambda(ctx context.Context, nodeInput *flowDto.NodeExecutionInput) ([]map[string]any, error) {
@@ -137,6 +151,9 @@ func HttpCallResultLambda(ctx context.Context, nodeInput *flowDto.NodeExecutionI
// 递归剥掉 {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
@@ -209,3 +226,155 @@ func HttpCallResultLambda(ctx context.Context, nodeInput *flowDto.NodeExecutionI
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
}