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" "unicode/utf8" 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] } } } } g.Log().Debugf(ctx, "httpCallResultLambda: body: %v", body) // 构建请求参数 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) // 打印入参 g.Log().Debugf(ctx, "httpCallResultLambda: newBody: %v", 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 } } // punctRe 切分/剥离用的中文标点(含顿号、) var punctRe = regexp.MustCompile(`[,。;!?、]`) // 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 } // 去标点后得到纯净片段(纯空白/纯标点片段跳过) var cleans []string for _, seg := range segList { c := strings.TrimSpace(cleanPunct(seg)) if c != "" { cleans = append(cleans, c) } } if len(cleans) == 0 || len(sent.Words) == 0 { continue } // 2. 词级文本与句子文本一致时,按词精确对齐取首尾词时间(最准) if spans, ok := alignAllSegments(sent.Words, cleans); ok { for i, span := range spans { subtitles = append(subtitles, flowDto.Subtitle{ Start: sent.Words[span[0]].StartTime, End: sent.Words[span[1]].EndTime, Text: cleans[i], }) } continue } // 3. ASR 词级转写与句子文本不一致时(如 血→谑、数字写法不一), // 整句回退为按片段字符占比分配时间,避免整句被吞成一条字幕 segWords := allocWordsByProportion(sent.Words, cleans) for i, ws := range segWords { if len(ws) == 0 { continue } subtitles = append(subtitles, flowDto.Subtitle{ Start: ws[0].StartTime, End: ws[len(ws)-1].EndTime, Text: cleans[i], }) } } return subtitles, nil } // splitTextByPunct 按中文标点分割句子,同时保留标点在分段内 // 例如:"这个叫高血压调理方,注意是根源调理不是临时缓解," // 会变成:["这个叫高血压调理方,", "注意是根源调理不是临时缓解,"] func splitTextByPunct(raw string) []string { // 匹配中文标点并保留在文本中,按标点位置切分 indexes := punctRe.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 } // cleanPunct 去掉中文标点,得到纯净文本 func cleanPunct(raw string) string { return punctRe.ReplaceAllString(raw, "") } // alignAllSegments 按顺序把各纯净片段与词级文本逐字符对齐(允许个别字符不一致)。 // 全部片段对齐成功且词被完整覆盖时返回各片段对应的词区间,否则 ok=false, // 由调用方回退到时间占比分配。 func alignAllSegments(words []flowDto.Word, cleans []string) ([][2]int, bool) { spans := make([][2]int, len(cleans)) wordIdx := 0 for i, seg := range cleans { start := wordIdx segRunes := []rune(seg) s := 0 for wordIdx < len(words) && s < len(segRunes) { for _, r := range []rune(words[wordIdx].Word) { if s < len(segRunes) && r == segRunes[s] { s++ } } wordIdx++ } // 片段文本没被完整匹配,或该片段没吃到任何词 → 无法精确对齐 if s < len(segRunes) || start == wordIdx { return nil, false } spans[i] = [2]int{start, wordIdx - 1} } // 有剩余词未被任何片段覆盖,说明对齐失败,避免吞掉剩余时间 if wordIdx < len(words) { return nil, false } return spans, true } // allocWordsByProportion 按纯净片段字符占比把整句时间区间切成段,再按时间中点把 // 每个 word 归属到所属片段(对词级转写与句子文本不一致的情况兜底)。 func allocWordsByProportion(words []flowDto.Word, cleans []string) [][]flowDto.Word { runes := make([]int, len(cleans)) totalChars := 0 for i, c := range cleans { runes[i] = utf8.RuneCountInString(c) totalChars += runes[i] } sentStart := words[0].StartTime sentEnd := words[len(words)-1].EndTime duration := sentEnd - sentStart if duration < 0 { duration = 0 } bounds := make([]float64, len(cleans)+1) bounds[0] = sentStart accum := 0.0 for i := range cleans { if totalChars > 0 { accum += float64(runes[i]) / float64(totalChars) } bounds[i+1] = sentStart + accum*duration } segWords := make([][]flowDto.Word, len(cleans)) for _, w := range words { mid := (w.StartTime + w.EndTime) / 2 idx := 0 for b := 0; b < len(bounds)-1; b++ { if mid >= bounds[b+1] { idx = b + 1 } } segWords[idx] = append(segWords[idx], w) } return segWords }