diff --git a/digital-human/model/dto/model_test_dto.go b/digital-human/model/dto/model_test_dto.go new file mode 100644 index 0000000..2346dff --- /dev/null +++ b/digital-human/model/dto/model_test_dto.go @@ -0,0 +1,34 @@ +package dto + +import ( + "gitea.redpowerfuture.com/red-future/common/beans" + "github.com/gogf/gf/v2/frame/g" +) + +// TestModelStreamReq 测试模型流式调用请求(流式调用上游 → 全量一次返回) +type TestModelStreamReq struct { + g.Meta `path:"/testModelStream" method:"post" tags:"模型流测试" summary:"测试模型流式调用" dc:"透传调用model-gateway的流式接口(上游流式调用,缓冲后全量一次返回)"` + ModelName string `json:"modelName" v:"required#modelName不能为空" dc:"模型名称"` + BizName string `json:"bizName" dc:"业务名称"` + SessionId string `json:"sessionId" v:"required#sessionId不能为空" dc:"会话ID"` + RequestParams map[string]any `json:"requestParams" v:"required#请求参数不能为空" dc:"模型请求参数"` +} + +// TestModelStreamAAReq 测试模型流式调用请求(流式调用上游 → SSE 分块返回) +type TestModelStreamAAReq struct { + g.Meta `path:"/testModelStreamAA" method:"post" tags:"模型流测试" summary:"测试模型流式调用AA" dc:"透传调用model-gateway的SSE流式接口(上游流式调用,逐分片SSE返回)"` + ModelName string `json:"modelName" v:"required#modelName不能为空" dc:"模型名称"` + BizName string `json:"bizName" dc:"业务名称"` + SessionId string `json:"sessionId" v:"required#sessionId不能为空" dc:"会话ID"` + RequestParams map[string]any `json:"requestParams" v:"required#请求参数不能为空" dc:"模型请求参数"` +} + +// TestModelStreamRes 测试模型流式调用响应(实际通过RawWriter直接写出,该结构体仅占位) +type TestModelStreamRes struct { + *beans.ResponseEmpty +} + +// TestModelStreamAARes 测试模型流式调用AA响应(实际通过RawWriter直接写出,该结构体仅占位) +type TestModelStreamAARes struct { + *beans.ResponseEmpty +} diff --git a/gateway/file.go b/gateway/file.go new file mode 100644 index 0000000..d4aa7db --- /dev/null +++ b/gateway/file.go @@ -0,0 +1,77 @@ +package gateway + +import ( + "bytes" + "context" + "io" + "mime/multipart" + "net/http" + "strings" + + commonHttp "gitea.redpowerfuture.com/red-future/common/http" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" +) + +// Upload 以 multipart 方式上传文件字节到 OSS,返回可访问 URL。 +// 原签名基于 workflow/model/dto 的 UploadFileBytesReq/Res,抽离时简化为直接传文件名与字节,便于业务侧解耦 workflow。 +func Upload(ctx context.Context, fileName string, fileBytes []byte) (string, error) { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + part, err := writer.CreateFormFile("file", fileName) + if err != nil { + return "", err + } + if _, err = part.Write(fileBytes); err != nil { + return "", err + } + if err = writer.Close(); err != nil { + return "", err + } + + 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] + } + } + } + headers["Content-Type"] = writer.FormDataContentType() + + res := &uploadFileRes{} + if err = commonHttp.Post(ctx, "oss/file/uploadFile", headers, res, body.Bytes()); err != nil { + return "", err + } + return res.FileURL, nil +} + +// uploadFileRes OSS 上传响应的最小字段(原 workflow/model/dto.UploadFileBytesRes 的 URL 部分)。 +type uploadFileRes struct { + FileURL string `json:"fileURL"` +} + +// GetFileBytesFromURL 下载远程文件内容(把 filePrefix 前缀替换为 minioPrefix 后经 GoFrame 客户端下载) +func GetFileBytesFromURL(ctx context.Context, fileUrl string) ([]byte, error) { + newS := strings.ReplaceAll(fileUrl, g.Cfg().MustGet(ctx, "filePrefix").String(), g.Cfg().MustGet(ctx, "minioPrefix").String()) + // 使用 GoFrame 客户端(自带超时、追踪、日志等能力) + resp, err := g.Client().Get(ctx, newS) + if err != nil { + return nil, gerror.Wrapf(err, "failed to request url: %s", newS) + } + defer resp.Close() + + // 校验状态码 + if resp.StatusCode != http.StatusOK { + return nil, gerror.Newf("request failed with status code: %d, url: %s", resp.StatusCode, newS) + } + + // 读取全部内容 + allBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, gerror.Wrapf(err, "failed to read response body, url: %s", fileUrl) + } + + return allBytes, nil +} diff --git a/gateway/model.go b/gateway/model.go new file mode 100644 index 0000000..4f75125 --- /dev/null +++ b/gateway/model.go @@ -0,0 +1,211 @@ +// Package gateway ai-agent 对外部服务的调用层:模型网关(model-gateway)的查询/提交/聊天调用,以及文件下载/上传(OSS)。 +// 从 workflow/service/flow 抽离为独立目录,供工作流节点与视频管线(video/)共用,业务侧不再直接依赖 workflow 引擎。 +package gateway + +import ( + "context" + "fmt" + "net/http" + "time" + + "ai-agent/workflow/consts/model" + "ai-agent/workflow/consts/public" + + "gitea.redpowerfuture.com/red-future/common/beans" + commonHttp "gitea.redpowerfuture.com/red-future/common/http" + gmq "github.com/bjang03/gmq/core/gmq" + "github.com/bjang03/gmq/mq" + "github.com/bjang03/gmq/types" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" + "github.com/google/uuid" +) + +// ListModelManageReq 配置列表 +type ListModelManageReq struct { + *beans.Page `json:"page"` + ModelName string `p:"modelName" json:"modelName" dc:"模型名称(模糊查询,可选)"` + ModelType int `p:"modelType" json:"modelType" dc:"模型类型"` +} + +// ModelManageListItem 模型配置列表项。listModelManage 返回平铺的 model-gateway entity(id 在顶层), +// 与 getModelManage 的 {modelManage:{...}} 嵌套不同,故独立成结构而非复用 GetModelInfoByIdRes。 +type ModelManageListItem struct { + Id int64 `json:"id" dc:"配置ID"` + ModelName string `json:"modelName" dc:"模型名称"` +} + +type ListModelManageRes struct { + List []*ModelManageListItem `json:"list" dc:"列表数据"` + Total int `json:"total" dc:"总数"` +} + +// GetModelInfoByIdReq 查询模型配置 +type GetModelInfoByIdReq struct { + ModelId int64 `json:"id" dc:"模型ID"` +} + +type GetModelInfoByIdRes struct { + ModelManage struct { + Id int64 `json:"id" dc:"模型ID"` + ModelType model.ModelType `json:"modelType" description:"模型类型"` + ModelName string `json:"modelName" description:"模型名称"` + MaxTokens int `json:"maxTokens" description:"最大token数"` + ChatModel *bool `json:"chatModel" description:"是否聊天模型"` + ResponseType model.ResponseType `json:"responseType" description:"返回类型:1同步,2异步,3流"` + Enabled *bool `json:"enabled" description:"是否启用"` + RequestBodyMapping map[string]any `json:"requestBodyMapping" description:"请求体映射(约等于视频模型 schema)"` + RequestBusinessFieldMapping map[string]string `json:"requestBusinessFieldMapping" description:"请求业务字段映射"` + MinDuration int `json:"minDuration" description:"最小时长(秒)"` + MaxDuration int `json:"maxDuration" description:"最大时长(秒)"` + } `json:"modelManage" dc:"模型配置"` +} + +// modelCallTopic 生成异步模型消息主题:带业务标识(bizName/modelId/sessionId)便于排查, +// uuid 保证每次调用唯一,避免并发调用共用同一主题导致结果串扰 +func modelCallTopic(bizName string, modelId int64, sessionId string) string { + return fmt.Sprintf("model-call-%s-%d-%s-%s", bizName, modelId, sessionId, uuid.NewString()) +} + +// ModelCallReq 调用模型网关 +type ModelCallReq struct { + ModelId int64 `json:"modelId" dc:"模型ID"` + BizName string `json:"bizName" dc:"业务名称(调用方模块/系统,用于统计)"` + SessionId string `json:"sessionId" dc:"会话ID"` + RequestParams map[string]any `json:"requestParams" dc:"请求参数"` + BusinessParams map[string]any `json:"businessParams" dc:"业务参数"` + MsgTopic string `json:"msgTopic" dc:"消息主题(异步必要参数)"` +} + +type ModelCallRes struct { + TaskId int64 `json:"id" dc:"任务ID"` + State int8 `json:"state" dc:"状态"` + TotalTokens int64 `json:"totalTokens" dc:"总token"` + PromptTokens int64 `json:"promptTokens" dc:"输入token"` + CompletionTokens int64 `json:"completionTokens" dc:"输出token"` + Tools []ModelTool `json:"tools" dc:"工具"` + Content map[string]any `json:"content" dc:"内容"` + Cost float64 `json:"cost" dc:"费用(元)"` + ErrorMsg string `json:"errorMsg" dc:"错误消息"` +} + +type ModelTool struct { + Id string `json:"id" dc:"工具ID"` + Type string `json:"type" dc:"工具类型"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` +} + +// requestHeaders 透传当前 HTTP 请求头(鉴权/链路信息)。 +// 浏览器 WebSocket 握手无法携带 Authorization 头,前端把 token 放在握手 URL query(?token=)里; +// 若请求头没有 Authorization,则从 query 补回,保证下游(model-gateway → admin-go)能拿到用户 token。 +func requestHeaders(ctx context.Context) map[string]string { + 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] + } + } + if headers["Authorization"] == "" { + if t := r.Request.URL.Query().Get("token"); t != "" { + headers["Authorization"] = "Bearer " + t + } + } + } + return headers +} + +// ListModelManage 配置列表 +func ListModelManage(ctx context.Context, req *ListModelManageReq) (res *ListModelManageRes, err error) { + res = new(ListModelManageRes) + err = commonHttp.Get(ctx, "model-gateway/model/manage/listModelManage", requestHeaders(ctx), res, req) + return +} + +// GetModelInfoById 查询模型配置 +func GetModelInfoById(ctx context.Context, req *GetModelInfoByIdReq) (res *GetModelInfoByIdRes, err error) { + res = new(GetModelInfoByIdRes) + err = commonHttp.Get(ctx, "model-gateway/model/manage/getModelManage", requestHeaders(ctx), res, req) + return +} + +// ModelCallResult 调模型网关生成一段内容并等待结果。 +// 视频等异步模型为"提交+等待"一体:内部订阅 GMQ 直到结果返回。 +func ModelCallResult(ctx context.Context, modelId int64, responseType model.ResponseType, sessionId string, requestParams map[string]any, businessParams map[string]any) (responseParams *ModelCallRes, err error) { + // 异步模型必须绑定消息主题接收结果:自动生成唯一主题, + // 带业务标识(bizName/modelId/sessionId)便于排查,每次调用唯一避免并发串结果 + msgTopic := "" + if *responseType == *model.ResponseTypeAsync.Code() { + msgTopic = modelCallTopic(g.Cfg().MustGet(ctx, "server.name").String(), modelId, sessionId) + } + + req := ModelCallReq{ + ModelId: modelId, + BizName: g.Cfg().MustGet(ctx, "server.name").String(), + SessionId: sessionId, + RequestParams: requestParams, + BusinessParams: businessParams, + MsgTopic: msgTopic, + } + // 2. 克隆 commonHttp 客户端(保留 Consul 服务发现),显式设置超时和 ResponseHeaderTimeout + client := commonHttp.Httpclient.Clone() + client.SetTimeout(30 * time.Minute) + if tr, ok := client.Transport.(*http.Transport); ok { + tr.ResponseHeaderTimeout = 30 * time.Minute + } + + res := new(ModelCallRes) + err = commonHttp.Post(ctx, "model-gateway/model/call/modelCall", requestHeaders(ctx), res, &req) + if err != nil { + return nil, err + } + if g.IsEmpty(res.TaskId) || !g.IsEmpty(res.ErrorMsg) { + return nil, fmt.Errorf("创建模型任务失败:%v", res.ErrorMsg) + } + // 3. 订阅模型结果(异步模型) + if *responseType == *model.ResponseTypeAsync.Code() { + resultCh := make(chan *ModelCallRes, 1) + errCh := make(chan error, 1) + + _, err = gmq.GetGmq(public.GmqMsgPluginsName).GmqSubscribe(ctx, &mq.NatsSubMessage{ + SubMessage: types.SubMessage{ + Topic: msgTopic, + ConsumerName: fmt.Sprintf("model-call-result-%s", uuid.NewString()), + AutoAck: false, + AutoUnsubscribe: true, + FetchCount: 1, + HandleFunc: func(ctx context.Context, msg any) error { + r := new(ModelCallRes) + if err := gconv.Struct(msg, r); err != nil { + errCh <- err + return nil + } + if g.IsEmpty(r.TaskId) || !g.IsEmpty(r.ErrorMsg) { + errCh <- fmt.Errorf("创建模型任务失败:%v", r.ErrorMsg) + return nil + } + resultCh <- r + return nil + }, + }, + Durable: true, + }) + if err != nil { + return + } + + select { + case responseParams = <-resultCh: + case err = <-errCh: + return nil, err + case <-ctx.Done(): + return nil, ctx.Err() + } + return + } + + return res, nil +} diff --git a/gateway/model_stream.go b/gateway/model_stream.go new file mode 100644 index 0000000..b138caf --- /dev/null +++ b/gateway/model_stream.go @@ -0,0 +1,157 @@ +package gateway + +import ( + "bufio" + "context" + "encoding/json" + "io" + "strings" + + commonHttp "gitea.redpowerfuture.com/red-future/common/http" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" +) + +// ModelCallStreamReq 调用模型网关流式(client 侧请求体) +type ModelCallStreamReq struct { + ModelId int64 `json:"modelId" dc:"模型ID"` + BizName string `json:"bizName" dc:"业务名称"` + SessionId string `json:"sessionId" dc:"会话ID"` + RequestParams map[string]any `json:"requestParams" dc:"请求参数(模板字段)"` + BusinessParams map[string]any `json:"businessParams" dc:"业务参数"` +} + +// ModelCallStream 流式调用模型网关:POST /modelCallStream,逐 chunk 调 onChunk(文本增量转发), +// 流结束返回 ModelCallRes:Content 为累加全量文本({"respBody": ...}),Tools 为流末工具列表。 +func ModelCallStream(ctx context.Context, modelId int64, sessionId string, requestParams, businessParams map[string]any, onChunk func(chunk map[string]any) error) (*ModelCallRes, error) { + req := ModelCallStreamReq{ + ModelId: modelId, + BizName: g.Cfg().MustGet(ctx, "server.name").String(), + SessionId: sessionId, + RequestParams: requestParams, + BusinessParams: businessParams, + } + body, err := commonHttp.PostStream(ctx, "model-gateway/model/call/modelCallStream", requestHeaders(ctx), &req) + if err != nil { + return nil, err + } + defer body.Close() + + res := new(ModelCallRes) + var contentBuf strings.Builder + scanErr := parseSSEStream(ctx, body, func(chunk map[string]any) error { + tools, done := processStreamChunk(chunk) + if done { + res.Tools = tools + // 流末 done 事件携带该次调用累计 token 与费用:纯工具调用步骤无文本 chunk,靠它取数(>0 才覆盖,兼容旧网关无 token/cost 的 done 事件) + if total := gconv.Int64(chunk["totalTokens"]); total > 0 { + res.TotalTokens = total + res.PromptTokens = gconv.Int64(chunk["promptTokens"]) + res.CompletionTokens = gconv.Int64(chunk["completionTokens"]) + } + if cost := gconv.Float64(chunk["cost"]); cost > 0 { + res.Cost = cost + } + return nil + } + if onChunk != nil { + if err := onChunk(chunk); err != nil { + return err + } + } + contentBuf.WriteString(streamTextDelta(chunk)) + // 网关端每个 chunk 携带累计 token 值,取最后一个文本 chunk 即为该步总消耗 + res.TotalTokens = gconv.Int64(chunk["totalTokens"]) + res.PromptTokens = gconv.Int64(chunk["promptTokens"]) + res.CompletionTokens = gconv.Int64(chunk["completionTokens"]) + res.Cost = gconv.Float64(chunk["cost"]) + return nil + }) + if scanErr != nil { + // 流被中断(如 ctx 取消)时仍返回已累计的 res,调用方可读取已产生的 token 正常落库 + return res, scanErr + } + if contentBuf.Len() > 0 { + res.Content = map[string]any{"respBody": contentBuf.String()} + } + return res, nil +} + +// processStreamChunk 处理单个流式 chunk。done 事件({"type":"done","tools":[...]})返回解析出的 +// tools(可为空)且 done=true;其余为文本增量 chunk,返回 done=false。 +func processStreamChunk(chunk map[string]any) (tools []ModelTool, done bool) { + if typ, _ := chunk["type"].(string); typ == "done" { + raw, _ := chunk["tools"].([]any) + for _, t := range raw { + m := gconv.Map(t) + if m == nil { + continue + } + var tool ModelTool + tool.Id = gconv.String(m["id"]) + tool.Type = gconv.String(m["type"]) + if fn := gconv.Map(m["function"]); fn != nil { + tool.Function.Name = gconv.String(fn["name"]) + tool.Function.Arguments = gconv.String(fn["arguments"]) + } + tools = append(tools, tool) + } + return tools, true + } + return nil, false +} + +// streamTextDelta 提取文本增量 chunk 的文本(读 content 子对象,字段名由网关端结构体统一管理) +func streamTextDelta(chunk map[string]any) string { + var sb strings.Builder + if content, ok := chunk["content"].(map[string]any); ok { + for _, v := range content { + if s := gconv.String(v); s != "" { + sb.WriteString(s) + } + } + } + return sb.String() +} + +// parseSSEStream 标准 SSE 解析:逐 data 行 JSON 回调,支持 [DONE] 与上下文取消。 +// 对齐 model-gateway ParseSSEStream 语义:跳过空行、[DONE]、非 data 行。 +func parseSSEStream(ctx context.Context, reader io.Reader, onChunk func(chunk map[string]any) error) error { + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + var sb strings.Builder + for scanner.Scan() { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + line := scanner.Text() + trimmed := strings.TrimSpace(line) + if trimmed == "" { + if sb.Len() == 0 { + continue + } + data := sb.String() + sb.Reset() + if data == "[DONE]" { + continue + } + var chunk map[string]any + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + continue + } + if onChunk != nil { + if err := onChunk(chunk); err != nil { + return err + } + } + continue + } + if strings.HasPrefix(line, "data:") { + sb.WriteString(strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + } + } + return scanner.Err() +} diff --git a/go.mod b/go.mod index 79be24f..8e91b0c 100644 --- a/go.mod +++ b/go.mod @@ -4,19 +4,25 @@ go 1.26.0 require ( gitea.redpowerfuture.com/red-future/common v0.0.24 + github.com/bjang03/gmq v0.0.1 github.com/cloudwego/eino v0.9.5 github.com/cloudwego/eino-examples v0.0.0-20260611092511-bd64846fbc1d github.com/cloudwego/eino-ext/components/model/qwen v0.1.9 github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.2 github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.2 + github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5 github.com/gogf/gf/v2 v2.10.2 github.com/google/uuid v1.6.0 github.com/stretchr/testify v1.11.1 - github.com/tidwall/gjson v1.19.0 + github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 go.opentelemetry.io/otel/trace v1.44.0 ) +replace gitea.redpowerfuture.com/red-future/common v0.0.24 => ../common + +replace github.com/bjang03/gmq v0.0.1 => ../gmq + require ( github.com/BurntSushi/toml v1.5.0 // indirect github.com/armon/go-metrics v0.4.1 // indirect @@ -41,10 +47,13 @@ require ( github.com/evanphx/json-patch v0.5.2 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/go-ego/gse v1.0.2 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/gogf/gf/contrib/trace/otlphttp/v2 v2.9.5 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect @@ -67,8 +76,9 @@ require ( github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/serf v0.10.1 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/lib/pq v1.10.9 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mailru/easyjson v0.9.0 // indirect @@ -80,6 +90,9 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/nats-io/nats.go v1.49.0 // indirect + github.com/nats-io/nkeys v0.4.15 // indirect + github.com/nats-io/nuid v1.0.1 // indirect github.com/nikolalohinski/gonja v1.5.3 // indirect github.com/olekukonko/errors v1.1.0 // indirect github.com/olekukonko/ll v0.0.9 // indirect @@ -88,10 +101,12 @@ require ( github.com/pkg/errors v0.9.2-0.20201214064552-5dd12d0cfe7f // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/r3labs/diff/v2 v2.15.1 // indirect - github.com/redis/go-redis/v9 v9.17.2 // indirect + github.com/rabbitmq/amqp091-go v1.10.0 // indirect + github.com/redis/go-redis/v9 v9.18.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect + github.com/spf13/cast v1.10.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tiger1103/gfast-token v1.0.10 // indirect @@ -109,11 +124,13 @@ require ( go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/otel/sdk v1.38.0 // indirect go.opentelemetry.io/proto/otlp v1.7.1 // indirect + go.uber.org/atomic v1.11.0 // indirect golang.org/x/arch v0.19.0 // indirect + golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.32.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect diff --git a/go.sum b/go.sum index 0c5ec37..7b8043a 100644 --- a/go.sum +++ b/go.sum @@ -1,23 +1,36 @@ +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -gitea.redpowerfuture.com/red-future/common v0.0.23 h1:xieoA00iKOCDm5SO9iXn+cSyMKBAlZwI0fuEVPWrHLg= -gitea.redpowerfuture.com/red-future/common v0.0.23/go.mod h1:50U1Xi+Ie56z09S5LQbZvaken0Mxv3OeS9LgR7U/ZRY= -gitea.redpowerfuture.com/red-future/common v0.0.24 h1:sXxhnmDmCgn+KwH/3gDnhAtAQ7FCmf/5AsMfvxRmri0= -gitea.redpowerfuture.com/red-future/common v0.0.24/go.mod h1:50U1Xi+Ie56z09S5LQbZvaken0Mxv3OeS9LgR7U/ZRY= +cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= +github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o= +github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alibaba/sentinel-golang v1.0.4/go.mod h1:Lag5rIYyJiPOylK8Kku2P+a23gdKMMqzQS7wTnjWEpk= +github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/alitto/pond v1.9.2/go.mod h1:xQn3P/sHTYcU/1BR3i86IGIrilcrGC2LiS+E2+CJWsI= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -25,6 +38,8 @@ github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+Ce github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= +github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= @@ -36,8 +51,6 @@ github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqR github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= -github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= -github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= github.com/bytedance/mockey v1.3.0 h1:ONLRdvhqmCfr9rTasUB8ZKCfvbdD2tohOg4u+4Q/ed0= @@ -46,6 +59,9 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cenk/backoff v2.2.1+incompatible/go.mod h1:7FtoeaSnHoZnmZzz47cM35Y9nSW7tNyaidugnHTaFDE= +github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -53,6 +69,9 @@ github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEex github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= +github.com/chromedp/chromedp v0.9.5/go.mod h1:D4I2qONslauw/C7INoCir1BJkSwBYMyZgx8X276z3+Y= +github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= @@ -64,13 +83,31 @@ github.com/cloudwego/eino v0.9.5 h1:0Nftjx9gPek/2S/hzm38LVxSjk5/6mqRr3I9VKrKvm4= github.com/cloudwego/eino v0.9.5/go.mod h1:OBD1mrkfkt/pJa4rkg1P0VnaMeOVl7l8IAdEqY//3IQ= github.com/cloudwego/eino-examples v0.0.0-20260611092511-bd64846fbc1d h1:NrAxhU58S5SgK5YbrYnaOQrQFwAz3x4/0qg46JM8Eo4= github.com/cloudwego/eino-examples v0.0.0-20260611092511-bd64846fbc1d/go.mod h1:VVmcWGhnLIxLkrQAaCoOtQoEbcvOCrMQvbXgbo9O34Q= +github.com/cloudwego/eino-ext/adk/backend/local v0.2.1/go.mod h1:os5Tq5FuSoz/MLqAdZER3ip49Oef9prc0kVsKsPYO48= +github.com/cloudwego/eino-ext/callbacks/cozeloop v0.3.0/go.mod h1:/biyKmCroUH3Y6fG2sBBiZyUmzwRhb3YKAsORGwxk1g= +github.com/cloudwego/eino-ext/components/document/parser/html v0.0.0-20251117090452-bd6375a0b3cf/go.mod h1:DBwsPrdNxPeE3HNr5XyjjFreG4ypqCUjN0T2C2JSy6k= +github.com/cloudwego/eino-ext/components/document/parser/pdf v0.0.0-20251117090452-bd6375a0b3cf/go.mod h1:kHC3xkGM/gv3IHpOk33p75BfBaEIYATOs2XmYFKffcs= +github.com/cloudwego/eino-ext/components/model/agenticark v0.2.0-beta.1/go.mod h1:dx+o4e/wfAmCNXIOTsXl+NiKokz2iU5P1f7eQClffnQ= +github.com/cloudwego/eino-ext/components/model/agenticopenai v0.2.0-beta.1/go.mod h1:aUmCsYjxXp6pkjDThNWmmEKnVEAFwv0XN1Qi2gdBByA= +github.com/cloudwego/eino-ext/components/model/ark v0.1.68/go.mod h1:IctHLV+EmEhf3o2fBw0N873mLIyNlEAAGcEpUGEQdvk= +github.com/cloudwego/eino-ext/components/model/deepseek v0.1.6/go.mod h1:YVjkAAxwLqk/dyA7AfVksaxPMG7snikx0hjje8iIZBE= +github.com/cloudwego/eino-ext/components/model/ollama v0.1.9/go.mod h1:C3rf3yy2nEoXFP/CQJne4gbiu1pREKplHKmFlhuOzPE= +github.com/cloudwego/eino-ext/components/model/openai v0.1.13/go.mod h1:mgIoqYYOc0eECCqvLbEYpOJrQNTNxkwXzSJzFU+v5sQ= github.com/cloudwego/eino-ext/components/model/qwen v0.1.9 h1:xCz/mp43JeWqupjPR3zLRArmwC6P29/6lTwbwh1yzYM= github.com/cloudwego/eino-ext/components/model/qwen v0.1.9/go.mod h1:slTGTuhzkzhNavf+1UtUg1FvUSA31iNAF+rq1mT4SnI= +github.com/cloudwego/eino-ext/components/retriever/volc_vikingdb v0.0.0-20251120060928-25485ef519b5/go.mod h1:He7AHJpLTs0MXKPx5JpI8MdYtLUcKhUAZwCsgNtaq6k= +github.com/cloudwego/eino-ext/components/tool/commandline v0.0.0-20251117090452-bd6375a0b3cf/go.mod h1:cMZb1KM71kM+2hTJwxLwXT+HC66HimZirDDA5Oo64Hw= +github.com/cloudwego/eino-ext/components/tool/duckduckgo/v2 v2.0.0-20251117090452-bd6375a0b3cf/go.mod h1:Np0BXy/9hPRu3wCgn+ij6L7YsjFcybVzg1k7uYOXh0M= +github.com/cloudwego/eino-ext/components/tool/mcp/officialmcp v0.1.0/go.mod h1:9kDYHgPkYf249MhpGxGxx21MRFdKzTRyK4wDxmowo7o= +github.com/cloudwego/eino-ext/devops v0.1.9/go.mod h1:A8EzSy78tyvRqZBaHkTG1r91HZSW0uBGYW+k2xGA+WU= github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 h1:EeVcR1TslRA2IdNW1h/2LaGbPlffwGhQm99jM3zWZiI= github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17/go.mod h1:Zkcx6DPTR2NfWmtSXbhItswGw6hqUezNPhNcke0pOG8= -github.com/cloudwego/hertz v0.10.5 h1:N4oBqAJShSjYQm2Jfr0ryTzzJ9fnY9qSvIvUBMkoFWg= -github.com/cloudwego/hertz v0.10.5/go.mod h1:Im9u6rUa1v2mL2HiDKKJoof/CPQ3mPBBpT92v67Cetg= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/cohesion-org/deepseek-go v1.3.4/go.mod h1:bOVyKj38r90UEYZFrmJOzJKPxuAh8sIzHOCnLOpiXeI= +github.com/corpix/uarand v0.2.0/go.mod h1:/3Z1QIqWkDIhf6XWn/08/uMHoQ8JUoTIKc2iPchBOmM= +github.com/coze-dev/cozeloop-go v0.1.20/go.mod h1:lM7cmUEZlnAlQYdwfk4Li0SC3RdZ++QMHX75nvKceSc= +github.com/coze-dev/cozeloop-go/spec v0.1.8/go.mod h1:/f3BrWehffwXIpd4b5rYIqktLd/v5dlLBw0h9F/LQIU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -81,34 +118,51 @@ github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWa github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dgryski/go-jump v0.0.0-20211018200510-ba001c3ffce0/go.mod h1:4hKCXuwrJoYvHZxJ86+bRVTOMyJ0Ej+RqfSm8mHi6KA= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dslipak/pdf v0.0.2/go.mod h1:2L3SnkI9cQwnAS9gfPz2iUoLC0rUZwbucpbKi5R1mUo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/edwingeng/doublejump v1.0.1/go.mod h1:ykMWX8JWePtMtk2OGjNE9kwtgpI+SF2FNIyXV4gS36k= github.com/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0= github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4= +github.com/eino-contrib/ollama v0.1.0/go.mod h1:mYsQ7b3DeqY8bHPuD3MZJYTqkgyL6LoemxoP/B7ZNhA= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU= github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= +github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a/go.mod h1:7Ga40egUymuWXxAe151lTNnCv97MddSOVsjpPPkityA= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI= github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= github.com/go-ego/gse v1.0.2 h1:+27lYFPhQEhA9igtdOsJPRKYL/k3TwYsxBF5jr6KFv4= github.com/go-ego/gse v1.0.2/go.mod h1:Fy35G+q7VV7Et1zIKO8o/sW1kkugV3znXap/lF/11zc= +github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= @@ -118,7 +172,26 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-ping/ping v1.2.0/go.mod h1:xIFjORFzTxqIV/tDVGO4eDy/bLuSyawEeojSm3GfRGk= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= +github.com/godzie44/go-uring v0.0.0-20220926161041-69611e8b13d5/go.mod h1:ermjEDUoT/fS+3Ona5Vd6t6mZkw1eHp99ILO5jGRBkM= github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.2 h1:u8EpP24GkprogROnJ7htMov9Fc66pTP1eVYrWxiCYOs= github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.2/go.mod h1:GmvM3r8GVByVMi4RD2+MCs5+CfxVXPMeT8mVDkAaAXE= @@ -133,6 +206,7 @@ github.com/gogf/gf/v2 v2.10.2/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4 github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -161,6 +235,7 @@ github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= github.com/google/flatbuffers v1.12.1 h1:MVlul7pQNoDzWRLTw5imwYsl+usrS1TXG2H4jg6ImGw= github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -170,6 +245,8 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -177,8 +254,11 @@ github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18= github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic= github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs= github.com/grokify/html-strip-tags-go v0.1.0 h1:03UrQLjAny8xci+R+qjCce/MYnpNXCtgzltlQbOBae4= github.com/grokify/html-strip-tags-go v0.1.0/go.mod h1:ZdzgfHEzAfz9X6Xe5eBLVblWIxXfYSQ40S/VKrAOGpc= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= @@ -228,21 +308,30 @@ github.com/hashicorp/memberlist v0.5.0/go.mod h1:yvyXLpo0QaGE59Y7hDTsTzDD25JYBZ4 github.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY= github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kaptinlin/jsonrepair v0.2.4/go.mod h1:FRcIChI/abePdetnkc8x0JQfmHNEjQTW/LsTfI1X0oc= github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= +github.com/kavu/go_reuseport v1.5.0/go.mod h1:CG8Ee7ceMFSMnx/xr25Vm0qXaj2Z4i5PWoUx+JZ5/CU= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/reedsolomon v1.12.4/go.mod h1:d3CzOMOt0JXGIFZm1StgkyF14EYr3xneR2rNWo7NcMU= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -252,12 +341,16 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/libp2p/go-sockaddr v0.2.0/go.mod h1:5NxulaB17yJ07IpzRIleys4un0PJ7WLWgMDLBBWrGw8= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/matoous/go-nanoid v1.5.1/go.mod h1:zyD2a71IubI24efhpvkJz+ZwfwagzgSO6UNiFsZKN7U= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -277,8 +370,10 @@ github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/meguminnnnnnnnn/go-openai v0.1.2 h1:iXombGGjqjBrmE9WaSidUhhi3YQhf42QTHvHLMkgvCA= github.com/meguminnnnnnnnn/go-openai v0.1.2/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY= +github.com/meilisearch/meilisearch-go v0.36.1/go.mod h1:hWcR0MuWLSzHfbz9GGzIr3s9rnXLm1jqkmHkJPbUSvM= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY= @@ -289,6 +384,7 @@ github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrk github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modelcontextprotocol/go-sdk v1.0.0/go.mod h1:nYtYQroQ2KQiM0/SbyEPUWQ6xs4B95gJjEalc9AQyOs= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -297,28 +393,45 @@ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3Rllmb github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= +github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= +github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c= github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4= +github.com/nikolalohinski/gonja/v2 v2.3.1/go.mod h1:1Wcc/5huTu6y36e0sOFR1XQoFlylw3c3H3L5WOz0RDg= github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI= github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY= github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo= +github.com/olekukonko/ts v0.0.0-20171002115256-78ecb04241c0/go.mod h1:F/7q8/HZz+TXjlsoZQQKVYvXTZaFH4QRa3y+j1p7MS0= +github.com/olivere/elastic/v7 v7.0.32/go.mod h1:c7PVmLe3Fxq77PIfY/bZmxY/TAamBhCzZ8xDOE09a9k= +github.com/ollama/ollama v0.11.4/go.mod h1:9+1//yWPsDE2u+l1a5mpaKrYw4VdnSsRU3ioq5BvMms= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.27.3/go.mod h1:5vG284IBtfDAmDyrK+eGyZmUgUlmi+Wngqo557cZ6Gw= +github.com/openai/openai-go/v3 v3.35.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.2-0.20201214064552-5dd12d0cfe7f h1:lJqhwddJVYAkyp72a4pwzMClI20xTwL7miDdm2W/KBM= github.com/pkg/errors v0.9.2-0.20201214064552-5dd12d0cfe7f/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -327,38 +440,63 @@ github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSg github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= +github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/quic-go/quic-go v0.49.0/go.mod h1:s2wDnmCdooUQBmQfpUSTCYBl1/D4FcqbULMMkASvR6s= github.com/r3labs/diff/v2 v2.15.1 h1:EOrVqPUzi+njlumoqJwiS/TgGgmZo83619FNDB9xQUg= github.com/r3labs/diff/v2 v2.15.1/go.mod h1:I8noH9Fc2fjSaMxqF3G2lhDdC0b+JXCfyx85tWFM9kc= -github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= -github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= +github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= +github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= +github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk= +github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ= +github.com/rpcxio/libkv v0.5.1/go.mod h1:zHGgtLr3cFhGtbalum0BrMPOjhFZFJXCKiws/25ewls= +github.com/rpcxio/rpcx-consul v0.1.1/go.mod h1:N4SjBS0M9HpVdq3CIIXm/MwWv6euXQ39ybhfH2iTh1c= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/rubyist/circuitbreaker v2.2.1+incompatible/go.mod h1:Ycs3JgJADPuzJDwffe12k6BZT8hxVi6lFK+gWYJLN4A= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/shirou/gopsutil/v3 v3.21.6/go.mod h1:JfVbDpIBLVzT8oKbvMg9P3wEIMDDpVn+LwHTKj0ST88= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI= github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg= +github.com/smallnest/quick v0.2.0/go.mod h1:ODNivpfZTaMgYrNb/fhDtqoEe2TTPxSRo8JaIT/QThI= +github.com/smallnest/rpcx v1.9.1/go.mod h1:owr4mDCReTn+dy9m5ilof0mBivFBeK0XrkYfZYdDGb4= +github.com/smallnest/rsocket v0.0.0-20241130031020-4a72eb6ff62a/go.mod h1:VJeIKKrDEzT4ZNVe87JN9uRLw1XLp/ZnnE9PfsyJ1jY= github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= +github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -378,9 +516,11 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/templexxx/cpufeat v0.0.0-20180724012125-cef66df7f161/go.mod h1:wM7WEvslTq+iOEAMDLSzhVuOt5BRZ05WirO+b09GHQU= +github.com/templexxx/xor v0.0.0-20191217153810-f85b25db303b/go.mod h1:5XA7W9S6mni3h5uvOC75dA3m9CCCaS83lltmc0ukdi4= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= -github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= @@ -388,31 +528,58 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= github.com/tiger1103/gfast-token v1.0.10 h1:fNiBE/Dq5iTHvTGlCx3DmXa2o4hr0NtumFpffZ39k6s= github.com/tiger1103/gfast-token v1.0.10/go.mod h1:a/21mxmj7zFeNvjhZSC0XpEAFHfb1aT2k6DXnufFU1s= +github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= +github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= +github.com/tklauser/go-sysconf v0.3.6/go.mod h1:MkWzOF4RMCshBAMXuhXJs64Rte09mITnppBXY/rYEFI= +github.com/tklauser/numcpus v0.2.2/go.mod h1:x3qojaO3uyYt0i56EW/VUYs7uBvdl2fkfZFu0T9wgjM= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fastrand v1.1.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/vcaesar/cedar v0.30.0 h1:9fSDpM7FTjjUdPiBUUa0MWYMRGSEcqgFXvppZcZ4d7Y= github.com/vcaesar/cedar v0.30.0/go.mod h1:lyuGvALuZZDPNXwpzv/9LyxW+8Y6faN7zauFezNsnik= github.com/vcaesar/tt v0.20.1 h1:D/jUeeVCNbq3ad8M7hhtB3J9x5RZ6I1n1eZ0BJp7M+4= github.com/vcaesar/tt v0.20.1/go.mod h1:cH2+AwGAJm19Wa6xvEa+0r+sXDJBT0QgNQey6mwqLeU= github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/volcengine/volc-sdk-golang v1.0.199/go.mod h1:stZX+EPgv1vF4nZwOlEe8iGcriUPRBKX8zA19gXycOQ= +github.com/volcengine/volcengine-go-sdk v1.2.28/go.mod h1:oxoVo+A17kvkwPkIeIHPVLjSw7EQAm+l/Vau1YGHN+A= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg= github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/xtaci/kcp-go v5.4.20+incompatible/go.mod h1:bN6vIwHQbfHaHtFpEssmWsN45a+AZwO7eyRCmEIbtvE= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc= github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= @@ -429,10 +596,14 @@ go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/ go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.19.0 h1:LmbDQUodHThXE+htjrnmVD73M//D9GTH6wFZjyDkjyU= golang.org/x/arch v0.19.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -440,8 +611,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4= golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= @@ -450,8 +621,8 @@ golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvx golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -468,9 +639,10 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -506,17 +678,18 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -526,8 +699,10 @@ golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -542,6 +717,7 @@ google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCID google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= @@ -569,14 +745,28 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4= +k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/main.go b/main.go index 1b40ff3..3ff9b1e 100644 --- a/main.go +++ b/main.go @@ -2,19 +2,19 @@ package main import ( digitalhumanController "ai-agent/digital-human/controller" + "ai-agent/workflow/service/flow" + // 空导入:加载内置模型工具与工作流处理器(组合根统一装载,业务侧不感知实现位置) _ "ai-agent/tools/builtin/current_time" "ai-agent/workflow/consts/public" workController "ai-agent/workflow/controller" workflowController "ai-agent/workflow/controller/flow" workflowNodeController "ai-agent/workflow/controller/node" + sessionController "ai-agent/workflow/controller/session" workflowSkillController "ai-agent/workflow/controller/skill" toolController "ai-agent/workflow/controller/tool" - sessionController "ai-agent/workflow/controller/session" - flowService "ai-agent/workflow/service/flow" _ "ai-agent/workflow/service/flow/processor/builtin/split_batch" _ "ai-agent/workflow/service/flow/processor/builtin/split_shots_pipeline" - toolWsService "ai-agent/workflow/service/tool" "context" "os" "os/signal" @@ -43,7 +43,6 @@ func main() { digitalhumanController.DigitalHuman, // 数字人相关接口 digitalhumanController.Video, // 视频相关接口 digitalhumanController.AsyncTask, // 异步任务相关接口 - digitalhumanController.ModelTest, // 模型流式测试接口 workController.CreationInfo, workflowController.FlowExecution, workflowController.FlowUser, @@ -75,8 +74,7 @@ func main() { signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) go func() { <-sigCh - flowService.FlowWsService.Close() - toolWsService.ToolWsService.Close() + flow.SessionWsService.Close() }() // 保持应用运行 diff --git a/tools/builtin/current_time/current_time.go b/tools/builtin/current_time/current_time.go new file mode 100644 index 0000000..1983b04 --- /dev/null +++ b/tools/builtin/current_time/current_time.go @@ -0,0 +1,32 @@ +// Package current_time 内置示例工具:返回服务器当前时间。 +// 仅用于验证独立工具对话入口(/tool/agent 将全部注册工具交给模型 function calling),可按需移除。 +package current_time + +import ( + "context" + "time" + + "gitea.redpowerfuture.com/red-future/common/tools" +) + +func init() { + tools.Register(CurrentTimeTool()) +} + +// CurrentTimeTool 返回服务器当前时间,作为 model 作用域示例工具 +func CurrentTimeTool() *tools.Tool { + return &tools.Tool{ + Name: "current_time", + Description: "返回服务器当前时间。当用户询问时间/日期时调用。", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{}, + "required": false, + }, + Func: func(ctx context.Context, args map[string]any) (tools.ToolResult, error) { + return tools.OK(map[string]any{ + "now": time.Now().Format("2006-01-02 15:04:05"), + }), nil + }, + } +} diff --git a/tools/runner/react_agent.go b/tools/runner/react_agent.go new file mode 100644 index 0000000..f700635 --- /dev/null +++ b/tools/runner/react_agent.go @@ -0,0 +1,258 @@ +// Package runner 工具框架的"使用面":ReAct 执行循环。 +// 模型调用按模型响应类型分流:流式走 gateway.ModelCallStream(逐 chunk 推增量),同步/异步走 gateway.ModelCallResult; +// 本包只保留"模型思考→调用工具→观察结果"的循环编排,工具的"使用"统一走 tools.Default.Call。 +package runner + +import ( + "ai-agent/gateway" + "ai-agent/workflow/consts/model" + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/gogf/gf/v2/util/gconv" + + "gitea.redpowerfuture.com/red-future/common/tools" +) + +// ReActEventType ReAct 过程事件类型(WebSocket 场景逐条推送,HTTP 同步场景不设置 OnEvent 则忽略) +type ReActEventType string + +const ( + ReActEventModelCall ReActEventType = "model_call" // 模型思考(step 开始) + ReActEventToolCall ReActEventType = "tool_call" // 模型请求调用工具 + ReActEventToolResult ReActEventType = "tool_result" // 工具返回结果 + ReActEventAnswer ReActEventType = "answer" // 最终回答 + ReActEventAnswerChunk ReActEventType = "answer_chunk" // 回答内容增量(逐 chunk) + ReActEventReasoningChunk ReActEventType = "reasoning_chunk" // 思考内容增量(逐 chunk) + ReActEventError ReActEventType = "error" +) + +// ReActEvent ReAct 过程事件,按发生顺序回调 +type ReActEvent struct { + Type ReActEventType + Step int + MaxStep int + Description string // 工具用途说明(tool_call/tool_result 推给前端展示,不暴露工具名/参数) + Answer string + Delta string // 流式文本增量(ReActEventAnswerChunk / ReActEventReasoningChunk 用) + Message string + Error string +} + +// ReActAgent 实现 ReAct 模式的智能体:模型思考→调用工具→观察工具结果→重复→最终回答。 +// 工具的"使用"统一走 tools.Default.Call:未知工具由 Server 返回结构化 not_found, +// 无需在 runner 内维护工具查找表。 +type ReActAgent struct { + ModelId int64 + SessionId string + ToolList []*tools.Tool + SystemPrompt string + MaxStep int + // OnEvent 过程回调(WebSocket 流式推送用;HTTP 同步调用不设置,忽略事件) + OnEvent func(ReActEvent) + // TotalTokens 本次 ReAct 循环累计 token 消耗(供调用方落库) + TotalTokens int64 + // TotalCost 本次 ReAct 循环累计费用(元,各步模型调用返回的 cost 求和,供调用方落库) + TotalCost float64 +} + +// NewReActAgent 创建 ReAct 智能体 +func NewReActAgent(modelId int64, sessionId string, toolList []*tools.Tool, systemPrompt string, maxStep int) *ReActAgent { + return &ReActAgent{ + ModelId: modelId, + SessionId: sessionId, + ToolList: toolList, + SystemPrompt: systemPrompt, + MaxStep: maxStep, + } +} + +// Run 执行 ReAct 循环 +// 标准流程: 思考 → 行动(调用工具) → 观察(工具结果) → 重复 → 最终回答 +func (a *ReActAgent) Run(ctx context.Context, userInput string) (string, error) { + // 模型须为已启用且勾选「聊天模型」的对话模型,再按其响应类型分流调用: + // 流式走 ModelCallStream(逐 chunk 推增量),同步/异步走 ModelCallResult(一次返回) + modelInfo, err := gateway.GetModelInfoById(ctx, &gateway.GetModelInfoByIdReq{ModelId: a.ModelId}) + if err != nil { + return "", fmt.Errorf("获取模型配置失败: %w", err) + } + if modelInfo.ModelManage.Enabled == nil || !*modelInfo.ModelManage.Enabled { + return "", fmt.Errorf("模型 [%s] 未启用", modelInfo.ModelManage.ModelName) + } + if modelInfo.ModelManage.ChatModel == nil || !*modelInfo.ModelManage.ChatModel { + return "", fmt.Errorf("模型 [%s] 不是对话模型,请在模型配置中勾选「聊天模型」", modelInfo.ModelManage.ModelName) + } + isStream := *modelInfo.ModelManage.ResponseType == *model.ResponseTypeStream.Code() + + messages := map[string]any{ + "user_prompt": userInput, + "system_prompt": a.SystemPrompt, + "tools": a.rawTools(a.ToolList), + } + for step := 0; step < a.MaxStep; step++ { + a.emit(ReActEvent{Type: ReActEventModelCall, Step: step + 1, MaxStep: a.MaxStep}) + var result *gateway.ModelCallRes + if isStream { + result, err = gateway.ModelCallStream(ctx, a.ModelId, a.SessionId, nil, messages, func(chunk map[string]any) error { + // 文本增量从 content 子对象取,字段名由网关端结构体统一管理 + if content, ok := chunk["content"].(map[string]any); ok { + for _, v := range content { + if s := gconv.String(v); s != "" { + a.emit(ReActEvent{Type: ReActEventAnswerChunk, Delta: s}) + } + } + } + // 思考内容增量从 reasoningContent 字段取(model-gateway 按 ResponseBusinessFieldMapping 配置返回) + if s := gconv.String(chunk["reasoningContent"]); s != "" { + a.emit(ReActEvent{Type: ReActEventReasoningChunk, Delta: s}) + } + return nil + }) + } else { + // 同步/异步对话模型:结果一次返回,无增量事件(异步 msgTopic 为空时由 gateway.ModelCallResult 自动生成) + result, err = gateway.ModelCallResult(ctx, a.ModelId, modelInfo.ModelManage.ResponseType, a.SessionId, nil, messages) + } + // 出错时 result 仍可能携带已累计的 token/cost(流中断返回 res),先累加再判错 + if result != nil { + a.TotalTokens += result.TotalTokens + a.TotalCost += result.Cost + } + if err != nil { + // 前端终止/上下文取消:静默停止,不推错误事件 + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return "", err + } + a.emit(ReActEvent{Type: ReActEventError, Message: "模型调用失败", Error: err.Error()}) + return "", fmt.Errorf("step %d: model call failed: %w", step, err) + } + var content string + var toolCalls []gateway.ModelTool + if isStream { + for _, v := range gconv.Map(result.Content) { + content += v.(string) + } + toolCalls = result.Tools + } else { + // 同步响应 Content 按 responseBodyMapping 抽取(content/toolCalls/finishReason),工具在 toolCalls 里 + content, toolCalls = syncChatResult(result.Content) + } + // 无工具调用 → 最终回答 + if len(toolCalls) == 0 { + a.emit(ReActEvent{Type: ReActEventAnswer, Answer: content}) + return content, nil + } + messages["assistant_prompt"] = content + for _, tool := range toolCalls { + fn := gconv.Map(tool.Function) + name := gconv.String(fn["name"]) + arguments := gconv.String(fn["arguments"]) + desc := a.toolDescription(name) + a.emit(ReActEvent{Type: ReActEventToolCall, Description: desc}) + // 执行每个工具调用 + toolMsg := a.executeToolCall(ctx, name, arguments) + a.emit(ReActEvent{Type: ReActEventToolResult, Description: desc}) + messages["tool_prompt"] = toolMsg + messages["tool_id"] = tool.Id + } + } + err = fmt.Errorf("max steps reached %d, generation incomplete", a.MaxStep) + a.emit(ReActEvent{Type: ReActEventError, Message: "最大步数达到,生成失败", Error: err.Error()}) + return "", err +} + +// syncChatResult 解析同步对话响应 Content(model-gateway 按 responseBodyMapping 抽取,固定 +// content/toolCalls/finishReason 三个 key):返回最终文本与工具调用列表(同步响应工具在 toolCalls 里,res.Tools 为空)。 +func syncChatResult(content map[string]any) (string, []gateway.ModelTool) { + text := gconv.String(content["content"]) + raw := content["toolCalls"] + if raw == nil { + raw = content["tools"] + } + var toolCalls []gateway.ModelTool + if arr, ok := raw.([]any); ok { + for _, t := range arr { + m := gconv.Map(t) + if m == nil { + continue + } + var tool gateway.ModelTool + tool.Id = gconv.String(m["id"]) + tool.Type = gconv.String(m["type"]) + if fn := gconv.Map(m["function"]); fn != nil { + tool.Function.Name = gconv.String(fn["name"]) + tool.Function.Arguments = gconv.String(fn["arguments"]) + } + toolCalls = append(toolCalls, tool) + } + } + return text, toolCalls +} + +// emit 触发过程事件回调;未设置 OnEvent 时忽略(HTTP 同步调用路径) +func (a *ReActAgent) emit(ev ReActEvent) { + if a.OnEvent != nil { + a.OnEvent(ev) + } +} + +// executeToolCall 执行单个工具调用,返回回灌给模型的工具结果消息。 +// 未知工具由 tools.Default.Call 返回结构化 not_found;工具业务失败进 ToolResult.Code。 +func (a *ReActAgent) executeToolCall(ctx context.Context, name, arguments string) string { + if arguments == "" { + return "工具参数为空" + } + + var args map[string]any + if err := json.Unmarshal([]byte(arguments), &args); err != nil { + return fmt.Sprintf("参数解析失败: %v", err) + } + + res, callErr := tools.Default.Call(ctx, name, args) + + switch { + case callErr != nil: + msg := fmt.Sprintf("工具执行失败: %v", callErr) + return msg + case res.Code != 0: + return res.Message + default: + dataJSON, err := json.Marshal(res.Data) + if err != nil { + return fmt.Sprintf("工具结果序列化失败: %v", err) + } + return string(dataJSON) + } +} + +// toolDescription 按工具名从 ToolList 中查找用途说明(推给前端展示用,不暴露工具名/参数) +func (a *ReActAgent) toolDescription(name string) string { + for _, t := range a.ToolList { + if t.Name == name { + return t.Description + } + } + return "" +} + +// RawTools 将工具定义转为 OpenAI 原始数组,parameters 保持 JSON Schema 原样。 +// businessParams 里传 tools 必须用它转换——直接传 []*tools.Tool 会被 json.Marshal 序列化 Func 字段而失败。 +func (a *ReActAgent) rawTools(toolList []*tools.Tool) []any { + raw := make([]any, 0, len(toolList)) + for _, t := range toolList { + params := t.Parameters + if params == nil { + params = map[string]any{} + } + raw = append(raw, map[string]any{ + "type": "function", + "function": map[string]any{ + "name": t.Name, + "description": t.Description, + "parameters": params, + }, + }) + } + return raw +} diff --git a/update.sql b/update.sql index 0a486d0..6b8df15 100644 --- a/update.sql +++ b/update.sql @@ -626,11 +626,15 @@ CREATE TABLE IF NOT EXISTS black_deacon_node_execution ( prompt_tokens INTEGER NOT NULL DEFAULT 0, -- 提示词token消耗 completion_tokens INTEGER NOT NULL DEFAULT 0, -- 补全token消耗 total_tokens INTEGER NOT NULL DEFAULT 0, -- 总token消耗 + token_info JSONB DEFAULT '[]', -- 节点token明细(汇总节点聚合 total_tokens/total_fee 用) status SMALLINT NOT NULL DEFAULT 1, -- 执行状态:1-运行中,2-成功,3-失败,4-暂停,5-等待执行 duration_ms BIGINT NOT NULL DEFAULT 0, -- 执行时长(毫秒) error_message TEXT DEFAULT '' -- 错误信息 ); +-- 存量库补列 +ALTER TABLE black_deacon_node_execution ADD COLUMN IF NOT EXISTS token_info JSONB DEFAULT '[]'; + -- 索引(高频查询) CREATE INDEX IF NOT EXISTS idx_bne_tenant_id ON black_deacon_node_execution(tenant_id); CREATE INDEX IF NOT EXISTS idx_bne_flow_execution_id ON black_deacon_node_execution(flow_execution_id); @@ -658,46 +662,54 @@ COMMENT ON COLUMN black_deacon_node_execution.output_params_path IS '节点输 COMMENT ON COLUMN black_deacon_node_execution.prompt_tokens IS '提示词token消耗'; COMMENT ON COLUMN black_deacon_node_execution.completion_tokens IS '补全token消耗'; COMMENT ON COLUMN black_deacon_node_execution.total_tokens IS '总token消耗'; +COMMENT ON COLUMN black_deacon_node_execution.token_info IS '节点token明细(汇总节点聚合 total_tokens/total_fee 用)'; COMMENT ON COLUMN black_deacon_node_execution.status IS '执行状态:1-运行中,2-成功,3-失败,4-暂停,5-等待执行'; COMMENT ON COLUMN black_deacon_node_execution.duration_ms IS '执行时长(毫秒)'; COMMENT ON COLUMN black_deacon_node_execution.error_message IS '错误信息'; --------------------pgsql创建black_deacon_node_execution表语句--------------------------- -- ========== 会话记录 + 工作流/普通会话结果表(2026-08-13) ========== + +--------------------pgsql创建black_deacon_session表语句--------------------------- +-- 会话记录表 CREATE TABLE IF NOT EXISTS black_deacon_session ( - id BIGINT PRIMARY KEY, -- 主键ID(非自增,hook snowflake) - tenant_id BIGINT NOT NULL DEFAULT 0, -- 租户ID + -- 基础字段(完全对齐项目规范) + id BIGINT PRIMARY KEY, -- 主键ID(非自增) + tenant_id BIGINT NOT NULL DEFAULT 0, -- 租户ID int8 creator VARCHAR(64) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updater VARCHAR(64) NOT NULL, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at timestamp(6), + + -- 业务字段 + session_id VARCHAR(64) NOT NULL, -- 会话ID(前端传入,雪花ID) session_name VARCHAR(128) NOT NULL DEFAULT '' -- 会话名称 ); -CREATE TABLE IF NOT EXISTS black_deacon_workflow_session_result ( - id BIGINT PRIMARY KEY, - tenant_id BIGINT NOT NULL DEFAULT 0, - creator VARCHAR(64) NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updater VARCHAR(64) NOT NULL, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at timestamp(6), - session_id BIGINT NOT NULL DEFAULT 0, -- 所属会话ID - flow_id BIGINT NOT NULL DEFAULT 0, -- 工作流ID - flow_name VARCHAR(128) NOT NULL DEFAULT '', -- 工作流名称 - request_params JSONB DEFAULT '{}', -- 请求参数 - result_params JSONB DEFAULT '[]'::JSONB, -- 返回结果 - status SMALLINT NOT NULL DEFAULT 1, -- 状态:1-运行中,2-成功,3-失败 - total_tokens INTEGER NOT NULL DEFAULT 0, -- 总token - total_fee NUMERIC(10,4) NOT NULL DEFAULT 0, -- 总费用 - error_msg TEXT NOT NULL DEFAULT '' -- 错误信息 -); -CREATE INDEX idx_wsr_session ON black_deacon_workflow_session_result (session_id); -CREATE INDEX idx_wsr_flow ON black_deacon_workflow_session_result (flow_id, creator); -CREATE INDEX idx_wsr_created ON black_deacon_workflow_session_result (created_at); +-- 索引(高频查询) +CREATE UNIQUE INDEX IF NOT EXISTS uk_session_tenant_session_id ON black_deacon_session(tenant_id, session_id); +CREATE INDEX IF NOT EXISTS idx_session_tenant_id ON black_deacon_session(tenant_id); +CREATE INDEX IF NOT EXISTS idx_session_creator ON black_deacon_session(creator); +CREATE INDEX IF NOT EXISTS idx_session_deleted_at ON black_deacon_session(deleted_at); -CREATE TABLE IF NOT EXISTS black_deacon_chat_session_result ( +-- 表和字段注释 +COMMENT ON TABLE black_deacon_session IS '会话记录表'; +COMMENT ON COLUMN black_deacon_session.id IS '主键ID(非自增)'; +COMMENT ON COLUMN black_deacon_session.tenant_id IS '租户ID'; +COMMENT ON COLUMN black_deacon_session.creator IS '创建人'; +COMMENT ON COLUMN black_deacon_session.created_at IS '创建时间'; +COMMENT ON COLUMN black_deacon_session.updater IS '更新人'; +COMMENT ON COLUMN black_deacon_session.updated_at IS '更新时间'; +COMMENT ON COLUMN black_deacon_session.deleted_at IS '删除时间(软删)'; +COMMENT ON COLUMN black_deacon_session.session_id IS '会话ID(前端传入,雪花ID)'; +COMMENT ON COLUMN black_deacon_session.session_name IS '会话名称'; +--------------------pgsql创建black_deacon_session表语句--------------------------- + +--------------------pgsql创建black_deacon_exec_chat表语句--------------------------- +-- 普通会话执行记录表 +CREATE TABLE IF NOT EXISTS black_deacon_exec_chat ( + -- 基础字段(完全对齐项目规范) id BIGINT PRIMARY KEY, tenant_id BIGINT NOT NULL DEFAULT 0, creator VARCHAR(64) NOT NULL, @@ -705,12 +717,125 @@ CREATE TABLE IF NOT EXISTS black_deacon_chat_session_result ( updater VARCHAR(64) NOT NULL, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at timestamp(6), - session_id BIGINT NOT NULL DEFAULT 0, -- 所属会话ID(级联软删) - question TEXT NOT NULL DEFAULT '', -- 用户问题 - answer TEXT NOT NULL DEFAULT '', -- 模型返回结果 - total_tokens INTEGER NOT NULL DEFAULT 0, -- 总token - total_fee NUMERIC(10,4) NOT NULL DEFAULT 0, -- 总费用 - error_msg TEXT NOT NULL DEFAULT '' -- 错误信息 + + -- 业务字段 + session_id VARCHAR(64) NOT NULL DEFAULT '', -- 会话ID + duration BIGINT NOT NULL DEFAULT 0, -- 执行时长(秒) + request_params JSONB DEFAULT '{}', -- 请求参数 + result_file_url VARCHAR(512) DEFAULT '', -- 结果文件路径(OSS) + total_tokens INTEGER NOT NULL DEFAULT 0, -- 总token消耗 + total_fee DOUBLE PRECISION NOT NULL DEFAULT 0, -- 总费用 + error_message TEXT DEFAULT '' -- 错误信息 ); -CREATE INDEX idx_csr_session ON black_deacon_chat_session_result (session_id); -CREATE INDEX idx_csr_created ON black_deacon_chat_session_result (created_at); \ No newline at end of file + +-- 索引(高频查询) +CREATE INDEX IF NOT EXISTS idx_exec_chat_tenant_id ON black_deacon_exec_chat(tenant_id); +CREATE INDEX IF NOT EXISTS idx_exec_chat_session_id ON black_deacon_exec_chat(session_id); +CREATE INDEX IF NOT EXISTS idx_exec_chat_deleted_at ON black_deacon_exec_chat(deleted_at); + +-- 表和字段注释 +COMMENT ON TABLE black_deacon_exec_chat IS '普通会话执行记录表'; +COMMENT ON COLUMN black_deacon_exec_chat.id IS '主键ID'; +COMMENT ON COLUMN black_deacon_exec_chat.tenant_id IS '租户ID'; +COMMENT ON COLUMN black_deacon_exec_chat.creator IS '创建人'; +COMMENT ON COLUMN black_deacon_exec_chat.created_at IS '创建时间'; +COMMENT ON COLUMN black_deacon_exec_chat.updater IS '更新人'; +COMMENT ON COLUMN black_deacon_exec_chat.updated_at IS '更新时间'; +COMMENT ON COLUMN black_deacon_exec_chat.deleted_at IS '删除时间(软删)'; +COMMENT ON COLUMN black_deacon_exec_chat.session_id IS '会话ID'; +COMMENT ON COLUMN black_deacon_exec_chat.duration IS '执行时长(秒)'; +COMMENT ON COLUMN black_deacon_exec_chat.request_params IS '请求参数'; +COMMENT ON COLUMN black_deacon_exec_chat.result_file_url IS '结果文件路径(OSS)'; +COMMENT ON COLUMN black_deacon_exec_chat.total_tokens IS '总token消耗'; +COMMENT ON COLUMN black_deacon_exec_chat.total_fee IS '总费用'; +COMMENT ON COLUMN black_deacon_exec_chat.error_message IS '错误信息'; +--------------------pgsql创建black_deacon_exec_chat表语句--------------------------- + +--------------------pgsql创建black_deacon_exec_workflow表语句--------------------------- +-- 工作流执行记录表 +CREATE TABLE IF NOT EXISTS black_deacon_exec_workflow ( + -- 基础字段(完全对齐项目规范) + id BIGINT PRIMARY KEY, + tenant_id BIGINT NOT NULL DEFAULT 0, + creator VARCHAR(64) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at timestamp(6), + + -- 业务字段 + session_id VARCHAR(64) NOT NULL DEFAULT '', -- 会话ID + flow_id BIGINT NOT NULL DEFAULT 0, -- 工作流ID + duration BIGINT NOT NULL DEFAULT 0, -- 执行时长(秒) + request_params JSONB DEFAULT '{}', -- 请求参数 + status SMALLINT NOT NULL DEFAULT 1, -- 状态:1-运行中,2-成功,3-失败 + total_tokens INTEGER NOT NULL DEFAULT 0, -- 总token消耗 + total_fee DOUBLE PRECISION NOT NULL DEFAULT 0, -- 总费用 + error_message TEXT DEFAULT '' -- 错误信息 +); + +-- 索引(高频查询) +CREATE INDEX IF NOT EXISTS idx_exec_workflow_tenant_id ON black_deacon_exec_workflow(tenant_id); +CREATE INDEX IF NOT EXISTS idx_exec_workflow_session_id ON black_deacon_exec_workflow(session_id); +CREATE INDEX IF NOT EXISTS idx_exec_workflow_flow_id ON black_deacon_exec_workflow(flow_id); +CREATE INDEX IF NOT EXISTS idx_exec_workflow_status ON black_deacon_exec_workflow(status); +CREATE INDEX IF NOT EXISTS idx_exec_workflow_deleted_at ON black_deacon_exec_workflow(deleted_at); + +-- 表和字段注释 +COMMENT ON TABLE black_deacon_exec_workflow IS '工作流执行记录表'; +COMMENT ON COLUMN black_deacon_exec_workflow.id IS '主键ID'; +COMMENT ON COLUMN black_deacon_exec_workflow.tenant_id IS '租户ID'; +COMMENT ON COLUMN black_deacon_exec_workflow.creator IS '创建人'; +COMMENT ON COLUMN black_deacon_exec_workflow.created_at IS '创建时间'; +COMMENT ON COLUMN black_deacon_exec_workflow.updater IS '更新人'; +COMMENT ON COLUMN black_deacon_exec_workflow.updated_at IS '更新时间'; +COMMENT ON COLUMN black_deacon_exec_workflow.deleted_at IS '删除时间(软删)'; +COMMENT ON COLUMN black_deacon_exec_workflow.session_id IS '会话ID'; +COMMENT ON COLUMN black_deacon_exec_workflow.flow_id IS '工作流ID'; +COMMENT ON COLUMN black_deacon_exec_workflow.duration IS '执行时长(秒)'; +COMMENT ON COLUMN black_deacon_exec_workflow.request_params IS '请求参数'; +COMMENT ON COLUMN black_deacon_exec_workflow.status IS '状态:1-运行中,2-成功,3-失败'; +COMMENT ON COLUMN black_deacon_exec_workflow.total_tokens IS '总token消耗'; +COMMENT ON COLUMN black_deacon_exec_workflow.total_fee IS '总费用'; +COMMENT ON COLUMN black_deacon_exec_workflow.error_message IS '错误信息'; +--------------------pgsql创建black_deacon_exec_workflow表语句--------------------------- + +--------------------pgsql创建black_deacon_exec_workflow_result表语句--------------------------- +-- 工作流执行结果表 +CREATE TABLE IF NOT EXISTS black_deacon_exec_workflow_result ( + -- 基础字段(完全对齐项目规范) + id BIGINT PRIMARY KEY, + tenant_id BIGINT NOT NULL DEFAULT 0, + creator VARCHAR(64) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at timestamp(6), + + -- 业务字段 + session_id VARCHAR(64) NOT NULL DEFAULT '', -- 会话ID + flow_id BIGINT NOT NULL DEFAULT 0, -- 工作流ID + exec_id BIGINT NOT NULL DEFAULT 0, -- 执行ID + result_file_url VARCHAR(512) DEFAULT '' -- 结果文件路径(OSS) +); + +-- 索引(高频查询) +CREATE INDEX IF NOT EXISTS idx_exec_workflow_result_tenant_id ON black_deacon_exec_workflow_result(tenant_id); +CREATE INDEX IF NOT EXISTS idx_exec_workflow_result_session_id ON black_deacon_exec_workflow_result(session_id); +CREATE INDEX IF NOT EXISTS idx_exec_workflow_result_exec_id ON black_deacon_exec_workflow_result(exec_id); +CREATE INDEX IF NOT EXISTS idx_exec_workflow_result_deleted_at ON black_deacon_exec_workflow_result(deleted_at); + +-- 表和字段注释 +COMMENT ON TABLE black_deacon_exec_workflow_result IS '工作流执行结果表'; +COMMENT ON COLUMN black_deacon_exec_workflow_result.id IS '主键ID'; +COMMENT ON COLUMN black_deacon_exec_workflow_result.tenant_id IS '租户ID'; +COMMENT ON COLUMN black_deacon_exec_workflow_result.creator IS '创建人'; +COMMENT ON COLUMN black_deacon_exec_workflow_result.created_at IS '创建时间'; +COMMENT ON COLUMN black_deacon_exec_workflow_result.updater IS '更新人'; +COMMENT ON COLUMN black_deacon_exec_workflow_result.updated_at IS '更新时间'; +COMMENT ON COLUMN black_deacon_exec_workflow_result.deleted_at IS '删除时间(软删)'; +COMMENT ON COLUMN black_deacon_exec_workflow_result.session_id IS '会话ID'; +COMMENT ON COLUMN black_deacon_exec_workflow_result.flow_id IS '工作流ID'; +COMMENT ON COLUMN black_deacon_exec_workflow_result.exec_id IS '执行ID'; +COMMENT ON COLUMN black_deacon_exec_workflow_result.result_file_url IS '结果文件路径(OSS)'; +--------------------pgsql创建black_deacon_exec_workflow_result表语句--------------------------- diff --git a/workflow/consts/model/model_type.go b/workflow/consts/model/model_type.go new file mode 100644 index 0000000..7820df6 --- /dev/null +++ b/workflow/consts/model/model_type.go @@ -0,0 +1,40 @@ +package model + +// 模型类型编码常量 +const ( + TypeInference = 100 // 推理模型 + TypeImage = 200 // 图片模型 + TypeAudio = 300 // 音频模型 + TypeVector = 400 // 向量化模型 + TypeOmni = 500 // 全模态模型 + TypeVideo = 600 // 视频模型 + + // 图片子类型 + ImageSubTextToImage = 201 + ImageSubImageToImage = 202 + ImageSubImageEdit = 203 + ImageSubImageVariation = 204 + ImageSubImageTextToImage = 205 + + // 音频子类型 + AudioSubTextToSpeech = 301 + AudioSubSpeechToText = 302 + AudioSubSpeechToSpeech = 303 + + // 向量化子类型 + VectorSubEmbedding = 401 + VectorSubRerank = 402 + + // 全模态子类型 + OmniSubTextImageAudio = 501 + OmniSubVision = 502 + + // 视频子类型 + VideoSubTextToVideo = 601 + VideoSubImageToVideo = 602 + VideoSubImageTextToVideo = 603 + VideoSubVideoToVideo = 604 +) + +// ModelType 编码类型 +type ModelType *int diff --git a/workflow/consts/model/response_type.go b/workflow/consts/model/response_type.go new file mode 100644 index 0000000..3e14c82 --- /dev/null +++ b/workflow/consts/model/response_type.go @@ -0,0 +1,27 @@ +package model + +import "github.com/gogf/gf/v2/util/gconv" + +var ( + ResponseTypeSync = newResponseType(gconv.PtrInt8(1), "sync") // 同步 + ResponseTypeAsync = newResponseType(gconv.PtrInt8(2), "async") // 异步 + ResponseTypeStream = newResponseType(gconv.PtrInt8(3), "stream") // 流 +) + +type ResponseType *int8 + +type responseType struct { + code ResponseType + desc string +} + +func (s responseType) Code() ResponseType { + return s.code +} +func (s responseType) Desc() string { + return s.desc +} + +func newResponseType(code ResponseType, desc string) responseType { + return responseType{code: code, desc: desc} +} diff --git a/workflow/consts/node/node_template.go b/workflow/consts/node/node_template.go index 30fe692..95a7032 100644 --- a/workflow/consts/node/node_template.go +++ b/workflow/consts/node/node_template.go @@ -1,119 +1,449 @@ package node -// ======================== 【常量定义:所有中文文案放这里!】 ======================== -// 分组名称 -const ( - NodeGroupNameComponent = "组件" - NodeGroupNameBase = "基础" - NodeGroupNameCustom = "自定义" -) - -// 节点名称 -const ( - NodeNameTextModel = "生成文案" - NodeNameImageModel = "生成图片" - NodeNameVideoModel = "生成视频" - NodeNameAudioModel = "生成音频" - NodeNameBatchModel = "批量处理一起返回" - NodeNameDataConversionModel = "参数转换" - NodeNameModel = "模型" - NodeNameMerge = "结果合并" - NodeNameDataMerge = "结果汇集" - NodeNameJudge = "条件判断" - NodeNameLoop = "循环" - NodeNameForm = "表单" - NodeSubFlow = "子流程" - NodeNameHttp = "HTTP(S)接口" - NodeNameCustomNode = "自定义节点" - NodeNameSystemSum = "系统-结果汇总" -) - -// 表单字段 Label -const ( - FormLabelApiKey = "API Key" - FormLabelModel = "模型名称" - FormLabelCondition = "判断条件" -) - -// ======================== 枚举类型 ======================== +// NodeGroup 节点分组标识 type NodeGroup string -const ( - NodeGroupComponent NodeGroup = "component" - NodeGroupBase NodeGroup = "base" - NodeGroupCustom NodeGroup = "custom" -) - +// NodeType 节点类型标识 type NodeType string +// 分组常量定义 const ( - // 组件 - NodeTypeTextModel NodeType = "text_model" - NodeTypeImageModel NodeType = "image_model" - NodeTypeVideoModel NodeType = "video_model" - NodeTypeAudioModel NodeType = "audio_model" - NodeTypeBatchModel NodeType = "batch_model" - NodeTypeDataConversionModel NodeType = "data_conversion_model" - // 基础 - NodeTypeModel NodeType = "model" - NodeTypeMerge NodeType = "merge" - NodeTypeDataMerge NodeType = "data_merge" - NodeTypeJudge NodeType = "judge" - NodeTypeForm NodeType = "form" - NodeTypeIntent NodeType = "intent" - NodeTypeSubFlow NodeType = "sub_flow" - NodeTypeHttp NodeType = "http" - // 自定义 - NodeTypeCustomNode NodeType = "custom_node" - // 系统 - NodeTypeSystemSum NodeType = "system_sum" + NodeGroupBase NodeGroup = "base" + NodeGroupSystem NodeGroup = "system" ) +// 节点类型常量定义 const ( - ModelTypeText = 100 - ModelTypeImage = 200 - ModelTypeAudio = 300 - ModelTypeModality = 500 - ModelTypeVideo = 600 + NodeTypeStart NodeType = "__start__" + NodeTypeModel NodeType = "model" + NodeTypeDataMerge NodeType = "data_merge" + NodeTypeForm NodeType = "form" + NodeTypeSubFlow NodeType = "sub_flow" + NodeTypeHttp NodeType = "http" + NodeTypeSystemSum NodeType = "system_sum" + NodeTypeScriptTranscribe NodeType = "script_transcribe" ) -// ======================== 结构定义 ======================== -type NodeFormField struct { - Value any `json:"value"` - Field string `json:"field"` - Label string `json:"label"` // 从常量来 - Type string `json:"type"` - Required bool `json:"required"` - Default any `json:"default,omitempty"` - Options []SelectOption `json:"options"` - Expand any `json:"expand"` - FieldConstraint any `json:"fieldConstraint"` +// NodeGroupMeta 节点分组元数据 +type NodeGroupMeta struct { + Key NodeGroup `json:"key"` // 后端存储标识 + Name string `json:"name"` // 前端展示中文名称 +} + +// NodeTypeMeta 节点元数据 +type NodeTypeMeta struct { + Key NodeType `json:"key"` // 节点类型标识 + Name string `json:"name"` // 节点中文名称 + Group NodeGroup `json:"group"` // 所属分组 + Sort int `json:"sort"` // UI排序字段 + Desc string `json:"desc,omitempty"` // 可选:节点简介 + BatchExecOption bool `json:"batchExecOption"` + PreToolOption bool `json:"preToolOption"` + PostToolOption bool `json:"postToolOption"` + IsSaveFileOption bool `json:"isSaveFileOption"` + FormConfigOption bool `json:"formConfigOption"` + ModelConfigOption bool `json:"modelConfigOption"` + SkillOption bool `json:"skillOption"` + PromptOption bool `json:"promptOption"` + NegativePromptOption bool `json:"negativePromptOption"` + PresetOption []NodePresetField `json:"presetOption"` + OutputField []string `json:"outputField"` +} + +type NodePresetField struct { + Value string `json:"value"` + Field string `json:"field"` + Label string `json:"label"` + Type string `json:"type"` + IsFormField bool `json:"isFormField"` + Constraint FieldConstraint `json:"constraint"` + Required bool `json:"required"` + Options []SelectOption `json:"options"` +} + +// FieldConstraint 字段约束 +type FieldConstraint struct { + // 数字类型:int、float、double、string + Type string `json:"type" dc:"类型"` + Min any `json:"min" dc:"最小值"` + Max any `json:"max" dc:"最大值"` } type SelectOption struct { - Label string `json:"label"` - Value string `json:"value"` + Key string `json:"key"` + Value string `json:"value"` + Config []NodePresetField `json:"config"` } -type ModelItem struct { - ModelName string `json:"modelName"` - ModelForm []NodeFormField `json:"modelForm"` +// NodeGroupTree 对外输出树形结构:分组 + 分组下所有节点 +type NodeGroupTree struct { + Group NodeGroupMeta `json:"group"` + Nodes []NodeTypeMeta `json:"nodes"` } -type NodeItem struct { - NodeId string `json:"nodeId"` - NodeCode NodeType `json:"nodeCode"` - ModelType int `json:"modelType"` - NodeName string `json:"nodeName"` // 从常量来 - SkillOption bool `json:"skillOption"` - PromptOption bool `json:"promptOption"` - IsSaveFile bool `json:"isSaveFile"` - FormConfig []NodeFormField `json:"formConfig"` - ModelConfig []ModelItem `json:"modelConfig"` +// ===================== 元数据集中注册区(英文标识+中文文案统一维护) ===================== +var NodeGroupMetaList = []NodeGroupMeta{ + { + Key: NodeGroupBase, + Name: "基础", + }, } -type NodeGroupItem struct { - Group NodeGroup `json:"group"` - Label string `json:"label"` // 从常量来 - Items []NodeItem `json:"items"` +var NodeTypeMetaList = []NodeTypeMeta{ + { + Key: NodeTypeModel, + Name: "模型", + Group: NodeGroupBase, + Sort: 1, + Desc: "模型调用节点,可配置模型参数、模型配置、技能、提示语、结果汇集、结果保存、结果返回、结果展示等信息。", + BatchExecOption: true, + PreToolOption: true, + PostToolOption: true, + IsSaveFileOption: true, + FormConfigOption: false, + ModelConfigOption: true, + SkillOption: false, + PromptOption: true, + NegativePromptOption: true, + }, + { + Key: NodeTypeDataMerge, + Name: "结果汇集", + Group: NodeGroupBase, + Sort: 2, + Desc: "结果汇集节点,可配置结果汇集方式、结果保存、结果返回、结果展示等信息。", + BatchExecOption: false, + PreToolOption: false, + PostToolOption: false, + IsSaveFileOption: false, + FormConfigOption: false, + ModelConfigOption: false, + SkillOption: false, + PromptOption: false, + NegativePromptOption: false, + }, + { + Key: NodeTypeForm, + Name: "表单", + Group: NodeGroupBase, + Sort: 3, + Desc: "表单节点,可配置表单字段、表单配置、结果汇集、结果保存、结果返回、结果展示等信息。", + BatchExecOption: false, + PreToolOption: false, + PostToolOption: false, + IsSaveFileOption: false, + FormConfigOption: true, + ModelConfigOption: false, + SkillOption: false, + PromptOption: false, + NegativePromptOption: false, + }, + { + Key: NodeTypeSubFlow, + Name: "子流程", + Group: NodeGroupBase, + Sort: 4, + PresetOption: []NodePresetField{ + { + Field: "maxConcurrency", + Label: "生成次数", + Type: "input", + Value: "1", + IsFormField: true, + Constraint: FieldConstraint{ + Type: "int", + Min: 1, + Max: 10, + }, + Required: true, + }, + }, + }, + { + Key: NodeTypeHttp, + Name: "HTTP(S)接口", + Group: NodeGroupBase, + Sort: 5, + Desc: "HTTP(S)接口节点,可配置HTTP(S)接口地址、请求方式、请求头、请求体、结果返回结构、结果返回方式、结果返回结构、结果返回方式等信息。", + BatchExecOption: false, + PreToolOption: true, + PostToolOption: true, + IsSaveFileOption: false, + FormConfigOption: false, + ModelConfigOption: false, + SkillOption: false, + PromptOption: false, + NegativePromptOption: false, + PresetOption: []NodePresetField{ + { + Field: "method", + Label: "请求方式", + Type: "select", + Required: true, + Options: []SelectOption{ + {Key: "GET", Value: "GET"}, + {Key: "POST", Value: "POST"}, + }, + }, + { + Field: "url", + Label: "请求地址", + Type: "input", + Constraint: FieldConstraint{ + Type: "string", + Min: 1, + Max: -1, + }, + Required: true, + }, + { + Field: "headers", + Label: "请求头(支持Authorization鉴权)", + Type: "keyValue", + Required: false, + }, + { + Field: "bodyType", + Label: "请求体类型", + Type: "select", + Required: true, + Options: []SelectOption{ + {Key: "None", Value: "无"}, + {Key: "JSON", Value: "JSON"}, + }, + }, + { + Field: "body", + Label: "请求体内容", + Type: "schemaJson", + Required: false, + }, + { + Field: "response", + Label: "结果返回结构", + Type: "schemaJson", + Required: true, + }, + { + Field: "responseType", + Label: "结果返回方式", + Type: "select", + Required: true, + Options: []SelectOption{ + {Key: "sync", Value: "同步返回"}, + {Key: "callback", Value: "等候回调", Config: []NodePresetField{ + { + Field: "callbackUrl", + Label: "回调地址(只需要填写字段名称)", + Type: "input", + Constraint: FieldConstraint{ + Type: "string", + Min: 1, + Max: -1, + }, + Required: true, + }, + }}, + {Key: "pull", Value: "主动拉取", Config: []NodePresetField{ + { + Field: "method", + Label: "请求方式", + Type: "select", + Required: true, + Options: []SelectOption{ + {Key: "GET", Value: "GET"}, + {Key: "POST", Value: "POST"}, + }, + }, + { + Field: "url", + Label: "请求地址", + Type: "input", + Constraint: FieldConstraint{ + Type: "string", + Min: 1, + Max: -1, + }, + Required: true, + }, + { + Field: "headers", + Label: "请求头(支持Authorization鉴权)", + Type: "keyValue", + Required: false, + }, + { + Field: "bodyType", + Label: "请求体类型", + Type: "select", + Required: true, + Options: []SelectOption{ + {Key: "None", Value: "无"}, + {Key: "JSON", Value: "JSON"}, + }, + }, + { + Field: "body", + Label: "请求体内容", + Type: "schemaJson", + Required: true, + }, + { + Field: "response", + Label: "结果返回结构", + Type: "schemaJson", + Required: true, + }, + }}, + }, + }, + }, + }, + { + Key: NodeTypeScriptTranscribe, + Name: "脚本转写", + Group: NodeGroupBase, + Sort: 6, + Desc: "脚本转写节点,把文案/视频分析结果通过大模型转写为结构化分镜脚本([]Shot),作为视频生成节点的 shots 输入。", + BatchExecOption: false, + PreToolOption: false, + PostToolOption: false, + IsSaveFileOption: false, + FormConfigOption: false, + ModelConfigOption: true, + SkillOption: false, + PromptOption: false, + NegativePromptOption: false, + PresetOption: []NodePresetField{ + { + Field: "totalDuration", + Label: "视频总时长", + Type: "input", + Constraint: FieldConstraint{ + Type: "int", + Min: 1, + Max: 300, + }, + Required: true, + }, + { + Field: "modelId", + Label: "视频模型", + Type: "select", + Required: true, + Options: []SelectOption{}, + }, + }, + OutputField: []string{"prompt", "duration", "seed", "negative_prompt", "reference_urls"}, + }, + { + Key: NodeTypeSystemSum, + Name: "工作流内置节点-保存结果", + Group: NodeGroupSystem, + Sort: 7, + }, +} + +// ===================== 缓存map & 工具函数 ===================== +var ( + nodeTypeMetaMap map[NodeType]NodeTypeMeta + nodeGroupMap map[NodeGroup]NodeGroupMeta +) + +func init() { + // 初始化分组缓存 + nodeGroupMap = make(map[NodeGroup]NodeGroupMeta, len(NodeGroupMetaList)) + for _, meta := range NodeGroupMetaList { + nodeGroupMap[meta.Key] = meta + } + + // 初始化节点缓存 + nodeTypeMetaMap = make(map[NodeType]NodeTypeMeta, len(NodeTypeMetaList)) + for _, meta := range NodeTypeMetaList { + nodeTypeMetaMap[meta.Key] = meta + } +} + +// GetNodeTypeMeta 根据节点类型获取元信息 +func GetNodeTypeMeta(typ NodeType) (NodeTypeMeta, bool) { + meta, ok := nodeTypeMetaMap[typ] + return meta, ok +} + +// GetNodeTypeName 快速获取节点中文名称,不存在返回原始key +func GetNodeTypeName(typ NodeType) string { + meta, ok := nodeTypeMetaMap[typ] + if !ok { + return string(typ) + } + return meta.Name +} + +// GetNodeGroupMeta 根据分组标识获取分组元信息 +func GetNodeGroupMeta(g NodeGroup) (NodeGroupMeta, bool) { + meta, ok := nodeGroupMap[g] + return meta, ok +} + +// GetNodeGroupName 快速获取分组中文名称 +func GetNodeGroupName(g NodeGroup) string { + meta, ok := nodeGroupMap[g] + if !ok { + return string(g) + } + return meta.Name +} + +// GetAllNodeTypeMeta 返回全部节点元数据副本 +func GetAllNodeTypeMeta() []NodeTypeMeta { + list := make([]NodeTypeMeta, len(NodeTypeMetaList)) + copy(list, NodeTypeMetaList) + return list +} + +// GetNodeGroupTree 获取【分组+节点】树形列表,完整所有节点 +func GetNodeGroupTree() []NodeGroupTree { + return getFilteredNodeTree(nil) +} + +// GetFilterNodeTree 根据指定分组过滤节点树形结构 +// groups: 需要展示的分组key列表,传入nil返回全部 +func GetFilterNodeTree(groups []NodeGroup) []NodeGroupTree { + return getFilteredNodeTree(groups) +} + +// getFilteredNodeTree 内部通用树形构建逻辑 +func getFilteredNodeTree(filterGroups []NodeGroup) []NodeGroupTree { + // 构建分组过滤集合 + filterSet := make(map[NodeGroup]bool) + for _, g := range filterGroups { + filterSet[g] = true + } + + groupNodeMap := make(map[NodeGroup][]NodeTypeMeta) + for _, node := range NodeTypeMetaList { + // 有过滤条件 && 当前分组不在过滤列表,则跳过 + if len(filterSet) > 0 && !filterSet[node.Group] { + continue + } + groupNodeMap[node.Group] = append(groupNodeMap[node.Group], node) + } + + var tree []NodeGroupTree + // 按定义顺序组装分组 + for _, gMeta := range NodeGroupMetaList { + // 如果开启过滤,跳过不在筛选列表中的分组 + if len(filterSet) > 0 && !filterSet[gMeta.Key] { + continue + } + nodes := groupNodeMap[gMeta.Key] + if len(nodes) == 0 { + continue + } + tree = append(tree, NodeGroupTree{ + Group: gMeta, + Nodes: nodes, + }) + } + return tree } diff --git a/workflow/consts/public/public.go b/workflow/consts/public/public.go new file mode 100644 index 0000000..e94253a --- /dev/null +++ b/workflow/consts/public/public.go @@ -0,0 +1,3 @@ +package public + +const GmqMsgPluginsName = "gmq_model_msg" diff --git a/workflow/consts/public/table_name.go b/workflow/consts/public/table_name.go index 475553e..2665069 100644 --- a/workflow/consts/public/table_name.go +++ b/workflow/consts/public/table_name.go @@ -7,18 +7,19 @@ const ( // 数据库表名 const ( - TableNameCreationInfo = "creation_info" - TableNameFlowExecution = "flow_execution" - TableNameFlowTemplate = "flow_template" - TableNameFlowUser = "flow_user" - TableNameSkillTemplate = "skill_template" - TableNameSkillUser = "skill_user" - TableNameFileTemp = "file_temp" - TableNameActivePull = "active_pull" - TableNameFlowCheckpoint = "flow_checkpoint" - TableNameNodePrompt = "node_prompt" - TableNameNodeExecution = "node_execution" - TableNameSession = "session" - TableNameWorkflowSessionResult = "workflow_session_result" - TableNameChatSessionResult = "chat_session_result" + TableNameCreationInfo = "creation_info" + TableNameFlowExecution = "flow_execution" + TableNameFlowTemplate = "flow_template" + TableNameFlowUser = "flow_user" + TableNameSkillTemplate = "skill_template" + TableNameSkillUser = "skill_user" + TableNameFileTemp = "file_temp" + TableNameActivePull = "active_pull" + TableNameFlowCheckpoint = "flow_checkpoint" + TableNameNodePrompt = "node_prompt" + TableNameNodeExecution = "node_execution" + TableNameSession = "session" + TableNameExecChat = "exec_chat" + TableNameExecWorkflow = "exec_workflow" + TableNameExecWorkflowResult = "exec_workflow_result" ) diff --git a/workflow/controller/flow/flow_execution_controller.go b/workflow/controller/flow/flow_execution_controller.go index dd45492..c0fd29f 100644 --- a/workflow/controller/flow/flow_execution_controller.go +++ b/workflow/controller/flow/flow_execution_controller.go @@ -4,32 +4,19 @@ import ( flowDto "ai-agent/workflow/model/dto/flow" flowService "ai-agent/workflow/service/flow" "context" - - "gitea.redpowerfuture.com/red-future/common/beans" ) type flowExecution struct{} var FlowExecution = new(flowExecution) -func (c *flowExecution) Execute(ctx context.Context, req *flowDto.ExecuteReq) (res *flowDto.ExecuteRes, err error) { - return flowService.FlowExecutionService.Execute(ctx, req) -} - -func (c *flowExecution) ComposeCallBack(ctx context.Context, req *flowDto.ComposeCallbackReq) (res *beans.ResponseEmpty, err error) { - err = flowService.FlowExecutionService.ComposeCallback(ctx, req) - return -} - -func (c *flowExecution) ModelCallback(ctx context.Context, req *flowDto.ModelCallbackReq) (res *beans.ResponseEmpty, err error) { - err = flowService.FlowExecutionService.ModelCallback(ctx, req) - return -} - -func (c *flowExecution) VideoCallback(ctx context.Context, req *flowDto.VideoCallbackReq) (res *beans.ResponseEmpty, err error) { - err = flowService.FlowExecutionService.VideoCallback(ctx, req) - return -} +//func (c *flowExecution) Execute(ctx context.Context, req *flowDto.ExecuteReq) (res *flowDto.ExecuteRes, err error) { +// return flowService.FlowExecutionService.Execute(ctx, req) +//} +// +//func (c *flowExecution) ReExecute(ctx context.Context, req *flowDto.ReExecuteReq) (res *flowDto.ExecuteRes, err error) { +// return flowService.FlowExecutionService.ReExecute(ctx, req) +//} func (c *flowExecution) Get(ctx context.Context, req *flowDto.GetFlowExecutionReq) (res *flowDto.VOFlowExecution, err error) { return flowService.FlowExecutionService.Get(ctx, req) diff --git a/workflow/controller/session/session_controller.go b/workflow/controller/session/session_controller.go index 15ee212..e00efb6 100644 --- a/workflow/controller/session/session_controller.go +++ b/workflow/controller/session/session_controller.go @@ -1,18 +1,34 @@ package session import ( + flowDto "ai-agent/workflow/model/dto/flow" + sessionDto "ai-agent/workflow/model/dto/session" + "ai-agent/workflow/service/flow" + sessionService "ai-agent/workflow/service/session" "context" - sessionDto "ai-agent/workflow/model/dto/session" - sessionService "ai-agent/workflow/service/session" - "gitea.redpowerfuture.com/red-future/common/beans" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/glog" ) type session struct{} var Session = new(session) +func (c *session) WsExecute(ctx context.Context, req *sessionDto.WebSocketConnectReq) (res *beans.ResponseEmpty, err error) { + r := g.RequestFromCtx(ctx) + // 首次连接仅升级 WebSocket,不区分普通对话/工作流;由后续消息 type 路由到对应处理器 + err = flow.WsConnect(ctx, r, req) + if err != nil { + glog.Errorf(ctx, "ws connect failed: %v", err) + return nil, err + } + // 标记响应缓冲区,使 MiddlewareHandlerResponse 跳过 + r.Response.Writeln("") + return +} + func (c *session) Get(ctx context.Context, req *sessionDto.GetSessionInfoReq) (res *sessionDto.GetSessionInfoRes, err error) { return sessionService.SessionService.Get(ctx, req) } @@ -27,3 +43,14 @@ func (c *session) Delete(ctx context.Context, req *sessionDto.DeleteSessionReq) } return &beans.ResponseEmpty{}, nil } + +func (c *session) DeleteRecord(ctx context.Context, req *sessionDto.DeleteSessionRecordReq) (res *beans.ResponseEmpty, err error) { + if err = sessionService.SessionService.DeleteRecord(ctx, req); err != nil { + return + } + return &beans.ResponseEmpty{}, nil +} + +func (c *session) ResultList(ctx context.Context, req *sessionDto.ListWorkflowResultReq) (res *flowDto.ListFlowExecutionTreeRes, err error) { + return sessionService.SessionService.ResultList(ctx, req) +} diff --git a/workflow/controller/tool/tool_controller.go b/workflow/controller/tool/tool_controller.go new file mode 100644 index 0000000..ab06b3a --- /dev/null +++ b/workflow/controller/tool/tool_controller.go @@ -0,0 +1,15 @@ +package tool + +import ( + toolDto "ai-agent/workflow/model/dto/tool" + toolService "ai-agent/workflow/service/tool" + "context" +) + +type tool struct{} + +var Tool = new(tool) + +func (c *tool) List(ctx context.Context, req *toolDto.ToolListReq) (res *toolDto.ToolListRes, err error) { + return toolService.ToolService.List(ctx, req) +} diff --git a/workflow/dao/flow/flow_checkpoint_dao.go b/workflow/dao/flow/flow_checkpoint_dao.go new file mode 100644 index 0000000..b9082c0 --- /dev/null +++ b/workflow/dao/flow/flow_checkpoint_dao.go @@ -0,0 +1,60 @@ +package flow + +import ( + "ai-agent/workflow/consts/public" + "ai-agent/workflow/model/entity" + "context" + + "gitea.redpowerfuture.com/red-future/common/db/gfdb" +) + +var FlowCheckpointDao = &flowCheckpointDao{} + +type flowCheckpointDao struct{} + +// Upsert 插入或更新checkpoint数据(按 checkpoint_id 冲突则更新) +func (d *flowCheckpointDao) Upsert(ctx context.Context, checkpointId string, data string) error { + record := &entity.FlowCheckpoint{ + CheckpointId: checkpointId, + Data: data, + } + // Save 在 PostgreSQL 中自动执行 INSERT ON CONFLICT DO UPDATE + _, err := gfdb.DB(ctx, public.DbNameBlackDeacon). + Model(ctx, public.TableNameFlowCheckpoint). + Save(record) + return err +} + +func (d *flowCheckpointDao) SaveOrUpdate(ctx context.Context, checkpointId string, data string) (err error) { + res := &entity.FlowCheckpoint{ + CheckpointId: checkpointId, + Data: data, + } + _, err = gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameFlowCheckpoint).Data(res).OnConflict(entity.FlowCheckpointCol.CheckpointId).Save() + return err +} + +// Get 根据 checkpoint_id 获取 checkpoint 数据 +func (d *flowCheckpointDao) Get(ctx context.Context, checkpointId string) (res *entity.FlowCheckpoint, err error) { + r, err := gfdb.DB(ctx, public.DbNameBlackDeacon). + Model(ctx, public.TableNameFlowCheckpoint). + Where(entity.FlowCheckpointCol.CheckpointId, checkpointId). + One() + if err != nil { + return nil, err + } + if r == nil { + return nil, nil + } + err = r.Struct(&res) + return +} + +// Delete 根据 checkpoint_id 删除checkpoint数据 +func (d *flowCheckpointDao) Delete(ctx context.Context, checkpointId string) error { + _, err := gfdb.DB(ctx, public.DbNameBlackDeacon). + Model(ctx, public.TableNameFlowCheckpoint). + Where(entity.FlowCheckpointCol.CheckpointId, checkpointId). + Delete() + return err +} diff --git a/workflow/dao/node/node_execution_dao.go b/workflow/dao/node/node_execution_dao.go index 8933965..c054c18 100644 --- a/workflow/dao/node/node_execution_dao.go +++ b/workflow/dao/node/node_execution_dao.go @@ -86,6 +86,9 @@ func (d *nodeExecutionDao) Get(ctx context.Context, req *nodeDto.GetNodeExecutio func (d *nodeExecutionDao) ListByFlowExecutionId(ctx context.Context, req *nodeDto.ListNodeExecutionByFlowReq, fields ...string) (res []*entity.NodeExecution, total int, err error) { model := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameNodeExecution).NoTenantId(ctx).Fields(fields).OmitEmpty() model.Where(entity.NodeExecutionCol.FlowExecutionId, req.FlowExecutionId) + model.Where(entity.NodeExecutionCol.NodeGroupId, req.NodeGroupId) + model.Where(entity.NodeExecutionCol.Status, req.Status) + model.Where(entity.NodeExecutionCol.NodeId, req.NodeId) model.OrderAsc(entity.NodeExecutionCol.CreatedAt) if req.Page != nil { model.Page(int(req.Page.PageNum), int(req.Page.PageSize)) diff --git a/workflow/dao/session/exec_chat_dao.go b/workflow/dao/session/exec_chat_dao.go index 7009443..e3822e1 100644 --- a/workflow/dao/session/exec_chat_dao.go +++ b/workflow/dao/session/exec_chat_dao.go @@ -51,7 +51,7 @@ func (d *execChatDao) List(ctx context.Context, creator string, page *beans.Page } // ListBySession 查询会话下普通对话执行记录(按创建时间倒序) -func (d *execChatDao) ListBySession(ctx context.Context, sessionId int64) (res []*entity.ExecChat, err error) { +func (d *execChatDao) ListBySession(ctx context.Context, sessionId string) (res []*entity.ExecChat, err error) { r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameExecChat). Where(entity.ExecChatCol.SessionId, sessionId). OrderDesc(entity.ExecChatCol.CreatedAt). diff --git a/workflow/dao/session/exec_workflow_dao.go b/workflow/dao/session/exec_workflow_dao.go index dc54dc5..dc54408 100644 --- a/workflow/dao/session/exec_workflow_dao.go +++ b/workflow/dao/session/exec_workflow_dao.go @@ -27,7 +27,7 @@ func (d *execWorkflowDao) Insert(ctx context.Context, req *sessionDto.CreateWork return r.LastInsertId() } -func (d *execWorkflowDao) Delete(ctx context.Context, req *sessionDto.DeleteWorkflowReq) (rows int64, err error) { +func (d *execWorkflowDao) Delete(ctx context.Context, req *sessionDto.DeleteExecWorkflowReq) (rows int64, err error) { r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameExecWorkflow).Where(entity.ExecWorkflowCol.Id, req.Id).Delete() if err != nil { return @@ -43,6 +43,17 @@ func (d *execWorkflowDao) Update(ctx context.Context, req *sessionDto.UpdateWork return r.RowsAffected() } +func (d *execWorkflowDao) GetById(ctx context.Context, id int64) (res *entity.ExecWorkflow, err error) { + r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameExecWorkflow). + Where(entity.ExecWorkflowCol.Id, id). + One() + if err != nil { + return + } + err = r.Struct(&res) + return +} + func (d *execWorkflowDao) List(ctx context.Context, creator string, page *beans.Page) (res []*entity.ExecWorkflow, total int, err error) { m := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameExecWorkflow). Where(entity.ExecWorkflowCol.Creator, creator) @@ -59,7 +70,7 @@ func (d *execWorkflowDao) List(ctx context.Context, creator string, page *beans. } // ListBySession 查询会话下工作流执行记录(按创建时间倒序) -func (d *execWorkflowDao) ListBySession(ctx context.Context, sessionId int64) (res []*entity.ExecWorkflow, err error) { +func (d *execWorkflowDao) ListBySession(ctx context.Context, sessionId string) (res []*entity.ExecWorkflow, err error) { r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameExecWorkflow). Where(entity.ExecWorkflowCol.SessionId, sessionId). OrderDesc(entity.ExecWorkflowCol.CreatedAt). @@ -71,4 +82,39 @@ func (d *execWorkflowDao) ListBySession(ctx context.Context, sessionId int64) (r return } +// ListDates 按创建人查询去重后的创建日期(倒序,支持分页;page 为 nil 返回全部日期) +func (d *execWorkflowDao) ListDates(ctx context.Context, creator string, page *beans.Page) (dates []string, err error) { + m := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameExecWorkflow). + Fields("DATE(created_at) AS create_date"). + Where(entity.ExecWorkflowCol.Creator, creator). + Group("create_date"). + OrderDesc("create_date") + if page != nil { + m.Page(int(page.PageNum), int(page.PageSize)) + } + r, err := m.All() + if err != nil { + return + } + for _, rec := range r { + dates = append(dates, rec["create_date"].String()) + } + return +} +// ListByDates 按创建人查询指定创建日期(DATE(created_at) 命中)内的执行记录,按创建时间倒序 +func (d *execWorkflowDao) ListByDates(ctx context.Context, creator string, dates []string) (res []*entity.ExecWorkflow, err error) { + if len(dates) == 0 { + return + } + r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameExecWorkflow). + Where(entity.ExecWorkflowCol.Creator, creator). + WhereIn("DATE(created_at)", dates). + OrderDesc(entity.ExecWorkflowCol.CreatedAt). + All() + if err != nil { + return + } + err = r.Structs(&res) + return +} diff --git a/workflow/dao/session/exec_workflow_result_dao.go b/workflow/dao/session/exec_workflow_result_dao.go index f996621..652adc4 100644 --- a/workflow/dao/session/exec_workflow_result_dao.go +++ b/workflow/dao/session/exec_workflow_result_dao.go @@ -27,6 +27,18 @@ func (d *execWorkflowResultDao) Insert(ctx context.Context, req *sessionDto.Crea return r.LastInsertId() } +func (d *execWorkflowResultDao) BatchInsert(ctx context.Context, req []*sessionDto.CreateWorkflowResultReq) (rows int64, err error) { + var res []*entity.ExecWorkflowResult + if err = gconv.Structs(req, &res); err != nil { + return + } + r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameExecWorkflowResult).Data(res).Save() + if err != nil { + return + } + return r.RowsAffected() +} + func (d *execWorkflowResultDao) Delete(ctx context.Context, req *sessionDto.DeleteWorkflowResultReq) (rows int64, err error) { r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameExecWorkflowResult).Where(entity.ExecWorkflowResultCol.Id, req.Id).Delete() if err != nil { @@ -51,7 +63,7 @@ func (d *execWorkflowResultDao) List(ctx context.Context, creator string, page * } // ListBySession 查询会话下工作流结果(按创建时间倒序) -func (d *execWorkflowResultDao) ListBySession(ctx context.Context, sessionId int64) (res []*entity.ExecWorkflowResult, err error) { +func (d *execWorkflowResultDao) ListBySession(ctx context.Context, sessionId string) (res []*entity.ExecWorkflowResult, err error) { r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameExecWorkflowResult). Where(entity.ExecWorkflowResultCol.SessionId, sessionId). OrderDesc(entity.ExecWorkflowResultCol.CreatedAt). @@ -62,3 +74,31 @@ func (d *execWorkflowResultDao) ListBySession(ctx context.Context, sessionId int err = r.Structs(&res) return } + +// ListByExecId 查询指定工作流执行记录下的结果文件路径 +func (d *execWorkflowResultDao) ListByExecId(ctx context.Context, execId int64) (res []*entity.ExecWorkflowResult, err error) { + r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameExecWorkflowResult). + Where(entity.ExecWorkflowResultCol.ExecId, execId). + All() + if err != nil { + return + } + err = r.Structs(&res) + return +} + +// ListByExecIds 批量查询多个执行记录下的结果文件(按创建时间正序) +func (d *execWorkflowResultDao) ListByExecIds(ctx context.Context, execIds []int64) (res []*entity.ExecWorkflowResult, err error) { + if len(execIds) == 0 { + return + } + r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameExecWorkflowResult). + WhereIn(entity.ExecWorkflowResultCol.ExecId, execIds). + OrderAsc(entity.ExecWorkflowResultCol.CreatedAt). + All() + if err != nil { + return + } + err = r.Structs(&res) + return +} diff --git a/workflow/dao/session/session_dao.go b/workflow/dao/session/session_dao.go index a9da7c9..2845217 100644 --- a/workflow/dao/session/session_dao.go +++ b/workflow/dao/session/session_dao.go @@ -27,8 +27,19 @@ func (d *sessionDao) Insert(ctx context.Context, req *sessionDto.CreateSessionRe return r.LastInsertId() } +func (d *sessionDao) GetById(ctx context.Context, id string) (res *entity.Session, err error) { + r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameSession). + Where(entity.SessionCol.SessionId, id). + One() + if err != nil { + return + } + err = r.Struct(&res) + return +} + func (d *sessionDao) Delete(ctx context.Context, req *sessionDto.DeleteSessionReq) (rows int64, err error) { - r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameSession).Where(entity.SessionCol.Id, req.Id).Delete() + r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameSession).Where(entity.SessionCol.SessionId, req.SessionId).Delete() if err != nil { return } diff --git a/workflow/model/dto/flow/flow_execution_dto.go b/workflow/model/dto/flow/flow_execution_dto.go index 2ffe70c..a8b8746 100644 --- a/workflow/model/dto/flow/flow_execution_dto.go +++ b/workflow/model/dto/flow/flow_execution_dto.go @@ -10,6 +10,14 @@ import ( "github.com/gogf/gf/v2/os/gtime" ) +// NodeExecutionState 记录图的节点执行进度,用于 checkpoint/resume 时追踪哪些节点已完成 +// SavedInput 用于在中断前保存节点入参,续跑后从状态中恢复 +type NodeExecutionState struct { + CompletedNodes []string `json:"completedNodes"` // 已成功执行的节点名称列表 + ExecutionCount int `json:"executionCount"` // 总执行调用次数 + SavedFlowInput *FlowExecutionInput `json:"savedFlowInput"` // 中断前保存的节点入参,续跑后读取 +} + // NodeExecutionInput 节点执行入参(包含配置+表单架构) type NodeExecutionInput struct { Config *entity.FlowNode `json:"config"` // 节点配置 @@ -17,130 +25,22 @@ type NodeExecutionInput struct { NodeExecutionId int64 `json:"nodeExecutionId"` } +// FlowExecutionInput 工作流执行入参(全程不变) +type FlowExecutionInput struct { + NodeGroupId string `json:"nodeGroupId"` + ExecutionId int64 `json:"executionId"` + FlowId int64 `json:"flowId"` + ConfigMap map[string]*entity.FlowNode `json:"configMap"` + SessionId string `json:"sessionId" dc:"会话ID"` + ExecutedNodes []ExecutedNode `json:"executedNodes"` // 已执行节点列表,包含执行状态 +} + // ExecutedNode 已执行节点记录,包含节点ID和执行状态 type ExecutedNode struct { NodeId string `json:"nodeId"` Status node.NodeExecutionStatus `json:"status"` // 执行状态:成功/失败 } -// FlowExecutionInput 工作流执行入参(全程不变) -type FlowExecutionInput struct { - NodeGroupId string `json:"nodeGroupId"` - IsDialogue bool `json:"isDialogue"` - ExecutionId int64 `json:"executionId"` - ConfigMap map[string]*entity.FlowNode `json:"configMap"` - SessionId string `json:"sessionId" dc:"会话ID"` - Desc string `json:"desc"` - SkillName string `json:"skillName"` - FileUrl []string `json:"fileUrl"` - ExecutedNodes []ExecutedNode `json:"executedNodes"` // 已执行节点列表,包含执行状态 -} - -type GetIsChatModelRes struct { - Model struct { - ModelName string `json:"modelName"` - ResponseBody map[string]any `json:"responseBody"` - } -} - -type GetModelInfoReq struct { - ModelName string `json:"modelName"` -} - -type GetModelInfoRes struct { - Model struct { - LastFrame string `json:"lastFrame"` - ResponseTokenField string `json:"responseTokenField"` - ResponseMapping map[string]any `json:"responseMapping"` - ResponseBody string `json:"responseBody"` - //QueryConfig struct { - // ResponseType string `json:"responseType"` - // CallbackUrl string `json:"callbackUrl"` - // Method string `json:"method"` - // Url string `json:"url"` - // Headers map[string]any `json:"headers"` - // Body map[string]any `json:"body"` - // Response []map[string]any `json:"response"` - // ResponseBody string `json:"responseBody"` - // ResponseTokenField string `json:"responseTokenField"` - //} `json:"queryConfig"` - } `json:"model"` -} - -type ComposeMessagesReq struct { - BuildType int `json:"buildType"` - ModelName string `json:"modelName"` - SkillName string `json:"skillName"` - CallbackUrl string `json:"callbackUrl"` - Form []map[string]any `json:"form"` - UserForm []map[string]any `json:"userForm"` - Consult []Consult `json:"consult"` - SessionId string `json:"sessionId" dc:"会话ID"` - NodeId string `json:"nodeId"` - Cause string `json:"cause"` -} - -type Consult struct { - Type string `json:"type"` - Url string `json:"url"` -} - -type ComposeMessagesRes struct { - TaskId string `json:"taskId"` -} - -type VideoConcatReq struct { - VideoUrls []string `json:"video_urls"` - Method string `json:"method"` - Upload bool `json:"upload"` - CallbackUrl string `json:"callback_url"` -} - -type VideoConcatRes struct { - TaskId string `json:"taskId"` -} - -type ModelGatewayReq struct { - ModelName string `json:"modelName"` - ModelKey string `json:"modelKey"` - BizName string `json:"bizName"` - CallbackUrl string `json:"callbackUrl"` - InputRef string `json:"inputRef"` - RequestPayload map[string]any `json:"requestPayload"` - EpicycleId int64 `json:"epicycleId" dc:"轮次ID"` -} - -type ModelGatewayRes struct { - TaskId string `json:"taskId"` -} - -type ComposeCallbackReq struct { - g.Meta `path:"/composeCallBack" method:"post" tags:"提示词处理" summary:"提示词 回调" dc:"提示词 成功后 GET 回调:callbackUrl/{bizName}"` - TaskId string `json:"taskId"` - Status string `json:"status"` - Messages struct { - TotalRounds int `json:"total_rounds"` // 总轮数 - Rounds []map[string]any `json:"rounds"` // 每轮详情(动态类型) - } `json:"messages,omitempty"` - EpicycleId int64 `json:"epicycleId"` - ErrorMsg string `json:"errorMsg,omitempty"` -} - -type ModelCallbackReq struct { - g.Meta `path:"/modelCallback" method:"post" tags:"提示词处理" summary:"model-gateway 回调" dc:"model-gateway 成功后 GET 回调:callbackUrl/{bizName}"` - TaskId string `p:"task_id" json:"task_id" v:"required#task_id不能为空" dc:"网关任务ID"` - State int `p:"state" json:"state" dc:"网关任务状态"` - OssFile string `p:"oss_file" json:"oss_file" dc:"结果文件地址"` - FileType string `p:"file_type" json:"file_type" dc:"结果文件类型"` - ErrorMsg string `json:"error_msg"` -} - -type VideoCallbackReq struct { - g.Meta `path:"/videoCallback" method:"post" tags:"视频处理" summary:"media 回调" dc:"media 成功后 GET 回调:callbackUrl/{bizName}"` - TaskId string `json:"taskId"` - FileURL string `json:"fileUrl"` -} - //============================================================================= // 原始入参结构体 @@ -175,11 +75,12 @@ type ExecuteReq struct { g.Meta `path:"/execute" method:"post" tags:"任务管理" summary:"执行任务" dc:"执行任务"` FlowId int64 `json:"flowId" dc:"用户流程ID"` + FlowContent *entity.FlowInfo `json:"flowContent" description:"流程内容"` + SessionId string `json:"sessionId" dc:"会话ID"` + SessionName string `json:"sessionName"` FlowName string `json:"flowName"` NodeGroupId string `json:"nodeGroupId"` - FlowContent *entity.FlowInfo `json:"flowContent" description:"流程内容"` NodeInputParams []*entity.FlowNode `json:"nodeInputParams" description:"节点输入参数"` - SessionId string `json:"sessionId" dc:"会话ID"` Desc string `json:"desc"` SkillName string `json:"skillName"` FileUrl []string `json:"fileUrl"` @@ -190,6 +91,12 @@ type ExecuteRes struct { Id int64 `json:"id,string" dc:"执行记录ID,用于查询执行状态和结果"` } +type ReExecuteReq struct { + g.Meta `path:"/reExecute" method:"post" tags:"任务管理" summary:"重新执行任务" dc:"重新执行任务"` + + ExecutionId int64 `json:"executionId"` +} + type CancelReq struct { g.Meta `path:"/cancel" method:"post" tags:"任务管理" summary:"取消任务" dc:"取消任务"` @@ -218,11 +125,14 @@ type CreateFlowExecutionRes struct { type UpdateFlowExecutionReq struct { Id int64 `json:"id" v:"required#ID不能为空"` NodeGroupId string `json:"nodeGroupId"` + FlowContent *entity.FlowInfo `json:"flowContent" description:"流程内容"` DurationMs int64 `json:"durationMs" description:"执行时长(毫秒)"` Status flow.FlowExecutionStatus `json:"status" description:"状态:1-运行中,2-成功,3-失败"` OutputParams []map[string]interface{} `json:"outputParams" description:"输出参数"` ErrorMessage string `json:"errorMessage" description:"错误信息"` TraceId string `json:"traceId" description:"跟踪ID"` + TotalTokens int `json:"totalTokens" description:"总token"` + TotalFee float64 `json:"totalFee" description:"总费用"` } type GetFlowExecutionReq struct { @@ -265,10 +175,9 @@ type VOFlowExecution struct { // ========== 核心:构建树状结构 ========== // 定义树结构 type OutputItem struct { - Timestamp string `json:"timestamp" description:"时间戳key"` - Content string `json:"content" description:"内容值"` - Type string `json:"type" description:"类型"` - Label string `json:"label" description:"后缀+数字标号"` + Content string `json:"content" description:"内容值"` + Type string `json:"type" description:"类型"` + Label string `json:"label" description:"后缀+数字标号"` } type FlowNode struct { FlowName string `json:"flowName" description:"流程名称"` diff --git a/workflow/model/dto/flow/flow_user_dto.go b/workflow/model/dto/flow/flow_user_dto.go index 6dd450b..8d64dce 100644 --- a/workflow/model/dto/flow/flow_user_dto.go +++ b/workflow/model/dto/flow/flow_user_dto.go @@ -52,6 +52,7 @@ type ListFlowUserReq struct { g.Meta `path:"/list" method:"get" tags:"用户流程管理" summary:"获取用户流程列表" dc:"分页查询用户流程列表,支持多条件筛选"` Page *beans.Page `json:"page"` + IsOwn bool `json:"isOwn" dc:"是否是自己的流程"` Creator string `json:"creator"` Keyword string `json:"keyword" dc:"关键词搜索"` } diff --git a/workflow/model/dto/node/node_execution_dto.go b/workflow/model/dto/node/node_execution_dto.go index e91ec1c..4480966 100644 --- a/workflow/model/dto/node/node_execution_dto.go +++ b/workflow/model/dto/node/node_execution_dto.go @@ -38,6 +38,7 @@ type UpdateNodeExecutionReq struct { PromptTokens int `json:"promptTokens"` CompletionTokens int `json:"completionTokens"` TotalTokens int `json:"totalTokens"` + TokenInfo []map[string]any `json:"tokenInfo"` Status node.NodeExecutionStatus `json:"status"` DurationMs int64 `json:"durationMs"` ErrorMessage string `json:"errorMessage"` @@ -58,8 +59,11 @@ type GetNodeExecutionReq struct { // ListNodeExecutionByFlowReq 查询流程下所有节点执行记录请求 type ListNodeExecutionByFlowReq struct { g.Meta `path:"/listByFlow" method:"get" tags:"节点执行记录" summary:"查询流程节点执行列表" dc:"查询指定流程执行下的所有节点执行记录"` - Page *beans.Page `json:"page"` - FlowExecutionId int64 `json:"flowExecutionId" v:"required#流程执行ID不能为空"` + Page *beans.Page `json:"page"` + FlowExecutionId int64 `json:"flowExecutionId" v:"required#流程执行ID不能为空"` + NodeGroupId string `json:"nodeGroupId"` + NodeId string `json:"nodeId"` + Status node.NodeExecutionStatus `json:"status"` } // NodeExecutionResp 节点执行记录响应 diff --git a/workflow/model/dto/node/node_library_dto.go b/workflow/model/dto/node/node_library_dto.go index 8b6848a..f8c77dc 100644 --- a/workflow/model/dto/node/node_library_dto.go +++ b/workflow/model/dto/node/node_library_dto.go @@ -13,21 +13,5 @@ type WorkflowNodeTreeReq struct { } type WorkflowNodeTreeRes struct { - Groups []node.NodeGroupItem `json:"groups"` -} - -type TypeGroup struct { - TypeId int `json:"typeId"` - Type string `json:"type"` - Items []ModelItem `json:"items"` -} - -type ModelItem struct { - ID int64 `json:"id"` - Name string `json:"name"` - Form []node.NodeFormField `json:"form"` -} - -type ModelTypeResponse struct { - Type map[int]string `json:"type"` // key 自动解析为整数 100/200/300... + Groups []node.NodeGroupTree `json:"groups"` } diff --git a/workflow/model/dto/session/exec_chat_dto.go b/workflow/model/dto/session/exec_chat_dto.go new file mode 100644 index 0000000..60d747e --- /dev/null +++ b/workflow/model/dto/session/exec_chat_dto.go @@ -0,0 +1,19 @@ +package session + +import ( + "ai-agent/workflow/model/entity" +) + +type CreateExecChatReq struct { + SessionId string `json:"sessionId" description:"所属会话ID"` + Duration int64 `json:"duration" description:"执行时长(秒)"` + RequestParams entity.ExecChatRequestParams `json:"requestParams" description:"请求参数"` + ResultFileUrl string `json:"resultFileUrl" description:"结果文件路径"` + TotalTokens int `json:"totalTokens" description:"总token消耗"` + TotalFee float64 `json:"totalFee" description:"总费用"` + ErrorMessage string `json:"errorMessage" description:"错误信息"` +} + +type DeleteExecChatReq struct { + Id []int64 `json:"id" v:"required#会话执行记录ID不能为空"` +} diff --git a/workflow/model/dto/session/exec_workflow_dto.go b/workflow/model/dto/session/exec_workflow_dto.go new file mode 100644 index 0000000..f5886f2 --- /dev/null +++ b/workflow/model/dto/session/exec_workflow_dto.go @@ -0,0 +1,29 @@ +package session + +import ( + "ai-agent/workflow/consts/flow" + "ai-agent/workflow/model/entity" +) + +type CreateWorkflowReq struct { + SessionId string `json:"sessionId" description:"所属会话ID"` + FlowId int64 `json:"flowId" description:"工作流ID"` + NodeGroupId string `json:"nodeGroupId" description:"节点组ID"` + Status flow.FlowExecutionStatus `json:"status" description:"状态:1-运行中,2-成功,3-失败"` + RequestParams *entity.FlowInfo `json:"requestParams" description:"请求参数"` + ErrorMessage string `json:"errorMessage" description:"错误信息"` +} + +type DeleteExecWorkflowReq struct { + Id []int64 `json:"id" v:"required#工作流执行记录ID不能为空"` +} + +type UpdateWorkflowReq struct { + Id int64 `json:"id" v:"required#工作流执行记录ID不能为空"` + NodeGroupId string `json:"nodeGroupId" description:"节点组ID"` + Status flow.FlowExecutionStatus `json:"status" description:"状态:1-运行中,2-成功,3-失败"` + Duration int64 `json:"duration" description:"执行时长(秒)"` + TotalTokens int `json:"totalTokens" description:"总token消耗"` + TotalFee float64 `json:"totalFee" description:"总费用"` + ErrorMessage string `json:"errorMessage" description:"错误信息"` +} diff --git a/workflow/model/dto/session/exec_workflow_result_dto.go b/workflow/model/dto/session/exec_workflow_result_dto.go new file mode 100644 index 0000000..f72c71d --- /dev/null +++ b/workflow/model/dto/session/exec_workflow_result_dto.go @@ -0,0 +1,12 @@ +package session + +type CreateWorkflowResultReq struct { + SessionId string `json:"sessionId" description:"所属会话ID"` + FlowId int64 `json:"flowId" description:"工作流ID"` + ExecId int64 `json:"execId" description:"执行ID"` + ResultFileUrl string `json:"resultFileUrl" description:"结果文件路径"` +} + +type DeleteWorkflowResultReq struct { + Id int64 `json:"id" v:"required#ID不能为空"` +} diff --git a/workflow/model/dto/session/session_dto.go b/workflow/model/dto/session/session_dto.go index 67115c6..ea93b28 100644 --- a/workflow/model/dto/session/session_dto.go +++ b/workflow/model/dto/session/session_dto.go @@ -1,30 +1,39 @@ package session import ( + "ai-agent/workflow/model/entity" + "gitea.redpowerfuture.com/red-future/common/beans" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" ) -// 结果状态(自包含,不复用 flow 的 *int8 指针类型) -const ( - ResultStatusRunning = 1 // 运行中 - ResultStatusSuccess = 2 // 成功 - ResultStatusFailed = 3 // 失败 -) - -type CreateSessionReq struct { - g.Meta `path:"/create" method:"post" tags:"会话管理" summary:"新建会话" dc:"新建会话"` - SessionName string `json:"sessionName" v:"required#会话名称不能为空" dc:"会话名称"` +// WebSocketConnectReq WebSocket 连接请求(query string 参数) +type WebSocketConnectReq struct { + g.Meta `path:"/wsExecute" method:"get" tags:"工作流执行" summary:"WebSocket执行工作流" dc:"通过WebSocket连接实时执行工作流,支持进度推送和取消"` + SessionId string `p:"sessionId" dc:"会话ID"` } -type CreateSessionRes struct { - Id int64 `json:"id,string" dc:"会话ID"` +type WebSocketExecChatReq struct { + ModelId int64 `json:"modelId" dc:"模型ID" v:"required#模型ID不能为空"` + Question string `json:"question" dc:"用户提问" v:"required#用户提问不能为空"` + SystemPrompt string `json:"systemPrompt" dc:"系统提示词"` +} + +type WebSocketExecWorkflowReq struct { + FlowId int64 `json:"flowId" dc:"用户流程ID" v:"required#用户流程ID不能为空"` + FlowContent *entity.FlowInfo `json:"flowContent" dc:"用户流程内容" v:"required#用户流程内容不能为空"` +} + +type CreateSessionReq struct { + SessionId string `json:"sessionId" dc:"会话ID"` + SessionName string `json:"sessionName" dc:"会话名称"` } type ListSessionReq struct { - g.Meta `path:"/list" method:"get" tags:"会话管理" summary:"会话列表" dc:"会话列表"` - Page *beans.Page `json:"page"` + g.Meta `path:"/list" method:"get" tags:"会话管理" summary:"会话列表" dc:"会话列表"` + PageNum int64 `json:"pageNum" dc:"页码,从1开始"` + PageSize int64 `json:"pageSize" dc:"每页数量"` } type ListSessionRes struct { @@ -33,69 +42,52 @@ type ListSessionRes struct { } type VOSession struct { - Id int64 `json:"id,string" dc:"会话ID"` + SessionId string `json:"sessionId" dc:"会话ID"` SessionName string `json:"sessionName" dc:"会话名称"` CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` } type DeleteSessionReq struct { - g.Meta `path:"/delete" method:"post" tags:"会话管理" summary:"删除会话" dc:"删除会话,级联删除该会话下普通对话结果,工作流结果保留"` - Id int64 `json:"id" v:"required#会话ID不能为空"` + g.Meta `path:"/delete" method:"post" tags:"会话管理" summary:"删除会话" dc:"删除会话"` + SessionId string `json:"sessionId" v:"required#会话ID不能为空"` } -type ListSessionResultsReq struct { - g.Meta `path:"/results" method:"get" tags:"会话管理" summary:"会话内结果列表" dc:"会话内结果列表,工作流+普通对话混排按时间倒序"` - SessionId int64 `json:"sessionId" v:"required#会话ID不能为空"` +type DeleteSessionRecordReq struct { + g.Meta `path:"/deleteRecord" method:"post" tags:"会话管理" summary:"删除会话记录" dc:"删除会话记录"` + Ids []struct { + Id int64 `json:"id" v:"required#ID不能为空"` + Type string `json:"type" v:"required#类型不能为空"` + } } -type ListSessionResultsRes struct { - List []*VOSessionResult `json:"list"` +type GetSessionInfoReq struct { + g.Meta `path:"/get" method:"get" tags:"会话管理" summary:"会话内结果列表" dc:"会话内结果列表,工作流+普通对话混排按时间倒序"` + PageNum int64 `json:"pageNum" dc:"页码,从1开始"` + PageSize int64 `json:"pageSize" dc:"每页数量"` + SessionId string `json:"sessionId" v:"required#会话ID不能为空"` } -// VOSessionResult 会话内单条结果(工作流/普通对话混排) -type VOSessionResult struct { - ResultId int64 `json:"resultId,string" dc:"结果ID"` - Type string `json:"type" dc:"workflow-工作流结果,chat-普通对话结果"` - Status int `json:"status" dc:"1-运行中,2-成功,3-失败"` - FlowId int64 `json:"flowId,string" dc:"工作流ID(chat类型为空)"` - FlowName string `json:"flowName" dc:"工作流名称(chat类型为空)"` - Question string `json:"question" dc:"用户问题(workflow类型为空)"` - Answer string `json:"answer" dc:"模型返回结果(workflow类型为空)"` - RequestParams map[string]any `json:"requestParams" dc:"请求参数(workflow类型)"` - ResultParams []map[string]any `json:"resultParams" dc:"返回结果"` - TotalTokens int `json:"totalTokens" dc:"总token消耗"` - TotalFee float64 `json:"totalFee" dc:"总费用"` - ErrorMsg string `json:"errorMsg" dc:"错误信息"` - CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` +type GetSessionInfoRes struct { + List []*VOSessionInfoResult `json:"list"` + Total int `json:"total"` } -type DeleteSessionResultReq struct { - g.Meta `path:"/resultDelete" method:"post" tags:"会话管理" summary:"删除单条结果" dc:"删除单条结果,不影响会话"` - Type string `json:"type" v:"required#结果类型不能为空" dc:"workflow-工作流结果,chat-普通对话结果"` - Id int64 `json:"id" v:"required#结果ID不能为空"` +// VOSessionInfoResult 会话内单条结果(工作流/普通对话混排) +type VOSessionInfoResult struct { + Id int64 `json:"id,string" dc:"结果ID"` + Type string `json:"type" dc:"workflow-工作流结果,chat-普通对话结果"` + Status int `json:"status" dc:"1-运行中,2-成功,3-失败"` + FlowId int64 `json:"flowId,string" dc:"工作流ID(chat类型为空)"` + RequestParams map[string]any `json:"requestParams" description:"请求参数"` + ResultFileUrl string `json:"resultFileUrl" description:"结果文件路径(供预览/下载)"` + ResultContent string `json:"resultContent" description:"结果文件内容(服务端已读取,前端直接展示)"` + TotalTokens int `json:"totalTokens" dc:"总token消耗"` + TotalFee float64 `json:"totalFee" dc:"总费用"` + ErrorMsg string `json:"errorMsg" dc:"错误信息"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` } -type ListWorkflowResultsReq struct { - g.Meta `path:"/workflowResults" method:"get" tags:"会话管理" summary:"工作流结果平铺列表" dc:"工作流维度所有结果平铺列表,含已删除会话下的结果"` +type ListWorkflowResultReq struct { + g.Meta `path:"/resultList" method:"get" tags:"会话管理" summary:"工作流执行结果树" dc:"按创建人分页查询工作流执行结果,按天分组返回树结构(日期→流程→结果文件),pageSize=每页天数,不传返回全部"` Page *beans.Page `json:"page"` - FlowId int64 `json:"flowId" dc:"按工作流ID过滤,可选"` } - -type ListWorkflowResultsRes struct { - List []*VOWorkflowResult `json:"list"` - Total int `json:"total"` -} - -type VOWorkflowResult struct { - ResultId int64 `json:"resultId,string" dc:"结果ID"` - SessionId int64 `json:"sessionId,string" dc:"所属会话ID"` - FlowId int64 `json:"flowId,string" dc:"工作流ID"` - FlowName string `json:"flowName" dc:"工作流名称"` - RequestParams map[string]any `json:"requestParams" dc:"请求参数"` - ResultParams []map[string]any `json:"resultParams" dc:"返回结果"` - Status int `json:"status" dc:"1-运行中,2-成功,3-失败"` - TotalTokens int `json:"totalTokens" dc:"总token消耗"` - TotalFee float64 `json:"totalFee" dc:"总费用"` - ErrorMsg string `json:"errorMsg" dc:"错误信息"` - CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` -} \ No newline at end of file diff --git a/workflow/model/dto/tool/tool_dto.go b/workflow/model/dto/tool/tool_dto.go new file mode 100644 index 0000000..055431e --- /dev/null +++ b/workflow/model/dto/tool/tool_dto.go @@ -0,0 +1,35 @@ +package tool + +import ( + "github.com/gogf/gf/v2/frame/g" +) + +// ToolListReq 工具列表查询请求 +type ToolListReq struct { + g.Meta `path:"/list" method:"get" tags:"工具管理" summary:"工具列表" dc:"查询已注册的模型工具列表"` +} + +// ToolVO 工具对外信息 +type ToolVO struct { + Name string `json:"name"` + Description string `json:"description"` +} + +// ToolListRes 工具列表查询响应 +type ToolListRes struct { + List []*ToolVO `json:"list"` +} + +// ToolAgentReq 工具对话请求:前端用户提问,模型通过 function calling 使用全部已注册模型工具作答 +type ToolAgentReq struct { + g.Meta `path:"/agent" method:"post" tags:"工具管理" summary:"工具对话" dc:"用户提问,模型通过 function calling 使用全部已注册模型工具作答"` + Question string `json:"question" dc:"用户提问"` + ModelId int64 `json:"modelId,string" dc:"模型ID"` + SessionId string `json:"sessionId" dc:"会话ID,透传给网关记账"` + SystemPrompt string `json:"systemPrompt" dc:"系统提示词,为空使用默认"` +} + +// ToolAgentRes 工具对话响应 +type ToolAgentRes struct { + Answer string `json:"answer"` +} diff --git a/workflow/model/entity/exec_chat.go b/workflow/model/entity/exec_chat.go new file mode 100644 index 0000000..ad5343b --- /dev/null +++ b/workflow/model/entity/exec_chat.go @@ -0,0 +1,41 @@ +package entity + +import "gitea.redpowerfuture.com/red-future/common/beans" + +// ExecChat 执行会话 +type ExecChat struct { + beans.SQLBaseDO `orm:",inherit"` + SessionId string `orm:"session_id" json:"sessionId" description:"会话ID"` + Duration int64 `orm:"duration" json:"duration" description:"执行时长(秒)"` + RequestParams ExecChatRequestParams `orm:"request_params" json:"requestParams" description:"请求参数"` + ResultFileUrl string `orm:"result_file_url" json:"resultFileUrl" description:"结果文件路径"` + TotalTokens int `orm:"total_tokens" json:"totalTokens" description:"总token消耗"` + TotalFee float64 `orm:"total_fee" json:"totalFee" description:"总费用"` + ErrorMessage string `orm:"error_message" json:"errorMessage" description:"错误信息"` +} + +type ExecChatRequestParams struct { + Question string `json:"question"` +} + +type execChatCol struct { + beans.SQLBaseCol + SessionId string + Duration string + RequestParams string + ResultFileUrl string + TotalTokens string + TotalFee string + ErrorMessage string +} + +var ExecChatCol = execChatCol{ + SQLBaseCol: beans.DefSQLBaseCol, + SessionId: "session_id", + Duration: "duration", + RequestParams: "request_params", + ResultFileUrl: "result_file_url", + TotalTokens: "total_tokens", + TotalFee: "total_fee", + ErrorMessage: "error_message", +} diff --git a/workflow/model/entity/exec_workflow.go b/workflow/model/entity/exec_workflow.go new file mode 100644 index 0000000..19c62ef --- /dev/null +++ b/workflow/model/entity/exec_workflow.go @@ -0,0 +1,47 @@ +package entity + +import ( + "ai-agent/workflow/consts/flow" + + "gitea.redpowerfuture.com/red-future/common/beans" +) + +// ExecWorkflow 执行工作流 +type ExecWorkflow struct { + beans.SQLBaseDO `orm:",inherit"` + SessionId string `orm:"session_id" json:"sessionId" description:"会话ID"` + FlowId int64 `orm:"flow_id" json:"flowId" description:"工作流ID"` + NodeGroupId string `orm:"node_group_id" json:"nodeGroupId" description:"节点组ID"` + Duration int64 `orm:"duration" json:"duration" description:"执行时长(秒)"` + RequestParams *FlowInfo `orm:"request_params" json:"requestParams" description:"请求参数"` + Status flow.FlowExecutionStatus `orm:"status" json:"status" description:"状态:1-运行中,2-成功,3-失败"` + TotalTokens int `orm:"total_tokens" json:"totalTokens" description:"总token消耗"` + TotalFee float64 `orm:"total_fee" json:"totalFee" description:"总费用"` + ErrorMessage string `orm:"error_message" json:"errorMessage" description:"错误信息"` +} + +type execWorkflowCol struct { + beans.SQLBaseCol + SessionId string + FlowId string + NodeGroupId string + Duration string + RequestParams string + Status string + TotalTokens string + TotalFee string + ErrorMessage string +} + +var ExecWorkflowCol = execWorkflowCol{ + SQLBaseCol: beans.DefSQLBaseCol, + SessionId: "session_id", + FlowId: "flow_id", + NodeGroupId: "node_group_id", + Duration: "duration", + RequestParams: "request_params", + Status: "status", + TotalTokens: "total_tokens", + TotalFee: "total_fee", + ErrorMessage: "error_message", +} diff --git a/workflow/model/entity/exec_workflow_result.go b/workflow/model/entity/exec_workflow_result.go new file mode 100644 index 0000000..84d2ec1 --- /dev/null +++ b/workflow/model/entity/exec_workflow_result.go @@ -0,0 +1,28 @@ +package entity + +import "gitea.redpowerfuture.com/red-future/common/beans" + +// ExecWorkflowResult 执行工作流结果 +type ExecWorkflowResult struct { + beans.SQLBaseDO `orm:",inherit"` + SessionId string `orm:"session_id" json:"sessionId" description:"会话ID"` + FlowId int64 `orm:"flow_id" json:"flowId" description:"工作流ID"` + ExecId int64 `orm:"exec_id" json:"execId" description:"执行ID"` + ResultFileUrl string `orm:"result_file_url" json:"resultFileUrl" description:"结果文件路径"` +} + +type execWorkflowResultCol struct { + beans.SQLBaseCol + SessionId string + FlowId string + ExecId string + ResultFileUrl string +} + +var ExecWorkflowResultCol = execWorkflowResultCol{ + SQLBaseCol: beans.DefSQLBaseCol, + SessionId: "session_id", + FlowId: "flow_id", + ExecId: "exec_id", + ResultFileUrl: "result_file_url", +} diff --git a/workflow/model/entity/flow_checkpoint.go b/workflow/model/entity/flow_checkpoint.go new file mode 100644 index 0000000..1930f8f --- /dev/null +++ b/workflow/model/entity/flow_checkpoint.go @@ -0,0 +1,25 @@ +package entity + +import ( + "gitea.redpowerfuture.com/red-future/common/beans" +) + +// FlowCheckpoint checkpoint数据实体,对应 workflow_interrupt 表 +type FlowCheckpoint struct { + beans.SQLBaseDO `orm:",inherit"` // 嵌入基础字段:Id, TenantId, Creator, CreatedAt, Updater, UpdatedAt, DeletedAt + // 业务字段 + CheckpointId string `orm:"checkpoint_id" json:"checkpointId" description:"Checkpoint ID(执行ID)"` + Data string `orm:"data" json:"data" description:"Checkpoint序列化数据"` +} + +type flowCheckpointCol struct { + beans.SQLBaseCol + CheckpointId string + Data string +} + +var FlowCheckpointCol = flowCheckpointCol{ + SQLBaseCol: beans.DefSQLBaseCol, + CheckpointId: "checkpoint_id", + Data: "data", +} diff --git a/workflow/model/entity/flow_execution.go b/workflow/model/entity/flow_execution.go index 6f90b0e..0bbdc3f 100644 --- a/workflow/model/entity/flow_execution.go +++ b/workflow/model/entity/flow_execution.go @@ -22,6 +22,7 @@ type FlowExecution struct { TraceId string `orm:"trace_id" json:"traceId" description:"跟踪ID"` SessionId string `orm:"session_id" json:"sessionId" description:"会话ID"` TotalTokens int `orm:"total_tokens" json:"totalTokens" description:"总token消耗"` + TotalFee int `orm:"total_fee" json:"totalFee" description:"总费用"` } type flowExecutionCol struct { @@ -39,6 +40,7 @@ type flowExecutionCol struct { TraceId string SessionId string TotalTokens string + TotalFee string } var FlowExecutionCol = flowExecutionCol{ @@ -56,4 +58,5 @@ var FlowExecutionCol = flowExecutionCol{ TraceId: "trace_id", SessionId: "session_id", TotalTokens: "total_tokens", + TotalFee: "total_fee", } diff --git a/workflow/model/entity/flow_user.go b/workflow/model/entity/flow_user.go index bb92c2f..66bc276 100644 --- a/workflow/model/entity/flow_user.go +++ b/workflow/model/entity/flow_user.go @@ -15,19 +15,33 @@ type FlowInfo struct { } type FlowNode struct { - Id string `json:"id"` - NodeCode node.NodeType `json:"nodeCode"` - Name string `json:"name"` - Config map[string]interface{} `json:"config"` - SkillName string `json:"skillName"` - PromptContent string `json:"promptContent"` - IsSaveFile bool `json:"isSaveFile"` - InputSource []FlowNodeInputSource `json:"inputSource"` // 前端指定:来源节点ID - SubConfig *SubFlowConfig `json:"subConfig"` - FormConfig []node.NodeFormField `json:"formConfig"` - ModelConfig node.ModelItem `json:"modelConfig"` - OutputConfig []node.NodeFormField `json:"outputConfig"` - OutputResult []node.NodeFormField `json:"outputResult" ds:"节点输出结果"` + Id string `json:"id"` + NodeCode node.NodeType `json:"nodeCode"` + Name string `json:"name"` + Desc string `json:"desc"` + Config map[string]interface{} `json:"config"` + IsBatchExec bool `json:"isBatchExec"` + PreTool string `json:"preTool"` + PostTool string `json:"postTool"` + IsSaveFile bool `json:"isSaveFile"` + SubConfig *SubFlowConfig `json:"subConfig"` + ModelConfig ModelItem `json:"modelConfig"` + OutputConfig []map[string]any `json:"outputConfig"` + Prompt string `json:"prompt"` + NegativePrompt string `json:"negativePrompt"` + //SkillName string `json:"skillName"` + //PromptContent string `json:"promptContent"` + //InputSource []FlowNodeInputSource `json:"inputSource"` // 前端指定:来源节点ID + //FormConfig []node.NodeFormField `json:"formConfig"` + OutputResult []map[string]any `json:"outputResult" ds:"节点输出结果"` +} + +type ModelItem struct { + ModelId int64 `json:"modelId,string"` + ModelName string `json:"modelName"` + ModelFormFields []map[string]any `json:"modelFormFields"` + ModelRequestParams map[string]any `json:"modelRequestParams"` + ModelResponseBodyMapping map[string]any `json:"modelResponseBodyMapping"` } type FlowNodeInputSource struct { @@ -45,7 +59,9 @@ type FlowField struct { // SubFlowConfig 子流程节点配置 type SubFlowConfig struct { - FlowId int64 `json:"flowId"` + WorkflowId int64 `json:"workflowId"` + WorkflowName string `json:"workflowName"` + Fields []map[string]any `json:"fields"` MaxConcurrency int `json:"maxConcurrency"` // 子流程并发数 InputSource []FlowNodeInputSource `json:"inputSource"` // 前端指定:来源节点ID } diff --git a/workflow/model/entity/node_execution.go b/workflow/model/entity/node_execution.go index b8b5e08..d9d9f3a 100644 --- a/workflow/model/entity/node_execution.go +++ b/workflow/model/entity/node_execution.go @@ -22,6 +22,7 @@ type NodeExecution struct { PromptTokens int `orm:"prompt_tokens" json:"promptTokens" description:"提示词token消耗"` CompletionTokens int `orm:"completion_tokens" json:"completionTokens" description:"补全token消耗"` TotalTokens int `orm:"total_tokens" json:"totalTokens" description:"总token消耗"` + TokenInfo []map[string]interface{} `orm:"token_info" json:"tokenInfo" description:"token信息"` Status node.NodeExecutionStatus `orm:"status" json:"status" description:"执行状态:1-运行中,2-成功,3-失败,4-暂停,5-等待执行"` DurationMs int64 `orm:"duration_ms" json:"durationMs" description:"执行时长(毫秒)"` ErrorMessage string `orm:"error_message" json:"errorMessage" description:"错误信息"` @@ -40,6 +41,7 @@ type nodeExecutionCol struct { PromptTokens string CompletionTokens string TotalTokens string + TokenInfo string Status string DurationMs string ErrorMessage string @@ -58,6 +60,7 @@ var NodeExecutionCol = nodeExecutionCol{ PromptTokens: "prompt_tokens", CompletionTokens: "completion_tokens", TotalTokens: "total_tokens", + TokenInfo: "token_info", Status: "status", DurationMs: "duration_ms", ErrorMessage: "error_message", diff --git a/workflow/model/entity/session.go b/workflow/model/entity/session.go index c345535..34daae3 100644 --- a/workflow/model/entity/session.go +++ b/workflow/model/entity/session.go @@ -5,86 +5,18 @@ import "gitea.redpowerfuture.com/red-future/common/beans" // Session 会话记录 type Session struct { beans.SQLBaseDO `orm:",inherit"` // 嵌入基础字段:Id, TenantId, Creator, CreatedAt, Updater, UpdatedAt, DeletedAt + SessionId string `orm:"session_id" json:"sessionId" description:"会话ID"` SessionName string `orm:"session_name" json:"sessionName" description:"会话名称"` } type sessionCol struct { beans.SQLBaseCol + SessionId string SessionName string } var SessionCol = sessionCol{ SQLBaseCol: beans.DefSQLBaseCol, + SessionId: "session_id", SessionName: "session_name", } - -// WorkflowSessionResult 工作流会话结果 -type WorkflowSessionResult struct { - beans.SQLBaseDO `orm:",inherit"` - SessionId int64 `orm:"session_id" json:"sessionId" description:"所属会话ID"` - FlowId int64 `orm:"flow_id" json:"flowId" description:"工作流ID"` - FlowName string `orm:"flow_name" json:"flowName" description:"工作流名称"` - RequestParams map[string]any `orm:"request_params" json:"requestParams" description:"请求参数"` - ResultParams []map[string]any `orm:"result_params" json:"resultParams" description:"返回结果"` - Status int `orm:"status" json:"status" description:"状态:1-运行中,2-成功,3-失败"` - TotalTokens int `orm:"total_tokens" json:"totalTokens" description:"总token消耗"` - TotalFee float64 `orm:"total_fee" json:"totalFee" description:"总费用"` - ErrorMessage string `orm:"error_message" json:"errorMessage" description:"错误信息"` -} - -type workflowSessionResultCol struct { - beans.SQLBaseCol - SessionId string - FlowId string - FlowName string - RequestParams string - ResultParams string - Status string - TotalTokens string - TotalFee string - ErrorMessage string -} - -var WorkflowSessionResultCol = workflowSessionResultCol{ - SQLBaseCol: beans.DefSQLBaseCol, - SessionId: "session_id", - FlowId: "flow_id", - FlowName: "flow_name", - RequestParams: "request_params", - ResultParams: "result_params", - Status: "status", - TotalTokens: "total_tokens", - TotalFee: "total_fee", - ErrorMessage: "error_message", -} - -// ChatSessionResult 普通会话结果 -type ChatSessionResult struct { - beans.SQLBaseDO `orm:",inherit"` - SessionId int64 `orm:"session_id" json:"sessionId" description:"所属会话ID"` - Question string `orm:"question" json:"question" description:"用户问题"` - Answer string `orm:"answer" json:"answer" description:"模型返回结果"` - TotalTokens int `orm:"total_tokens" json:"totalTokens" description:"总token消耗"` - TotalFee float64 `orm:"total_fee" json:"totalFee" description:"总费用"` - ErrorMessage string `orm:"error_message" json:"errorMessage" description:"错误信息"` -} - -type chatSessionResultCol struct { - beans.SQLBaseCol - SessionId string - Question string - Answer string - TotalTokens string - TotalFee string - ErrorMessage string -} - -var ChatSessionResultCol = chatSessionResultCol{ - SQLBaseCol: beans.DefSQLBaseCol, - SessionId: "session_id", - Question: "question", - Answer: "answer", - TotalTokens: "total_tokens", - TotalFee: "total_fee", - ErrorMessage: "error_message", -} diff --git a/workflow/service/flow/flow_checkpoint_store.go b/workflow/service/flow/flow_checkpoint_store.go new file mode 100644 index 0000000..31349b3 --- /dev/null +++ b/workflow/service/flow/flow_checkpoint_store.go @@ -0,0 +1,66 @@ +package flow + +import ( + "ai-agent/workflow/consts/node" + flowDto "ai-agent/workflow/model/dto/flow" + "ai-agent/workflow/model/entity" + "context" + "encoding/json" + "time" + + flowDao "ai-agent/workflow/dao/flow" + + "github.com/cloudwego/eino/compose" + "github.com/cloudwego/eino/schema" +) + +// 注册 checkpoint 序列化类型 +func init() { + // ========== 1. Eino 断点核心状态(根类型) ========== + schema.RegisterName[*flowDto.NodeExecutionState]("flow.NodeExecutionState") + schema.RegisterName[*flowDto.NodeExecutionInput]("flow.NodeExecutionInput") + schema.RegisterName[*flowDto.FlowExecutionInput]("flow.FlowExecutionInput") + + // ========== 2. 原有第三方类型 ========== + schema.RegisterName[json.Number]("json.Number") + schema.RegisterName[time.Time]("time.Time") + schema.RegisterName[time.Duration]("time.Duration") + + // ========== 3. flowDto 内部嵌套类型 ========== + schema.RegisterName[flowDto.ExecutedNode]("flow.ExecutedNode") + + // ========== 4. entity 核心链路类型(递归自 *entity.FlowNode) ========== + schema.RegisterName[*entity.FlowNode]("entity.FlowNode") + schema.RegisterName[node.NodeType]("node.NodeType") + schema.RegisterName[entity.FlowNodeInputSource]("entity.FlowNodeInputSource") + schema.RegisterName[entity.FlowField]("entity.FlowField") + schema.RegisterName[*entity.SubFlowConfig]("entity.SubFlowConfig") + //schema.RegisterName[node.NodeFormField]("node.NodeFormField") + schema.RegisterName[entity.ModelItem]("node.ModelItem") + + // ========== 5. FlowInfo 相关(流程拓扑结构) ========== + schema.RegisterName[entity.FlowInfo]("entity.FlowInfo") + schema.RegisterName[entity.FlowEdge]("entity.FlowEdge") +} + +// DbCheckPointStore 数据库存储实现 +type DbCheckPointStore struct{} + +func NewDbCheckPointStore() compose.CheckPointStore { + return &DbCheckPointStore{} +} + +func (d *DbCheckPointStore) Get(ctx context.Context, id string) ([]byte, bool, error) { + record, err := flowDao.FlowCheckpointDao.Get(ctx, id) + if err != nil { + return nil, false, err + } + if record == nil || record.Data == "" { + return nil, false, nil + } + return []byte(record.Data), true, nil +} + +func (d *DbCheckPointStore) Set(ctx context.Context, id string, val []byte) error { + return flowDao.FlowCheckpointDao.SaveOrUpdate(ctx, id, string(val)) +} diff --git a/workflow/service/flow/flow_execution_service.go b/workflow/service/flow/flow_execution_service.go index ea5a74a..da2edcc 100644 --- a/workflow/service/flow/flow_execution_service.go +++ b/workflow/service/flow/flow_execution_service.go @@ -1,32 +1,18 @@ package flow import ( - "ai-agent/workflow/consts/flow" - "ai-agent/workflow/consts/node" - fileDao "ai-agent/workflow/dao/file" flowDao "ai-agent/workflow/dao/flow" - nodeDao "ai-agent/workflow/dao/node" - "ai-agent/workflow/model/dto" - fileDto "ai-agent/workflow/model/dto/file" flowDto "ai-agent/workflow/model/dto/flow" - nodeDto "ai-agent/workflow/model/dto/node" "ai-agent/workflow/model/entity" "context" - "errors" "fmt" "sort" "strconv" - "strings" - "sync" - "time" "gitea.redpowerfuture.com/red-future/common/utils" - "github.com/cloudwego/eino/compose" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" "github.com/gogf/gf/v2/util/gconv" - "github.com/google/uuid" - "go.opentelemetry.io/otel/trace" ) var FlowExecutionService = &flowExecutionService{} @@ -58,43 +44,34 @@ func (s *flowExecutionService) List(ctx context.Context, req *flowDto.ListFlowEx return nil, err } - // ===================== 核心修复:只统计【有数据】的执行记录,空的直接跳过 ===================== - - executionNumber := make(map[int64]int) // executionId -> 倒序编号(最新=1) - - // 第一次遍历:只处理【有输出参数】的记录,统计并分配编号 - var validList []*entity.FlowExecution // 只存有效(非空)记录 + executionNumber := make(map[int64]int) + var validList []*entity.FlowExecution for _, execution := range list { if g.IsEmpty(execution.OutputParams) { - continue // 空数据直接过滤,不参与编号、不展示 + continue } validList = append(validList, execution) } - // 给有效记录分配【时间倒序编号】(最新=1) totalValid := len(validList) for idx, execution := range validList { executionNumber[execution.Id] = totalValid - idx } - // 2. 分组映射:日期 -> 流程节点 type flowWrap struct { flowNode flowDto.FlowNode createdAt *gtime.Time } dateMap := make(map[string]*[]flowWrap) - // 遍历【有效数据】构建结构 for _, execution := range validList { createDate := execution.CreatedAt.Format("Y-m-d") flowName := execution.FlowName outputParams := execution.OutputParams - // 编号只算有效数据,不会把空的算进去 num := executionNumber[execution.Id] displayFlowName := fmt.Sprintf("会话-%d(%s)", num, flowName) - // 3. 解析 outputParams var tempItems []flowDto.OutputItem for _, paramMap := range outputParams { for tsKey, value := range paramMap { @@ -102,32 +79,21 @@ func (s *flowExecutionService) List(ctx context.Context, req *flowDto.ListFlowEx continue } tempItems = append(tempItems, flowDto.OutputItem{ - Timestamp: tsKey, - Content: gconv.String(value), + Content: gconv.String(value), }) } } - // ===================== 修复1:如果解析后依然为空,直接跳过,不生成第二层节点 ===================== if len(tempItems) == 0 { continue } - // 时间戳正序 - sort.Slice(tempItems, func(i, j int) bool { - t1, _ := strconv.ParseInt(tempItems[i].Timestamp, 10, 64) - t2, _ := strconv.ParseInt(tempItems[j].Timestamp, 10, 64) - return t1 < t2 - }) - - // 标号:相同类型递增,不同重置 suffixCount := make(map[string]int) for idx := range tempItems { item := &tempItems[idx] val := item.Content suffix := "内容" - ext := "" - ext = GetFileTypeByPath(val) + ext := GetFileTypeByPath(val) if ext == "image" { suffix = "图片" } @@ -148,7 +114,6 @@ func (s *flowExecutionService) List(ctx context.Context, req *flowDto.ListFlowEx item.Label = fmt.Sprintf("%s_%d", suffix, suffixCount[suffix]) } - // 组装节点 flowNode := flowDto.FlowNode{ FlowName: displayFlowName, Id: execution.Id, @@ -165,10 +130,8 @@ func (s *flowExecutionService) List(ctx context.Context, req *flowDto.ListFlowEx }) } - // 6. 构建树 + 排序 var tree []flowDto.DateNode for date, wraps := range dateMap { - // 第二层按创建时间倒序(最新在前) sort.Slice(*wraps, func(i, j int) bool { return (*wraps)[i].createdAt.After((*wraps)[j].createdAt) }) @@ -178,7 +141,6 @@ func (s *flowExecutionService) List(ctx context.Context, req *flowDto.ListFlowEx flowNodes = append(flowNodes, w.flowNode) } - // ===================== 修复2:日期下没有流程,也过滤掉 ===================== if len(flowNodes) == 0 { continue } @@ -189,7 +151,6 @@ func (s *flowExecutionService) List(ctx context.Context, req *flowDto.ListFlowEx }) } - // 第一层日期倒序 sort.Slice(tree, func(i, j int) bool { return tree[i].CreateDate > tree[j].CreateDate }) @@ -201,26 +162,6 @@ func (s *flowExecutionService) List(ctx context.Context, req *flowDto.ListFlowEx }, nil } -// ComposeCallback 提示词回调接口 -func (s *flowExecutionService) ComposeCallback(ctx context.Context, req *flowDto.ComposeCallbackReq) (err error) { - Notify(req.TaskId, req) - return nil -} - -// ModelCallback 模型回调接口 -func (s *flowExecutionService) ModelCallback(ctx context.Context, req *flowDto.ModelCallbackReq) (err error) { - // 唤醒等待的任务 - Notify(req.TaskId, req) - return nil -} - -// VideoCallback 视频拼接回调接口 -func (s *flowExecutionService) VideoCallback(ctx context.Context, req *flowDto.VideoCallbackReq) (err error) { - // 唤醒等待的任务 - Notify(req.TaskId, req) - return nil -} - // HttpNodeCallback http节点回调接口 func (s *flowExecutionService) HttpNodeCallback(ctx context.Context) (err error) { r := g.RequestFromCtx(ctx) @@ -228,511 +169,3 @@ func (s *flowExecutionService) HttpNodeCallback(ctx context.Context) (err error) Notify(taskId, r) return nil } - -// ===================== 核心改造:替换为 sync.Map 存储取消上下文 ===================== -var ( - // cancelMap: traceID -> context.CancelFunc - cancelMap sync.Map -) - -func (s *flowExecutionService) Cancel(ctx context.Context, req *flowDto.CancelReq) (err error) { - getRes, err := flowDao.FlowExecutionDao.Get(ctx, &flowDto.GetFlowExecutionReq{ - SessionId: req.SessionId, - }) - if err != nil { - return err - } - if g.IsEmpty(getRes) { - return fmt.Errorf("会话[%s] 不存在", req.SessionId) - } - // 从 sync.Map 获取取消函数 - cancelVal, exist := cancelMap.Load(getRes.TraceId) - if !exist { - return fmt.Errorf("traceID[%s] 不存在或已执行完成", getRes.TraceId) - } - - // 执行取消 - cancel, ok := cancelVal.(context.CancelFunc) - if !ok { - return fmt.Errorf("traceID[%s] 对应的取消函数类型错误", getRes.TraceId) - } - cancel() - - // 取消后清理(可选:也可以在流程结束时统一清理) - cancelMap.Delete(getRes.TraceId) - - // 同步更新流程执行状态为已取消 - _, err = flowDao.FlowExecutionDao.Update(ctx, &flowDto.UpdateFlowExecutionReq{ - Id: getRes.Id, - Status: flow.FlowExecutionStatusCancel.Code(), - }) - if err != nil { - return fmt.Errorf("更新取消状态失败: %v", err) - } - - return nil -} - -func (s *flowExecutionService) Execute(ctx context.Context, req *flowDto.ExecuteReq) (res *flowDto.ExecuteRes, err error) { - // ===================== 核心改造1:创建可取消的上下文 ===================== - execCtx, cancel := context.WithCancel(ctx) - traceId := "" - defer func() { - // 流程结束(成功/失败)时清理 cancelMap - if traceId != "" { - cancelMap.Delete(traceId) - } - cancel() - }() - - //getRes, err := FlowUserService.Get(ctx, &flowDto.GetFlowUserReq{ - // Id: req.FlowId, - //}) - //if err != nil { - // return nil, err - //} - nodeInputParams := ExtractFlowNodeFrom(req.FlowContent) - flowInfo, err := flowDao.FlowExecutionDao.Get(ctx, &flowDto.GetFlowExecutionReq{ - SessionId: req.SessionId, - }) - if err != nil { - return - } - var executionId int64 - var isDialogue bool - var nodeGroupId = uuid.NewString() - if flowInfo == nil { - isDialogue = false - var r = new(flowDto.CreateFlowExecutionReq) - r.FlowUserId = req.FlowId - r.FlowName = req.FlowName - r.NodeGroupId = nodeGroupId - r.TriggerType = flow.FlowExecutionTriggerTypeManual.Code() - r.FlowContent = req.FlowContent - //r.NodeInputParams = nodeInputParams - r.SessionId = req.SessionId - r.Status = flow.FlowExecutionStatusRunning.Code() - span := trace.SpanFromContext(ctx) - if span != nil && span.SpanContext().HasTraceID() { - r.TraceId = span.SpanContext().TraceID().String() - traceId = r.TraceId - cancelMap.Store(traceId, cancel) - } - executionId, err = flowDao.FlowExecutionDao.Insert(ctx, r) - if err != nil { - return - } - } else { - isDialogue = true - executionId = flowInfo.Id - span := trace.SpanFromContext(ctx) - if span != nil && span.SpanContext().HasTraceID() { - traceId = span.SpanContext().TraceID().String() - cancelMap.Store(traceId, cancel) - } - executionReq := flowDto.UpdateFlowExecutionReq{ - Id: executionId, - NodeGroupId: nodeGroupId, - Status: flow.FlowExecutionStatusRunning.Code(), - TraceId: traceId, - } - _, err = flowDao.FlowExecutionDao.Update(ctx, &executionReq) - if err != nil { - return - } - } - - if !g.IsEmpty(req.FileUrl) { - createFileTempReq := make([]*fileDto.CreateFileTempReq, 0, len(req.FileUrl)) - for _, fileUrl := range req.FileUrl { - var createReq = new(fileDto.CreateFileTempReq) - createReq.BusinessId = req.SessionId - createReq.FileUrl = fileUrl - createFileTempReq = append(createFileTempReq, createReq) - } - _, err = fileDao.FileTempDao.BatchInsert(ctx, createFileTempReq) - if err != nil { - return nil, err - } - } - - if isDialogue && !g.IsEmpty(flowInfo) && !g.IsEmpty(req.ResultUrl) { - req.NodeGroupId = nodeGroupId - if strings.HasSuffix(gconv.String(req.ResultUrl), ".inc") { - err = TextModelSingleLambda(ctx, req, flowInfo) - return - } else if strings.HasSuffix(gconv.String(req.ResultUrl), ".png") { - err = ImgModelSingleLambda(ctx, req, flowInfo) - return - } else if strings.HasSuffix(gconv.String(req.ResultUrl), ".html") { - err = TextImgModelSingleLambda(ctx, req, flowInfo) - return - } - return nil, errors.New("文件格式不支持") - } - // ========================================================================= - // ✅【第2步】构建执行图 - // ========================================================================= - var nodeList []entity.FlowNode - var runGraph compose.Runnable[any, any] - nodeList, runGraph, err = BuildGraphFromFlowContent(execCtx, req.FlowContent) - if err != nil { - executionReq := flowDto.UpdateFlowExecutionReq{ - Id: executionId, - Status: flow.FlowExecutionStatusFailed.Code(), - ErrorMessage: err.Error(), - } - _, err1 := flowDao.FlowExecutionDao.Update(ctx, &executionReq) - if err1 != nil { - return - } - return nil, fmt.Errorf("执行工作流失败: %v", err) - } - // ========================================================================= - // ✅【第3步】构建 ConfigMap - // ========================================================================= - configMap := make(map[string]*entity.FlowNode) - for _, cfg := range nodeInputParams { - configMap[cfg.Id] = cfg - } - for _, i := range nodeList { - configMap[i.Id] = &i - } - // ========================================================================= - // ✅【第4步】构建全局执行入参(现在 schemaMap 是有值的!) - // ========================================================================= - execInput := &flowDto.FlowExecutionInput{ - NodeGroupId: nodeGroupId, - IsDialogue: isDialogue, - ExecutionId: executionId, - ConfigMap: configMap, - SessionId: req.SessionId, - Desc: req.Desc, - SkillName: req.SkillName, - FileUrl: req.FileUrl, - } - // 执行工作流 - _, err = runGraph.Invoke(execCtx, execInput) - if err != nil { - // 检测是否是取消导致的错误 - if errors.Is(execCtx.Err(), context.Canceled) { - executionReq := flowDto.UpdateFlowExecutionReq{ - Id: executionId, - Status: flow.FlowExecutionStatusCancel.Code(), - } - _, _ = flowDao.FlowExecutionDao.Update(ctx, &executionReq) - return nil, fmt.Errorf("工作流已被取消: %v", err) - } - executionReq := flowDto.UpdateFlowExecutionReq{ - Id: executionId, - Status: flow.FlowExecutionStatusFailed.Code(), - ErrorMessage: err.Error(), - } - _, err1 := flowDao.FlowExecutionDao.Update(ctx, &executionReq) - if err1 != nil { - return - } - return nil, fmt.Errorf("执行工作流失败: %v", err) - } - return -} - -func BuildGraph(ctx context.Context, flowContent *entity.FlowInfo) ([]entity.FlowNode, *compose.Graph[any, any]) { - // 注册自定义合并函数:处理 *flowDto.FlowExecutionInput 类型合并 - // 由于 ConfigMap 是 map 引用类型,所有并行分支修改已经写入共享内存 - // 直接返回第一个实例即可,所有修改都已经可见 - compose.RegisterValuesMergeFunc(func(values []*flowDto.FlowExecutionInput) (*flowDto.FlowExecutionInput, error) { - if len(values) == 0 { - return nil, nil - } - // 返回第一个实例,ConfigMap 是指针,所有修改都已经写入共享数据结构 - return values[0], nil - }) - - graph := compose.NewGraph[any, any]() - - var nodeList []entity.FlowNode - nodeId := uuid.NewString() - originalEndNodes := findEndNodes(flowContent.StartNodeId, flowContent.Edges) - for i := range originalEndNodes { - sprintf := fmt.Sprintf("%v_%d", nodeId, i) - summaryNode := entity.FlowNode{ - Id: sprintf, - NodeCode: node.NodeTypeSystemSum, - Name: node.NodeNameSystemSum, - InputSource: []entity.FlowNodeInputSource{}, // 后续自动聚合所有节点输出 - FormConfig: nil, - ModelConfig: node.ModelItem{}, - } - nodeList = append(nodeList, summaryNode) - flowContent.Nodes = append(flowContent.Nodes, summaryNode) - } - - // 注册所有节点 - nodeMap := make(map[string]entity.FlowNode) - for _, item := range flowContent.Nodes { - nodeMap[item.Id] = item - if item.NodeCode != node.NodeTypeJudge { - registerNodeToGraph(graph, item) - } - } - - // 注册所有边 - if flowContent.StartNodeId != "" { - _ = graph.AddEdge(compose.START, flowContent.StartNodeId) - } - for i, endID := range originalEndNodes { - sprintf := fmt.Sprintf("%v_%d", nodeId, i) - _ = graph.AddEdge(endID, sprintf) - _ = graph.AddEdge(sprintf, compose.END) - } - - // 构建边关系 - upstreamMap := make(map[string][]string) - edgeMap := make(map[string][]entity.FlowEdge) - for _, edge := range flowContent.Edges { - edgeMap[edge.From] = append(edgeMap[edge.From], edge) - upstreamMap[edge.To] = append(upstreamMap[edge.To], edge.From) - } - - // 处理连线 & 分支 - for fromNodeID, edges := range edgeMap { - fromNode := nodeMap[fromNodeID] - - // 判断节点 → 分支处理 - if fromNode.NodeCode == node.NodeTypeJudge { - branchMap := make(map[string]bool) - for _, e := range edges { - branchMap[e.To] = true - } - - judgeLambda := func(ctx context.Context, input any) (string, error) { - execInput, ok := input.(*flowDto.FlowExecutionInput) - if !ok { - return "", fmt.Errorf("入参类型错误") - } - - currentConfig := execInput.ConfigMap[fromNodeID] - if currentConfig == nil { - return "", fmt.Errorf("判断节点%s无配置", fromNodeID) - } - - branchIdNameMap := make(map[string]string) - var branchIDs []string - for nodeID := range branchMap { - branchIDs = append(branchIDs, nodeID) - // 从configMap获取分支节点的名称 - if branchNodeCfg, ok := execInput.ConfigMap[nodeID]; ok { - branchIdNameMap[nodeID] = branchNodeCfg.Name - } else { - branchIdNameMap[nodeID] = "未命名节点" // 兜底 - } - } - - // 把分支ID-名称映射塞进 ModelConfig,带给意图节点 - m := make(map[string]interface{}) - m["branch_ids"] = branchIDs - m["branch_id_name_map"] = branchIdNameMap // 传递ID-名称映射 - currentConfig.Config = m - - // 关键修改:构造 NodeExecutionInput 传入 JudgeLambda - nodeExecInput := &flowDto.NodeExecutionInput{ - Config: currentConfig, // 当前判断节点配置 - Global: execInput, // 全局执行入参 - } - return JudgeLambda(ctx, nodeExecInput) // 传入 NodeExecutionInput 类型 - } - - _ = graph.AddBranch(upstreamMap[fromNodeID][0], compose.NewGraphBranch(judgeLambda, branchMap)) - continue - } - - // 普通节点连线 - for _, e := range edges { - toNode := nodeMap[e.To] - if toNode.NodeCode == node.NodeTypeJudge { - continue - } - _ = graph.AddEdge(e.From, e.To) - } - } - return nodeList, graph -} - -// BuildGraphFromFlowContent 根据前端保存的工作流JSON,自动构建执行图 -func BuildGraphFromFlowContent(ctx context.Context, flowContent *entity.FlowInfo) ([]entity.FlowNode, compose.Runnable[any, any], error) { - nodeList, graph := BuildGraph(ctx, flowContent) - compile, err := graph.Compile(ctx, compose.WithGraphName("auto_build_workflow")) - return nodeList, compile, err -} - -// -------------------------- 节点自动注册器(核心分发) -------------------------- -func registerNodeToGraph(graph *compose.Graph[any, any], flowNode entity.FlowNode) { - // 通用包装:全程入参都是 *FlowExecutionInput - wrapLambda := func(lambda func(ctx context.Context, input any) (any, error)) func(ctx context.Context, input any) (any, error) { - return func(ctx context.Context, input any) (any, error) { - // ✅ 【关键】全程入参类型永远不变 - execInput, ok := input.(*flowDto.FlowExecutionInput) - if !ok { - return nil, fmt.Errorf("入参必须是 *FlowExecutionInput, 实际是 %T", input) - } - - configMap := execInput.ConfigMap - currentConfig := configMap[flowNode.Id] - if currentConfig == nil { - return nil, fmt.Errorf("节点%s无配置", flowNode.Id) - } - - // 获取入参 - 适配切片类型:遍历所有来源节点 - realInput := new(flowDto.NodeExecutionInput) - if len(flowNode.InputSource) > 0 { // 改为判断切片长度 - // 遍历所有指定的来源节点,聚合输出结果 - for _, inputSource := range flowNode.InputSource { // 遍历切片 - if sourceConfig, ok := configMap[inputSource.NodeId]; ok { - currentConfig.OutputResult = append(currentConfig.OutputResult, sourceConfig.OutputResult...) - } - } - } - - // ✅ 封装节点执行入参(配置+表单架构) - realInput = &flowDto.NodeExecutionInput{ - Config: currentConfig, - Global: execInput, // ✅ 把【全部节点】的对象直接塞进来 - } - // ✅ 插入节点执行记录,初始状态为运行中 - startTime := time.Now() - - // 上传OSS(每条独立上传) - ossResult, err := Upload(ctx, &dto.UploadFileBytesReq{ - FileBytes: gconv.Bytes(gconv.String(realInput)), - FileName: fmt.Sprintf("nodeInput:%v.txt", time.Now().UnixMilli()), - }) - if err != nil { - return nil, err - } - - nodeExecutionId, err := nodeDao.NodeExecutionDao.Insert(ctx, &nodeDto.CreateNodeExecutionReq{ - FlowExecutionId: execInput.ExecutionId, - NodeId: flowNode.Id, - NodeName: flowNode.Name, - NodeGroupId: execInput.NodeGroupId, - InputParamsPath: ossResult.FileURL, - Status: node.NodeExecutionStatusRunning.Code(), - }) - if err != nil { - // 记录失败到已执行列表 - execInput.ExecutedNodes = append(execInput.ExecutedNodes, flowDto.ExecutedNode{ - NodeId: flowNode.Id, - Status: node.NodeExecutionStatusFailed.Code(), - }) - return nil, err - } - realInput.NodeExecutionId = nodeExecutionId - // 执行节点 - _, err = lambda(ctx, realInput) - durationMs := time.Since(startTime).Milliseconds() - updateReq := &nodeDto.UpdateNodeExecutionReq{ - Id: nodeExecutionId, - DurationMs: durationMs, - } - if err != nil { - // 执行失败,更新状态 - updateReq.Status = node.NodeExecutionStatusFailed.Code() - updateReq.ErrorMessage = err.Error() - _, _ = nodeDao.NodeExecutionDao.Update(ctx, updateReq) - // 记录失败到已执行列表 - execInput.ExecutedNodes = append(execInput.ExecutedNodes, flowDto.ExecutedNode{ - NodeId: flowNode.Id, - Status: node.NodeExecutionStatusFailed.Code(), - }) - return nil, err - } - // 上传OSS(每条独立上传) - ossResult1, err := Upload(ctx, &dto.UploadFileBytesReq{ - FileBytes: gconv.Bytes(gconv.String(realInput)), - FileName: fmt.Sprintf("nodeInput:%v.txt", time.Now().UnixMilli()), - }) - if err != nil { - return nil, err - } - updateReq.OutputParamsPath = ossResult1.FileURL - // 执行成功,更新状态 - updateReq.Status = node.NodeExecutionStatusSuccess.Code() - _, _ = nodeDao.NodeExecutionDao.Update(ctx, updateReq) - // 记录成功到已执行列表 - execInput.ExecutedNodes = append(execInput.ExecutedNodes, flowDto.ExecutedNode{ - NodeId: flowNode.Id, - Status: node.NodeExecutionStatusSuccess.Code(), - }) - - // ✅ 关键:返回整个 execInput,让下一个节点继续用! - return execInput, nil - } - } - switch flowNode.NodeCode { - case "__start__": - _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(StartLambda))) - case node.NodeTypeSystemSum: - _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(SummaryLambda))) - case node.NodeTypeTextModel: - _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(TextModelLambda))) - case node.NodeTypeImageModel: - _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(ImageModelLambda))) - case node.NodeTypeVideoModel: - _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(VideoModelLambda))) - case node.NodeTypeAudioModel: - _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(AudioModelLambda))) - case node.NodeTypeBatchModel: - _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(BatchModelLambda))) - case node.NodeTypeDataConversionModel: - _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(DataConversionLambda))) - case node.NodeTypeCustomNode: - _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(CustomLambda))) - case node.NodeTypeForm: - _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(FormLambda))) - //case node.NodeTypeIntent: - // _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(IntentLambda))) - case node.NodeTypeMerge: - _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(MergeLambda))) - case node.NodeTypeDataMerge: - _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(DataMergeLambda)), compose.WithGraphCompileOptions(compose.WithNodeTriggerMode(compose.AllPredecessor))) - case node.NodeTypeSubFlow: - _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(SubFlowLambda))) - case node.NodeTypeHttp: - _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(HttpLambda))) - } -} - -func findEndNodes(startNodeId string, edges []entity.FlowEdge) []string { - nextMap := make(map[string][]string) - for _, e := range edges { - nextMap[e.From] = append(nextMap[e.From], e.To) - } - - endNodeSet := make(map[string]struct{}) - visited := make(map[string]struct{}) - queue := []string{startNodeId} - - for len(queue) > 0 { - node := queue[0] - queue = queue[1:] - - if _, exist := visited[node]; exist { - continue - } - visited[node] = struct{}{} - - nextList := nextMap[node] - if len(nextList) == 0 { - endNodeSet[node] = struct{}{} - continue - } - queue = append(queue, nextList...) - } - - res := make([]string, 0, len(endNodeSet)) - for k := range endNodeSet { - res = append(res, k) - } - return res -} diff --git a/workflow/service/flow/flow_graph_builder.go b/workflow/service/flow/flow_graph_builder.go new file mode 100644 index 0000000..c0be8fe --- /dev/null +++ b/workflow/service/flow/flow_graph_builder.go @@ -0,0 +1,262 @@ +package flow + +import ( + "ai-agent/gateway" + "ai-agent/workflow/consts/node" + flowDto "ai-agent/workflow/model/dto/flow" + "ai-agent/workflow/model/entity" + "context" + "fmt" + "time" + + "github.com/cloudwego/eino/compose" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" +) + +// BuildGraph 根据 FlowInfo 构建完整的 Eino Graph 拓扑 +func BuildGraph(ctx context.Context, flowContent *entity.FlowInfo) ([]entity.FlowNode, *compose.Graph[any, any]) { + // 注册自定义合并函数:处理 *flowDto.FlowExecutionInput 类型合并 + // 由于 ConfigMap 是 map 引用类型,所有并行分支修改已经写入共享内存 + // 直接返回第一个实例即可,所有修改都已经可见 + compose.RegisterValuesMergeFunc(func(values []*flowDto.FlowExecutionInput) (*flowDto.FlowExecutionInput, error) { + if len(values) == 0 { + return nil, nil + } + // 返回第一个实例,ConfigMap 是指针,所有修改都已经写入共享数据结构 + return values[0], nil + }) + + graph := compose.NewGraph[any, any]( + // 本地状态初始化 + compose.WithGenLocalState(func(ctx context.Context) *flowDto.NodeExecutionState { + return &flowDto.NodeExecutionState{} + }), + ) + + // 注册所有节点 + nodeMap := make(map[string]entity.FlowNode) + for _, item := range flowContent.Nodes { + nodeMap[item.Id] = item + //if item.NodeCode != node.NodeTypeJudge { + registerNodeToGraph(graph, item) + //} + } + + // 注册开始节点 + if flowContent.StartNodeId != "" { + _ = graph.AddEdge(compose.START, flowContent.StartNodeId) + } + + var nodeList []entity.FlowNode + originalEndNodes := FindEndNodes(flowContent.StartNodeId, flowContent.Edges) + for _, endID := range originalEndNodes { + // 保存结果节点 ID 必须稳定:ReExecute 续跑重建图时复用同一 ID, + // 否则 checkpoint 里 ConfigMap 存的是上次的旧 ID,续跑时新图按新 ID 查不到配置, + // 报"节点信息为空"。一个末端节点对应一个保存结果节点,用 endID 派生即唯一且稳定。 + summaryNodeId := fmt.Sprintf("%s_%s", node.NodeTypeSystemSum, endID) + summaryNode := entity.FlowNode{ + Id: summaryNodeId, + NodeCode: node.NodeTypeSystemSum, + Name: node.GetNodeTypeName(node.NodeTypeSystemSum), + } + nodeList = append(nodeList, summaryNode) + flowContent.Nodes = append(flowContent.Nodes, summaryNode) + + registerNodeToGraph(graph, summaryNode) + _ = graph.AddEdge(endID, summaryNodeId) + _ = graph.AddEdge(summaryNodeId, compose.END) + } + + // 构建边关系 + upstreamMap := make(map[string][]string) + edgeMap := make(map[string][]entity.FlowEdge) + for _, edge := range flowContent.Edges { + edgeMap[edge.From] = append(edgeMap[edge.From], edge) + upstreamMap[edge.To] = append(upstreamMap[edge.To], edge.From) + } + + // 处理连线 & 分支 + for _, edges := range edgeMap { + //fromNode := nodeMap[fromNodeID] + + // 判断节点 → 分支处理 + //if fromNode.NodeCode == node.NodeTypeJudge { + // branchMap := make(map[string]bool) + // for _, e := range edges { + // branchMap[e.To] = true + // } + // + // judgeLambda := func(ctx context.Context, input any) (string, error) { + // execInput, ok := input.(*flowDto.FlowExecutionInput) + // if !ok { + // return "", fmt.Errorf("入参类型错误") + // } + // + // currentConfig := execInput.ConfigMap[fromNodeID] + // if currentConfig == nil { + // return "", fmt.Errorf("判断节点%s无配置", fromNodeID) + // } + // + // branchIdNameMap := make(map[string]string) + // var branchIDs []string + // for nodeID := range branchMap { + // branchIDs = append(branchIDs, nodeID) + // // 从configMap获取分支节点的名称 + // if branchNodeCfg, ok := execInput.ConfigMap[nodeID]; ok { + // branchIdNameMap[nodeID] = branchNodeCfg.Name + // } else { + // branchIdNameMap[nodeID] = "未命名节点" // 兜底 + // } + // } + // + // // 把分支ID-名称映射塞进 ModelConfig,带给意图节点 + // m := make(map[string]interface{}) + // m["branch_ids"] = branchIDs + // m["branch_id_name_map"] = branchIdNameMap + // currentConfig.Config = m + // + // // 构造 NodeExecutionInput 传入 JudgeLambda + // nodeExecInput := &flowDto.NodeExecutionInput{ + // Config: currentConfig, + // Global: execInput, + // } + // return JudgeLambda(ctx, nodeExecInput) + // } + // + // _ = graph.AddBranch(upstreamMap[fromNodeID][0], compose.NewGraphBranch(judgeLambda, branchMap)) + // continue + //} + + // 普通节点连线 + for _, e := range edges { + //toNode := nodeMap[e.To] + //if toNode.NodeCode == node.NodeTypeJudge { + // continue + //} + _ = graph.AddEdge(e.From, e.To) + } + } + return nodeList, graph +} + +// BuildGraphFromFlowContent 根据前端保存的工作流JSON,自动构建执行图并编译 +func BuildGraphFromFlowContent(ctx context.Context, flowContent *entity.FlowInfo) ([]entity.FlowNode, compose.Runnable[any, any], error) { + nodeList, graph := BuildGraph(ctx, flowContent) + compile, err := graph.Compile(ctx, compose.WithGraphName("auto_build_workflow"), compose.WithCheckPointStore(NewDbCheckPointStore())) + return nodeList, compile, err +} + +// registerNodeToGraph 将单个节点注册到图中(包含通用包装逻辑) +func registerNodeToGraph(graph *compose.Graph[any, any], flowNode entity.FlowNode) { + // 通用包装:全程入参都是 *FlowExecutionInput + wrapLambda := func(lambda func(ctx context.Context, input any) (any, error)) func(ctx context.Context, input any) (any, error) { + return func(ctx context.Context, input any) (any, error) { + startTime := time.Now() + + // 构建节点执行入参(含中断恢复) + execInput, realInput, err := BuildNodeExecutionInput(ctx, input, flowNode) + if err != nil { + return nil, err + } + + flowNodeDesc := flowNode.Desc + if g.IsEmpty(flowNodeDesc) { + flowNodeDesc = flowNode.Name + } + + // 上报节点执行进度(WebSocket场景下推送进度给前端) + if reporter := GetProgressReporter(ctx); reporter != nil { + nodeIndex := len(execInput.ExecutedNodes) + 1 + if IndexOf(execInput.ExecutedNodes, flowNode.Id) != -1 { + nodeIndex = IndexOf(execInput.ExecutedNodes, flowNode.Id) + } + reporter.ReportStart(flowNode.Id, flowNodeDesc, nodeIndex, len(execInput.ConfigMap)) + } + + // 上传入参到OSS + ossResult, err := gateway.Upload(ctx, fmt.Sprintf("nodeInput:%v.txt", time.Now().UnixMilli()), gconv.Bytes(gconv.String(realInput))) + if err != nil { + return nil, HandleFailedNodeExecution(ctx, execInput, 0, flowNode, err, 0) + } + + // 创建节点执行记录 + nodeExecutionId, err := CreateNodeExecutionRecord(ctx, execInput, flowNode, ossResult) + if err != nil { + return nil, HandleFailedNodeExecution(ctx, execInput, 0, flowNode, err, 0) + } + realInput.NodeExecutionId = nodeExecutionId + + // 执行节点 + _, err = lambda(ctx, realInput) + durationMs := time.Since(startTime).Milliseconds() + + if err != nil { + // 执行失败处理 + return nil, HandleFailedNodeExecution(ctx, execInput, nodeExecutionId, flowNode, err, durationMs) + } + + // 执行成功处理 + if err = HandleSuccessfulNodeExecution(ctx, execInput, realInput, nodeExecutionId, flowNode, durationMs); err != nil { + return nil, HandleFailedNodeExecution(ctx, execInput, nodeExecutionId, flowNode, err, durationMs) + } + + // 上报节点执行进度(WebSocket场景下推送进度给前端) + if reporter := GetProgressReporter(ctx); reporter != nil { + nodeIndex := len(execInput.ExecutedNodes) + if IndexOf(execInput.ExecutedNodes, flowNode.Id) != -1 { + nodeIndex = IndexOf(execInput.ExecutedNodes, flowNode.Id) + } + reporter.ReportComplete(flowNode.Id, flowNodeDesc, nodeIndex, len(execInput.ConfigMap)) + } + + // 返回整个 execInput,让下一个节点继续用 + return execInput, nil + } + } + + switch flowNode.NodeCode { + case node.NodeTypeStart: + _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(StartLambda))) + case node.NodeTypeSystemSum: + _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(SummaryLambda))) + case node.NodeTypeModel: + _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(ModelLambda))) + case node.NodeTypeForm: + _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(FormLambda))) + case node.NodeTypeDataMerge: + _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(DataMergeLambda)), compose.WithGraphCompileOptions(compose.WithNodeTriggerMode(compose.AllPredecessor))) + case node.NodeTypeSubFlow: + _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(SubFlowLambda))) + case node.NodeTypeHttp: + _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(HttpLambda))) + case node.NodeTypeScriptTranscribe: + _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(ScriptTranscribeLambda))) + //case node.NodeTypeTextModel: + // _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(TextModelLambda))) + //case node.NodeTypeImageModel: + // _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(ImageModelLambda))) + //case node.NodeTypeVideoModel: + // _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(VideoModelLambda))) + //case node.NodeTypeAudioModel: + // _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(AudioModelLambda))) + //case node.NodeTypeBatchModel: + // _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(BatchModelLambda))) + //case node.NodeTypeDataConversionModel: + // _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(DataConversionLambda))) + //case node.NodeTypeCustomNode: + // _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(CustomLambda))) + //case node.NodeTypeMerge: + // _ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(MergeLambda))) + } +} + +// IndexOf 返回元素第一次出现的下标,不存在返回 -1 +func IndexOf(slice []flowDto.ExecutedNode, target string) int { + for i, v := range slice { + if v.NodeId == target { + return i + 1 + } + } + return -1 +} diff --git a/workflow/service/flow/flow_graph_util.go b/workflow/service/flow/flow_graph_util.go new file mode 100644 index 0000000..01bf692 --- /dev/null +++ b/workflow/service/flow/flow_graph_util.go @@ -0,0 +1,160 @@ +package flow + +import ( + "ai-agent/gateway" + "ai-agent/workflow/consts/node" + nodeDao "ai-agent/workflow/dao/node" + flowDto "ai-agent/workflow/model/dto/flow" + nodeDto "ai-agent/workflow/model/dto/node" + "ai-agent/workflow/model/entity" + "context" + "fmt" + "time" + + "github.com/cloudwego/eino/compose" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" +) + +// BuildNodeExecutionInput 构建节点执行入参,包含中断恢复逻辑 +func BuildNodeExecutionInput(ctx context.Context, input any, flowNode entity.FlowNode) (*flowDto.FlowExecutionInput, *flowDto.NodeExecutionInput, error) { + execInput := new(flowDto.FlowExecutionInput) + + wasInterrupted, _, _ := compose.GetInterruptState[any](ctx) + if wasInterrupted { + if err := compose.ProcessState(ctx, func(_ context.Context, s *flowDto.NodeExecutionState) error { + execInput = s.SavedFlowInput + return nil + }); err != nil { + return nil, nil, fmt.Errorf("节点:%v 进程状态读取失败: %v", flowNode.Name, err) + } + // 兼容旧 checkpoint(无 SavedFlowInput 时,降级使用 input 参数) + if execInput == nil { + var ok bool + execInput, ok = input.(*flowDto.FlowExecutionInput) + if !ok { + return nil, nil, fmt.Errorf("节点:%v 进程状态为空,节点入参类型不匹配", flowNode.Name) + } + if g.IsEmpty(execInput) { + return nil, nil, fmt.Errorf("节点:%v 进程状态为空,节点入参参数为空", flowNode.Name) + } + } + } else { + var ok bool + execInput, ok = input.(*flowDto.FlowExecutionInput) + if !ok { + return nil, nil, fmt.Errorf("节点:%v 入参类型不匹配", flowNode.Name) + } + if g.IsEmpty(execInput) { + return nil, nil, fmt.Errorf("节点:%v 入参参数为空", flowNode.Name) + } + } + + configMap := execInput.ConfigMap + currentConfig := configMap[flowNode.Id] + if currentConfig == nil { + return nil, nil, fmt.Errorf("节点:%v 节点信息为空", flowNode.Name) + } + + // 聚合输入来源 + //if len(flowNode.InputSource) > 0 { + // for _, inputSource := range currentConfig.InputSource { + // if sourceConfig, ok := configMap[inputSource.NodeId]; ok { + // currentConfig.OutputResult = append(currentConfig.OutputResult, sourceConfig.OutputResult...) + // } + // } + //} + + // 构建节点执行入参 + realInput := &flowDto.NodeExecutionInput{ + Config: currentConfig, + Global: execInput, + } + + return execInput, realInput, nil +} + +// HandleSuccessfulNodeExecution 处理节点执行成功的后续操作 +func HandleSuccessfulNodeExecution(ctx context.Context, execInput *flowDto.FlowExecutionInput, realInput *flowDto.NodeExecutionInput, nodeExecutionId int64, flowNode entity.FlowNode, durationMs int64) error { + // 上传输出到OSS + ossResult, err := gateway.Upload(ctx, fmt.Sprintf("nodeInput:%v.txt", time.Now().UnixMilli()), gconv.Bytes(gconv.String(realInput))) + if err != nil { + return fmt.Errorf("节点:%v 上传OSS失败: %v", realInput.Config.Name, err) + } + + // 更新执行记录为成功 + if err := UpdateNodeExecutionRecord(ctx, nodeExecutionId, durationMs, node.NodeExecutionStatusSuccess.Code(), ossResult, ""); err != nil { + return fmt.Errorf("节点:%v 更新成功状态错误: %v", flowNode.Name, err) + } + + // 记录成功到已执行列表 + RecordExecutionResult(execInput, flowNode.Id, node.NodeExecutionStatusSuccess.Code()) + return nil +} + +// HandleFailedNodeExecution 处理节点执行失败的后续操作 +func HandleFailedNodeExecution(ctx context.Context, execInput *flowDto.FlowExecutionInput, nodeExecutionId int64, flowNode entity.FlowNode, err error, durationMs int64) error { + // 保存状态用于续跑 + if stateErr := compose.ProcessState(ctx, func(_ context.Context, s *flowDto.NodeExecutionState) error { + s.CompletedNodes = append(s.CompletedNodes, flowNode.Name) + s.SavedFlowInput = execInput + s.ExecutionCount++ + return nil + }); stateErr != nil { + fmt.Printf("节点:%v 进程状态保存失败: %v", flowNode.Name, stateErr) + } + + if !g.IsEmpty(nodeExecutionId) { + // 更新执行记录为失败 + if updateErr := UpdateNodeExecutionRecord(ctx, nodeExecutionId, durationMs, node.NodeExecutionStatusFailed.Code(), "", err.Error()); updateErr != nil { + fmt.Printf("节点:%v 更新失败状态错误: %v", flowNode.Name, updateErr) + } + } + + // 记录失败到已执行列表 + //RecordExecutionResult(execInput, flowNode.Id, node.NodeExecutionStatusFailed.Code()) + + // 触发中断 + return compose.Interrupt(ctx, map[string]string{ + "node": flowNode.Name, + "error": err.Error(), + }) +} + +// RecordExecutionResult 将节点执行结果写入 Global.ExecutedNodes +func RecordExecutionResult(execInput *flowDto.FlowExecutionInput, nodeId string, status node.NodeExecutionStatus) { + execInput.ExecutedNodes = append(execInput.ExecutedNodes, flowDto.ExecutedNode{ + NodeId: nodeId, + Status: status, + }) +} + +// UpdateNodeExecutionRecord 更新节点执行记录 +func UpdateNodeExecutionRecord(ctx context.Context, nodeExecutionId int64, durationMs int64, status node.NodeExecutionStatus, outputParamsPath string, errMsg string) error { + if _, err := nodeDao.NodeExecutionDao.Update(ctx, &nodeDto.UpdateNodeExecutionReq{ + Id: nodeExecutionId, + DurationMs: durationMs, + Status: status, + OutputParamsPath: outputParamsPath, + ErrorMessage: errMsg, + }); err != nil { + return err + } + return nil +} + +// CreateNodeExecutionRecord 创建节点执行记录,返回记录ID +func CreateNodeExecutionRecord(ctx context.Context, execInput *flowDto.FlowExecutionInput, flowNode entity.FlowNode, inputOssUrl string) (int64, error) { + id, err := nodeDao.NodeExecutionDao.Insert(ctx, &nodeDto.CreateNodeExecutionReq{ + FlowExecutionId: execInput.ExecutionId, + NodeId: flowNode.Id, + NodeName: flowNode.Name, + NodeGroupId: execInput.NodeGroupId, + InputParamsPath: inputOssUrl, + Status: node.NodeExecutionStatusRunning.Code(), + }) + if err != nil { + return 0, fmt.Errorf("节点:%v 创建节点执行记录失败: %v", flowNode.Name, err) + } + return id, nil +} diff --git a/workflow/service/flow/flow_helper.go b/workflow/service/flow/flow_helper.go new file mode 100644 index 0000000..09ac4bf --- /dev/null +++ b/workflow/service/flow/flow_helper.go @@ -0,0 +1,217 @@ +package flow + +import ( + "ai-agent/workflow/model/entity" + "net/url" + "path" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +// FindEndNodes 从指定起始节点开始,遍历图找到所有末端节点(没有出边的节点) +func FindEndNodes(startNodeId string, edges []entity.FlowEdge) []string { + nextMap := make(map[string][]string) + for _, e := range edges { + nextMap[e.From] = append(nextMap[e.From], e.To) + } + + endNodeSet := make(map[string]struct{}) + visited := make(map[string]struct{}) + queue := []string{startNodeId} + + for len(queue) > 0 { + node := queue[0] + queue = queue[1:] + + if _, exist := visited[node]; exist { + continue + } + visited[node] = struct{}{} + + nextList := nextMap[node] + if len(nextList) == 0 { + endNodeSet[node] = struct{}{} + continue + } + queue = append(queue, nextList...) + } + + res := make([]string, 0, len(endNodeSet)) + for k := range endNodeSet { + res = append(res, k) + } + return res +} + +// ExtractFlowNodeFrom 从 FlowInfo 中提取节点列表,并自动补齐 DataMerge 节点的 InputSource +func ExtractFlowNodeFrom(flowContent *entity.FlowInfo) []*entity.FlowNode { + // 构建每个节点的上游节点映射 + upstreamMap := make(map[string][]string) + for _, edge := range flowContent.Edges { + upstreamMap[edge.To] = append(upstreamMap[edge.To], edge.From) + } + + // 同时更新 flowContent.Nodes 中的 DataMerge 节点 + //for i := range flowContent.Nodes { + // n := &flowContent.Nodes[i] + // // 对于 DataMerge 节点,自动根据边关系填充 InputSource + // if n.NodeCode == node.NodeTypeDataMerge { + // n.InputSource = nil + // for _, fromId := range upstreamMap[n.Id] { + // n.InputSource = append(n.InputSource, entity.FlowNodeInputSource{ + // NodeId: fromId, + // }) + // } + // } + //} + + var flowNodes []*entity.FlowNode + for _, item := range flowContent.Nodes { + flowNodes = append(flowNodes, &item) + } + return flowNodes +} + +// GetFileTypeByPath 根据文件路径/URL的后缀名判断文件类型 +func GetFileTypeByPath(filePath string) string { + if filePath == "" { + return "" + } + + // 解析 URL,获取真实路径(兼容 http 链接) + u, err := url.Parse(filePath) + if err == nil { + filePath = u.Path + } + + // 获取后缀(小写) + ext := filepath.Ext(filePath) + ext = strings.ToLower(ext) + + // 判断类型 + switch ext { + case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp": + return "image" + case ".mp4", ".mov", ".avi", ".flv", ".wmv", ".mkv": + return "video" + case ".mp3", ".wav", ".m4a", ".flac", ".aac", ".ogg": + return "audio" + case ".txt", ".md", ".log", ".json", ".xml", ".inc": + return "text" + case ".html": + return "html" + case ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx": + return "document" + default: + return "" + } +} + +// GetUrlSuffix 获取URL文件后缀 +// rawUrl: 原始链接 +// withDot: true 返回 .mp4 false 返回 mp4 +func GetUrlSuffix(rawUrl string, withDot bool) string { + // 解析URL,剥离查询参数 + u, err := url.Parse(rawUrl) + if err != nil { + return "" + } + + // 提取路径部分 + filePath := u.Path + // 获取文件名 + fileName := path.Base(filePath) + if fileName == "" || !strings.Contains(fileName, ".") { + return "" + } + + // 截取后缀 + suffix := path.Ext(fileName) + if !withDot { + suffix = strings.TrimPrefix(suffix, ".") + } + return suffix +} + +// ExtractImageCount 修复:支持单引号/双引号 + 换行 + 空格 +func ExtractImageCount(content string) int { + // 🔥 关键:支持 class='image-count' (单引号) + re := regexp.MustCompile(`

]*>.*?(\d+).*?

`) + match := re.FindStringSubmatch(content) + if len(match) >= 2 { + num, err := strconv.Atoi(match[1]) + if err == nil { + return num + } + } + return 0 +} + +func ImageTagRegex(html string) string { + // 🔥 修复:支持单引号、双引号、空格、换行,100% 删除

+ imageTagRegex := regexp.MustCompile(`

]*>[\s\S]*?

`) + return imageTagRegex.ReplaceAllString(html, "") +} + +// StripHtmlTags 去掉所有HTML标签,保留换行和文本结构,并删除配图标记行 +func StripHtmlTags(html string) string { + // 1. 替换块级标签为换行,保证排版 + blockTags := regexp.MustCompile(`]*>`) + text := blockTags.ReplaceAllString(html, "\n") + + // 2. 去掉所有剩余的 HTML 标签 + allTags := regexp.MustCompile(`<[^>]+>`) + text = allTags.ReplaceAllString(text, "") + + // 4. 清理多余空行(多个换行只保留一个) + text = regexp.MustCompile(`\n\s*\n`).ReplaceAllString(text, "\n") + + // 5. 只去掉首尾空白,中间换行保留 + text = strings.TrimSpace(text) + + return text +} + +// SplitMultiContents 拆分模型返回的多条文案(基于HTML标签分隔) +func SplitMultiContents(htmlContent string) []string { + var contents []string + // 正则匹配
包裹的内容 + re := regexp.MustCompile(`
([\s\S]*?)
`) + matches := re.FindAllStringSubmatch(htmlContent, -1) + for _, match := range matches { + if len(match) > 1 { + // 清理空内容 + trimmed := strings.TrimSpace(match[1]) + if trimmed != "" { + contents = append(contents, trimmed) + } + } + } + // 兜底:如果没有匹配到结构化内容,按换行/分隔符拆分 + if len(contents) == 0 { + contents = strings.Split(htmlContent, "===分隔符===") // 提示词中可新增此兜底规则 + } + return contents +} + +// GetAllImgSrcFromHtml 先把提取img src的工具方法放在外面 +func GetAllImgSrcFromHtml(html string) []string { + var imgSrcList []string + re := regexp.MustCompile(`]*src\s*=\s*["']([^"']+)["']`) + submatch := re.FindAllStringSubmatch(html, -1) + for _, match := range submatch { + if len(match) >= 2 { + imgSrcList = append(imgSrcList, match[1]) + } + } + return imgSrcList +} + +// ReplaceImgSrc 替换img src的方法 +func ReplaceImgSrc(html string, oldSrc string, newSrc string) string { + // 精准替换:找到 + re := regexp.MustCompile(`(]*src\s*=\s*["'])` + regexp.QuoteMeta(oldSrc) + `(["'])`) + return re.ReplaceAllString(html, `${1}`+newSrc+`${2}`) +} diff --git a/workflow/service/flow/flow_user_service.go b/workflow/service/flow/flow_user_service.go index 70abe26..6b73706 100644 --- a/workflow/service/flow/flow_user_service.go +++ b/workflow/service/flow/flow_user_service.go @@ -2,7 +2,6 @@ package flow import ( "ai-agent/workflow/consts/flow" - "ai-agent/workflow/consts/node" flowDao "ai-agent/workflow/dao/flow" flowDto "ai-agent/workflow/model/dto/flow" "ai-agent/workflow/model/entity" @@ -80,34 +79,6 @@ func (s *flowUserService) Update(ctx context.Context, req *flowDto.UpdateFlowUse return } -func ExtractFlowNodeFrom(flowContent *entity.FlowInfo) []*entity.FlowNode { - // 构建每个节点的上游节点映射 - upstreamMap := make(map[string][]string) - for _, edge := range flowContent.Edges { - upstreamMap[edge.To] = append(upstreamMap[edge.To], edge.From) - } - - // 同时更新 flowContent.Nodes 中的 DataMerge 节点 - for i := range flowContent.Nodes { - n := &flowContent.Nodes[i] - // 对于 DataMerge 节点,自动根据边关系填充 InputSource - if n.NodeCode == node.NodeTypeDataMerge { - n.InputSource = nil - for _, fromId := range upstreamMap[n.Id] { - n.InputSource = append(n.InputSource, entity.FlowNodeInputSource{ - NodeId: fromId, - }) - } - } - } - - var flowNodes []*entity.FlowNode - for _, item := range flowContent.Nodes { - flowNodes = append(flowNodes, &item) - } - return flowNodes -} - func (s *flowUserService) Delete(ctx context.Context, req *flowDto.DeleteFlowUserReq) (err error) { admin, err := service.UtilService.IsAdmin(ctx) if err != nil { @@ -178,23 +149,29 @@ func (s *flowUserService) List(ctx context.Context, req *flowDto.ListFlowUserReq } return } + res = &flowDto.ListFlowRes{ + IsAdmin: admin, + } + if !req.IsOwn { + var t int + var l []*entity.FlowTemplate + l, t, err = flowDao.FlowTemplateDao.List(ctx, &flowDto.ListFlowTemplateReq{ + Keyword: req.Keyword, + Page: req.Page, + }) + if err != nil { + return + } + r := &flowDto.ListFlowTemplateRes{ + Total: t, + } + err = gconv.Struct(l, &r.List) + if err != nil { + return + } + res.ListFlowTemplateRes = r + } - var t int - var l []*entity.FlowTemplate - l, t, err = flowDao.FlowTemplateDao.List(ctx, &flowDto.ListFlowTemplateReq{ - Keyword: req.Keyword, - Page: req.Page, - }) - if err != nil { - return - } - r := &flowDto.ListFlowTemplateRes{ - Total: t, - } - err = gconv.Struct(l, &r.List) - if err != nil { - return - } var user *beans.User user, err = utils.GetUserInfo(ctx) if err != nil { @@ -212,11 +189,7 @@ func (s *flowUserService) List(ctx context.Context, req *flowDto.ListFlowUserReq if err != nil { return } - res = &flowDto.ListFlowRes{ - ListFlowUserRes: re, - ListFlowTemplateRes: r, - IsAdmin: admin, - } + res.ListFlowUserRes = re return } diff --git a/workflow/service/flow/flow_ws_exec.go b/workflow/service/flow/flow_ws_exec.go new file mode 100644 index 0000000..8e48b92 --- /dev/null +++ b/workflow/service/flow/flow_ws_exec.go @@ -0,0 +1,331 @@ +package flow + +import ( + "ai-agent/workflow/consts/flow" + flowDao "ai-agent/workflow/dao/flow" + sessionDao "ai-agent/workflow/dao/session" + flowDto "ai-agent/workflow/model/dto/flow" + sessionDto "ai-agent/workflow/model/dto/session" + "ai-agent/workflow/model/entity" + "context" + "errors" + "fmt" + "sync" + "time" + + "gitea.redpowerfuture.com/red-future/common/utils" + wsCommon "gitea.redpowerfuture.com/red-future/common/websocket" + "github.com/cloudwego/eino/compose" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/glog" + "github.com/gogf/gf/v2/util/gconv" + "github.com/google/uuid" +) + +// ====================== WebSocket 服务器 ====================== + +func init() { + // 工作流消息处理器注册在统一的 SessionWsService(见 ws_server.go)上: + // 首次连接仅升级,连接后按消息 type 路由,不再建连时区分普通对话/工作流 + SessionWsService.OnMessage("workflow", handleExecute) + SessionWsService.OnMessage("workflow_cancel", handleCancel) +} + +// defaultSessionName 工作流执行但查不到流程名时,会话的兜底名称 +const defaultSessionName = "工作流执行" + +// errWorkflowTerminated 前端终止工作流执行时的错误标记(写入 exec_workflow.error_message) +var errWorkflowTerminated = "用户已终止执行" + +// ====================== 进度上报 ====================== +type wsProgressCtxKey struct{} + +// ProgressReporter 节点执行进度回调接口 +type ProgressReporter interface { + ReportStart(nodeId, nodeName string, nodeIndex, nodeCount int) + ReportComplete(nodeId, nodeName string, nodeIndex, nodeCount int) +} + +// GetProgressReporter 从context中获取进度上报器 +func GetProgressReporter(ctx context.Context) ProgressReporter { + if reporter, ok := ctx.Value(wsProgressCtxKey{}).(ProgressReporter); ok { + return reporter + } + return nil +} + +type wsProgressReporter struct { + conn *wsCommon.WsConnection + mu sync.Mutex +} + +func (r *wsProgressReporter) ReportStart(nodeId, nodeName string, nodeIndex, nodeCount int) { + if r.conn.IsClosed() { + return + } + r.mu.Lock() + msg := &wsCommon.WsPushMsg{ + Type: "node_start", + Message: fmt.Sprintf("开始执行(%d/%d): %s ", nodeIndex, nodeCount, nodeName), + Data: map[string]interface{}{ + "nodeId": nodeId, "nodeName": nodeName, + "nodeIndex": nodeIndex, "nodeCount": nodeCount, + }, + } + r.mu.Unlock() + _ = writeJSON(r.conn, msg) +} + +func (r *wsProgressReporter) ReportComplete(nodeId, nodeName string, nodeIndex, nodeCount int) { + if r.conn.IsClosed() { + return + } + r.mu.Lock() + msg := &wsCommon.WsPushMsg{ + Type: "node_complete", + Message: fmt.Sprintf("执行完成(%d/%d): %s ", nodeIndex, nodeCount, nodeName), + Data: map[string]interface{}{ + "nodeId": nodeId, "nodeName": nodeName, + "nodeIndex": nodeIndex, "nodeCount": nodeCount, + }, + } + r.mu.Unlock() + _ = writeJSON(r.conn, msg) +} + +// ====================== 消息处理 ====================== + +// handleExecute 处理工作流执行(由 workerPool 异步调用,不阻塞读循环) +func handleExecute(ctx context.Context, conn *wsCommon.WsConnection, payload interface{}) { + execPayload := new(sessionDto.WebSocketExecWorkflowReq) + if err := gconv.Struct(payload, execPayload); err != nil { + _ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "error", Message: "执行参数解析失败", Error: err.Error()}) + return + } + + execCtx, execCancel := context.WithCancel(ctx) + + // 替换旧 cancel,设入新 cancel + if oldCancel := getExecCancel(conn); oldCancel != nil { + oldCancel() + } + conn.SetMeta("execCancel", execCancel) + + //_ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "ack", Message: "开始执行工作流"}) + + // 异步执行工作流(直接 goroutine,不依赖上游 workerPool 二次排队) + go func() { + defer func() { + if r := recover(); r != nil { + glog.Errorf(execCtx, "workflow panic: %v", r) + _ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "error", Message: "工作流异常", Error: fmt.Sprintf("%v", r)}) + } + }() + defer conn.SetMeta("execCancel", nil) + + // 落库用不带取消的 ctx(保留 request 值),保证前端终止/断连后记录仍能写入 + saveCtx := context.WithoutCancel(ctx) + + // 会话落库:前端 sessionId 对应会话已存在则复用,否则按流程名新建 + flowName := defaultSessionName + if flowUser, e := flowDao.FlowUserDao.Get(saveCtx, &flowDto.GetFlowUserReq{Id: execPayload.FlowId}); e == nil && flowUser != nil && flowUser.FlowName != "" { + flowName = flowUser.FlowName + } + if e := ensureSession(saveCtx, conn.SessionId, flowName); e != nil { + _ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "error", Message: "工作流会话创建失败", Error: fmt.Sprintf("%v", e)}) + } + + start := time.Now() + + reporter := &wsProgressReporter{conn: conn} + progressCtx := context.WithValue(execCtx, wsProgressCtxKey{}, reporter) + + _ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "ack", Message: fmt.Sprintf("开始执行工作流(共 %d 个节点)", len(execPayload.FlowContent.Nodes))}) + + execId, err := execute(progressCtx, conn.SessionId, execPayload) + recordWorkflow(saveCtx, execId, time.Since(start), err) + if err != nil { + _ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "error", Message: "工作流执行失败", Error: err.Error()}) + return + } + // 成功:把本次执行保存的结果文件路径(exec_workflow_result)一并推给前端 + _ = writeJSON(conn, &wsCommon.WsPushMsg{ + Type: "flow_complete", + Message: "工作流执行完成", + Data: map[string]interface{}{ + "resultFileUrls": workflowResultFileUrls(saveCtx, execId), + }, + }) + }() +} + +// recordWorkflow 把一次工作流执行写入 exec_workflow/exec_workflow_result:运行记录 + 输出文件结果 +func recordWorkflow(ctx context.Context, id int64, duration time.Duration, runErr error) { + // exec_workflow 状态沿用 1-运行中,2-成功,3-失败;前端结果卡片也只识别 1/2/3 + // (4 会误显示为"运行中"),故取消同样记为失败,错误信息写"用户已终止执行" + status := flow.FlowExecutionStatusSuccess + errorMessage := "" + if runErr != nil { + status = flow.FlowExecutionStatusFailed + if errors.Is(runErr, context.Canceled) { + errorMessage = errWorkflowTerminated + } else { + errorMessage = runErr.Error() + } + } + _, err := sessionDao.ExecWorkflowDao.Update(ctx, &sessionDto.UpdateWorkflowReq{ + Id: id, + Status: status.Code(), + Duration: int64(duration.Seconds()), + ErrorMessage: errorMessage, + }) + if err != nil { + glog.Errorf(ctx, "exec_workflow 落库失败: %v", err) + return + } +} + +// workflowResultFileUrls 查询指定工作流执行保存的结果文件路径(带文件前缀,与 session/get 返回一致) +func workflowResultFileUrls(ctx context.Context, execId int64) []string { + results, err := sessionDao.ExecWorkflowResultDao.ListByExecId(ctx, execId) + if err != nil { + glog.Errorf(ctx, "查询工作流结果路径失败: %v", err) + return nil + } + prefix, _ := utils.GetFileAddressPrefix(ctx) + urls := make([]string, 0, len(results)) + for _, r := range results { + if r.ResultFileUrl != "" { + urls = append(urls, prefix+r.ResultFileUrl) + } + } + return urls +} + +// handleCancel 取消工作流执行 +func handleCancel(ctx context.Context, conn *wsCommon.WsConnection, _ interface{}) { + if cancel := getExecCancel(conn); cancel != nil { + cancel() + } + _ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "ack", Message: "已取消工作流执行"}) +} + +// ====================== 工具函数 ====================== + +func getExecCancel(conn *wsCommon.WsConnection) context.CancelFunc { + cancel, _ := wsCommon.GetMetaT[context.CancelFunc](conn, "execCancel") + return cancel +} + +// writeJSON 业务层写入,委托 WsConnection.WriteJSON(共享 writeMu 写锁) +func writeJSON(conn *wsCommon.WsConnection, data interface{}) error { + return conn.WriteJSON(data) +} + +// execute 执行工作流(首次执行) +func execute(ctx context.Context, sessionId string, req *sessionDto.WebSocketExecWorkflowReq) (id int64, err error) { + var nodeGroupId = uuid.NewString() + id, err = sessionDao.ExecWorkflowDao.Insert(ctx, &sessionDto.CreateWorkflowReq{ + SessionId: sessionId, + FlowId: req.FlowId, + NodeGroupId: nodeGroupId, + Status: flow.FlowExecutionStatusRunning.Code(), + RequestParams: req.FlowContent, + }) + if err != nil { + return + } + err = BuildExecution(ctx, true, req.FlowId, id, nodeGroupId, sessionId, req.FlowContent) + if err != nil { + return + } + return id, nil +} + +// reExecute 重新执行工作流 +func reExecute(ctx context.Context, execWorkflowId int64) (id int64, err error) { + flowInfo, err := sessionDao.ExecWorkflowDao.GetById(ctx, execWorkflowId) + if err != nil { + return + } + var nodeGroupId = uuid.NewString() + _, err = sessionDao.ExecWorkflowDao.Update(ctx, &sessionDto.UpdateWorkflowReq{ + Id: flowInfo.Id, + NodeGroupId: nodeGroupId, + Status: flow.FlowExecutionStatusRunning.Code(), + }) + if err != nil { + return + } + err = BuildExecution(ctx, false, flowInfo.FlowId, flowInfo.Id, nodeGroupId, flowInfo.SessionId, flowInfo.RequestParams) + if err != nil { + return + } + return flowInfo.Id, nil +} + +func BuildExecution(ctx context.Context, forceNewRun bool, flowId, executionId int64, nodeGroupId string, sessionId string, flowContent *entity.FlowInfo) (err error) { + // ========================================================================= + // 构建执行图 + // ========================================================================= + var nodeList []entity.FlowNode + var runGraph compose.Runnable[any, any] + nodeList, runGraph, err = BuildGraphFromFlowContent(ctx, flowContent) + if err != nil { + return fmt.Errorf("执行工作流失败: %v", err) + } + + // ========================================================================= + // 构建 ConfigMap + // ========================================================================= + nodeInputParams := ExtractFlowNodeFrom(flowContent) + configMap := make(map[string]*entity.FlowNode) + for _, cfg := range nodeInputParams { + configMap[cfg.Id] = cfg + } + for _, i := range nodeList { + configMap[i.Id] = &i + } + + // ========================================================================= + // 构建全局执行入参 + // ========================================================================= + execInput := &flowDto.FlowExecutionInput{ + NodeGroupId: nodeGroupId, + ExecutionId: executionId, + FlowId: flowId, + ConfigMap: configMap, + SessionId: sessionId, + } + + var opts []compose.Option + opts = append(opts, compose.WithCheckPointID(gconv.String(executionId))) + if forceNewRun { + opts = append(opts, compose.WithForceNewRun()) + } + _, err = runGraph.Invoke(ctx, execInput, opts...) + if err != nil { + info, infoOk := compose.ExtractInterruptInfo(err) + if infoOk { + var errMsg string + var errNodeCount int + for _, item := range info.InterruptContexts { + if item.Info == nil { + continue + } + if g.NewVar(item.Info).IsMap() { + errNodeCount++ + valMap := gconv.Map(item.Info) + errMsg = fmt.Sprintf("%v\n%v", errMsg, fmt.Sprintf("节点:%v, 失败原因:%v", valMap["node"], valMap["error"])) + } + } + if !g.IsEmpty(errMsg) { + err = fmt.Errorf("%v个节点,%v", errNodeCount, errMsg) + } + } + return fmt.Errorf("执行工作流失败: %v", err) + } + // 清理断点数据 + _ = flowDao.FlowCheckpointDao.Delete(ctx, gconv.String(executionId)) + return +} diff --git a/workflow/service/flow/lambda_node.go b/workflow/service/flow/lambda_node.go index 0a5e902..3b45def 100644 --- a/workflow/service/flow/lambda_node.go +++ b/workflow/service/flow/lambda_node.go @@ -1,37 +1,183 @@ package flow import ( + "ai-agent/gateway" "ai-agent/workflow/consts/flow" "ai-agent/workflow/consts/node" "ai-agent/workflow/consts/public" - fileDao "ai-agent/workflow/dao/file" - flowDao "ai-agent/workflow/dao/flow" - "ai-agent/workflow/model/dto" - fileDto "ai-agent/workflow/model/dto/file" + nodeDao "ai-agent/workflow/dao/node" + sessionDao "ai-agent/workflow/dao/session" flowDto "ai-agent/workflow/model/dto/flow" + nodeDto "ai-agent/workflow/model/dto/node" + sessionDto "ai-agent/workflow/model/dto/session" "ai-agent/workflow/model/entity" + "ai-agent/workflow/service/flow/processor" "context" + "encoding/base64" + "encoding/json" "fmt" - "strconv" "strings" "sync" - "time" "gitea.redpowerfuture.com/red-future/common/db/gfdb" - "gitea.redpowerfuture.com/red-future/common/utils" "github.com/cloudwego/eino-examples/compose/batch/batch" "github.com/cloudwego/eino/compose" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/util/gconv" + "github.com/google/uuid" ) +// StartLambda 启动节点 func StartLambda(ctx context.Context, input any) (any, error) { return input, nil } +// FormLambda 表单调用节点 func FormLambda(ctx context.Context, input any) (any, error) { - return input, nil + nodeInput, ok := input.(*flowDto.NodeExecutionInput) + if !ok { + return nil, fmt.Errorf("入参类型错误") + } + // 解析 valueSource 引用,填充表单节点输出配置(供下游引用) + for _, output := range nodeInput.Config.OutputConfig { + ProcessValueSourceRecursive(output, nodeInput.Global) + } + return nodeInput, nil +} + +// ModelLambda 模型调用节点 +func ModelLambda(ctx context.Context, input any) (any, error) { + nodeInput, ok := input.(*flowDto.NodeExecutionInput) + if !ok { + return nil, fmt.Errorf("入参类型错误") + } + + // 1. 解析 valueSource 引用,填充模型请求参数 + ProcessValueSourceRecursive(nodeInput.Config.ModelConfig.ModelRequestParams, nodeInput.Global) + + // 1.5 剔除 value 为空的字段;数组/枚举元素整体为空时移除整个元素(0/false 视为有效值) + CleanEmptyModelParams(nodeInput.Config.ModelConfig.ModelRequestParams) + + // 2. 前置工具:决定模型调用入参(单次/多次) + paramsList, err := invokePreTool(ctx, nodeInput.Config.PreTool, nodeInput.Config.ModelConfig.ModelRequestParams) + if err != nil { + return nil, err + } + + // 3. 逐批调用模型,汇总输出(保持请求顺序),累计 token/费用供节点记录落库 + var outputRes []map[string]any + var totalTokens int64 + var totalCost float64 + if nodeInput.Config.IsBatchExec && len(paramsList) > 1 { + // 异步批量执行:并发请求模型,等待全部返回后再继续,避免下游读到空结果 + results := make([][]map[string]any, len(paramsList)) + tokenRes := make([]*gateway.ModelCallRes, len(paramsList)) + errs := make([]error, len(paramsList)) + var wg sync.WaitGroup + for i, params := range paramsList { + wg.Add(1) + go func(i int, params map[string]any) { + defer wg.Done() + results[i], tokenRes[i], errs[i] = ModelCallResultLambda(ctx, nodeInput.Config.ModelConfig.ModelId, nodeInput.Global.SessionId, params) + }(i, params) + } + wg.Wait() + for i, res := range results { + if errs[i] != nil { + return nil, errs[i] + } + if tokenRes[i] != nil { + totalTokens += tokenRes[i].TotalTokens + totalCost += tokenRes[i].Cost + } + outputRes = append(outputRes, res...) + } + } else { + for _, params := range paramsList { + res, modelRes, err := ModelCallResultLambda(ctx, nodeInput.Config.ModelConfig.ModelId, nodeInput.Global.SessionId, params) + if err != nil { + return nil, err + } + if modelRes != nil { + totalTokens += modelRes.TotalTokens + totalCost += modelRes.Cost + } + outputRes = append(outputRes, res...) + } + } + + // 3.5 把本次节点消耗的 token/费用写入节点执行记录,供汇总节点聚合到 exec_workflow + if nodeInput.NodeExecutionId > 0 && (totalTokens > 0 || totalCost > 0) { + if _, err := nodeDao.NodeExecutionDao.Update(ctx, &nodeDto.UpdateNodeExecutionReq{ + Id: nodeInput.NodeExecutionId, + TokenInfo: []map[string]any{{ + "total_tokens": totalTokens, + "total_fee": totalCost, + }}, + }); err != nil { + return nil, fmt.Errorf("节点:%v 写入token信息失败: %v", nodeInput.Config.Name, err) + } + } + + // 4. 后置工具:加工模型输出(透传原始请求参数,供后置工具读取合并配置等) + outputRes, err = invokePostTool(ctx, nodeInput.Config.PostTool, outputRes, nodeInput.Config.ModelConfig.ModelRequestParams) + if err != nil { + return nil, err + } + + nodeInput.Config.OutputResult = outputRes + return nodeInput, nil +} + +// invokePreTool 执行前置处理器,把模型请求参数转换为模型调用入参列表。 +// 前置处理器契约:入参即模型请求参数本体;返回值: +// - map[string]any 一次模型调用,入参为返回值 +// - []map[string]any 多次模型调用,逐个入参请求 +// - nil 视为异常,节点失败(不允许静默跳过模型调用) +func invokePreTool(ctx context.Context, processorName string, modelParams map[string]any) (paramsList []map[string]any, err error) { + if processorName == "" { + return []map[string]any{modelParams}, nil + } + data, err := processor.Call(ctx, processorName, modelParams) + if err != nil { + return nil, fmt.Errorf("执行前置处理器[%s]失败: %v", processorName, err) + } + switch v := data.(type) { + case nil: + return nil, fmt.Errorf("前置处理器[%s]返回空", processorName) + case map[string]any: + return []map[string]any{v}, nil + case []map[string]any: + return v, nil + default: + return nil, fmt.Errorf("前置处理器[%s]返回类型不支持: %T", processorName, data) + } +} + +// invokePostTool 执行后置处理器,加工模型调用结果。 +// 后置处理器契约:入参 {"output": 模型输出结果列表, "request": 原始模型请求参数}(列表须包成对象传入);返回值: +// - []map[string]any 替换模型输出 +// - map[string]any 替换为单条输出 +// - nil 保留原输出 +func invokePostTool(ctx context.Context, processorName string, outputRes []map[string]any, requestParams map[string]any) ([]map[string]any, error) { + if processorName == "" { + return outputRes, nil + } + data, err := processor.Call(ctx, processorName, map[string]any{"output": outputRes, "request": requestParams}) + if err != nil { + return nil, fmt.Errorf("执行后置处理器[%s]失败: %v", processorName, err) + } + switch v := data.(type) { + case nil: + return outputRes, nil + case []map[string]any: + return v, nil + case map[string]any: + return []map[string]any{v}, nil + default: + return nil, fmt.Errorf("后置处理器[%s]返回类型不支持: %T", processorName, data) + } } func SubFlowLambda(ctx context.Context, input any) (any, error) { @@ -46,12 +192,17 @@ func SubFlowLambda(ctx context.Context, input any) (any, error) { return nil, fmt.Errorf("子流程节点缺少配置") } getRes, err := FlowUserService.Get(ctx, &flowDto.GetFlowUserReq{ - Id: subFlowConfig.FlowId, + Id: subFlowConfig.WorkflowId, }) if err != nil { return nil, err } - // 3. 编译子流程Graph(复用现有 BuildGraphFromFlowContent 逻辑) + // 3. 引入参数解析:把首页表单值/上游引用值/静态默认值写入子流程开始节点 outputConfig。 + // 须在 BuildGraph / ExtractFlowNodeFrom 之前执行,batchInputs 深拷贝的才是注入后的开始节点。 + injectSubFlowFields(nodeExecInput.Global, getRes.FlowContent, subFlowConfig.Fields) + // 4. 并发数:从主流程开始节点 outputConfig 的 maxConcurrency 字段读取(前端把子流程节点生成次数表单字段聚合到主流程开始节点),读不到再用子流程节点配置兜底 + maxConcurrency := mainFlowMaxConcurrency(nodeExecInput.Global, subFlowConfig.MaxConcurrency) + // 4. 编译子流程Graph(复用现有 BuildGraphFromFlowContent 逻辑) nodeList, subGraph := BuildGraph(ctx, getRes.FlowContent) // 4. 构建子流程Workflow(绑定START/END,和示例对齐) innerWorkflow := compose.NewWorkflow[*flowDto.FlowExecutionInput, *flowDto.FlowExecutionInput]() @@ -59,19 +210,19 @@ func SubFlowLambda(ctx context.Context, input any) (any, error) { innerWorkflow.AddGraphNode("sub_flow_graph", subGraph).AddInput(compose.START) // 绑定子图输出到全局END innerWorkflow.End().AddInput("sub_flow_graph") + // 生成次数(批量条数):maxConcurrency<=0 时按 1 次兜底 + batchCount := maxConcurrency + if batchCount <= 0 { + batchCount = 1 + } // 5. 构建BatchNode(批量执行子流程,复用示例逻辑) batchNode := batch.NewBatchNode(&batch.NodeConfig[*flowDto.FlowExecutionInput, *flowDto.FlowExecutionInput]{ Name: fmt.Sprintf("sub_flow_batch_%s", nodeExecInput.Config.Id), InnerTask: innerWorkflow, - MaxConcurrency: subFlowConfig.MaxConcurrency, + MaxConcurrency: batchCount, }) - //skillName, from, userFrom := BuildParam(nodeExecInput) - //fmt.Printf("skillName: %s, from: %s, userFrom: %s\n", skillName, from, userFrom) - - // 6. 提取批量输入(从全局入参中获取) - batchInputs := make([]*flowDto.FlowExecutionInput, 0) - + // 6. 提取批量输入:按生成次数生成 N 份(每份独立克隆 ConfigMap,避免并发执行时节点输出写串) nodeInputParams := ExtractFlowNodeFrom(getRes.FlowContent) configMap := make(map[string]*entity.FlowNode) for _, cfg := range nodeInputParams { @@ -80,322 +231,144 @@ func SubFlowLambda(ctx context.Context, input any) (any, error) { for _, i := range nodeList { configMap[i.Id] = &i } - // ========================================================================= - // ✅【第4步】构建全局执行入参(现在 schemaMap 是有值的!) - // ========================================================================= - execInput := &flowDto.FlowExecutionInput{ - NodeGroupId: nodeExecInput.Global.NodeGroupId, - IsDialogue: nodeExecInput.Global.IsDialogue, - ExecutionId: nodeExecInput.Global.ExecutionId, - ConfigMap: configMap, - SessionId: nodeExecInput.Global.SessionId, - Desc: nodeExecInput.Global.Desc, - SkillName: nodeExecInput.Global.SkillName, - FileUrl: nodeExecInput.Global.FileUrl, + batchInputs := make([]*flowDto.FlowExecutionInput, 0, batchCount) + for j := 0; j < batchCount; j++ { + batchInputs = append(batchInputs, &flowDto.FlowExecutionInput{ + NodeGroupId: nodeExecInput.Global.NodeGroupId, + ExecutionId: nodeExecInput.Global.ExecutionId, + FlowId: nodeExecInput.Global.FlowId, + ConfigMap: cloneConfigMap(configMap), + SessionId: nodeExecInput.Global.SessionId, + }) } - batchInputs = append(batchInputs, execInput) - // 7. 执行批量子流程 batchOutput, err := batchNode.Invoke(ctx, batchInputs) if err != nil { return nil, fmt.Errorf("执行子流程BatchNode失败: %v", err) } - for idx, singleSubResult := range batchOutput { - fmt.Printf("【批量任务%d 最终消息条数】: %v\n", idx+1, singleSubResult) + // 8. 展平每份子流程执行的节点输出,写回当前节点 OutputResult 供下游引用 + var outputRes []map[string]any + for _, single := range batchOutput { + if single == nil { + continue + } + outputRes = append(outputRes, collectFlowNodeResults(single)...) } - // 8. 保存子流程执行结果到当前节点输出 - //nodeExecInput.Config.OutputResult = append(nodeExecInput.Config.OutputResult, batchOutput) + g.Log().Info(ctx, fmt.Sprintf("子流程执行完成,共 %d 次,输出 %d 条", batchCount, len(outputRes))) + nodeExecInput.Config.OutputResult = outputRes return nodeExecInput, nil } -// JudgeLambda 分支判断核心:读取IntentLambda的输出 → 返回目标节点ID做路由 -func JudgeLambda(ctx context.Context, input any) (string, error) { - nodeInput, ok := input.(*flowDto.NodeExecutionInput) - if !ok { - return "", fmt.Errorf("入参类型错误,期望 *flowDto.NodeExecutionInput,实际 %T", input) +// injectSubFlowFields 将子流程节点引入参数(subConfig.Fields)解析后写入子流程开始节点 +// outputConfig,使子流程启动时能读到首页表单值/上游引用值/静态默认值。 +// 每个字段的取值优先级:valueSource 引用解析成功 → field.value → field.defaultValue; +// 匹配键为 field(前端约定以 field 为主,不兼容 path)。 +func injectSubFlowFields(global *flowDto.FlowExecutionInput, subFlowContent *entity.FlowInfo, fields []map[string]any) { + if global == nil || subFlowContent == nil || len(fields) == 0 { + return } - //inputMap, outputMap, modelMap := GetNodeContextContent(nodeInput.Global, nodeInput.Config) - //fmt.Printf("JudgeLambda路由:输入=%s\n", gjson.MustEncode(inputMap)) - //fmt.Printf("JudgeLambda路由:输出=%s\n", gjson.MustEncode(outputMap)) - //fmt.Printf("JudgeLambda路由:模型=%s\n", gjson.MustEncode(modelMap)) - //configMap := gconv.Map(nodeInput.Config.Config) - //ids := gconv.Strings(configMap["branch_ids"]) - //fmt.Printf("JudgeLambda路由:目标节点ID=%s\n", gconv.String(ids)) - // - //m := map[string]bool{ - // "80000a50-81e1-4c15-adae-aab6c0d781ad": true, - // "59a6ffa2-3252-4535-b6ed-d3e49cdf6c55": true, - //} - // - //return m, nil - // 1. 直接用你原来的方法(返回两个 map) - inputMap, outputMap, modelMap := GetNodeContextContent(nodeInput.Global, nodeInput.Config) - var outputResult []node.NodeFormField - outputResult = append(outputResult, inputMap...) - outputResult = append(outputResult, outputMap...) - //for _, valueAny := range inputMap { - // if field, ok := valueAny.(node.NodeFormField); ok { - // outputResult = append(outputResult, field) - // } - //} - //for _, valueAny := range outputMap { - // if field, ok := valueAny.(node.NodeFormField); ok { - // outputResult = append(outputResult, field) - // } - //} - for _, valueAny := range modelMap { - if field, ok := valueAny.(node.NodeFormField); ok { - outputResult = append(outputResult, field) + startNode := subFlowStartNode(subFlowContent) + if startNode == nil { + return + } + byField := make(map[string]map[string]any, len(startNode.OutputConfig)) + for _, output := range startNode.OutputConfig { + byField[gconv.String(output["field"])] = output + } + for _, field := range fields { + entry := byField[gconv.String(field["field"])] + if entry == nil { + continue } - } - contextParts := "" - for _, v := range nodeInput.Config.FormConfig { - contextParts = fmt.Sprintf("%s,%s:%s", contextParts, v.Label, v.Value) - } - if !nodeInput.Global.IsDialogue { - for _, v := range outputResult { - contextParts = fmt.Sprintf("%s,%s:%s", contextParts, v.Label, v.Value) - } - } - if !g.IsEmpty(nodeInput.Global.Desc) { - contextParts = fmt.Sprintf("%s,%s:%s", contextParts, "描述", nodeInput.Global.Desc) - } - configMap := gconv.Map(nodeInput.Config.Config) - ids := gconv.Strings(configMap["branch_ids"]) - branchIdNameMap := gconv.Map(configMap["branch_id_name_map"]) - var branchIdNameLines []string - for _, id := range ids { - name := gconv.String(branchIdNameMap[id]) - branchIdNameLines = append(branchIdNameLines, fmt.Sprintf("%s: %s", id, name)) - } - getIsChatModel, err := GetIsChatModel(ctx) - if err != nil { - return "", err - } - composeResult, err := GetComposeResult(ctx, 2, getIsChatModel.Model.ModelName, "", "", []map[string]any{{"prompt": strings.Join(branchIdNameLines, "\n")}}, []map[string]any{{"prompt": contextParts}}, nodeInput.Global.FileUrl, nodeInput.Global.SessionId, nodeInput.Config.Id, "判断节点") - if err != nil { - return "", err - } - if g.IsEmpty(composeResult.TaskId) { - return "", fmt.Errorf("msg is empty") - } - content := "" - for key, _ := range getIsChatModel.Model.ResponseBody { - content = gconv.String(composeResult.Messages.Rounds[0][key]) - } - fmt.Printf("JudgeLambda路由:目标节点ID=%s\n", gconv.String(content)) - return content, nil -} - -func BatchModelLambda(ctx context.Context, input any) (any, error) { - nodeInput, ok := input.(*flowDto.NodeExecutionInput) - if !ok { - return nil, fmt.Errorf("入参类型错误") - } - skillName, from, userFrom := BuildParam(nodeInput) - reqMap := make([]map[string]any, 0) - for _, userItem := range userFrom { - m := gconv.Map(userItem) - for _, i := range nodeInput.Config.InputSource { - for _, f := range i.Field { - val := m[f] - if !g.IsEmpty(val) { - if g.NewVar(val).IsSlice() { - slice := gconv.SliceAny(val) - for _, item := range slice { - reqMap = append(reqMap, map[string]any{f: item}) - } - } else { - reqMap = append(reqMap, map[string]any{f: val}) - } + value := field["value"] + if vs, has := field["valueSource"]; has && vs != nil { + vsMap := gconv.Map(vs) + vsNodeId := gconv.String(vsMap["nodeId"]) + vsField := gconv.String(vsMap["fieldName"]) + if vsField == "" { + vsField = gconv.String(vsMap["field"]) + } + if vsNodeId != "" && vsField != "" { + if v, _, ok := resolveValueSource(global, vsNodeId, vsField); ok { + value = v } } } - } - // 结果按索引存放,切片不同下标并发写无竞争,不用锁 - res := make([][]node.NodeFormField, len(reqMap)) - var wg sync.WaitGroup - - subCtx, cancel := context.WithCancel(ctx) - defer cancel() - - // 缓冲1错误通道,仅接收第一个错误 - errCh := make(chan error, 1) - - // 并发执行任务 - for idx, item := range reqMap { - wg.Add(1) - go func(idx int, userItem map[string]any) { - defer wg.Done() - - // 上下文已取消则直接退出 - select { - case <-subCtx.Done(): - return - default: - } - - singleUserFrom := []map[string]any{userItem} - output, err := TextNode(subCtx, nodeInput, skillName, from, singleUserFrom) - if err != nil { - // 仅第一个错误写入通道 - select { - case errCh <- err: - cancel() // 触发全局取消,其他协程快速退出 - default: - } - return - } - res[idx] = output - }(idx, item) - } - - // 任务全部结束后关闭错误通道 - go func() { - wg.Wait() - close(errCh) - }() - - // ========== 修正后的等待逻辑 ========== - var execErr error - select { - // 优先捕获业务错误 - case execErr = <-errCh: - if execErr != nil { - // 收到真实业务错误,等待剩余协程收尾后返回 - wg.Wait() - return nil, execErr + if value == nil { + value = field["defaultValue"] } - // execErr == nil 代表通道关闭、无任何错误,走到下方返回完整结果 - case <-subCtx.Done(): - // 上下文被取消,阻塞读完errCh,确认是否存在业务错误 - execErr = <-errCh - } - - // 拼接输出结果 - var globalIndex int - var outputRes []node.NodeFormField - for _, items := range res { - for _, item := range items { - oldField := item.Field - if idx := strings.LastIndex(oldField, ":"); idx != -1 { - item.Field = oldField[:idx+1] + fmt.Sprint(globalIndex) - } - oldLabel := item.Label - if idx := strings.LastIndex(oldLabel, ":"); idx != -1 { - item.Label = oldLabel[:idx+1] + fmt.Sprint(globalIndex) - } - outputRes = append(outputRes, item) - } - globalIndex++ - } - - nodeInput.Config.OutputResult = outputRes - return nodeInput, nil -} - -// TextModelLambda 构建文案 -func TextModelLambda(ctx context.Context, input any) (any, error) { - nodeInput, ok := input.(*flowDto.NodeExecutionInput) - if !ok { - return nil, fmt.Errorf("入参类型错误") - } - skillName, from, userFrom := BuildParam(nodeInput) - outputRes, err := TextNode(ctx, nodeInput, skillName, from, userFrom) - if err != nil { - return nil, err - } - nodeInput.Config.OutputResult = outputRes - //} - - return nodeInput, nil -} - -// ImageModelLambda 构建图片 -func ImageModelLambda(ctx context.Context, input any) (any, error) { - nodeInput, ok := input.(*flowDto.NodeExecutionInput) - if !ok { - return nil, fmt.Errorf("入参类型错误") - } - skillName, from, userFrom := BuildParam(nodeInput) - outputRes, err := ImgNode(ctx, nodeInput, skillName, from, userFrom) - if err != nil { - return nil, err - } - nodeInput.Config.OutputResult = outputRes - return nodeInput, nil -} - -// AudioModelLambda 构建音频 -func AudioModelLambda(ctx context.Context, input any) (any, error) { - nodeInput, ok := input.(*flowDto.NodeExecutionInput) - if !ok { - return nil, fmt.Errorf("入参类型错误") - } - skillName, from, userFrom := BuildParam(nodeInput) - outputRes, err := AudioOptimizeNode(ctx, nodeInput, skillName, from, userFrom) - if err != nil { - return nil, err - } - nodeInput.Config.OutputResult = outputRes - return nodeInput, nil -} - -// VideoModelLambda 构建视频 -func VideoModelLambda(ctx context.Context, input any) (any, error) { - nodeInput, ok := input.(*flowDto.NodeExecutionInput) - if !ok { - return nil, fmt.Errorf("入参类型错误") - } - - skillName, from, userFrom := BuildParam(nodeInput) - res, err := VideoOptimizeNode(ctx, nodeInput, skillName, from, userFrom) - if err != nil { - return nil, err - } - - videoURL := make([]string, 0) - for _, v := range res { - if strings.Contains(v.Field, "content") { - videoURL = append(videoURL, gconv.String(v.Value)) + if value != nil { + entry["value"] = value } } - if g.IsEmpty(videoURL) { - return nil, fmt.Errorf("视频合成失败:模型生成视频失败") - } - waitRes, err := VideoConcat(ctx, videoURL) - if err != nil { - return nil, err - } - msg := new(flowDto.VideoCallbackReq) - if err = gconv.Struct(waitRes, msg); err != nil { - return nil, err - } +} - urlPrefix, err := utils.GetFileAddressPrefix(ctx) - if err != nil { - return nil, err +// subFlowStartNode 返回工作流开始节点 +func subFlowStartNode(content *entity.FlowInfo) *entity.FlowNode { + if content == nil { + return nil } - newS := strings.ReplaceAll(urlPrefix, g.Cfg().MustGet(ctx, "filePrefix").String(), g.Cfg().MustGet(ctx, "minioPrefix").String()) - - outputRes := make([]node.NodeFormField, 0) - if nodeInput.Config.IsSaveFile { - outputRes = append(outputRes, node.NodeFormField{ - Field: fmt.Sprintf("video_oss_url:content:%d", 0), - Value: msg.FileURL, - Label: fmt.Sprintf("video_oss_url:content:%d", 0), - Type: "string", - }) + for i := range content.Nodes { + if content.Nodes[i].Id == content.StartNodeId { + return &content.Nodes[i] + } } - outputRes = append(outputRes, node.NodeFormField{ - Field: fmt.Sprintf("concat_video_url:content:%d", 0), - Value: newS + msg.FileURL, - Label: fmt.Sprintf("视频内容:content:%d", 0), - Type: "string", - }) - nodeInput.Config.OutputResult = outputRes + return nil +} - return nodeInput, nil +// mainFlowMaxConcurrency 取子流程批量执行并发数:从主流程开始节点 outputConfig +// 的 maxConcurrency 字段读取(前端把子流程节点的生成次数表单字段聚合到主流程开始节点), +// 读不到再用子流程节点配置的兜底值。 +func mainFlowMaxConcurrency(global *flowDto.FlowExecutionInput, fallback int) int { + if global == nil { + return fallback + } + for _, n := range global.ConfigMap { + if n == nil || n.NodeCode != node.NodeTypeStart { + continue + } + for _, output := range n.OutputConfig { + if gconv.String(output["field"]) != "maxConcurrency" && gconv.String(output["path"]) != "maxConcurrency" { + continue + } + if v := gconv.Int(output["value"]); v > 0 { + return v + } + } + return fallback + } + return fallback +} + +// cloneConfigMap 深拷贝 ConfigMap,保证各批次子流程并发执行时节点输出互不串扰。 +// 浅拷贝会共享 *entity.FlowNode,并发写 OutputResult 产生竞态。 +func cloneConfigMap(src map[string]*entity.FlowNode) map[string]*entity.FlowNode { + dst := make(map[string]*entity.FlowNode, len(src)) + for k, v := range src { + data, err := json.Marshal(v) + if err != nil { + dst[k] = v + continue + } + n := new(entity.FlowNode) + if err = json.Unmarshal(data, n); err != nil { + dst[k] = v + continue + } + dst[k] = n + } + return dst +} + +// collectFlowNodeResults 收集一次子流程执行中所有已执行节点的输出,展平成 {字段:值} 列表 +func collectFlowNodeResults(execInput *flowDto.FlowExecutionInput) []map[string]any { + var res []map[string]any + for _, executed := range execInput.ExecutedNodes { + if nodeConfig := execInput.ConfigMap[executed.NodeId]; nodeConfig != nil { + res = append(res, nodeConfig.OutputResult...) + } + } + return res } // HttpLambda 构建HTTP(S)接口 @@ -404,82 +377,7 @@ func HttpLambda(ctx context.Context, input any) (any, error) { if !ok { return nil, fmt.Errorf("入参类型错误") } - outputRes := make([]node.NodeFormField, 0) - var err error - outputRes, err = HttpNode(ctx, nodeInput) - //if nodeInput.Config.Name == "生成视频" { - // outputRes, err = HttpNode(ctx, nodeInput) - //} else { - // a := []map[string]any{ - // { - // "timeline": "0.0-2.1", - // "url": "https://ark-auto-2127201628-cn-beijing-default.tos-cn-beijing.volces.com/%E8%A7%86%E9%A2%91/1.mp4", - // }, - // { - // "timeline": "2.1-4.5", - // "url": "https://ark-auto-2127201628-cn-beijing-default.tos-cn-beijing.volces.com/%E8%A7%86%E9%A2%91/2.mp4", - // }, - // { - // "timeline": "4.5-12.2", - // "url": "https://ark-auto-2127201628-cn-beijing-default.tos-cn-beijing.volces.com/%E8%A7%86%E9%A2%91/3.mp4", - // }, - // { - // "timeline": "12.2-13.6", - // "url": "https://ark-auto-2127201628-cn-beijing-default.tos-cn-beijing.volces.com/%E8%A7%86%E9%A2%91/4.mp4", - // }, - // { - // "timeline": "13.6-17.7", - // "url": "https://ark-auto-2127201628-cn-beijing-default.tos-cn-beijing.volces.com/%E8%A7%86%E9%A2%91/5.mp4model-gateway", - // }, - // { - // "timeline": "17.7-31.0", - // "url": "https://ark-auto-2127201628-cn-beijing-default.tos-cn-beijing.volces.com/%E8%A7%86%E9%A2%91/6.mp4", - // }, - // { - // "timeline": "31.0-33.2", - // "url": "https://ark-auto-2127201628-cn-beijing-default.tos-cn-beijing.volces.com/%E8%A7%86%E9%A2%91/7.mp4", - // }, - // { - // "timeline": "33.2-37.4", - // "url": "https://ark-auto-2127201628-cn-beijing-default.tos-cn-beijing.volces.com/%E8%A7%86%E9%A2%91/8.mp4", - // }, - // { - // "timeline": "37.4-38.9", - // "url": "https://ark-auto-2127201628-cn-beijing-default.tos-cn-beijing.volces.com/%E8%A7%86%E9%A2%91/9.mp4", - // }, - // { - // "timeline": "38.9-57.9", - // "url": "https://ark-auto-2127201628-cn-beijing-default.tos-cn-beijing.volces.com/%E8%A7%86%E9%A2%91/10.mp4", - // }, - // } - // outputRes = append(outputRes, node.NodeFormField{ - // Field: fmt.Sprintf("segments"), - // Value: a, - // Label: fmt.Sprintf("segments"), - // Type: "string", - // }) - // outputRes = append(outputRes, node.NodeFormField{ - // Field: fmt.Sprintf("audioUrl"), - // Value: "http://116.204.74.41:9000/tenantid-94/2026-06-11/9915351c-55b9-46d8-b783-3815126b.m4a", - // Label: fmt.Sprintf("audioUrl"), - // Type: "string", - // }) - //} - if err != nil { - return nil, err - } - nodeInput.Config.OutputResult = outputRes - return nodeInput, nil -} - -// DataConversionLambda 构建数据转换 -func DataConversionLambda(ctx context.Context, input any) (any, error) { - nodeInput, ok := input.(*flowDto.NodeExecutionInput) - if !ok { - return nil, fmt.Errorf("入参类型错误") - } - skillName, from, userFrom := BuildParam(nodeInput) - outputRes, err := DataConversionNode(ctx, nodeInput, skillName, from, userFrom) + outputRes, err := HttpCallResultLambda(ctx, nodeInput) if err != nil { return nil, err } @@ -492,244 +390,6 @@ func DataMergeLambda(ctx context.Context, input any) (res any, err error) { if !ok { return nil, fmt.Errorf("参数合并入参类型错误") } - - // var nodeIds []string - // for _, item := range nodeInput.Config.InputSource { - // nodeIds = append(nodeIds, item.NodeId) - // } - // - // // 检查是否所有输入节点都执行完成,并且检查是否有节点失败 - // checkAllExecuted := func() (allExecuted bool, hasFailed bool, failedNode string) { - // executedCount := 0 - // for _, executedNode := range nodeInput.Global.ExecutedNodes { - // // 检查是否是我们需要的输入节点,并且它失败了 - // for _, targetId := range nodeIds { - // if executedNode.NodeId == targetId { - // if executedNode.Status == node.NodeExecutionStatusFailed.Code() { - // return false, true, targetId - // } - // executedCount++ - // break - // } - // } - // } - // return executedCount == len(nodeIds), false, "" - // } - // - // // 初次检查 - // allExecuted, hasFailed, failedNode := checkAllExecuted() - // if hasFailed { - // return nil, fmt.Errorf("输入节点[%s]执行失败", failedNode) - // } - // - // // 如果不是全部都已执行,阻塞等待直到全部完成、上下文取消或有节点失败 - // if !allExecuted { - // // 轮询检查,每500ms检查一次,依赖ctx超时控制 - // ticker := time.NewTicker(500 * time.Millisecond) - // defer ticker.Stop() - // - // for { - // select { - // case <-ctx.Done(): - // // 如果上下文已经取消,说明已有节点报错,直接退出 - // return nil, ctx.Err() - // case <-ticker.C: - // // 重新检查所有节点 - // allExecuted, hasFailed, failedNode := checkAllExecuted() - // if hasFailed { - // // 有一个输入节点失败,直接退出 - // return nil, fmt.Errorf("输入节点[%s]执行失败", failedNode) - // } - // if allExecuted { - // // 全部执行完成,退出循环继续执行 - // goto allDone - // } - // - // // 再次检查上下文是否已经取消,如果已经取消则立即退出 - // select { - // case <-ctx.Done(): - // return nil, ctx.Err() - // default: - // } - // } - // } - // } - //allDone: - // - // // 最终检查:所有输入节点都成功了吗 - // _, hasFailed, failedNode = checkAllExecuted() - // if hasFailed { - // // 有一个输入节点失败,直接退出 - // return nil, fmt.Errorf("输入节点[%s]执行失败", failedNode) - // } - // - // // 构建已执行节点ID的map,方便合并时查找 - // executedMap := make(map[string]*flowDto.ExecutedNode, len(nodeInput.Global.ExecutedNodes)) - // for _, en := range nodeInput.Global.ExecutedNodes { - // executedMap[en.NodeId] = &en - // } - // - // // 合并所有输入源节点的输出结果 - // for _, inputSource := range nodeInput.Config.InputSource { - // // 每次循环都检查上下文是否已取消,提前退出 - // select { - // case <-ctx.Done(): - // return nil, ctx.Err() - // default: - // } - // // 再次检查该节点是否失败 - // if en, ok := executedMap[inputSource.NodeId]; ok && en.Status == node.NodeExecutionStatusFailed.Code() { - // return nil, fmt.Errorf("输入节点[%s]执行失败", inputSource.NodeId) - // } - // sourceNodeConfig := nodeInput.Global.ConfigMap[inputSource.NodeId] - // if sourceNodeConfig != nil && len(sourceNodeConfig.OutputResult) > 0 { - // nodeInput.Config.OutputResult = append(nodeInput.Config.OutputResult, sourceNodeConfig.OutputResult...) - // } - // } - - return nodeInput, nil -} - -func MergeLambda(ctx context.Context, input any) (res any, err error) { - nodeInput, ok := input.(*flowDto.NodeExecutionInput) - if !ok { - return nil, fmt.Errorf("汇总节点入参类型错误") - } - - // 1. 把所有节点输出拍平成 字段名->内容 的map - dataMap := make(map[string]node.NodeFormField) - _, outputMap, _ := GetNodeContextContent(nodeInput.Global, nodeInput.Config) - for _, field := range outputMap { - dataMap[field.Field] = field - } - - // 2. 提取所有文案:text_content_0,1,2... - var contents []node.NodeFormField - for i := 0; ; i++ { - key := fmt.Sprintf("text_content:%d", i) - val, has := dataMap[key] - if !has || val.Value == "" { - break - } - contents = append(contents, val) - } - - // 3. 提取所有图片:image_0,1,2... - var images []string - for i := 0; ; i++ { - key := fmt.Sprintf("img_url:%d", i) - val, has := dataMap[key] - if !has || val.Value == "" { - break - } - images = append(images, gconv.String(val.Value)) - } - - // 4. 🔥 核心算法:图片按顺序连续归属给每条文案 - textImgMap := make(map[int][]string) // key:文案下标,value:图片列表 - if len(contents) > 0 && len(images) > 0 { - imgIndex := 0 // 当前用到第几张图片 - totalImg := len(images) - - for i, item := range contents { - // 图片已分配完,直接退出 - if imgIndex >= totalImg { - break - } - - // 当前文案需要挂载的图片数量 - needCount := gconv.Int(item.Expand) - if needCount <= 0 { - continue - } - - var imgList []string - for imgc := 0; imgc < needCount; imgc++ { - // 关键:必须判断是否越界 - if imgIndex >= totalImg { - break - } - imgList = append(imgList, images[imgIndex]) - imgIndex++ - } - - // 有图片才存入 map - if len(imgList) > 0 { - textImgMap[i] = imgList - } - } - } - type Item struct { - Content string // 文案(可为空) - Images []string // 图片(可空、可多张) - } - - // 🔥 把现有数据转换成通用 Item 列表(支持:纯文案、纯图片、图文任意组合) - var allItems []Item - - url, err := utils.GetFileAddressPrefix(ctx) - if err != nil { - return nil, err - } - // 情况1:有文案 → 按文案条目生成 Item(每条文案+对应图片) - if len(contents) > 0 { - for i, val := range contents { - item := Item{ - Content: url + gconv.String(val.Value), // 文案 - Images: textImgMap[i], // 自动绑定该条目的图片(没有则为空切片) - } - allItems = append(allItems, item) - } - } else { - // 情况2:没有文案,只有图片 → 每张/每组图片生成独立 Item(纯图片条目) - if len(images) > 0 { - for _, img := range images { - allItems = append(allItems, Item{ - Content: "", - Images: []string{img}, - }) - } - } - } - - // 5. 生成多条独立HTML记录(通用方案:任意图文组合,每条独立生成+独立上传) - var outputRecords []node.NodeFormField - - // 遍历所有【独立图文条目】 → 每条生成独立HTML、独立上传OSS、独立输出记录 - for idx, item := range allItems { - // 生成单条HTML - htmlContent := BuildHtml(item.Content, item.Images) - outputRecords = append(outputRecords, - node.NodeFormField{ - Field: fmt.Sprintf("item_html_%d", idx), - Value: htmlContent, - Label: fmt.Sprintf("条目%d HTML", idx+1), - Type: "textarea", - }, - ) - if nodeInput.Config.IsSaveFile { - // 上传OSS(每条独立上传) - fileName := fmt.Sprintf("item_%d_%d.html", idx, time.Now().UnixMilli()) - ossResult, err := Upload(ctx, &dto.UploadFileBytesReq{ - FileBytes: []byte(htmlContent), - FileName: fileName, - }) - if err != nil { - return nil, err - } - outputRecords = append(outputRecords, - node.NodeFormField{ - Field: fmt.Sprintf("item_html_url_%d", idx), - Value: ossResult.FileURL, - Label: fmt.Sprintf("条目%d 地址", idx+1), - Type: "text", - }, - ) - } - } - - // 最终输出多条记录 - nodeInput.Config.OutputResult = outputRecords return nodeInput, nil } @@ -739,65 +399,42 @@ func SummaryLambda(ctx context.Context, input any) (any, error) { return nil, fmt.Errorf("汇总节点入参类型错误,实际是 %T", input) } - // 聚合所有已执行节点的输出结果 - var summaryResult []map[string]interface{} - for _, executedNode := range execInput.Global.ExecutedNodes { - nodeID := executedNode.NodeId - nodeConfig := execInput.Global.ConfigMap[nodeID] - if nodeConfig != nil && len(nodeConfig.OutputResult) > 0 { - for _, field := range nodeConfig.OutputResult { - if strings.Contains(field.Field, "http_file_url") || strings.Contains(field.Field, "audio_oss_url") || strings.Contains(field.Field, "video_oss_url") || strings.Contains(field.Field, "item_html_url") || strings.Contains(field.Field, "img_oss_url") || strings.Contains(field.Field, "text_url") { - // 生成 毫秒时间戳 作为 KEY - timeKey := strconv.FormatInt(time.Now().UnixMilli(), 10) - item := make(map[string]interface{}) - item[timeKey] = field.Value - summaryResult = append(summaryResult, item) - } - } - } - } + // 聚合所有已执行节点中需入库的文件结果(两层规则) + summaryResult := collectSaveFileResults(ctx, execInput.Global) // 把汇总结果存入当前节点的输出 g.Log().Info(ctx, fmt.Sprintf("结果汇总完成,汇总数据:%+v", summaryResult)) err := gfdb.DB(ctx, public.DbNameBlackDeacon).Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { - flowInfo, err := flowDao.FlowExecutionDao.Get(ctx, &flowDto.GetFlowExecutionReq{ - SessionId: execInput.Global.SessionId, + res, _, err := nodeDao.NodeExecutionDao.ListByFlowExecutionId(ctx, &nodeDto.ListNodeExecutionByFlowReq{ + NodeGroupId: execInput.Global.NodeGroupId, + }, entity.NodeExecutionCol.TokenInfo) + if err != nil { + return err + } + var totalTokens int + var totalFee float64 + for _, item := range res { + for _, itemToken := range item.TokenInfo { + m := gconv.Map(itemToken) + totalTokens += gconv.Int(m["total_tokens"]) + totalFee += gconv.Float64(m["total_fee"]) + } + } + _, err = sessionDao.ExecWorkflowDao.Update(ctx, &sessionDto.UpdateWorkflowReq{ + Id: execInput.Global.ExecutionId, + Status: flow.FlowExecutionStatusSuccess.Code(), + TotalTokens: totalTokens, + TotalFee: totalFee, }) if err != nil { return err } - - executionReq := flowDto.UpdateFlowExecutionReq{ - Id: execInput.Global.ExecutionId, - Status: flow.FlowExecutionStatusSuccess.Code(), - OutputParams: summaryResult, - } - _, err = flowDao.FlowExecutionDao.Update(ctx, &executionReq) - - if flowInfo != nil { - var url string - url, err = utils.GetFileAddressPrefix(ctx) + if len(summaryResult) > 0 { + _, err = sessionDao.ExecWorkflowResultDao.BatchInsert(ctx, summaryResult) if err != nil { return err } - - createFileTempReq := make([]*fileDto.CreateFileTempReq, 0, len(flowInfo.OutputParams)) - for _, fileUrl := range flowInfo.OutputParams { - m := gconv.Map(fileUrl) - for _, v := range m { - var createReq = new(fileDto.CreateFileTempReq) - createReq.BusinessId = flowInfo.SessionId - createReq.FileUrl = url + gconv.String(v) - createFileTempReq = append(createFileTempReq, createReq) - } - } - if len(createFileTempReq) > 0 { - _, err = fileDao.FileTempDao.BatchInsert(ctx, createFileTempReq) - if err != nil { - return err - } - } } return nil }) @@ -805,8 +442,141 @@ func SummaryLambda(ctx context.Context, input any) (any, error) { return execInput, err } -// CustomLambda 构建自定义 -func CustomLambda(ctx context.Context, input any) (any, error) { - fmt.Println("CustomLambda:", input) - return input, nil +// collectSaveFileResults 按两层规则收集需入库的文件结果: +// 第一层:节点须开启"保存文件"(IsSaveFile); +// 第二层:key 取自模型响应内容 respBody(即节点 OutputResult 的各字段),命中 +// ModelResponseBodyMapping 才入库;原始响应体 key(respBody)恒入库(不要求映射声明)。 +// 结果值为 http(s) URL 直接使用;非路径值(base64 图片/文本)先上传 OSS 换取 URL, +// 文本内容以 .inc 扩展名存储。 +func collectSaveFileResults(ctx context.Context, execInput *flowDto.FlowExecutionInput) []*sessionDto.CreateWorkflowResultReq { + if execInput == nil { + return nil + } + var summaryResult []*sessionDto.CreateWorkflowResultReq + for _, executedNode := range execInput.ExecutedNodes { + nodeConfig := execInput.ConfigMap[executedNode.NodeId] + if nodeConfig == nil || len(nodeConfig.OutputResult) == 0 || !nodeConfig.IsSaveFile { + continue + } + // 第二层:key 取自模型响应内容 respBody(即节点 OutputResult 的各字段), + // 命中 ModelResponseBodyMapping 才入库;原始响应体 key(respBody)恒入库 + saveKeys := nodeConfig.ModelConfig.ModelResponseBodyMapping + for _, respBody := range nodeConfig.OutputResult { + for key, val := range gconv.Map(respBody) { + if _, ok := saveKeys[key]; !ok { + if key != "respBody" { + continue + } + } + fileUrl, err := resolveSaveFileResult(ctx, val) + if err != nil { + g.Log().Warningf(ctx, "collectSaveFileResults 上传结果文件失败 key=%s err=%v", key, err) + continue + } + summaryResult = append(summaryResult, &sessionDto.CreateWorkflowResultReq{ + SessionId: execInput.SessionId, + FlowId: execInput.FlowId, + ExecId: execInput.ExecutionId, + ResultFileUrl: fileUrl, + }) + } + } + } + return summaryResult +} + +// resolveSaveFileResult 解析结果值为可入库的 URL: +// - 已是 http(s) URL → 直接返回 +// - 非路径(base64 图片/文本)→ 上传 OSS 换取 URL +func resolveSaveFileResult(ctx context.Context, val any) (string, error) { + isPath, path, fileBytes, ext := resolveFileContent(val) + if isPath { + return path, nil + } + if ext == "" { + ext = ".png" + } + fileUrl, err := gateway.Upload(ctx, fmt.Sprintf("workflow_result_%s%s", uuid.NewString(), ext), fileBytes) + if err != nil { + return "", err + } + return fileUrl, nil +} + +// resolveFileContent 判断结果值形态: +// - 已是 URL 路径(http/https 开头)→ 直接使用 +// - data URI(data:;base64,)→ 解码为字节,扩展名按 mime 推断 +// - 纯 base64(可解码且长度足以认为是编码数据)→ 解码为字节,默认 .png +// - 其余(文本)→ 以 .inc 扩展名上传原文 +func resolveFileContent(val any) (isPath bool, path string, fileBytes []byte, ext string) { + s := gconv.String(val) + if isFileURL(s) { + return true, s, nil, "" + } + // data URI:data:;base64, + if b, mime, ok := parseDataURI(s); ok { + return false, "", b, extOfMime(mime) + } + // 纯 base64:可解码且长度足够,视为编码后的文件内容 + trimmed := strings.TrimSpace(s) + if len(trimmed) >= 64 { + if b, err := base64.StdEncoding.DecodeString(trimmed); err == nil && len(b) > 0 { + return false, "", b, ".png" + } + } + // 文本:以 .inc 存储 + return false, "", []byte(s), ".inc" +} + +// isFileURL 判断字符串是否已是对外可访问的 URL 路径(http/https 开头) +func isFileURL(s string) bool { + lower := strings.ToLower(s) + return strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") +} + +// extOfMime 按 MIME 类型推断文件扩展名 +func extOfMime(mime string) string { + switch strings.ToLower(strings.TrimSpace(mime)) { + case "image/png", "png": + return ".png" + case "image/jpeg", "image/jpg", "jpeg", "jpg": + return ".jpg" + case "image/webp": + return ".webp" + case "image/gif": + return ".gif" + case "audio/mpeg", "audio/mp3", "mp3": + return ".mp3" + case "audio/wav", "wav": + return ".wav" + case "video/mp4", "mp4": + return ".mp4" + case "application/json", "json": + return ".json" + default: + return "" + } +} + +// parseDataURI 解析 data URI:data:;base64,,返回解码字节与 mime +func parseDataURI(s string) ([]byte, string, bool) { + const prefix = "data:" + if !strings.HasPrefix(s, prefix) { + return nil, "", false + } + rest := s[len(prefix):] + comma := strings.Index(rest, ",") + if comma < 0 { + return nil, "", false + } + mime := rest[:comma] + if semicolon := strings.Index(mime, ";"); semicolon >= 0 { + mime = mime[:semicolon] + } + payload := strings.TrimPrefix(rest[comma+1:], "base64,") + b, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + return nil, "", false + } + return b, mime, true } diff --git a/workflow/service/flow/lambda_node_imp.go b/workflow/service/flow/lambda_node_imp.go deleted file mode 100644 index c57bb54..0000000 --- a/workflow/service/flow/lambda_node_imp.go +++ /dev/null @@ -1,1026 +0,0 @@ -package flow - -import ( - "ai-agent/workflow/consts/flow" - "ai-agent/workflow/consts/node" - flowDao "ai-agent/workflow/dao/flow" - nodeDao "ai-agent/workflow/dao/node" - "ai-agent/workflow/model/dto" - flowDto "ai-agent/workflow/model/dto/flow" - nodeDto "ai-agent/workflow/model/dto/node" - "ai-agent/workflow/model/entity" - "context" - "fmt" - "regexp" - "strconv" - "strings" - "time" - - 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" - "github.com/tidwall/gjson" - "github.com/tidwall/sjson" -) - -func getNodeInfo(flowInfo *entity.FlowExecution) (htmlUrl []string, textIsSaveFile bool, textPromptContent, textModelName string, textResultFrom []map[string]any, imgIsSaveFile bool, imgPromptContent, imgModelName string, imgResultFrom []map[string]any) { - textPromptContent = "" - textIsSaveFile = false - textModelName = "" - textResultFrom = []map[string]any{} - imgPromptContent = "" - imgIsSaveFile = false - imgModelName = "" - imgResultFrom = []map[string]any{} - // 查询节点中是否包含结果合并节点 - for _, item := range flowInfo.NodeInputParams { - if item.NodeCode == node.NodeTypeMerge { - for _, outputParamsItem := range flowInfo.OutputParams { - outputParamsMap := gconv.Map(outputParamsItem) - for _, mapItem := range outputParamsMap { - if strings.HasSuffix(gconv.String(mapItem), ".html") { - htmlUrl = append(htmlUrl, gconv.String(mapItem)) - } - } - } - } - if item.NodeCode == node.NodeTypeTextModel { - textPromptContent = item.PromptContent - textIsSaveFile = item.IsSaveFile - textModelName = item.ModelConfig.ModelName - for key, modelFormItem := range item.ModelConfig.ModelForm { - textResultFrom[key] = map[string]any{ - "value": modelFormItem, - } - } - } - if item.NodeCode == node.NodeTypeImageModel { - imgPromptContent = item.PromptContent - imgIsSaveFile = item.IsSaveFile - imgModelName = item.ModelConfig.ModelName - for key, modelFormItem := range item.ModelConfig.ModelForm { - imgResultFrom[key] = map[string]any{ - "value": modelFormItem, - } - } - } - } - - return htmlUrl, textIsSaveFile, textPromptContent, textModelName, textResultFrom, imgIsSaveFile, imgPromptContent, imgModelName, imgResultFrom - -} - -func TextImgModelSingleLambda(ctx context.Context, req *flowDto.ExecuteReq, flowInfo *entity.FlowExecution) (err error) { - textStartTime := time.Now() - - _, textIsSaveFile, textPromptContent, textModelName, textResultFrom, imgIsSaveFile, imgPromptContent, imgModelName, imgResultFrom := getNodeInfo(flowInfo) - - resultUserFrom := []map[string]any{ - { - "desc": req.Desc, - }, - } - var textNode []node.NodeFormField - textNodeInput := new(flowDto.NodeExecutionInput) - textNodeInput.Global.SessionId = req.SessionId - textNodeInput.Global.NodeGroupId = req.NodeGroupId - textNodeInput.Global.Desc = req.Desc - textNodeInput.Global.FileUrl = req.FileUrl - textNodeInput.Config.IsSaveFile = textIsSaveFile - textNodeInput.Config.PromptContent = textPromptContent - textNodeInput.Config.ModelConfig.ModelName = textModelName - var textNodeExecutionId int64 - textNodeExecutionId, err = nodeDao.NodeExecutionDao.Insert(ctx, &nodeDto.CreateNodeExecutionReq{ - FlowExecutionId: textNodeInput.Global.ExecutionId, - NodeId: textNodeInput.Config.Id, - NodeName: textNodeInput.Config.Name, - NodeGroupId: textNodeInput.Global.NodeGroupId, - InputParams: textNodeInput, - Status: node.NodeExecutionStatusRunning.Code(), - }) - if err != nil { - return - } - - textNode, err = TextNode(ctx, textNodeInput, req.SkillName, textResultFrom, resultUserFrom) - textUpdateReq := &nodeDto.UpdateNodeExecutionReq{ - Id: textNodeExecutionId, - InputParams: textNodeInput, - } - if err != nil { - textUpdateReq.Status = node.NodeExecutionStatusFailed.Code() - textUpdateReq.ErrorMessage = err.Error() - _, _ = nodeDao.NodeExecutionDao.Update(ctx, textUpdateReq) - return - } - textUpdateReq.DurationMs = time.Since(textStartTime).Milliseconds() - textUpdateReq.Status = node.NodeExecutionStatusSuccess.Code() - _, err = nodeDao.NodeExecutionDao.Update(ctx, textUpdateReq) - var textContent string - var textUrl string - for _, item := range textNode { - if strings.Contains(item.Field, "text_url") { - textUrl = gconv.String(item.Value) - } - } - - imgStartTime := time.Now() - - resultUserFrom = append(resultUserFrom, map[string]any{ - "text_content": textContent, - }) - var imgNode []node.NodeFormField - - imgNodeInput := new(flowDto.NodeExecutionInput) - imgNodeInput.Global.SessionId = req.SessionId - imgNodeInput.Global.NodeGroupId = req.NodeGroupId - imgNodeInput.Global.Desc = req.Desc - imgNodeInput.Global.FileUrl = req.FileUrl - imgNodeInput.Config.IsSaveFile = imgIsSaveFile - imgNodeInput.Config.PromptContent = imgPromptContent - imgNodeInput.Config.ModelConfig.ModelName = imgModelName - var imgNodeExecutionId int64 - imgNodeExecutionId, err = nodeDao.NodeExecutionDao.Insert(ctx, &nodeDto.CreateNodeExecutionReq{ - FlowExecutionId: imgNodeInput.Global.ExecutionId, - NodeId: imgNodeInput.Config.Id, - NodeName: imgNodeInput.Config.Name, - NodeGroupId: imgNodeInput.Global.NodeGroupId, - InputParams: imgNodeInput, - Status: node.NodeExecutionStatusRunning.Code(), - }) - if err != nil { - return - } - - imgNode, err = ImgNode(ctx, imgNodeInput, req.SkillName, imgResultFrom, resultUserFrom) - imgUpdateReq := &nodeDto.UpdateNodeExecutionReq{ - Id: imgNodeExecutionId, - InputParams: imgNodeInput, - } - if err != nil { - imgUpdateReq.Status = node.NodeExecutionStatusFailed.Code() - imgUpdateReq.ErrorMessage = err.Error() - _, _ = nodeDao.NodeExecutionDao.Update(ctx, imgUpdateReq) - return - } - var imgUrl []string - for _, item := range imgNode { - if strings.Contains(item.Field, "img_url") { - imgUrl = append(imgUrl, gconv.String(item.Value)) - } - } - - // 生成单条HTML - htmlContent := BuildHtml(textUrl, imgUrl) - // 上传OSS(每条独立上传) - fileName := fmt.Sprintf("item_%d_%d.html", 0, time.Now().UnixMilli()) - var ossResult *dto.UploadFileBytesRes - ossResult, err = Upload(ctx, &dto.UploadFileBytesReq{ - FileBytes: []byte(htmlContent), - FileName: fileName, - }) - if err != nil { - imgUpdateReq.Status = node.NodeExecutionStatusFailed.Code() - imgUpdateReq.ErrorMessage = err.Error() - _, _ = nodeDao.NodeExecutionDao.Update(ctx, imgUpdateReq) - return - } - fmt.Printf("上传OSS成功:%s", ossResult.FileURL) - - var summaryResult []map[string]interface{} - for _, outputParamsItem := range flowInfo.OutputParams { - mapItem := gconv.Map(outputParamsItem) - for _, mapValue := range mapItem { - if strings.Contains(req.ResultUrl, gconv.String(mapValue)) { - // 生成 毫秒时间戳 作为 KEY - timeKey := strconv.FormatInt(time.Now().UnixMilli(), 10) - item := make(map[string]interface{}) - item[timeKey] = ossResult.FileURL - summaryResult = append(summaryResult, item) - continue - } - summaryResult = append(summaryResult, outputParamsItem) - } - } - if !g.IsEmpty(summaryResult) { - executionReq := flowDto.UpdateFlowExecutionReq{ - Id: flowInfo.Id, - Status: flow.FlowExecutionStatusSuccess.Code(), - OutputParams: summaryResult, - } - _, err = flowDao.FlowExecutionDao.Update(ctx, &executionReq) - - imgUpdateReq.DurationMs = time.Since(imgStartTime).Milliseconds() - imgUpdateReq.Status = node.NodeExecutionStatusSuccess.Code() - _, err = nodeDao.NodeExecutionDao.Update(ctx, imgUpdateReq) - } - return -} - -func ImgModelSingleLambda(ctx context.Context, req *flowDto.ExecuteReq, flowInfo *entity.FlowExecution) (err error) { - startTime := time.Now() - - var url string - url, err = utils.GetFileAddressPrefix(ctx) - if err != nil { - return - } - - htmlUrl, _, _, _, _, imgIsSaveFile, imgPromptContent, imgModelName, imgResultFrom := getNodeInfo(flowInfo) - - resultUserFrom := []map[string]any{ - { - "desc": req.Desc, - }, - } - - var imgNode []node.NodeFormField - imgNodeInput := new(flowDto.NodeExecutionInput) - imgNodeInput.Global.SessionId = req.SessionId - imgNodeInput.Global.NodeGroupId = req.NodeGroupId - imgNodeInput.Global.Desc = req.Desc - imgNodeInput.Global.FileUrl = req.FileUrl - imgNodeInput.Config.IsSaveFile = imgIsSaveFile - imgNodeInput.Config.PromptContent = imgPromptContent - imgNodeInput.Config.ModelConfig.ModelName = imgModelName - var nodeExecutionId int64 - nodeExecutionId, err = nodeDao.NodeExecutionDao.Insert(ctx, &nodeDto.CreateNodeExecutionReq{ - FlowExecutionId: imgNodeInput.Global.ExecutionId, - NodeId: imgNodeInput.Config.Id, - NodeName: imgNodeInput.Config.Name, - NodeGroupId: imgNodeInput.Global.NodeGroupId, - InputParams: imgNodeInput, - Status: node.NodeExecutionStatusRunning.Code(), - }) - if err != nil { - return - } - imgNode, err = ImgNode(ctx, imgNodeInput, req.SkillName, imgResultFrom, resultUserFrom) - updateReq := &nodeDto.UpdateNodeExecutionReq{ - Id: nodeExecutionId, - InputParams: imgNodeInput, - } - if err != nil { - updateReq.Status = node.NodeExecutionStatusFailed.Code() - updateReq.ErrorMessage = err.Error() - _, _ = nodeDao.NodeExecutionDao.Update(ctx, updateReq) - return - } - - var imgUrl string - for _, item := range imgNode { - if strings.Contains(item.Field, "img_url") { - imgUrl = gconv.String(item.Value) - } - } - - var htmlContentUrl string - var oldHtmlUrl string - if !g.IsEmpty(htmlUrl) { - for i, item := range htmlUrl { - var htmlBytes []byte - htmlBytes, err = GetFileBytesFromURL(ctx, url+item) - if err != nil { - updateReq.Status = node.NodeExecutionStatusFailed.Code() - updateReq.ErrorMessage = err.Error() - _, _ = nodeDao.NodeExecutionDao.Update(ctx, updateReq) - return - } - htmlContent := string(htmlBytes) - - imgSrcFromHtml := GetAllImgSrcFromHtml(htmlContent) - // 3. 标记是否需要替换 - needReplace := false - for _, imgSrc := range imgSrcFromHtml { - if imgSrc == req.ResultUrl { - needReplace = true - break // 找到一个就可以替换 - } - } - - // 4. 如果匹配到,执行替换(把旧的 req.ResultUrl 替换成 新链接) - if needReplace { - oldHtmlUrl = url + item - htmlContent = ReplaceImgSrc(htmlContent, req.ResultUrl, imgUrl) - // 上传OSS(每条独立上传) - fileName := fmt.Sprintf("item_%d_%d.html", i, time.Now().UnixMilli()) - var ossResult *dto.UploadFileBytesRes - ossResult, err = Upload(ctx, &dto.UploadFileBytesReq{ - FileBytes: []byte(htmlContent), - FileName: fileName, - }) - if err != nil { - updateReq.Status = node.NodeExecutionStatusFailed.Code() - updateReq.ErrorMessage = err.Error() - _, _ = nodeDao.NodeExecutionDao.Update(ctx, updateReq) - return - } - fmt.Printf("上传OSS成功:%s", ossResult.FileURL) - htmlContentUrl = ossResult.FileURL - } - - } - } - - var summaryResult []map[string]interface{} - if !g.IsEmpty(imgUrl) { - for _, outputParamsItem := range flowInfo.OutputParams { - mapItem := gconv.Map(outputParamsItem) - for _, mapValue := range mapItem { - if strings.Contains(oldHtmlUrl, gconv.String(mapValue)) || strings.Contains(req.ResultUrl, gconv.String(mapValue)) { - if strings.Contains(oldHtmlUrl, gconv.String(mapValue)) { - // 生成 毫秒时间戳 作为 KEY - timeKey := strconv.FormatInt(time.Now().UnixMilli(), 10) - item := make(map[string]interface{}) - item[timeKey] = htmlContentUrl - summaryResult = append(summaryResult, item) - } - if strings.Contains(req.ResultUrl, gconv.String(mapValue)) { - // 生成 毫秒时间戳 作为 KEY - timeKey := strconv.FormatInt(time.Now().UnixMilli(), 10) - item := make(map[string]interface{}) - item[timeKey] = imgUrl - summaryResult = append(summaryResult, item) - } - continue - } - summaryResult = append(summaryResult, outputParamsItem) - } - - } - } - if !g.IsEmpty(summaryResult) { - executionReq := flowDto.UpdateFlowExecutionReq{ - Id: flowInfo.Id, - Status: flow.FlowExecutionStatusSuccess.Code(), - OutputParams: summaryResult, - } - _, err = flowDao.FlowExecutionDao.Update(ctx, &executionReq) - - updateReq.DurationMs = time.Since(startTime).Milliseconds() - updateReq.Status = node.NodeExecutionStatusSuccess.Code() - _, err = nodeDao.NodeExecutionDao.Update(ctx, updateReq) - } - return -} - -func TextModelSingleLambda(ctx context.Context, req *flowDto.ExecuteReq, flowInfo *entity.FlowExecution) (err error) { - startTime := time.Now() - - var url string - url, err = utils.GetFileAddressPrefix(ctx) - if err != nil { - return - } - htmlUrl, textIsSaveFile, textPromptContent, textModelName, textResultFrom, _, _, _, _ := getNodeInfo(flowInfo) - - resultUserFrom := []map[string]any{ - { - "desc": req.Desc, - }, - } - - var textNode []node.NodeFormField - nodeInput := new(flowDto.NodeExecutionInput) - nodeInput.Global.SessionId = req.SessionId - nodeInput.Global.NodeGroupId = req.NodeGroupId - nodeInput.Global.Desc = req.Desc - nodeInput.Global.FileUrl = req.FileUrl - nodeInput.Config.IsSaveFile = textIsSaveFile - nodeInput.Config.PromptContent = textPromptContent - nodeInput.Config.ModelConfig.ModelName = textModelName - var nodeExecutionId int64 - nodeExecutionId, err = nodeDao.NodeExecutionDao.Insert(ctx, &nodeDto.CreateNodeExecutionReq{ - FlowExecutionId: nodeInput.Global.ExecutionId, - NodeId: nodeInput.Config.Id, - NodeName: nodeInput.Config.Name, - NodeGroupId: nodeInput.Global.NodeGroupId, - InputParams: nodeInput, - Status: node.NodeExecutionStatusRunning.Code(), - }) - if err != nil { - return - } - textNode, err = TextNode(ctx, nodeInput, req.SkillName, textResultFrom, resultUserFrom) - updateReq := &nodeDto.UpdateNodeExecutionReq{ - Id: nodeExecutionId, - InputParams: nodeInput, - } - if err != nil { - updateReq.Status = node.NodeExecutionStatusFailed.Code() - updateReq.ErrorMessage = err.Error() - _, _ = nodeDao.NodeExecutionDao.Update(ctx, updateReq) - return - } - var textUrl string - for _, item := range textNode { - if strings.Contains(item.Field, "text_url") { - textUrl = gconv.String(item.Value) - } - } - - var htmlContentUrl string - var oldHtmlUrl string - if !g.IsEmpty(htmlUrl) { - for i, item := range htmlUrl { - var htmlBytes []byte - htmlBytes, err = GetFileBytesFromURL(ctx, url+item) - if err != nil { - updateReq.Status = node.NodeExecutionStatusFailed.Code() - updateReq.ErrorMessage = err.Error() - _, _ = nodeDao.NodeExecutionDao.Update(ctx, updateReq) - return - } - htmlContent := string(htmlBytes) - - // 1) 匹配出 incUrl 的值 - incRegex := regexp.MustCompile(`incUrl\s*=\s*"([^"]+)"`) - match := incRegex.FindStringSubmatch(htmlContent) - // 2) 获取模板里原来的 incUrl - oldIncUrl := "" - if len(match) >= 2 { - oldIncUrl = match[1] // 这是模板里的旧链接 - } - // 3) 对比:不一样才替换 - if oldIncUrl == req.ResultUrl { - oldHtmlUrl = url + item - // 替换成新的链接 - htmlContent = incRegex.ReplaceAllString(htmlContent, fmt.Sprintf(`incUrl = "%s"`, url+textUrl)) - // 上传OSS(每条独立上传) - fileName := fmt.Sprintf("item_%d_%d.html", i, time.Now().UnixMilli()) - var ossResult *dto.UploadFileBytesRes - ossResult, err = Upload(ctx, &dto.UploadFileBytesReq{ - FileBytes: []byte(htmlContent), - FileName: fileName, - }) - if err != nil { - updateReq.Status = node.NodeExecutionStatusFailed.Code() - updateReq.ErrorMessage = err.Error() - _, _ = nodeDao.NodeExecutionDao.Update(ctx, updateReq) - return - } - fmt.Printf("上传OSS成功:%s", ossResult.FileURL) - htmlContentUrl = ossResult.FileURL - } - } - } - - var summaryResult []map[string]interface{} - if !g.IsEmpty(textUrl) { - for _, outputParamsItem := range flowInfo.OutputParams { - mapItem := gconv.Map(outputParamsItem) - for _, mapValue := range mapItem { - if strings.Contains(oldHtmlUrl, gconv.String(mapValue)) || strings.Contains(req.ResultUrl, gconv.String(mapValue)) { - if strings.Contains(oldHtmlUrl, gconv.String(mapValue)) { - // 生成 毫秒时间戳 作为 KEY - timeKey := strconv.FormatInt(time.Now().UnixMilli(), 10) - item := make(map[string]interface{}) - item[timeKey] = htmlContentUrl - summaryResult = append(summaryResult, item) - } - if strings.Contains(req.ResultUrl, gconv.String(mapValue)) { - // 生成 毫秒时间戳 作为 KEY - timeKey := strconv.FormatInt(time.Now().UnixMilli(), 10) - item := make(map[string]interface{}) - item[timeKey] = textUrl - summaryResult = append(summaryResult, item) - } - continue - } - summaryResult = append(summaryResult, outputParamsItem) - } - } - } - if !g.IsEmpty(summaryResult) { - executionReq := flowDto.UpdateFlowExecutionReq{ - Id: flowInfo.Id, - Status: flow.FlowExecutionStatusSuccess.Code(), - OutputParams: summaryResult, - } - _, err = flowDao.FlowExecutionDao.Update(ctx, &executionReq) - - updateReq.DurationMs = time.Since(startTime).Milliseconds() - updateReq.Status = node.NodeExecutionStatusSuccess.Code() - _, err = nodeDao.NodeExecutionDao.Update(ctx, updateReq) - } - return -} - -func TextNode(ctx context.Context, nodeInput *flowDto.NodeExecutionInput, skillName string, form []map[string]any, userForm []map[string]any) ([]node.NodeFormField, error) { - //contentStr := "你是专业内容生成助手,请严格按以下规则输出内容:1、输出标准 HTML 片段,不要 Markdown,不要 ``` 符号,不要多余解释,2、整体用
包裹,3、主标题使用

,4、章节标题使用

,5、正文段落使用

,6、列表使用

  • ...
,7、重点内容使用 加粗,8、段落之间清晰分隔,结构规整,9、如果生成多条文案,每条文案独立用
包裹(序号从1开始),10、每条文案内部必须在最上方添加一行固定格式:

需要配图:N 张

N 是这条文案需要的图片数量,只能是数字,不能是其他文字,11、只输出 HTML 结构,不输出任何额外文字" - - mapTaskResult, err := GetModelResult(ctx, nodeInput.Global.SessionId, nodeInput, skillName, form, userForm) - if err != nil { - return nil, err - } - if g.IsEmpty(mapTaskResult) { - return nil, fmt.Errorf("生成内容为空") - } - - outputRes := make([]node.NodeFormField, 0) - for _, item := range mapTaskResult { - for k, v := range item { - // 拆分多条文案 - contentList := SplitMultiContents(gconv.String(v)) - for i, contentItem := range contentList { - if nodeInput.Config.IsSaveFile { - // 1. 构建html文本 - plainText := BuildText(contentItem) - // 2. 上传纯文本到 OSS - textFileName := fmt.Sprintf("ai_text_%d_%d.inc", time.Now().UnixMilli(), i) - var textUrl *dto.UploadFileBytesRes - textUrl, err = Upload(ctx, &dto.UploadFileBytesReq{ - FileBytes: []byte(plainText), - FileName: textFileName, - }) - if err != nil { - return nil, err - } - // 3. 把纯文本地址存入输出 - outputRes = append(outputRes, node.NodeFormField{ - Field: fmt.Sprintf("text_url:%v:%d", k, i), - Value: textUrl.FileURL, - Label: fmt.Sprintf("text_url:%v:%d", k, i), - Type: "string", - Expand: ExtractImageCount(contentItem), - }) - } - outputRes = append(outputRes, node.NodeFormField{ - Field: fmt.Sprintf("text_content:%v:%d", k, i), - Value: contentItem, - Label: fmt.Sprintf("文案内容%v:%d", k, i), - Type: "string", - Expand: ExtractImageCount(gconv.String(v)), - }) - } - } - } - return outputRes, nil -} - -func ImgNode(ctx context.Context, nodeInput *flowDto.NodeExecutionInput, skillName string, form []map[string]any, userForm []map[string]any) ([]node.NodeFormField, error) { - mapTaskResult, err := GetModelResult(ctx, nodeInput.Global.SessionId, nodeInput, skillName, form, userForm) - if err != nil { - return nil, err - } - if g.IsEmpty(mapTaskResult) { - return nil, fmt.Errorf("生成内容为空") - } - outputRes := make([]node.NodeFormField, 0) - for i, item := range mapTaskResult { - for k, v := range item { - if nodeInput.Config.IsSaveFile { - outputRes = append(outputRes, node.NodeFormField{ - Field: fmt.Sprintf("img_oss_url:%v:%d", k, i), - Value: v, - Label: fmt.Sprintf("img_oss_url%v:%d", k, i), - Type: "string", - }) - } - outputRes = append(outputRes, node.NodeFormField{ - Field: fmt.Sprintf("img_url:%v:%d", k, i), - Value: v, - Label: fmt.Sprintf("图片内容%v:%d", k, i), - Type: "string", - }) - } - } - - //var resultContent []string - //for _, item := range mapTaskResult { - // for _, i := range gconv.Strings(item[modelInfo.Model.ResponseBody]) { - // resultContent = append(resultContent, i) - // } - //} - //var images []string - //for _, item := range resultContent { - // mapItem := gconv.Map(item) - // for _, value := range mapItem { - // values, ok := value.(string) - // if !ok { - // return nil, fmt.Errorf("图片地址类型错误") - // } - // // 下载官方临时图片 - // var imgBytes []byte - // imgBytes, err = GetFileBytesFromURL(ctx, values) - // if err != nil { - // return nil, fmt.Errorf("下载图片失败: %w", err) - // } - // // 构造文件名 - // fileName := fmt.Sprintf("ai_image_%d.png", time.Now().UnixMilli()) - // // 上传到你的OSS(你项目已有的Upload方法) - // var upResp *dto.UploadFileBytesRes - // upResp, err = Upload(ctx, &dto.UploadFileBytesReq{ - // FileName: fileName, - // FileBytes: imgBytes, - // }) - // if err != nil { - // return nil, fmt.Errorf("上传OSS失败: %w", err) - // } - // images = append(images, upResp.FileURL) - // } - //} - // - //var url string - //url, err = utils.GetFileAddressPrefix(ctx) - //if err != nil { - // return nil, err - //} - //outputRes := make([]node.NodeFormField, 0) - // - //for i, item := range images { - // // 额外存储关联关系 - // outputRes = append(outputRes, node.NodeFormField{ - // Field: fmt.Sprintf("img_url:%d", i), - // Value: fmt.Sprintf("%s%s", url, item), - // Label: fmt.Sprintf("图片路径:%d", i), - // Type: "string", - // }) - //} - - return outputRes, nil -} - -func AudioOptimizeNode(ctx context.Context, nodeInput *flowDto.NodeExecutionInput, skillName string, form []map[string]any, userForm []map[string]any) ([]node.NodeFormField, error) { - mapTaskResult, err := GetModelResult(ctx, "", nodeInput, skillName, form, userForm) - if err != nil { - return nil, err - } - if g.IsEmpty(mapTaskResult) { - return nil, fmt.Errorf("生成内容为空") - } - outputRes := make([]node.NodeFormField, 0) - for i, item := range mapTaskResult { - for k, v := range item { - if nodeInput.Config.IsSaveFile { - outputRes = append(outputRes, node.NodeFormField{ - Field: fmt.Sprintf("audio_oss_url:%v:%d", k, i), - Value: v, - Label: fmt.Sprintf("audio_oss_url:%v:%d", k, i), - Type: "string", - }) - } - if k == "sentences" { - a := new([]flowDto.Sentence) - err = gconv.Structs(v, a) - v, err = BuildSubtitles(a) - if err != nil { - return nil, err - } - } - outputRes = append(outputRes, node.NodeFormField{ - Field: fmt.Sprintf("audio_url:%v:%d", k, i), - Value: v, - Label: fmt.Sprintf("音频内容:%v:%d", k, i), - Type: "string", - }) - } - } - - return outputRes, nil -} - -func splitTextByPunct(raw string) []string { - // 按标点切分+拼接标点 - slice := regexp.MustCompile(`([,。;!?])`).Split(raw, -1) - var res []string - var builder strings.Builder - for idx, s := range slice { - if s == "" { - continue - } - builder.WriteString(s) - // 偶数位是分隔标点(split后规律:文本、标点、文本、标点...) - if idx%2 == 1 { - res = append(res, builder.String()) - builder.Reset() - } - } - if builder.Len() > 0 { - res = append(res, builder.String()) - } - return res -} - -// BuildSubtitles 核心工具:单个sentence生成多条subtitle -func BuildSubtitles(sents *[]flowDto.Sentence) ([]flowDto.Subtitle, error) { - var subtitles []flowDto.Subtitle - for _, sent := range *sents { - segList := splitTextByPunct(sent.Text) - if len(segList) == 0 { - return nil, nil - } - - var subs []flowDto.Subtitle - wordIdx := 0 - allWords := sent.Words - - for _, seg := range segList { - var collectWords []flowDto.Word - currentText := "" - // 循环取 word,直到拼接内容 包含/匹配 seg - for { - if wordIdx >= len(allWords) { - break - } - word := allWords[wordIdx] - currentText += word.Word - collectWords = append(collectWords, word) - wordIdx++ - - // 只要包含分段文本,就认为匹配(无视末尾标点差异) - if strings.Contains(currentText, seg) { - break - } - } - if len(collectWords) == 0 { - continue - } - // 生成字幕 - sub := flowDto.Subtitle{ - Start: collectWords[0].StartTime, - End: collectWords[len(collectWords)-1].EndTime, - Text: seg, - } - subs = append(subs, sub) - } - subtitles = append(subtitles, subs...) - } - - return subtitles, nil -} - -func VideoOptimizeNode(ctx context.Context, nodeInput *flowDto.NodeExecutionInput, skillName string, form []map[string]any, userForm []map[string]any) ([]node.NodeFormField, error) { - mapTaskResult, err := GetModelResult(ctx, nodeInput.Global.SessionId, nodeInput, skillName, form, userForm) - if err != nil { - return nil, err - } - if g.IsEmpty(mapTaskResult) { - return nil, fmt.Errorf("生成内容为空") - } - outputRes := make([]node.NodeFormField, 0) - for i, item := range mapTaskResult { - for k, v := range item { - outputRes = append(outputRes, node.NodeFormField{ - Field: fmt.Sprintf("video_url:%v:%d", k, i), - Value: v, - Label: fmt.Sprintf("video_url:%v:%d", k, i), - Type: "string", - }) - } - } - return outputRes, nil -} - -func DataConversionNode(ctx context.Context, nodeInput *flowDto.NodeExecutionInput, skillName string, form []map[string]any, userForm []map[string]any) ([]node.NodeFormField, error) { - jsonStr := `` - jsonVal := "输出字段规范:" - for _, field := range nodeInput.Config.OutputConfig { - jsonStr, _ = sjson.Set(jsonStr, field.Field, "") - jsonVal += fmt.Sprintf("%s:%s;", field.Field, field.Value) - } - jsonVal += fmt.Sprintf("输出模板结构,仅修改每个字段对应数值:%s", jsonStr) - nodeInput.Config.PromptContent = fmt.Sprintf("%s;%s", nodeInput.Config.PromptContent, jsonVal) - - mapTaskResult, err := GetModelResult(ctx, "", nodeInput, skillName, form, userForm) - if err != nil { - return nil, err - } - if g.IsEmpty(mapTaskResult) { - return nil, fmt.Errorf("生成内容为空") - } - outputRes := make([]node.NodeFormField, 0) - for i, item := range mapTaskResult { - for k, v := range item { - outputRes = append(outputRes, node.NodeFormField{ - Field: fmt.Sprintf("data_conversion:%v:%d", k, i), - Value: v, - Label: fmt.Sprintf("data_conversion:%v:%d", k, i), - Type: "string", - }) - } - } - return outputRes, nil -} - -func HttpNode(ctx context.Context, nodeInput *flowDto.NodeExecutionInput) ([]node.NodeFormField, error) { - var method, url, responseType, callbackUrl string - var headers map[string]string - var body map[string]any - var responseMapping map[string]any - for _, item := range nodeInput.Config.FormConfig { - 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": - responseMapping = gconv.Map(item.Value) - case "responseType": - responseType = gconv.String(item.Value) - case "callbackUrl": - callbackUrl = gconv.String(item.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] - } - } - } - } - - // 构建请求参数 - newBody := BuildNestedJson(body, nodeInput.Global.ConfigMap) - // 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. 发送请求(不变) - var err error - 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 - } - - finalResult := make(map[string]any) - if responseType == "sync" { - httpResultJson := gconv.String(rawHttpResult) - for key, jsonPath := range responseMapping { - path := gconv.String(jsonPath) - if !g.IsEmpty(gjson.Get(httpResultJson, path).Value()) { - finalResult[key] = gjson.Get(httpResultJson, path).Value() - } - } - } - 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() - for key, jsonPath := range responseMapping { - path := gconv.String(jsonPath) - val := gjson.Get(bodyStr, path) - // 如果是数组,直接返回整个数组 - if val.IsArray() { - finalResult[key] = val.Value() - } else { - // 普通值,非空才赋值 - if !g.IsEmpty(val.Value()) { - finalResult[key] = val.Value() - } - } - } - } - if responseType == "pull" { - - } - - outputRes := make([]node.NodeFormField, 0) - for i, item := range finalResult { - if nodeInput.Config.IsSaveFile { - outputRes = append(outputRes, node.NodeFormField{ - Field: fmt.Sprintf("http_file_url:%v", i), - Value: item, - Label: fmt.Sprintf("http_file_url:%v", i), - Type: "string", - }) - } - outputRes = append(outputRes, node.NodeFormField{ - Field: fmt.Sprintf("%v", i), - Value: item, - Label: fmt.Sprintf("%v", i), - Type: "string", - }) - } - - return outputRes, nil -} - -func BuildParam(nodeInput *flowDto.NodeExecutionInput) (skillName string, resultFrom []map[string]any, resultUserFrom []map[string]any) { - inputMap, outputMap, modelMap := GetNodeContextContent(nodeInput.Global, nodeInput.Config) - var outputResult []node.NodeFormField - outputResult = append(outputResult, inputMap...) - - resultUserFrom = []map[string]any{} - for _, field := range outputMap { - if !strings.Contains(field.Field, "text_url") && !strings.Contains(field.Field, "img_url") { - if strings.Contains(field.Field, "text_content") { - field.Value = StripHtmlTags(gconv.String(field.Value)) - } - resultUserFrom = append(resultUserFrom, map[string]any{ - field.Label: field.Value, - }) - } - } - for _, valueAny := range modelMap { - if field, ok := valueAny.(node.NodeFormField); ok { - outputResult = append(outputResult, field) - } - } - if !nodeInput.Global.IsDialogue { - for _, item := range outputResult { - resultUserFrom = append(resultUserFrom, map[string]any{ - item.Label: item.Value, - }) - } - } - if !g.IsEmpty(nodeInput.Global.Desc) { - resultUserFrom = append(resultUserFrom, map[string]any{ - "desc": nodeInput.Global.Desc, - }) - } - - resultFrom = []map[string]any{} - for _, item := range nodeInput.Config.ModelConfig.ModelForm { - if g.IsEmpty(item.Value) { - continue - } - resultFrom = append(resultFrom, map[string]any{ - item.Label: item.Value, - }) - } - skillName = nodeInput.Config.SkillName - if g.IsEmpty(nodeInput.Config.SkillName) { - skillName = nodeInput.Global.SkillName - } - - return skillName, resultFrom, resultUserFrom -} - -func GetNodeContextContent(execInput *flowDto.FlowExecutionInput, nodeEntity *entity.FlowNode) ([]node.NodeFormField, []node.NodeFormField, map[string]any) { - var input []node.NodeFormField - var output []node.NodeFormField - model := make(map[string]any) - if len(nodeEntity.InputSource) > 0 { - for _, source := range nodeEntity.InputSource { - refNode, ok := execInput.ConfigMap[source.NodeId] - if !ok { - continue - } - if len(source.Field) > 0 { - // 取指定字段 - for _, f := range source.Field { - for _, v := range refNode.FormConfig { - if strings.Contains(v.Label, f) { - input = append(input, v) - } - } - for _, v := range refNode.ModelConfig.ModelForm { - if g.IsEmpty(v.Value) { - continue - } - if strings.Contains(v.Label, f) { - model[f] = v - } - } - for _, v := range refNode.OutputResult { - if strings.Contains(v.Label, f) { - output = append(output, v) - } - } - } - } - } - } - return input, output, model -} diff --git a/workflow/service/flow/lambda_node_util.go b/workflow/service/flow/lambda_node_util.go index 923dc08..3686ba7 100644 --- a/workflow/service/flow/lambda_node_util.go +++ b/workflow/service/flow/lambda_node_util.go @@ -1,31 +1,19 @@ package flow import ( + "ai-agent/gateway" "ai-agent/workflow/consts/node" - nodeDao "ai-agent/workflow/dao/node" - "ai-agent/workflow/model/dto" flowDto "ai-agent/workflow/model/dto/flow" - nodeDto "ai-agent/workflow/model/dto/node" - "ai-agent/workflow/model/entity" - "bytes" "context" "fmt" - "io" - "mime/multipart" - "net/http" - "net/url" - "path/filepath" - "regexp" - "strconv" - "strings" "sync" commonHttp "gitea.redpowerfuture.com/red-future/common/http" "gitea.redpowerfuture.com/red-future/common/utils" - "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" "github.com/gogf/gf/v2/util/gconv" - "github.com/tidwall/sjson" + "github.com/google/uuid" ) // 全局等待任务回调的工具 @@ -69,804 +57,155 @@ func Notify(taskId string, result any) { delete(asyncTasks, taskId) } -func GetIsChatModel(ctx context.Context) (res *flowDto.GetIsChatModelRes, err error) { - 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] - } - } +// 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) { + modelInfo, err := gateway.GetModelInfoById(ctx, &gateway.GetModelInfoByIdReq{ModelId: modelId}) + if err != nil { + return nil, nil, fmt.Errorf("获取模型配置失败: %w", err) } - res = new(flowDto.GetIsChatModelRes) - err = commonHttp.Get(ctx, "model-gateway/model/getIsChatModel", headers, res, nil) - return -} - -func GetModelInfo(ctx context.Context, req *flowDto.GetModelInfoReq) (res *flowDto.GetModelInfoRes, err error) { - 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] - } - } + // 异步模型 msgTopic 由 gateway.ModelCallResult 在为空时自动生成(唯一、带业务标识),调用方无需管理 + responseParams, err := gateway.ModelCallResult(ctx, modelId, modelInfo.ModelManage.ResponseType, sessionId, modelRequestParams, nil) + if err != nil { + return nil, nil, err } - res = new(flowDto.GetModelInfoRes) - err = commonHttp.Get(ctx, "model-gateway/model/getModel", headers, res, req) - return -} - -func GetComposeResult(ctx context.Context, buildType int, modelName, promptContent, skillName string, form []map[string]any, userForm []map[string]any, fileUrl []string, sessionId, nodeId string, cause string) (res *flowDto.ComposeCallbackReq, err error) { - if !g.IsEmpty(promptContent) { - userForm = append(userForm, map[string]any{ - "prompt": promptContent, + if g.IsEmpty(responseParams) { + return nil, nil, fmt.Errorf("生成内容为空") + } + outputRes := make([]map[string]any, 0) + for key, val := range responseParams.Content { + outputRes = append(outputRes, map[string]any{ + key: val, }) } - var callbackUrl = utils.GetCallbackURL(ctx, "/flow/execution/composeCallBack") - var consult = make([]flowDto.Consult, 0) - var collectFileUrls func(val any) (fullyConsumed bool) - collectFileUrls = func(val any) (fullyConsumed bool) { - switch { - case g.NewVar(val).IsSlice(): - slice := gconv.SliceAny(val) - allConsumed := false - for _, item := range slice { - if collectFileUrls(item) { - allConsumed = true - } + return outputRes, responseParams, 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 } - return allConsumed - case g.NewVar(val).IsMap(): - m := gconv.Map(val) - allConsumed := false - for _, item := range m { - if collectFileUrls(item) { - allConsumed = true - } - } - return allConsumed - default: - s := gconv.String(val) - if s != "" { - getFileTypeByPath := GetFileTypeByPath(s) - if getFileTypeByPath != "" { - consult = append(consult, flowDto.Consult{ - Type: getFileTypeByPath, - Url: s, - }) - return true - } - } - return false } } - var newUserForm []map[string]any - for _, m := range userForm { - for k, v := range m { - if collectFileUrls(v) { - delete(m, k) + + 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] + } } } - if len(m) > 0 { - newUserForm = append(newUserForm, m) - } } - for _, v := range fileUrl { - getFileTypeByPath := GetFileTypeByPath(gconv.String(v)) - if getFileTypeByPath != "" { - consult = append(consult, flowDto.Consult{ - Type: getFileTypeByPath, - Url: gconv.String(v), + + // 构建请求参数 + ProcessValueSourceRecursive(body, nodeInput.Global) + // 递归剥掉 {type, value/attrs} 包裹层,只保留 key/value + wrapper := UnwrapSchemaWrapper(body) + newBody := gconv.Map(wrapper) + + // 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, + }) } - msgReq := flowDto.ComposeMessagesReq{ - BuildType: buildType, - ModelName: modelName, - SkillName: skillName, - CallbackUrl: callbackUrl, - Cause: cause, - Form: form, - UserForm: newUserForm, - Consult: consult, - SessionId: sessionId, - NodeId: nodeId, - } - 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] - } - } - } - msgRes := new(flowDto.ComposeMessagesRes) - err = commonHttp.Post(ctx, "prompts-core/prompt/composeMessages", headers, msgRes, &msgReq) - if err != nil { - return - } - if g.IsEmpty(msgRes.TaskId) { - return nil, fmt.Errorf("msg is empty") - } - waitRes, err := Wait(ctx, msgRes.TaskId) - if err != nil { - return nil, err - } - msg := new(flowDto.ComposeCallbackReq) - if err = gconv.Struct(waitRes, msg); err != nil { - return nil, err - } - if !g.IsEmpty(msg.ErrorMsg) { - return nil, fmt.Errorf(msg.ErrorMsg) - } - return msg, nil -} -func CreateGatewayTask(ctx context.Context, epicycleId int64, model string, content map[string]any) (map[string]any, error) { - taskId, err := createGatewayTaskOnly(ctx, epicycleId, model, content) - if err != nil { - return nil, err - } - return waitGatewayResult(ctx, taskId) -} - -// createGatewayTaskOnly creates a gateway task and returns the taskId only -// doesn't wait for completion -func createGatewayTaskOnly(ctx context.Context, epicycleId int64, model string, content map[string]any) (string, error) { - callbackUrl := utils.GetCallbackURL(ctx, "/flow/execution/modelCallback") - req := flowDto.ModelGatewayReq{ - ModelName: model, - BizName: g.Cfg().MustGet(ctx, "server.name").String(), - CallbackUrl: callbackUrl, - RequestPayload: content, - EpicycleId: epicycleId, - } - - 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] - } - } - } - - res := new(flowDto.ModelGatewayRes) - err := commonHttp.Post(ctx, "model-gateway/task/createTask", headers, res, &req) - if err != nil { - return "", err - } - if g.IsEmpty(res.TaskId) { - return "", fmt.Errorf("创建模型任务失败,taskId为空") - } - - return res.TaskId, nil -} - -// waitGatewayResult waits for a created gateway task to complete and returns the result -func waitGatewayResult(ctx context.Context, taskId string) (map[string]any, error) { - waitRes, err := Wait(ctx, taskId) - if err != nil { - return nil, err - } - - task := new(flowDto.ModelCallbackReq) - if err = gconv.Struct(waitRes, task); err != nil { - return nil, err - } - if task.State == 3 || !g.IsEmpty(task.ErrorMsg) { - return nil, fmt.Errorf("模型执行失败:%s", task.ErrorMsg) - } - if g.IsEmpty(task.OssFile) { - return nil, fmt.Errorf("模型返回结果为空") - } - // 获取远程文件内容 - file, err := GetFileBytesFromURL(ctx, task.OssFile) - if err != nil { - return nil, err - } - return gconv.Map(file), nil -} - -// updateTokenCount updates the token count in node execution -func updateTokenCount(ctx context.Context, nodeExecutionId int64, responseField string, result map[string]any) { - if responseField == "" { - return - } - _, _ = nodeDao.NodeExecutionDao.Update(ctx, &nodeDto.UpdateNodeExecutionReq{ - Id: nodeExecutionId, - CompletionTokens: gconv.Int(result[responseField]), - TotalTokens: gconv.Int(result[responseField]), - }) -} - -func GetModelResult(ctx context.Context, sessionId string, nodeInput *flowDto.NodeExecutionInput, skillName string, form []map[string]any, userForm []map[string]any) (mapTaskResult []map[string]any, err error) { - buildType := 1 - if nodeInput.Config.NodeCode == node.NodeTypeDataConversionModel { - buildType = 3 - } - - if !nodeInput.Global.IsDialogue { - sessionId = "" - } - - composeResult, err := GetComposeResult(ctx, buildType, nodeInput.Config.ModelConfig.ModelName, nodeInput.Config.PromptContent, skillName, form, userForm, nodeInput.Global.FileUrl, sessionId, nodeInput.Config.Id, nodeInput.Config.Name) - if err != nil { - return nil, err - } - if composeResult.Status != "success" { - return nil, fmt.Errorf("模型提示词构建错误") - } - - modelInfo, err := GetModelInfo(ctx, &flowDto.GetModelInfoReq{ModelName: nodeInput.Config.ModelConfig.ModelName}) - if err != nil { - return nil, err - } - - mapTaskResult = make([]map[string]any, len(composeResult.Messages.Rounds)) - var taskResultMap map[string]any - - needSequential := false - if buildType == 1 { - if needSequential { - for idx, item := range composeResult.Messages.Rounds { - if !g.IsEmpty(taskResultMap) { - var set string - set, err = sjson.Set(gconv.String(item), modelInfo.Model.LastFrame, gconv.String(taskResultMap[modelInfo.Model.ResponseBody])) - if err != nil { - return nil, err - } - item = gconv.Map(set) - } - - var taskResult map[string]any - taskResult, err = CreateGatewayTask(ctx, composeResult.EpicycleId, nodeInput.Config.ModelConfig.ModelName, item) - if err != nil { - return nil, err - } - if g.IsEmpty(taskResult) { - return nil, fmt.Errorf("模型返回结果为空") - } - - if nodeInput.Config.NodeCode == node.NodeTypeVideoModel { - ext := GetFileTypeByPath(gconv.String(taskResult[modelInfo.Model.ResponseBody])) - if ext == "image" { - taskResultMap = taskResult - } else { - taskResultMap = make(map[string]any) - } - } else { - taskResultMap = make(map[string]any) - } - - mapTaskResult[idx] = taskResult - updateTokenCount(ctx, nodeInput.NodeExecutionId, modelInfo.Model.ResponseTokenField, taskResult) - } - } else { - taskIdList := make([]string, len(composeResult.Messages.Rounds)) - - for idx, item := range composeResult.Messages.Rounds { - taskId, err := createGatewayTaskOnly(ctx, composeResult.EpicycleId, nodeInput.Config.ModelConfig.ModelName, item) - if err != nil { - return nil, err - } - taskIdList[idx] = taskId - } - - // 全局共享子上下文,实现一处报错全部终止 - subCtx, globalCancel := context.WithCancel(ctx) - defer globalCancel() // 函数退出兜底释放 - - var wg sync.WaitGroup - errChan := make(chan error, len(taskIdList)) - - // 加互斥锁保护结果map - var mu sync.Mutex - - for idx, taskId := range taskIdList { - wg.Add(1) - - go func(idx int, taskId string) { - defer wg.Done() - - taskResult, err := waitGatewayResult(subCtx, taskId) - if err != nil { - errChan <- err - globalCancel() // 全局取消,所有协程收到ctx取消信号快速退出 - return - } - - // 加锁写入map,解决并发竞态 - mu.Lock() - mapTaskResult[idx] = taskResult - mu.Unlock() - - updateTokenCount(ctx, nodeInput.NodeExecutionId, modelInfo.Model.ResponseTokenField, taskResult) - }(idx, taskId) - } - - wg.Wait() - close(errChan) - - // 收集全部错误,而非只读一条 - var errs []error - for len(errChan) > 0 { - errs = append(errs, <-errChan) - } - - if len(errs) > 0 { - // 返回第一个错误;如需汇总所有错误可拼接 - return nil, errs[0] - } - } - } else { - for idx, item := range composeResult.Messages.Rounds { - mapTaskResult[idx] = item - updateTokenCount(ctx, nodeInput.NodeExecutionId, modelInfo.Model.ResponseTokenField, item) - } - } - - return mapTaskResult, nil -} - -func BuildNestedJson(body g.Map, mockConfigMap map[string]*entity.FlowNode) g.Map { - jsonStr := "{}" - for originKey, originItem := range body { - bodyItemMap := gconv.Map(originItem) - val := bodyItemMap["value"] - if v, ok := bodyItemMap["value"]; ok { - jsonStr, _ = sjson.Set(jsonStr, originKey, v) - } - // 判断 value 是不是引用结构(map) - if g.NewVar(val).IsMap() { - valMap := gconv.Map(val) - nodeId := gconv.String(valMap["nodeId"]) - fieldName := gconv.String(valMap["field"]) - if configValue, ok := mockConfigMap[nodeId]; ok { - if !g.IsEmpty(configValue.OutputResult) { - for _, v := range configValue.OutputResult { - if strings.Contains(v.Field, fieldName) { - if configValue.NodeCode == node.NodeTypeDataConversionModel { - switch { - case g.NewVar(v.Value).IsSlice() || g.NewVar(v.Value).IsMap(): - // 核心:自动判断两种结构,精准赋值 - vm := gconv.Map(v.Value) - // 先判断是否是 单个key包裹的对象(如 {"subtitle_style": {...}}) - if len(vm) == 1 { - // 遍历取出唯一的 key 和 真实值 - for innerKey, innerVal := range vm { - // 直接用 innerKey(subtitle_style)赋值 - jsonStr, _ = sjson.Set(jsonStr, innerKey, innerVal) - } - } else { - // 直接是对象,用 originKey 赋值 - jsonStr, _ = sjson.Set(jsonStr, originKey, v.Value) - } - default: - jsonStr, _ = sjson.Set(jsonStr, originKey, v.Value) - } - } else { - jsonStr, _ = sjson.Set(jsonStr, originKey, v.Value) - } - } - } - } - if !g.IsEmpty(configValue.FormConfig) { - for _, v := range configValue.FormConfig { - if v.Field == fieldName { - if v.Type == "uploadMultiple" { - if g.NewVar(v.FieldConstraint).IsMap() { - mapFieldConstraint := gconv.Map(v.FieldConstraint) - for key, value := range mapFieldConstraint { - if key == "maxFileCount" { - if gconv.Int(value) == 1 { - // 如果是单文件上传,则替换成字符串重新赋值给v.Value - if g.NewVar(v.Value).IsSlice() { - sliceVal := gconv.SliceAny(v.Value) - if len(sliceVal) > 0 { - v.Value = sliceVal[0] - } - } - } - } - } - } - } - jsonStr, _ = sjson.Set(jsonStr, originKey, v.Value) - } - } - } - } - } - } - return gconv.Map(jsonStr) -} - -func VideoConcat(ctx context.Context, videoUrls []string) (r any, err error) { - var httpUrl = "media/video/concat/async" - 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] - } - } - } - var callbackUrl = utils.GetCallbackURL(ctx, "/flow/execution/videoCallback") - var newBody = flowDto.VideoConcatReq{ - VideoUrls: videoUrls, - Method: "auto", - Upload: true, - CallbackUrl: callbackUrl, - } - res := new(flowDto.VideoConcatRes) - err = commonHttp.Post(ctx, httpUrl, headers, &res, newBody) - if err != nil { - return nil, err - } - return Wait(ctx, res.TaskId) -} - -func GetFileBytesFromURL(ctx context.Context, fileUrl string) ([]byte, error) { - newS := strings.ReplaceAll(fileUrl, g.Cfg().MustGet(ctx, "filePrefix").String(), g.Cfg().MustGet(ctx, "minioPrefix").String()) - // 使用 GoFrame 客户端(自带超时、追踪、日志等能力) - resp, err := g.Client().Get(ctx, newS) - if err != nil { - return nil, gerror.Wrapf(err, "failed to request url: %s", newS) - } - defer resp.Close() - - // 校验状态码 - if resp.StatusCode != http.StatusOK { - return nil, gerror.Newf("request failed with status code: %d, url: %s", resp.StatusCode, newS) - } - - // 读取全部内容 - allBytes, err := io.ReadAll(resp.Body) - if err != nil { - return nil, gerror.Wrapf(err, "failed to read response body, url: %s", fileUrl) - } - - return allBytes, nil -} - -func Upload(ctx context.Context, req *dto.UploadFileBytesReq) (*dto.UploadFileBytesRes, error) { - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - - part, err := writer.CreateFormFile("file", req.FileName) - if err != nil { - return nil, err - } - if _, err = part.Write(req.FileBytes); err != nil { - return nil, err - } - if err = writer.Close(); err != nil { - return nil, err - } - - headers := make(map[string]string) - headers["Content-Type"] = writer.FormDataContentType() - if r := g.RequestFromCtx(ctx); r != nil { - if auth := r.Header.Get("Authorization"); auth != "" { - headers["Authorization"] = auth - } - } - - // 发起上传请求 - res := &dto.UploadFileBytesRes{} - httpUrl := "oss/file/uploadFile" - if err = commonHttp.Post(ctx, httpUrl, headers, res, body.Bytes()); err != nil { - return nil, err - } - - g.Log().Infof(ctx, "[Upload] success url=%s size=%d", res.FileURL, res.FileSize) - return res, nil -} - -func GetFileTypeByPath(filePath string) string { - if filePath == "" { - return "" - } - - // 解析 URL,获取真实路径(兼容 http 链接) - u, err := url.Parse(filePath) - if err == nil { - filePath = u.Path - } - - // 获取后缀(小写) - ext := filepath.Ext(filePath) - ext = strings.ToLower(ext) - - // 判断类型 - switch ext { - case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp": - return "image" - case ".mp4", ".mov", ".avi", ".flv", ".wmv", ".mkv": - return "video" - case ".mp3", ".wav", ".m4a", ".flac", ".aac", ".ogg": - return "audio" - case ".txt", ".md", ".log", ".json", ".xml", ".inc": - return "text" - case ".html": - return "html" - case ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx": - return "document" - default: - return "" - } -} - -func BuildText(text string) string { - // 生成单条HTML - var htmlBuilder strings.Builder - htmlBuilder.WriteString(` - - - - - - - - -
-
-`) - // 🔥 写入文案前:删除

需要配图:X 张

- if text != "" { - // 写入清理后的文案 - htmlBuilder.WriteString(fmt.Sprintf(`
%s
`, ImageTagRegex(text))) - } - htmlBuilder.WriteString(`
-
- -`) - - return htmlBuilder.String() -} - -func BuildHtml(text string, images []string) string { - var htmlBuilder strings.Builder - htmlBuilder.WriteString(` - - - - - - - -
-`) - // 写入图片(支持0张、1张、多张) - if len(images) > 0 { - htmlBuilder.WriteString(`
`) - for _, imgUrl := range images { - htmlBuilder.WriteString(fmt.Sprintf(`图片`, imgUrl)) - } - htmlBuilder.WriteString(`
`) - } - htmlBuilder.WriteString(` -
加载中...
-
- - - -`) - - return htmlBuilder.String() -} - -// ExtractImageCount 修复:支持单引号/双引号 + 换行 + 空格 -func ExtractImageCount(content string) int { - // 🔥 关键:支持 class='image-count' (单引号) - re := regexp.MustCompile(`

]*>.*?(\d+).*?

`) - match := re.FindStringSubmatch(content) - if len(match) >= 2 { - num, err := strconv.Atoi(match[1]) - if err == nil { - return num - } - } - return 0 -} - -func ImageTagRegex(html string) string { - // 🔥 修复:支持单引号、双引号、空格、换行,100% 删除

- imageTagRegex := regexp.MustCompile(`

]*>[\s\S]*?

`) - return imageTagRegex.ReplaceAllString(html, "") -} - -// StripHtmlTags 去掉所有HTML标签,保留换行和文本结构,并删除配图标记行 -func StripHtmlTags(html string) string { - // 1. 替换块级标签为换行,保证排版 - blockTags := regexp.MustCompile(`]*>`) - text := blockTags.ReplaceAllString(html, "\n") - - // 2. 去掉所有剩余的 HTML 标签 - allTags := regexp.MustCompile(`<[^>]+>`) - text = allTags.ReplaceAllString(text, "") - - // 4. 清理多余空行(多个换行只保留一个) - text = regexp.MustCompile(`\n\s*\n`).ReplaceAllString(text, "\n") - - // 5. 只去掉首尾空白,中间换行保留 - text = strings.TrimSpace(text) - - return text -} - -// SplitMultiContents 拆分模型返回的多条文案(基于HTML标签分隔) -func SplitMultiContents(htmlContent string) []string { - var contents []string - // 正则匹配
包裹的内容 - re := regexp.MustCompile(`
([\s\S]*?)
`) - matches := re.FindAllStringSubmatch(htmlContent, -1) - for _, match := range matches { - if len(match) > 1 { - // 清理空内容 - trimmed := strings.TrimSpace(match[1]) - if trimmed != "" { - contents = append(contents, trimmed) - } - } - } - // 兜底:如果没有匹配到结构化内容,按换行/分隔符拆分 - if len(contents) == 0 { - contents = strings.Split(htmlContent, "===分隔符===") // 提示词中可新增此兜底规则 - } - return contents -} - -// GetAllImgSrcFromHtml 先把提取img src的工具方法放在外面 -func GetAllImgSrcFromHtml(html string) []string { - var imgSrcList []string - re := regexp.MustCompile(`]*src\s*=\s*["']([^"']+)["']`) - submatch := re.FindAllStringSubmatch(html, -1) - for _, match := range submatch { - if len(match) >= 2 { - imgSrcList = append(imgSrcList, match[1]) - } - } - return imgSrcList -} - -// ReplaceImgSrc 替换img src的方法 -func ReplaceImgSrc(html string, oldSrc string, newSrc string) string { - // 精准替换:找到 - re := regexp.MustCompile(`(]*src\s*=\s*["'])` + regexp.QuoteMeta(oldSrc) + `(["'])`) - return re.ReplaceAllString(html, `${1}`+newSrc+`${2}`) + return outputRes, nil } diff --git a/workflow/service/flow/lambda_script_transcribe.go b/workflow/service/flow/lambda_script_transcribe.go new file mode 100644 index 0000000..8ad5c45 --- /dev/null +++ b/workflow/service/flow/lambda_script_transcribe.go @@ -0,0 +1,309 @@ +package flow + +import ( + "ai-agent/workflow/consts/node" + "ai-agent/workflow/service/flow/processor" + "ai-agent/workflow/service/flow/processor/builtin/split_shots_pipeline" + "context" + "encoding/json" + "fmt" + "strings" + + "ai-agent/gateway" + flowDto "ai-agent/workflow/model/dto/flow" + "ai-agent/workflow/service/flow/processor/builtin/split_shots_pipeline/pipeline" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" +) + +// 脚本转写节点默认系统提示词(字段与 domain.Shot 的 JSON tag 对齐,结构化输出与 content 兜底两条路径一致) +const defaultScriptTranscribeSystemPrompt = `你是短剧分镜脚本师。请根据提供的文案/视频分析结果,把内容拆分为连续的分镜镜头脚本。 +每个镜头输出一个 JSON 对象,字段固定为: +- index:镜头序号(数字) +- startTime:开始时间,格式 MM:SS +- endTime:结束时间,格式 MM:SS +- event:事件描述/动作描写 +- narration:旁白/画外音,没有则省略 +- dialogue:角色开口说的主台词,没有则省略 +- ambientSound:环境音,没有则省略 +- cameraMovement:运镜描述 +- shotSize:景别 +- characters:出演人物名列表(字符串数组) +- scene:场景名 +- props:道具名列表(字符串数组) +时间码需前后衔接、覆盖整个内容时长。直接输出 JSON 数组,不要输出其他文字。` + +// ScriptTranscribeLambda 脚本转写节点: +// 把节点输入(文案/视频分析结果,经 valueSource 解析)通过大模型转写为固定结构的 []domain.Shot, +// 产出为 [{"shots": [...]}],供视频生成节点的 ModelRequestParams.shots 引用。 +func ScriptTranscribeLambda(ctx context.Context, input any) (any, error) { + nodeInput, ok := input.(*flowDto.NodeExecutionInput) + if !ok { + return nil, fmt.Errorf("入参类型错误") + } + + n := new([]node.NodePresetField) + err := gconv.Structs(nodeInput.Config.OutputConfig, n) + if err != nil { + return nil, err + } + var totalDuration int + var modelId int64 + for _, item := range *n { + switch item.Field { + case "totalDuration": + totalDuration = gconv.Int(item.Value) + case "modelId": + modelId = gconv.Int64(item.Value) + } + } + + // 1. 解析 valueSource 引用,填充节点输入 + ProcessValueSourceRecursive(nodeInput.Config.ModelConfig.ModelRequestParams, nodeInput.Global) + + // 2. 构建系统提示词 + 用户输入 + systemPrompt := nodeInput.Config.Prompt + if systemPrompt == "" { + systemPrompt = defaultScriptTranscribeSystemPrompt + } + // 参考素材名单注入:转写模型只从名单选名,保证镜头里的角色/场景/道具名与参考素材精确一致 + // (名字绑定/类别推断都依赖名字对上) + refsName, refsItem := pipeline.ExtractRefs(nodeInput.Config.ModelConfig.ModelRequestParams) + if len(refsName) > 0 { + systemPrompt += "\n\n参考素材名单:" + strings.Join(refsName, "、") + + "\n约束:镜头里的 characters/scene/props 必须原样使用名单中的名字,不得改写、不得加修饰(如“主角小明”)、不得造新名;名单外的名字按原文输出。" + } + if totalDuration > 0 { + systemPrompt += fmt.Sprintf("\n\n视频总时长 %d 秒(MM:SS 为 %s):所有镜头的时间码需前后衔接并完整覆盖该总时长,最后一镜的 endTime 对齐到总时长。", totalDuration, formatSecondsToMMSS(totalDuration)) + } + // 单镜头时长上限约束:按视频模型推导单段最大时长注入转写提示词,从源头避免超长镜头 + //(SplitOversized 仍是机械兜底);推导失败仅降级跳过约束注入,不影响转写主流程。 + if maxSeg, _, err := split_shots_pipeline.SegmentBounds(ctx, modelId); err != nil { + g.Log().Warningf(ctx, "获取视频模型单段时长约束失败,跳过单镜时长约束注入: %v", err) + } else { + systemPrompt += shotDurationConstraintPrompt(maxSeg) + } + + info, err := gateway.GetModelInfoById(ctx, &gateway.GetModelInfoByIdReq{ModelId: nodeInput.Config.ModelConfig.ModelId}) + if err != nil { + return nil, err + } + params := map[string]any{ + "system_prompt": systemPrompt, + } + // 结构化输出:chat 模型映射配置了 response_format(StructuredOutput)即走原生 json_schema 保证结构; + // 否则模型 content 直接出 JSON,靠容错解析兜底 + val, ok := info.ModelManage.RequestBusinessFieldMapping["response_format"] + if ok { + if !g.IsEmpty(val) { + params["response_format"] = pipeline.ShotsStructuredFormat() + } + } + modelInfo, err := gateway.GetModelInfoById(ctx, &gateway.GetModelInfoByIdReq{ModelId: nodeInput.Config.ModelConfig.ModelId}) + if err != nil { + return "", fmt.Errorf("获取模型配置失败: %w", err) + } + result, err := gateway.ModelCallResult(ctx, nodeInput.Config.ModelConfig.ModelId, modelInfo.ModelManage.ResponseType, nodeInput.Global.SessionId, nodeInput.Config.ModelConfig.ModelRequestParams, params) + if err != nil { + return nil, err + } + + var content string + for _, v := range gconv.Map(result.Content) { + content += v.(string) + } + // 4. 解析镜头数组(兼容英文键结构化输出与中文键 content 兜底) + shots, err := unmarshalShots(content) + if err != nil { + return nil, err + } + + // 5. 产出固定结构 {"shots": [...]} + var arr []any + if b, err := json.Marshal(shots); err == nil { + _ = json.Unmarshal(b, &arr) + } + + args := split_shots_pipeline.SplitShotsInput{ + ModelID: modelId, + Shots: shots, + TotalDuration: totalDuration, + FlatRefs: refsItem, + Seed: nodeInput.Global.ExecutionId % 1000000, + NegativePrompt: nodeInput.Config.NegativePrompt, + } + data, err := processor.Call(ctx, "split_shots_pipeline", gconv.Map(args)) + if err != nil { + return nil, err + } + nodeInput.Config.OutputResult = gconv.Maps(data) + return nodeInput, nil +} + +// unmarshalShots 解析镜头数组 JSON,容忍 markdown 代码围栏、json_schema 结构化输出的 {"shots":[...]} 包装, +// 以及模型按中文键输出(时间码/事件/台词旁白/景别/运镜/出演角色/场景/道具)的容错映射。 +func unmarshalShots(s string) ([]pipeline.Shot, error) { + s = strings.TrimSpace(s) + if strings.HasPrefix(s, "```") { + s = strings.TrimPrefix(s, "```json") + s = strings.TrimPrefix(s, "```") + s = strings.TrimSuffix(s, "```") + s = strings.TrimSpace(s) + } + var raw []map[string]any + if err := json.Unmarshal([]byte(s), &raw); err == nil && len(raw) > 0 { + return parseShots(raw) + } + var wrapped struct { + Shots []map[string]any `json:"shots"` + } + if err := json.Unmarshal([]byte(s), &wrapped); err == nil && len(wrapped.Shots) > 0 { + return parseShots(wrapped.Shots) + } + return nil, fmt.Errorf("解析分镜脚本失败: %v", s) +} + +// parseShots 把原始镜头对象数组归一为 domain.Shot,跳过没有内容字段的镜头。 +func parseShots(raw []map[string]any) ([]pipeline.Shot, error) { + shots := make([]pipeline.Shot, 0, len(raw)) + for i, m := range raw { + shot := shotFromMap(m) + if shot.Index == 0 { + shot.Index = i + 1 + } + if isEmptyShot(shot) { + continue + } + shots = append(shots, shot) + } + if len(shots) == 0 { + return nil, fmt.Errorf("解析分镜脚本失败: 镜头内容为空") + } + return shots, nil +} + +// isEmptyShot 镜头是否没有可用内容(仅有时间码/序号,或字段名对不上导致全空)。 +func isEmptyShot(s pipeline.Shot) bool { + return s.Event == "" && s.Dialogue == "" && s.Narration == "" && s.AmbientSound == "" && + s.CameraMovement == "" && s.ShotSize == "" && s.Scene == "" && + len(s.Characters) == 0 && len(s.Props) == 0 +} + +// shotFromMap 把单个镜头对象映射为 domain.Shot,兼容英文键(结构化输出)与中文键(提示词兜底输出)。 +func shotFromMap(m map[string]any) pipeline.Shot { + get := func(keys ...string) string { + for _, k := range keys { + switch v := m[k].(type) { + case string: + if t := strings.TrimSpace(v); t != "" { + return t + } + case float64: + return gconv.String(v) + } + } + return "" + } + getSlice := func(keys ...string) []string { + for _, k := range keys { + switch v := m[k].(type) { + case []any: + var out []string + for _, e := range v { + if t, ok := e.(string); ok && strings.TrimSpace(t) != "" { + out = append(out, strings.TrimSpace(t)) + } + } + if len(out) > 0 { + return out + } + case string: + if out := splitList(v); len(out) > 0 { + return out + } + } + } + return nil + } + + shot := pipeline.Shot{ + Index: gconv.Int(m["index"]), + StartTime: get("startTime", "开始时间"), + EndTime: get("endTime", "结束时间"), + Event: get("event", "事件"), + Dialogue: get("dialogue", "台词"), + Narration: get("narration", "旁白"), + AmbientSound: get("ambientSound", "环境音"), + CameraMovement: get("cameraMovement", "运镜"), + ShotSize: get("shotSize", "景别"), + Scene: get("scene", "场景"), + Characters: getSlice("characters", "出演角色"), + Props: getSlice("props", "道具"), + } + if shot.StartTime == "" && shot.EndTime == "" { + shot.StartTime, shot.EndTime = splitTimeRange(get("时间码")) + } + if shot.Dialogue == "" && shot.Narration == "" { + shot.Dialogue, shot.Narration = splitDialogueNarration(get("台词/旁白")) + } + return shot +} + +// formatSecondsToMMSS 秒转 MM:SS 时间码。 +func formatSecondsToMMSS(sec int) string { + if sec < 0 { + sec = 0 + } + return fmt.Sprintf("%02d:%02d", sec/60, sec%60) +} + +// splitTimeRange 解析时间码 "MM:SS-MM:SS"(兼容 "—"/"~"/"到" 等分隔,或单个时间点)。 +func splitTimeRange(s string) (start, end string) { + if s == "" { + return "", "" + } + normalized := strings.NewReplacer("—", "-", "–", "-", "~", "-", "~", "-", "到", "-", "至", "-").Replace(s) + parts := strings.Split(normalized, "-") + start = strings.TrimSpace(parts[0]) + if len(parts) > 1 { + end = strings.TrimSpace(parts[1]) + } else { + end = start + } + return start, end +} + +// splitDialogueNarration 把"台词/旁白"合字段拆成 dialogue 与 narration: +// 以"旁白"/"画外音"开头的内容归为旁白,其余视为角色开口的主台词。 +func splitDialogueNarration(s string) (dialogue, narration string) { + s = strings.TrimSpace(s) + switch { + case strings.HasPrefix(s, "旁白"): + return "", strings.TrimSpace(strings.TrimLeft(strings.TrimPrefix(s, "旁白"), "::")) + case strings.HasPrefix(s, "画外音"): + return "", strings.TrimSpace(strings.TrimLeft(strings.TrimPrefix(s, "画外音"), "::")) + default: + return s, "" + } +} + +// splitList 按常见分隔符拆分人名/道具列表(兼容中英文顿号、逗号、分号、"和""及"等)。 +func splitList(s string) []string { + repl := strings.NewReplacer("、", "|", ",", "|", ",", "|", ";", "|", ";", "|", "和", "|", "及", "|", "&", "|", "/", "|", " ", "|") + var out []string + for _, p := range strings.Split(repl.Replace(strings.TrimSpace(s)), "|") { + if t := strings.TrimSpace(p); t != "" { + out = append(out, t) + } + } + return out +} + +// shotDurationConstraintPrompt 生成"单个镜头时长不超过 maxSeg 秒"的转写约束提示词片段;maxSeg<=0 返回空串。 +func shotDurationConstraintPrompt(maxSeg int) string { + if maxSeg <= 0 { + return "" + } + return fmt.Sprintf("\n\n单个镜头时长不超过 %d 秒:每镜的 startTime 与 endTime 之差必须 ≤ %d 秒。", maxSeg, maxSeg) +} diff --git a/workflow/service/flow/lambda_value_source.go b/workflow/service/flow/lambda_value_source.go new file mode 100644 index 0000000..90df922 --- /dev/null +++ b/workflow/service/flow/lambda_value_source.go @@ -0,0 +1,287 @@ +package flow + +import ( + "ai-agent/workflow/consts/node" + flowDto "ai-agent/workflow/model/dto/flow" + "regexp" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" + "github.com/tidwall/gjson" +) + +var ( + // 匹配 [数字] + regNumIndex = regexp.MustCompile(`\[\d+\]`) + // 匹配 .attrs + regAttrs = regexp.MustCompile(`\.attrs`) +) + +// CleanFieldPath 清理字段路径:移除 .attrs、数字下标转为 [*] +// 示例:usage.attrs.total_tokens → usage.total_tokens +// 示例:choices.attrs[0].attrs.message.attrs.content → choices[*].message.content +func CleanFieldPath(path string) string { + // 1. 替换 [数字] 为 [*] + s := regNumIndex.ReplaceAllString(path, `.#`) + // 2. 移除所有 .attrs + s = regAttrs.ReplaceAllString(s, "") + return s +} + +// UnwrapSchemaWrapper 递归剥掉 json-schema-editor 输出的 {type, value/attrs} 包裹层, +// 只保留干净的 key/value 嵌套结构。 +// 示例: +// +// {"a": {"type":"string","value":"hi"}} → {"a": "hi"} +// {"b": {"type":"object","attrs":{"c":1}}} → {"b": {"c": 1}} +// {"arr": {"type":"array","attrs":[{"type":"number","value":1}]}} → {"arr": [1]} +func UnwrapSchemaWrapper(v any) any { + switch val := v.(type) { + case map[string]any: + // 识别包裹节点:{type: "", value/attrs: <实际值>, ...} + if t, ok := val["type"].(string); ok && isSchemaEditorType(t) { + dataKey := "value" + if t == "object" || t == "array" { + dataKey = "attrs" + } + if raw, has := val[dataKey]; has { + return UnwrapSchemaWrapper(raw) + } + } + res := make(map[string]any, len(val)) + for k, child := range val { + res[k] = UnwrapSchemaWrapper(child) + } + return res + case []any: + res := make([]any, len(val)) + for i, item := range val { + res[i] = UnwrapSchemaWrapper(item) + } + return res + default: + return val + } +} + +// isSchemaEditorType 是否为 json-schema-editor 的 6 种类型标识 +func isSchemaEditorType(t string) bool { + switch t { + case "string", "number", "boolean", "null", "object", "array": + return true + } + return false +} + +// MapResultByTemplate 按 template 定义的结构,从 source 中拷贝对应字段的值。 +// 只保留 template 里出现的字段:对象字段按同名字段递归拷贝,标量/数组字段直接拷贝 source 的值。 +func MapResultByTemplate(template map[string]any, source map[string]any) map[string]any { + result := make(map[string]any, len(template)) + for key, tmplVal := range template { + srcVal, ok := source[key] + if !ok { + continue + } + if tmplMap, isMap := tmplVal.(map[string]any); isMap { + if srcMap, isMap := srcVal.(map[string]any); isMap { + result[key] = MapResultByTemplate(tmplMap, srcMap) + } + continue + } + result[key] = srcVal + } + return result +} + +// ProcessValueSourceRecursive 递归遍历map,同级同时存在value和valueSource则把value设置为"AA" +func ProcessValueSourceRecursive(rawParams map[string]interface{}, globalParams *flowDto.FlowExecutionInput) { + walkMap(rawParams, globalParams) +} + +// resolveValueSource 解析 valueSource {nodeId, fieldName} 引用的实际值。 +// 返回 (value, refsName, ok);ok=false 表示引用节点不存在或引用值仍为空。 +// - 开始/表单节点:OutputConfig 平铺条目按 field == fieldName 匹配(前端约定以 field 为主, +// 不兼容 path),直接读 entry 的 value / refsName +// - scriptTranscribe 节点:读 OutputResult 的 shots +// - 其他节点:读 OutputResult 中 fieldName 路径对应的值 +func resolveValueSource(global *flowDto.FlowExecutionInput, nodeId, fieldName string) (value any, refsName any, ok bool) { + if global == nil || global.ConfigMap == nil { + return nil, nil, false + } + nodeConfig := global.ConfigMap[nodeId] + if nodeConfig == nil { + return nil, nil, false + } + switch nodeConfig.NodeCode { + case node.NodeTypeStart, node.NodeTypeForm: + for _, output := range nodeConfig.OutputConfig { + if gconv.String(output["field"]) != fieldName { + continue + } + if !g.IsEmpty(output["value"]) { + return output["value"], output["refsName"], true + } + } + case node.NodeTypeScriptTranscribe: + for _, output := range nodeConfig.OutputResult { + value := gjson.Get(gconv.String(output), CleanFieldPath("shots")).Value() + if !g.IsEmpty(value) { + return value, gjson.Get(gconv.String(output), CleanFieldPath("refsName")).Value(), true + } + } + default: + for _, output := range nodeConfig.OutputResult { + value := gjson.Get(gconv.String(output), CleanFieldPath(fieldName)).Value() + if !g.IsEmpty(value) { + return value, gjson.Get(gconv.String(output), CleanFieldPath("refsName")).Value(), true + } + } + } + return nil, nil, false +} + +// walkMap 递归处理map/数组 +func walkMap(data interface{}, globalParams *flowDto.FlowExecutionInput) { + switch v := data.(type) { + case map[string]interface{}: + // 当前对象同时存在 value 和 valueSource + if valueSource, hasSource := v["valueSource"]; hasSource { + mapValueSource := gconv.Map(valueSource) + nodeId := gconv.String(mapValueSource["nodeId"]) + fieldName := gconv.String(mapValueSource["fieldName"]) + if fieldName == "" { + fieldName = gconv.String(mapValueSource["field"]) + } + if nodeId != "" && fieldName != "" { + if value, refsName, ok := resolveValueSource(globalParams, nodeId, fieldName); ok { + v["value"] = value + if !g.IsEmpty(refsName) { + v["refsName"] = refsName + } + return + } + } + } + // 递归遍历所有子元素 + for _, child := range v { + walkMap(child, globalParams) + } + case []interface{}: + // 数组遍历 + for _, item := range v { + walkMap(item, globalParams) + } + } +} + +// CleanEmptyModelParams 剔除模型请求参数中 value 为空的字段; +// 数组/枚举(attrs / enumValues)元素整体为空时移除整个元素。0/false 视为有效值。 +func CleanEmptyModelParams(params map[string]interface{}) { + cleanSchemaMap(params) +} + +// cleanSchemaMap 递归清理普通 map:包装节点按 schema 语义清理,空字段删除 +func cleanSchemaMap(m map[string]interface{}) { + for key, val := range m { + switch v := val.(type) { + case map[string]interface{}: + if isSchemaWrapperNode(v) { + cleanSchemaWrapper(v) + if isSchemaNodeEmpty(v) { + delete(m, key) + } + } else { + cleanSchemaMap(v) + } + case []interface{}: + m[key] = cleanSchemaSlice(v) + } + } +} + +// cleanSchemaWrapper 清理单个 {type,...} 包装节点:递归 value / attrs / enumValues 容器 +func cleanSchemaWrapper(node map[string]interface{}) { + if mv, ok := node["value"].(map[string]interface{}); ok { + cleanSchemaMap(mv) + } + if lv, ok := node["value"].([]interface{}); ok { + node["value"] = cleanSchemaSlice(lv) + } + if attrs, ok := node["attrs"].(map[string]interface{}); ok { + cleanSchemaMap(attrs) + } + if attrs, ok := node["attrs"].([]interface{}); ok { + node["attrs"] = cleanSchemaSlice(attrs) + } + if evs, ok := node["enumValues"].([]interface{}); ok { + node["enumValues"] = cleanSchemaSlice(evs) + } +} + +// cleanSchemaSlice 清理数组/枚举元素,元素为包装节点且整体为空时移除 +func cleanSchemaSlice(list []interface{}) []interface{} { + i := 0 + for i < len(list) { + if item, ok := list[i].(map[string]interface{}); ok { + if isSchemaWrapperNode(item) { + cleanSchemaWrapper(item) + if isSchemaNodeEmpty(item) { + list = append(list[:i], list[i+1:]...) + continue + } + } else { + cleanSchemaMap(item) + } + } + i++ + } + return list +} + +// isSchemaWrapperNode 是否为 {type: } 包装节点 +func isSchemaWrapperNode(m map[string]interface{}) bool { + t, ok := m["type"].(string) + return ok && isSchemaEditorType(t) +} + +// isSchemaNodeEmpty 判断 schema 节点是否已无有效内容: +// 标量看 value(0/false 有效);object/array 看 value/attrs/enumValues 容器是否都为空 +func isSchemaNodeEmpty(node map[string]interface{}) bool { + t, _ := node["type"].(string) + switch t { + case "object": + return schemaContainerEmpty(node, "value") && schemaContainerEmpty(node, "attrs") + case "array": + return schemaContainerEmpty(node, "value") && schemaContainerEmpty(node, "attrs") && schemaContainerEmpty(node, "enumValues") + default: + return schemaValueEmpty(node["value"]) + } +} + +// schemaContainerEmpty 容器(value/attrs/enumValues)是否为空 +func schemaContainerEmpty(node map[string]interface{}, key string) bool { + switch v := node[key].(type) { + case []interface{}: + return len(v) == 0 + case map[string]interface{}: + return len(v) == 0 + default: + return v == nil + } +} + +// schemaValueEmpty 值是否为空;0/false 视为有效值不剔除 +func schemaValueEmpty(v interface{}) bool { + switch val := v.(type) { + case nil: + return true + case string: + return val == "" + case []interface{}: + return len(val) == 0 + case map[string]interface{}: + return len(val) == 0 + default: + return false + } +} diff --git a/workflow/service/flow/processor/builtin/media/media.go b/workflow/service/flow/processor/builtin/media/media.go new file mode 100644 index 0000000..bc65a45 --- /dev/null +++ b/workflow/service/flow/processor/builtin/media/media.go @@ -0,0 +1,261 @@ +package video + +import ( + "ai-agent/workflow/service/flow/processor" + "context" + "fmt" + "strings" + "time" + + commonHttp "gitea.redpowerfuture.com/red-future/common/http" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" +) + +// TaskKind 媒体任务类型(拼接/拼接+混音),决定提交与查询的接口路径 +type TaskKind string + +const ( + TaskKindConcat TaskKind = "concat" // 纯拼接,无 BGM + TaskKindMerge TaskKind = "merge" // 拼接+混音,有 BGM +) + +// MergeTask media 服务异步任务状态 +type MergeTask struct { + TaskID string `json:"taskId"` + Status string `json:"status"` // pending/running/success/failed + FileURL string `json:"fileURL,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` + DurationStr string `json:"durationStr,omitempty"` +} + +type mergeSubmitReq struct { + VideoURLs []string `json:"video_urls"` + AudioURLs []string `json:"audio_urls,omitempty"` + Method string `json:"method,omitempty"` + Upload bool `json:"upload"` + CallbackURL string `json:"callback_url"` +} + +type mergeSubmitRes struct { + TaskID string `json:"taskId"` +} + +func init() { + processor.Register(ConcatVideosProcessor()) +} + +// ConcatVideosProcessor 合并视频 +func ConcatVideosProcessor() *processor.Processor { + return &processor.Processor{ + Name: "concat_videos", + Description: "合并视频", + Func: func(ctx context.Context, args map[string]any) (any, error) { + outputRes := parseOutputList(args) + segments, err := collectSegmentResults(outputRes) + if err != nil { + return nil, err + } + + videoURLs := make([]string, 0, len(segments)) + for _, seg := range segments { + videoURLs = append(videoURLs, seg.VideoURL) + } + + reqParams := gconv.Map(args["request"]) + bgmURLs := gconv.Strings(reqParams["bgm_urls"]) + if len(bgmURLs) == 0 { + // 兼容串行结果把 bgm 带回 output 的情况 + for _, m := range outputRes { + bgmURLs = append(bgmURLs, gconv.Strings(m["bgm_urls"])...) + } + } + upload := gconv.Bool(reqParams["upload"]) + callback := gconv.String(reqParams["callback_url"]) + + merged, err := MergeSegments(ctx, videoURLs, bgmURLs, upload, callback, 30*time.Minute) + if err != nil { + return nil, err + } + return map[string]any{ + "video_url": merged.FileURL, + "duration_str": merged.DurationStr, + "task_id": merged.TaskID, + }, nil + }, + } +} + +// parseOutputList 兼容不同反序列化形态的 output 列表 +func parseOutputList(args map[string]any) []map[string]any { + switch v := args["output"].(type) { + case []map[string]any: + return v + case []any: + out := make([]map[string]any, 0, len(v)) + for _, it := range v { + if m, ok := it.(map[string]any); ok { + out = append(out, m) + } else if m := gconv.Map(it); m != nil { + out = append(out, m) + } + } + return out + } + return nil +} + +// MergeSegments 把分段视频按序合并:有 BGM 走拼接+混音(merge),否则纯拼接(concat)。 +// 返回最终合并结果(FileURL)。 +func MergeSegments(ctx context.Context, videoURLs, bgmURLs []string, upload bool, callbackURL string, timeout time.Duration) (*MergeTask, error) { + var kind TaskKind + var taskID string + var err error + if len(bgmURLs) > 0 { + kind = TaskKindMerge + taskID, err = SubmitMergeAsync(ctx, videoURLs, bgmURLs, upload, callbackURL) + } else { + kind = TaskKindConcat + taskID, err = SubmitConcatAsync(ctx, videoURLs, upload, callbackURL) + } + if err != nil { + return nil, err + } + return WaitMediaTask(ctx, kind, taskID, timeout) +} + +// SubmitConcatAsync 提交纯拼接异步任务 +func SubmitConcatAsync(ctx context.Context, videoURLs []string, upload bool, callbackURL string) (string, error) { + return submitMediaTask(ctx, TaskKindConcat, &mergeSubmitReq{ + VideoURLs: videoURLs, + Method: "auto", + Upload: upload, + CallbackURL: callbackURL, + }) +} + +// SubmitMergeAsync 提交拼接+混音异步任务 +func SubmitMergeAsync(ctx context.Context, videoURLs, audioURLs []string, upload bool, callbackURL string) (string, error) { + return submitMediaTask(ctx, TaskKindMerge, &mergeSubmitReq{ + VideoURLs: videoURLs, + AudioURLs: audioURLs, + Upload: upload, + CallbackURL: callbackURL, + }) +} + +func submitMediaTask(ctx context.Context, kind TaskKind, req *mergeSubmitReq) (string, error) { + path := "media/video/" + string(kind) + "/async" + res := new(mergeSubmitRes) + if err := commonHttp.Post(ctx, path, requestHeaders(ctx), res, req); err != nil { + return "", fmt.Errorf("提交%s任务失败: %v", kind, err) + } + if res.TaskID == "" { + return "", fmt.Errorf("media 返回空 taskId") + } + return res.TaskID, nil +} + +// WaitMediaTask 轮询媒体任务直到 success/failed,或超时。 +func WaitMediaTask(ctx context.Context, kind TaskKind, taskID string, timeout time.Duration) (*MergeTask, error) { + deadline := time.Now().Add(timeout) + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + for { + t, err := getMediaTask(ctx, kind, taskID) + if err == nil { + switch t.Status { + case "success": + if t.FileURL == "" { + return nil, fmt.Errorf("%s任务成功但未返回文件URL", kind) + } + return t, nil + case "failed": + return nil, fmt.Errorf("%s任务失败: %s", kind, t.ErrorMessage) + } + } + if time.Now().After(deadline) { + return nil, fmt.Errorf("%s任务[%s]超时", kind, taskID) + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + } + } +} + +func getMediaTask(ctx context.Context, kind TaskKind, taskID string) (*MergeTask, error) { + path := "media/video/" + string(kind) + "/task/" + taskID + res := new(MergeTask) + if err := commonHttp.Get(ctx, path, requestHeaders(ctx), res); err != nil { + return nil, err + } + return res, nil +} + +// requestHeaders 透传当前请求头(含 Authorization / X-User-Info),供内部服务鉴权使用 +func requestHeaders(ctx context.Context) map[string]string { + 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] + } + } + } + return headers +} + +// segmentResult 一段视频生成的产出(原 ai-agent/video/plan.SegmentResult,plan 包已并入处理器树)。 +type segmentResult struct { + SegmentIndex int `json:"segment_index"` + VideoURL string `json:"video_url"` + Duration int `json:"duration"` +} + +// collectSegmentResults 把模型节点/串行工具的产出([]map[string]any)收敛为有序的分段结果列表。 +// 兼容两种形状:串行工具产出的 {segment_index, video_url, duration}; +// 并行模型调用产出的 {: url}(按列表顺序对应各段)。 +func collectSegmentResults(outputRes []map[string]any) ([]segmentResult, error) { + if len(outputRes) == 0 { + return nil, fmt.Errorf("没有可合并的分段视频") + } + var segs []segmentResult + for i, m := range outputRes { + seg := segmentResult{ + SegmentIndex: i, + Duration: gconv.Int(m["duration"]), + VideoURL: findVideoURL(m), + } + if idx := gconv.Int(m["segment_index"]); len(outputRes) > 1 && idx > 0 { + seg.SegmentIndex = idx + } + if seg.VideoURL == "" { + return nil, fmt.Errorf("第 %d 段未获取到视频URL", i) + } + segs = append(segs, seg) + } + return segs, nil +} + +// findVideoURL 从模型返回参数中提取视频 URL:优先命中常见键,再兜底任意含 url 的 http 字段。 +func findVideoURL(params map[string]any) string { + if params == nil { + return "" + } + for _, key := range []string{"video_url", "video_oss_url", "http_file_url", "file_url", "url"} { + if v := gconv.String(params[key]); v != "" { + return v + } + } + for k, v := range params { + if !strings.Contains(strings.ToLower(k), "url") { + continue + } + if s := gconv.String(v); s != "" && strings.HasPrefix(s, "http") { + return s + } + } + return "" +} diff --git a/workflow/service/flow/processor/builtin/split_batch/split_batch.go b/workflow/service/flow/processor/builtin/split_batch/split_batch.go new file mode 100644 index 0000000..466ce59 --- /dev/null +++ b/workflow/service/flow/processor/builtin/split_batch/split_batch.go @@ -0,0 +1,207 @@ +// Package split_batch 工作流前置处理器:按各字段 constraint.uploadTotalMaxCount 拆分模型请求参数。 +// 处理器实现自包含(算法随处理器走,不依赖业务包),通过 init 注册进 processor 注册表。 +package split_batch + +import ( + "context" + "fmt" + "sort" + + "github.com/gogf/gf/v2/util/gconv" + + "ai-agent/workflow/service/flow/processor" +) + +func init() { + processor.Register(SplitBatchModelParamsProcessor()) +} + +// SplitBatchModelParamsProcessor 将模型请求参数按各字段 constraint.uploadTotalMaxCount 分批的前置处理器。 +// 入参 args 即模型请求参数本体(需已解析好 valueSource,value 已填充)。 +func SplitBatchModelParamsProcessor() *processor.Processor { + return &processor.Processor{ + Name: "split_batch_model_params", + Description: "按 constraint.uploadTotalMaxCount 分批模型请求参数", + Func: func(ctx context.Context, args map[string]any) (any, error) { + if args == nil { + return nil, fmt.Errorf("缺少模型请求参数") + } + return SplitBatchModelParams(args), nil + }, + } +} + +// splitBatchField 描述一个需要按 constraint.uploadTotalMaxCount 分批的字段 +type splitBatchField struct { + path []any // 从参数根节点到该字段 value 的路径(map 用 key,数组用下标) + items []any // 该字段 value 拆出的待分批元素列表 + maxCount int // 每批最大元素数(constraint.uploadTotalMaxCount) +} + +// SplitBatchModelParams 把模型请求参数拆成多批,供分批请求模型使用。 +// 处理流程: +// 1. 深拷贝入参,避免污染调用方数据; +// 2. 递归解析 valueSource,把引用节点输出的字段值写入对应字段的 value; +// 3. 找出所有带 constraint.uploadTotalMaxCount 且 value 为集合(map/slice)的字段, +// map 按 key 排序取 value 列表作为元素;总批数 = 各字段 (元素数/上限) 向上取整的最大值; +// 4. 每批 = 整份参数深拷贝 + 各分批字段 value 替换为对应切片。 +// +// 未超量时返回单份(value 已被解析填充)参数。调用方可遍历返回值逐个请求模型。 +func SplitBatchModelParams(rawParams map[string]any) []map[string]any { + params, ok := deepCopyAny(rawParams).(map[string]any) + if !ok { + params = make(map[string]any) + } + + fields := make([]splitBatchField, 0) + collectBatchFields(params, nil, &fields) + + batchCount := 1 + for _, f := range fields { + n := (len(f.items) + f.maxCount - 1) / f.maxCount + if n > batchCount { + batchCount = n + } + } + if batchCount <= 1 { + return []map[string]any{params} + } + + batches := make([]map[string]any, 0, batchCount) + for i := 0; i < batchCount; i++ { + batch, _ := deepCopyAny(params).(map[string]any) + for _, f := range fields { + start := i * f.maxCount + if start >= len(f.items) { + setValueAtPath(batch, f.path, []any{}) + continue + } + end := start + f.maxCount + if end > len(f.items) { + end = len(f.items) + } + setValueAtPath(batch, f.path, f.items[start:end]) + } + batches = append(batches, batch) + } + return batches +} + +// collectBatchFields 递归收集所有带 constraint.uploadTotalMaxCount 且 value 为集合的分批字段 +func collectBatchFields(node any, path []any, out *[]splitBatchField) { + switch v := node.(type) { + case map[string]any: + if maxCount, ok := uploadTotalMaxCountOf(v); ok { + if items, has := toItems(v["value"]); has { + *out = append(*out, splitBatchField{ + path: append(append([]any{}, path...), "value"), + items: items, + maxCount: maxCount, + }) + } + } + for key, child := range v { + collectBatchFields(child, append(append([]any{}, path...), key), out) + } + case []any: + for i, child := range v { + collectBatchFields(child, append(append([]any{}, path...), i), out) + } + } +} + +// uploadTotalMaxCountOf 读取 schema 节点 constraint.uploadTotalMaxCount +func uploadTotalMaxCountOf(m map[string]any) (int, bool) { + c, ok := m["constraint"] + if !ok { + return 0, false + } + cm := gconv.Map(c) + if cm == nil { + return 0, false + } + n := gconv.Int(cm["uploadTotalMaxCount"]) + if n <= 0 { + return 0, false + } + return n, true +} + +// toItems 把集合 value 转成元素列表:切片原样返回;map 按 key 排序取 value,保证分批顺序稳定 +func toItems(v any) ([]any, bool) { + switch val := v.(type) { + case []any: + return val, len(val) > 0 + case map[string]any: + if len(val) == 0 { + return nil, false + } + keys := make([]string, 0, len(val)) + for k := range val { + keys = append(keys, k) + } + sort.Strings(keys) + items := make([]any, 0, len(keys)) + for _, k := range keys { + items = append(items, val[k]) + } + return items, true + } + return nil, false +} + +// setValueAtPath 沿 path 逐级导航(map 用 key、数组用下标),在最后一级写入 value +func setValueAtPath(root map[string]any, path []any, value any) { + if len(path) == 0 { + return + } + var cur any = root + for i := 0; i < len(path)-1; i++ { + switch step := path[i].(type) { + case string: + m, ok := cur.(map[string]any) + if !ok { + return + } + cur = m[step] + case int: + s, ok := cur.([]any) + if !ok || step < 0 || step >= len(s) { + return + } + cur = s[step] + default: + return + } + } + switch last := path[len(path)-1].(type) { + case string: + if m, ok := cur.(map[string]any); ok { + m[last] = value + } + case int: + if s, ok := cur.([]any); ok && last >= 0 && last < len(s) { + s[last] = value + } + } +} + +// deepCopyAny 深拷贝 map[string]any / []any 嵌套结构,避免批次之间互相影响 +func deepCopyAny(v any) any { + switch val := v.(type) { + case map[string]any: + res := make(map[string]any, len(val)) + for k, child := range val { + res[k] = deepCopyAny(child) + } + return res + case []any: + res := make([]any, len(val)) + for i, child := range val { + res[i] = deepCopyAny(child) + } + return res + default: + return val + } +} diff --git a/workflow/service/flow/processor/builtin/split_shots_pipeline/pipeline/errors.go b/workflow/service/flow/processor/builtin/split_shots_pipeline/pipeline/errors.go new file mode 100644 index 0000000..a2f3c55 --- /dev/null +++ b/workflow/service/flow/processor/builtin/split_shots_pipeline/pipeline/errors.go @@ -0,0 +1,29 @@ +package pipeline + +import "fmt" + +// 业务错误码(设计 §8.1)。上层可据 Code 差异化处理。 +const ( + ErrEmptyShots = "ERR_EMPTY_SHOTS" // 镜头为空 + ErrInvalidInput = "ERR_INVALID_INPUT" // 参数非法(TotalDuration/Max/Min 等) + ErrSegmentInfeasible = "ERR_SEGMENT_INFEASIBLE" // 时长拆分不可行 + ErrTimelineInvariant = "ERR_TIMELINE_INVARIANT" // 时间线不变量破坏 +) + +// PipelineError 带业务分类码的错误。Segment 关联段序号,-1 表示不特定于某段。 +type PipelineError struct { + Code string + Message string + Segment int +} + +func (e *PipelineError) Error() string { + if e.Segment >= 0 { + return fmt.Sprintf("[%s] 段%d: %s", e.Code, e.Segment, e.Message) + } + return fmt.Sprintf("[%s] %s", e.Code, e.Message) +} + +func pipeErr(code, msg string) *PipelineError { + return &PipelineError{Code: code, Message: msg, Segment: -1} +} diff --git a/workflow/service/flow/processor/builtin/split_shots_pipeline/pipeline/pipeline.go b/workflow/service/flow/processor/builtin/split_shots_pipeline/pipeline/pipeline.go new file mode 100644 index 0000000..d20c641 --- /dev/null +++ b/workflow/service/flow/processor/builtin/split_shots_pipeline/pipeline/pipeline.go @@ -0,0 +1,244 @@ +// Package pipeline 分镜 → 时间线 → 模型语言 Pipeline(设计文档 docs/superpowers/specs/2026-08-11-shots-timeline-pipeline-design.md)。 +// +// 纯函数包:只依赖 video/domain + 标准库,零 I/O、零 workflow 依赖。 +// 入口 PlanSegments 编排 ①时间线构建 → ②prompt 构建;各阶段函数均可独立调用/单测。 +package pipeline + +import ( + "strconv" + "strings" +) + +// Input 外部直接传入的生成请求参数。 +type Input struct { + Shots []Shot // 镜头脚本(调用方已归一为 domain.Shot) + TotalDuration int // 目标总时长(秒),<=0 按镜头时间码累加兜底,仍<=0 默认 60 + MaxSegmentDur int // 单段最大时长(秒),<=0 默认 15 + MinSegmentDur int // 单段最小时长(秒),<=0 默认 5 + Refs Refs // 参考素材(角色/场景/道具/产品,具名) + Seed int64 // 随机种子基数,各段 = Seed + 段序号 + NegativePrompt string // 全局负面 prompt(单段可覆盖,见 §7.5) + Cfg Config // 阈值/语速/容差等统一配置,零值取 DefaultConfig() + TokenCfg TokenConfig // 实体名替换 token 的生成配置 +} + +// TokenConfig token 前缀策略:按素材媒体类型(视频/图片/音频)分别配置。 +type TokenConfig struct { + // 视频素材前缀模板,如 "video%d"。为空用默认前缀 "video" + 独立编号。 + VideoTemplate string + // 图片素材前缀模板,如 "img%d"。为空用默认前缀 "image" + 独立编号。 + ImageTemplate string + // 音频素材前缀模板,如 "audio%d"。为空用默认前缀 "audio" + 独立编号。 + AudioTemplate string +} + +// Refs 参考素材,与 domain/plan 的 Refs 概念一致(自包含定义,不依赖 plan)。 +type Refs struct { + Characters []RefItem `json:"characters,omitempty"` + Scenes []RefItem `json:"scenes,omitempty"` + Props []RefItem `json:"props,omitempty"` + Products []RefItem `json:"products,omitempty"` +} + +// RefItem 一个具名参考素材。Weight 选择权重(缺省按类别 演员4/场景3/道具2/产品1)。 +type RefItem struct { + Name string `json:"name"` + URL string `json:"url"` + Weight int `json:"weight,omitempty"` +} + +// Lookup 按类别和名称查找参考图 URL,未找到返回空串。 +func (r Refs) Lookup(category, name string) string { + var list []RefItem + switch category { + case catCharacter: + list = r.Characters + case catScene: + list = r.Scenes + case catProp: + list = r.Props + case catProduct: + list = r.Products + } + for _, it := range list { + if it.Name == name { + return it.URL + } + } + return "" +} + +// ExtractRefs 递归收集请求参数中散布的参考素材对象({refsName: 名称, value: URL},无固定数组 key)。 +// 返回名称列表(去重保序,供转写提示词注入参考素材名单)与具名素材列表(RefItem{Name,URL})。 +// 处理器解析与转写节点取素材复用同一入口。 +func ExtractRefs(v any) (names []string, items []RefItem) { + seenName := map[string]bool{} + seenItem := map[string]bool{} + var walk func(any) + walk = func(v any) { + switch val := v.(type) { + case map[string]any: + if name := refString(val["refsName"]); name != "" { + if !seenName[name] { + seenName[name] = true + names = append(names, name) + } + url := refString(val["value"]) + if url == "" { + url = refString(val["url"]) + } + key := name + "\x00" + url + if !seenItem[key] { + seenItem[key] = true + items = append(items, RefItem{Name: name, URL: url}) + } + } + for _, child := range val { + walk(child) + } + case []any: + for _, item := range val { + walk(item) + } + } + } + walk(v) + return names, items +} + +// refString 从 JSON/gconv 值中取字符串,数字按原样转串(避免 URL 被科学计数法破坏)。 +func refString(v any) string { + switch t := v.(type) { + case string: + return strings.TrimSpace(t) + case float64: + return strconv.FormatFloat(t, 'f', -1, 64) + } + return "" +} + +// RefBinding token 与参考素材的显式绑定(设计 P0-4)。 +type RefBinding struct { + Token string // "video1"(媒体类型前缀 + 独立编号) + Entity string // 原始实体名(归一前) + Category string // 演员/场景/道具/产品 + URL string +} + +// Segment 一个视频生成段:时间轴、段内镜头、prompt、参考素材。 +type Segment struct { + Index int // 段序号(从 0 起) + StartSec int // 段在全局时间轴上的起点(秒) + Duration int // 段时长(秒) + Shots []Shot // 段内镜头(已回写对齐,不跨段) + Prompt string // 实体名→token 替换后的 prompt 文本 + Refs []RefBinding // 参考素材显式绑定(token→URL),reference_urls/reference_labels 与 prompt 都由它生成 + NegativePrompt string // 本段负面 prompt(默认继承 Input.NegativePrompt,可被段内镜头覆盖) + Seed int64 +} + +const ( + catCharacter = "演员" + catScene = "场景" + catProp = "道具" + catProduct = "产品" +) + +// NormalizeInput 补齐时长/阈值零值:总时长<=0 按镜头时间码累加兜底(仍<=0 默认 60), +// MaxSegmentDur<=0 默认 15、MinSegmentDur<=0 默认 5,且 min>max 时收敛 min=max。 +func NormalizeInput(in Input) Input { + total := in.TotalDuration + if total <= 0 { + total = sumShotDurations(in.Shots) + } + if total <= 0 { + total = 60 + } + maxSeg := in.MaxSegmentDur + if maxSeg <= 0 { + maxSeg = 15 + } + minSeg := in.MinSegmentDur + if minSeg <= 0 { + minSeg = 5 + } + if minSeg > maxSeg { + minSeg = maxSeg + } + in.TotalDuration = total + in.MaxSegmentDur = maxSeg + in.MinSegmentDur = minSeg + return in +} + +// PlanSegments 编排①时间线构建 → ②prompt 构建(含参考素材绑定),产出各段。 +// 适配层(workflow 前置处理器)产出 FLAT 请求参数、不产嵌套请求体,直接调用本函数。 +func PlanSegments(in Input) ([]Segment, error) { + if len(in.Shots) == 0 { + return nil, pipeErr(ErrEmptyShots, "镜头为空") + } + segShots, segDurs, err := BuildTimeline(in) + if err != nil { + return nil, err + } + + var allShots []Shot + for _, ss := range segShots { + allShots = append(allShots, ss...) + } + registry := NewTokenRegistry(allShots, in.Refs, in.TokenCfg) + + segs := make([]Segment, 0, len(segShots)) + startSec := 0 + for i, ss := range segShots { + prompt, segRefs := BuildSegmentPrompt(ss, registry, in) + segs = append(segs, Segment{ + Index: i, + StartSec: startSec, + Duration: segDurs[i], + Shots: ss, + Prompt: prompt, + Refs: segRefs, + NegativePrompt: buildSegmentNegativePrompt(ss, in.NegativePrompt), + Seed: in.Seed + int64(i), + }) + startSec += segDurs[i] + } + return segs, nil +} + +func sumShotDurations(shots []Shot) int { + total := 0 + for _, sh := range shots { + total += parseSec(sh.EndTime) - parseSec(sh.StartTime) + } + return total +} + +// buildSegmentNegativePrompt 按 §7.5 计算本段负面 prompt: +// 默认继承全局;段内镜头带负面时叠加;镜头负面以 "-" 前缀开头时仅用镜头自身的(抑制全局)。 +func buildSegmentNegativePrompt(shots []Shot, global string) string { + var shotNps []string + useOnlyShot := false + for _, sh := range shots { + np := strings.TrimSpace(sh.NegativePrompt) + if np == "" { + continue + } + if strings.HasPrefix(np, "-") { + shotNps = append(shotNps, strings.TrimPrefix(np, "-")) + useOnlyShot = true + } else { + shotNps = append(shotNps, np) + } + } + if len(shotNps) == 0 { + return global + } + var parts []string + if !useOnlyShot && global != "" { + parts = append(parts, global) + } + parts = append(parts, shotNps...) + return strings.Join(parts, "\n") +} diff --git a/workflow/service/flow/processor/builtin/split_shots_pipeline/pipeline/prompt.go b/workflow/service/flow/processor/builtin/split_shots_pipeline/pipeline/prompt.go new file mode 100644 index 0000000..e75cdf7 --- /dev/null +++ b/workflow/service/flow/processor/builtin/split_shots_pipeline/pipeline/prompt.go @@ -0,0 +1,473 @@ +package pipeline + +import ( + "fmt" + "sort" + "strconv" + "strings" + "unicode/utf8" +) + +// ---------- 实体名归一(四审 P0-1)---------- + +// 素材媒体类型(token 前缀与独立编号基准)。 +const ( + mediaVideo = "video" + mediaImage = "image" + mediaAudio = "audio" +) + +// MediaTypeOf 按 URL 扩展名判定素材媒体类型;无法判定返回 ("", false)。 +func MediaTypeOf(url string) (string, bool) { + u := url + if i := strings.IndexAny(u, "?#"); i >= 0 { + u = u[:i] + } + switch strings.ToLower(pathExt(u)) { + case ".mp4", ".mov", ".avi", ".webm", ".mkv": + return mediaVideo, true + case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp": + return mediaImage, true + case ".mp3", ".wav", ".ogg", ".aac", ".m4a", ".flac": + return mediaAudio, true + } + return "", false +} + +// pathExt 提取路径最后一段的扩展名(含点),无则返回空串。 +func pathExt(url string) string { + for i := len(url) - 1; i >= 0; i-- { + if url[i] == '/' { + break + } + if url[i] == '.' { + return url[i:] + } + } + return "" +} + +// normalizeEntityName 归一清洗实体名,返回注册表唯一标准名: +// 去括号注释 → 统一全半角 → 去首尾标点 → 英文小写。 +func normalizeEntityName(name string) string { + name = strings.TrimSpace(name) + name = toHalfWidth(name) + name = stripBrackets(name) + name = strings.Trim(name, "。,!?;:、()【】「」《》…,.!?;:\"'` \t\n\r") + return strings.ToLower(name) +} + +// toHalfWidth 全角字母数字/符号转半角,全角空格转半角空格。 +func toHalfWidth(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch { + case r == ' ': + b.WriteRune(' ') + case r >= '!' && r <= '~': + b.WriteRune(r - 0xfee0) + default: + b.WriteRune(r) + } + } + return b.String() +} + +// stripBrackets 剔除括号注释,如 "小红(女主)" → "小红"。 +func stripBrackets(s string) string { + for { + next := stripBracketsOnce(s) + if next == s { + return s + } + s = next + } +} + +var bracketPairs = []struct{ open, close rune }{ + {'(', ')'}, {'(', ')'}, {'[', ']'}, {'【', '】'}, {'「', '」'}, {'《', '》'}, +} + +func stripBracketsOnce(s string) string { + rs := []rune(s) + for i := 0; i < len(rs); i++ { + for _, p := range bracketPairs { + if rs[i] != p.open { + continue + } + depth := 1 + for j := i + 1; j < len(rs); j++ { + if rs[j] == p.open { + depth++ + } + if rs[j] == p.close { + depth-- + if depth == 0 { + out := make([]rune, 0, len(rs)-(j-i+1)) + out = append(out, rs[:i]...) + out = append(out, rs[j+1:]...) + return string(out) + } + } + } + // 无匹配闭合 → 去掉从此处到末尾 + return string(rs[:i]) + } + } + return s +} + +// ---------- TokenRegistry(全局稳定编号,设计 §6.1 + P2-4)---------- + +type entity struct { + Category string // 演员/场景/道具/产品 + Canonical string // 归一化标准名(注册表唯一 key 的一部分) + Aliases []string // 原始异形体(供文本替换匹配) + URL string + Weight int + MediaType string // 素材媒体类型:video/image/audio(注册时按 URL 判定) + order int // 全局首次出现顺序 +} + +func entKey(ent *entity) string { + return ent.Category + "/" + ent.Canonical +} + +// TokenRegistry 一次执行内的全局 token 注册表:同一实体在所有段使用同一 token。 +type TokenRegistry struct { + entities map[string]*entity // key = 类别/标准名 + order []*entity // 注册顺序(token 编号基准) + tokenByKey map[string]string // key → token + aliasToToken map[string]string // 原始异形体 → token + tc TokenConfig + refs Refs +} + +// NewTokenRegistry 遍历全部镜头(+ refs 产品)建立注册表。无 URL 的实体不进注册表(§6.3 保留原名)。 +func NewTokenRegistry(allShots []Shot, refs Refs, tc TokenConfig) *TokenRegistry { + reg := &TokenRegistry{ + entities: map[string]*entity{}, + tokenByKey: map[string]string{}, + aliasToToken: map[string]string{}, + tc: tc, + refs: refs, + } + for _, sh := range allShots { + for _, c := range sh.Characters { + reg.register(catCharacter, c) + } + if sh.Scene != "" { + reg.register(catScene, sh.Scene) + } + for _, p := range sh.Props { + reg.register(catProp, p) + } + } + // 分析替换业务:产品图常驻注册表 + for _, p := range refs.Products { + reg.register(catProduct, p.Name) + } + return reg +} + +func (r *TokenRegistry) register(category, raw string) { + raw = strings.TrimSpace(raw) + if raw == "" { + return + } + canonical := normalizeEntityName(raw) + if canonical == "" { + return + } + key := category + "/" + canonical + if ent, ok := r.entities[key]; ok { + r.recordAlias(ent, raw) + return + } + url := r.refs.Lookup(category, raw) + if url == "" { + url = r.refs.Lookup(category, canonical) + } + if url == "" { + return + } + media, ok := MediaTypeOf(url) + if !ok { + return // 无法判定媒体类型:不进注册表,保留原名 + } + weight := r.weightOf(category, raw) + if weight <= 0 { + weight = r.weightOf(category, canonical) + } + ent := &entity{ + Category: category, + Canonical: canonical, + URL: url, + Weight: weight, + MediaType: media, + order: len(r.order), + } + r.entities[key] = ent + r.tokenByKey[key] = r.nextToken(ent) + r.order = append(r.order, ent) + r.recordAlias(ent, raw) +} + +func (r *TokenRegistry) recordAlias(ent *entity, raw string) { + for _, a := range ent.Aliases { + if a == raw { + return + } + } + ent.Aliases = append(ent.Aliases, raw) + r.aliasToToken[raw] = r.tokenByKey[entKey(ent)] +} + +func (r *TokenRegistry) nextToken(ent *entity) string { + tpl, base := tokenBase(r.tc, ent.MediaType) + n := 1 + for _, e := range r.order { + if e.MediaType == ent.MediaType { + n++ + } + } + if tpl != "" { + if strings.Contains(tpl, "%d") { + return fmt.Sprintf(tpl, n) + } + return tpl + strconv.Itoa(n) + } + return fmt.Sprintf("%s%d", base, n) +} + +// tokenBase 返回该媒体类型的前置模板(可为空)与默认前缀。 +func tokenBase(tc TokenConfig, media string) (tpl, base string) { + switch media { + case mediaVideo: + return tc.VideoTemplate, mediaVideo + case mediaImage: + return tc.ImageTemplate, mediaImage + case mediaAudio: + return tc.AudioTemplate, mediaAudio + } + return "", media +} + +func (r *TokenRegistry) entityFor(category, name string) *entity { + canonical := normalizeEntityName(name) + if canonical == "" { + return nil + } + return r.entities[category+"/"+canonical] +} + +func (r *TokenRegistry) weightOf(category, name string) int { + if it, ok := r.refs.findItem(category, name); ok && it.Weight > 0 { + return it.Weight + } + switch category { + case catCharacter: + return 4 + case catScene: + return 3 + case catProp: + return 2 + case catProduct: + return 1 + } + return 0 +} + +func (r Refs) findItem(category, name string) (RefItem, bool) { + var list []RefItem + switch category { + case catCharacter: + list = r.Characters + case catScene: + list = r.Scenes + case catProp: + list = r.Props + case catProduct: + list = r.Products + } + for _, it := range list { + if it.Name == name { + return it, true + } + } + return RefItem{}, false +} + +// ---------- BuildSegmentPrompt(设计 §6 + P0-4/P2-3/P3-1)---------- + +// BuildSegmentPrompt 构建单段 prompt 与参考素材显式绑定。 +// 加权筛选(开口角色必选 + 权重降序)选取 ≤ MaxRefs 个实体;prompt 用选中的 token 替换。 +func BuildSegmentPrompt(segShots []Shot, reg *TokenRegistry, in Input) (string, []RefBinding) { + cfg := resolveConfig(in.Cfg, in.MaxSegmentDur) + + type cand struct { + ent *entity + appOrder int + } + var cands []cand + seen := map[string]bool{} + speaking := map[string]bool{} + app := 0 + + add := func(category, name string) { + ent := reg.entityFor(category, name) + if ent == nil { + return + } + key := entKey(ent) + if seen[key] { + return + } + seen[key] = true + cands = append(cands, cand{ent: ent, appOrder: app}) + app++ + } + + for _, sh := range segShots { + for _, c := range sh.Characters { + add(catCharacter, c) + } + if sh.Scene != "" { + add(catScene, sh.Scene) + } + for _, p := range sh.Props { + add(catProp, p) + } + if sh.Dialogue != "" || sh.Narration != "" { + for _, c := range sh.Characters { + if ent := reg.entityFor(catCharacter, c); ent != nil { + speaking[entKey(ent)] = true + } + } + } + } + // 产品图常驻候选 + for _, e := range reg.order { + if e.Category == catProduct { + key := entKey(e) + if !seen[key] { + seen[key] = true + cands = append(cands, cand{ent: e, appOrder: app}) + app++ + } + } + } + + // 硬优先级:开口角色;其余按权重降序、同权重按出现顺序 + var speakCands, otherCands []cand + for _, c := range cands { + if speaking[entKey(c.ent)] { + speakCands = append(speakCands, c) + } else { + otherCands = append(otherCands, c) + } + } + sort.SliceStable(otherCands, func(i, j int) bool { + if otherCands[i].ent.Weight != otherCands[j].ent.Weight { + return otherCands[i].ent.Weight > otherCands[j].ent.Weight + } + return otherCands[i].appOrder < otherCands[j].appOrder + }) + + var chosen []*entity + for _, c := range speakCands { + if len(chosen) >= cfg.MaxRefs { + break + } + chosen = append(chosen, c.ent) + } + for _, c := range otherCands { + if len(chosen) >= cfg.MaxRefs { + break + } + chosen = append(chosen, c.ent) + } + + // 显式绑定:token→URL 一一对应 + segRefs := make([]RefBinding, 0, len(chosen)) + for _, e := range chosen { + segRefs = append(segRefs, RefBinding{ + Token: reg.tokenByKey[entKey(e)], + Entity: e.Aliases[0], + Category: e.Category, + URL: e.URL, + }) + } + + // 文本替换:按别名长度降序,命中任一异形体即替换为该 token + prompt := ShotsToPromptText(segShots) + type repl struct { + alias string + token string + } + var repls []repl + for _, e := range chosen { + token := reg.tokenByKey[entKey(e)] + for _, a := range e.Aliases { + repls = append(repls, repl{alias: a, token: token}) + } + } + sort.SliceStable(repls, func(i, j int) bool { + return utf8.RuneCountInString(repls[i].alias) > utf8.RuneCountInString(repls[j].alias) + }) + for _, r := range repls { + prompt = strings.ReplaceAll(prompt, r.alias, r.token) + } + + prompt = truncatePrompt(prompt, cfg) + return prompt, segRefs +} + +// truncatePrompt 语义保护截断(设计 §6.4 / P3-1): +// 先裁环境音/运镜/景别块,再从尾部最近句子边界截断,保底 MinPromptFloor 字符。 +func truncatePrompt(p string, cfg Config) string { + max := cfg.MaxPromptChars + if max <= 0 { + return p + } + if utf8.RuneCountInString(p) <= max { + return p + } + var kept []string + for _, ln := range strings.Split(p, "\n") { + if strings.HasPrefix(ln, "环境音:") || strings.HasPrefix(ln, "运镜:") || strings.HasPrefix(ln, "景别:") { + continue + } + kept = append(kept, ln) + } + p2 := strings.Join(kept, "\n") + if utf8.RuneCountInString(p2) <= max { + return p2 + } + rs := []rune(p2) + floor := cfg.MinPromptFloor + if floor <= 0 { + floor = 50 + } + if floor >= len(rs) { + floor = len(rs) - 1 + } + cut := max + if cut > len(rs) { + cut = len(rs) + } + for i := cut; i > floor; i-- { + if isSentenceEnd(rs[i-1]) { + cut = i + break + } + } + out := strings.TrimRight(string(rs[:cut]), " \t\n") + if out == "" { + out = "…" + } else { + out += "…" + } + return out +} diff --git a/workflow/service/flow/processor/builtin/split_shots_pipeline/pipeline/shot.go b/workflow/service/flow/processor/builtin/split_shots_pipeline/pipeline/shot.go new file mode 100644 index 0000000..aeb2b8a --- /dev/null +++ b/workflow/service/flow/processor/builtin/split_shots_pipeline/pipeline/shot.go @@ -0,0 +1,134 @@ +// 镜头模型(原 video/domain.Shot,随处理器自包含并入本包):脚本与视频模型之间的统一中间契约。 +package pipeline + +import ( + "encoding/json" + "fmt" + "strings" +) + +// Shot 单个镜头。文生视频 / 分析替换两种模式产出的脚本都归一为该结构, +// 后续拆段、prompt 构建、参考素材筛选都以它为准。 +type Shot struct { + Index int `json:"index"` + StartTime string `json:"startTime"` // "MM:SS" + EndTime string `json:"endTime"` // "MM:SS" + Event string `json:"event"` // 事件描述/动作描写 + Narration string `json:"narration,omitempty"` // 旁白/画外音 + Dialogue string `json:"dialogue,omitempty"` // 主台词 + AmbientSound string `json:"ambientSound,omitempty"` // 环境音 + CameraMovement string `json:"cameraMovement"` // 运镜描述 + ShotSize string `json:"shotSize"` // 景别 + Characters []string `json:"characters"` // 出演人物名列表 + Scene string `json:"scene"` // 场景名 + Props []string `json:"props"` // 道具名列表 + NegativePrompt string `json:"negativePrompt,omitempty"` // 本镜头负面 prompt(可选,见 video/pipeline §7.5) +} + +// ShotsToText 将镜头数组转回纯文本格式(带【镜头X】标题),供需要完整脚本文本的场景使用。 +func ShotsToText(shots []Shot) string { + var b strings.Builder + if len(shots) == 0 { + return "" + } + for _, s := range shots { + fmt.Fprintf(&b, "【镜头%d】(%s-%s)\n", s.Index, s.StartTime, s.EndTime) + writeShotFields(&b, s) + } + return strings.TrimRight(b.String(), "\n") +} + +// ShotsToPromptText 将镜头数组转为纯文本格式(不含【镜头X】标题),用于构建视频模型请求的 prompt。 +func ShotsToPromptText(shots []Shot) string { + var b strings.Builder + if len(shots) == 0 { + return "" + } + for _, s := range shots { + fmt.Fprintf(&b, "(%s-%s)\n", s.StartTime, s.EndTime) + writeShotFields(&b, s) + } + return strings.TrimRight(b.String(), "\n") +} + +func writeShotFields(b *strings.Builder, s Shot) { + if s.Event != "" { + fmt.Fprintf(b, "事件:%s\n", s.Event) + } + if s.Dialogue != "" { + fmt.Fprintf(b, "台词:%s(角色开口说)\n", s.Dialogue) + } + if s.Narration != "" { + fmt.Fprintf(b, "画外音:%s(角色不开口,仅播放旁白配音)\n", s.Narration) + } + if s.AmbientSound != "" { + fmt.Fprintf(b, "环境音:%s\n", s.AmbientSound) + } + if s.ShotSize != "" { + fmt.Fprintf(b, "景别:%s\n", s.ShotSize) + } + if s.CameraMovement != "" { + fmt.Fprintf(b, "运镜:%s\n", s.CameraMovement) + } + b.WriteString("\n") +} + +// ShotsJSONSchema 返回 []Shot 的 JSON Schema,供"脚本转写"节点作为模型 function 定义的 InputSchema, +// 让模型以 function calling 形式产出固定结构的镜头数组。 +func ShotsJSONSchema() map[string]any { + item := map[string]any{ + "type": "object", + "properties": map[string]any{ + "index": map[string]any{"type": "integer"}, + "startTime": map[string]any{"type": "string", "description": "开始时间,格式 MM:SS"}, + "endTime": map[string]any{"type": "string", "description": "结束时间,格式 MM:SS"}, + "event": map[string]any{"type": "string", "description": "事件描述/动作描写"}, + "narration": map[string]any{"type": "string", "description": "旁白/画外音"}, + "dialogue": map[string]any{"type": "string", "description": "主台词"}, + "ambientSound": map[string]any{"type": "string", "description": "环境音"}, + "cameraMovement": map[string]any{"type": "string", "description": "运镜描述"}, + "shotSize": map[string]any{"type": "string", "description": "景别"}, + "characters": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "出演人物名列表"}, + "scene": map[string]any{"type": "string", "description": "场景名"}, + "props": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "道具名列表"}, + }, + "required": []string{"index", "startTime", "endTime", "event", "cameraMovement", "shotSize", "characters", "scene"}, + } + return map[string]any{ + "type": "array", + "items": item, + } +} + +// ShotsStructuredFormat 返回 []Shot 的原生结构化输出(response_format)值: +// OpenAI 兼容 json_schema 模式,object 包装 {shots:[...]}。模型是否支持由 chat 模型 +// RequestBusinessFieldMapping 是否配置 response_format 决定(见 gateway.ModelConfig.StructuredOutput)。 +func ShotsStructuredFormat() map[string]any { + return map[string]any{ + "type": "json_schema", + "json_schema": map[string]any{ + "name": "shots", + "schema": map[string]any{ + "type": "object", + "properties": map[string]any{"shots": ShotsJSONSchema()}, + "required": []string{"shots"}, + }, + }, + } +} + +// IsShotsJSON 判断 script 内容是否为 JSON 镜头数组 +func IsShotsJSON(script string) bool { + if len(script) == 0 { + return false + } + trimmed := strings.TrimSpace(script) + if !strings.HasPrefix(trimmed, "[") { + return false + } + var shots []Shot + if err := json.Unmarshal([]byte(trimmed), &shots); err != nil { + return false + } + return len(shots) > 0 +} diff --git a/workflow/service/flow/processor/builtin/split_shots_pipeline/pipeline/timeline.go b/workflow/service/flow/processor/builtin/split_shots_pipeline/pipeline/timeline.go new file mode 100644 index 0000000..2f52285 --- /dev/null +++ b/workflow/service/flow/processor/builtin/split_shots_pipeline/pipeline/timeline.go @@ -0,0 +1,706 @@ +package pipeline + +import ( + "fmt" + "math" + "strconv" + "strings" + "unicode/utf8" +) + +// ---------- 时间工具 ---------- + +func parseSec(t string) int { + parts := strings.Split(t, ":") + if len(parts) != 2 { + return 0 + } + m, err1 := strconv.Atoi(parts[0]) + s, err2 := strconv.Atoi(parts[1]) + if err1 != nil || err2 != nil { + return 0 + } + return m*60 + s +} + +func mmss(total int) string { + return fmt.Sprintf("%02d:%02d", total/60, total%60) +} + +func shotDur(sh Shot) int { + return parseSec(sh.EndTime) - parseSec(sh.StartTime) +} + +func absInt(v int) int { + if v < 0 { + return -v + } + return v +} + +// ---------- 断句(SplitOversized 与 GroupSegments 共用的语义原语,设计 P0-2/四审 P0-1)---------- + +func isSentenceEnd(r rune) bool { + switch r { + case '。', '!', '?', ';', '\n', '!', '?', '.': + return true + } + return false +} + +// sentenceEnds 返回 text 中所有句子结束位置(rune 下标,end-exclusive)。 +func sentenceEnds(text string) []int { + var out []int + rs := []rune(text) + for i, r := range rs { + if isSentenceEnd(r) { + out = append(out, i+1) + } + } + return out +} + +// findBoundary 在 rune 序列中寻找离 pos 最近(窗口内)的句末断点,返回 end-exclusive 下标。 +func findBoundary(rs []rune, pos int, forwardFirst bool, window int) int { + if len(rs) == 0 || pos <= 0 { + return 0 + } + if pos >= len(rs) { + return len(rs) + } + if window <= 0 { + window = 60 + } + if forwardFirst { + end := pos + window + if end > len(rs) { + end = len(rs) + } + for i := pos; i < end; i++ { + if isSentenceEnd(rs[i]) { + return i + 1 + } + } + start := pos - window + if start < 0 { + start = 0 + } + for i := pos - 1; i >= start; i-- { + if isSentenceEnd(rs[i]) { + return i + 1 + } + } + } else { + start := pos - window + if start < 0 { + start = 0 + } + for i := pos - 1; i >= start; i-- { + if isSentenceEnd(rs[i]) { + return i + 1 + } + } + end := pos + window + if end > len(rs) { + end = len(rs) + } + for i := pos; i < end; i++ { + if isSentenceEnd(rs[i]) { + return i + 1 + } + } + } + return pos +} + +// ---------- ① RebuildDurations(尊重 AI 时间码,设计 §5.1 修订 + P1-1 + 四审 P0-3)---------- + +// RebuildDurations 优先保留 AI 的 startTime/endTime:时间码可用且可容纳时按相对时长整体缩放对齐到 +// TotalDuration(normalizeAIDurations,整数化 + 均衡修正,构造即 Σ==total);否则回退按文本量重建 +// (rebuildFromText,旧算法保留)。AI 时间码是模型对镜头节奏的真实意图,文本量重建只作兜底。 +func RebuildDurations(in Input) ([]Shot, error) { + shots := append([]Shot(nil), in.Shots...) + if len(shots) == 0 { + return nil, pipeErr(ErrEmptyShots, "镜头为空") + } + total := in.TotalDuration + if total <= 0 { + total = sumShotDurations(shots) + } + if total <= 0 { + total = 60 + } + if aiDurationsUsable(shots) && total >= len(shots) { + return normalizeAIDurations(shots, total) + } + return rebuildFromText(shots, total, resolveConfig(in.Cfg, in.MaxSegmentDur)) +} + +// aiDurationsUsable AI 时间码是否值得保留:所有镜头时间码非空且时长之和 > 0。 +// 单镜零/负时长在 normalizeAIDurations 中防御性钳到 1(设计决策:零时长镜头不应出现)。 +func aiDurationsUsable(shots []Shot) bool { + sum := 0 + for _, sh := range shots { + if strings.TrimSpace(sh.StartTime) == "" || strings.TrimSpace(sh.EndTime) == "" { + return false + } + sum += parseSec(sh.EndTime) - parseSec(sh.StartTime) + } + return sum > 0 +} + +// normalizeAIDurations 保留 AI 镜头相对时长,整体缩放对齐到目标总时长: +// 各镜时长 d[i] 按 total/Σd 等比缩放并四舍五入整数化,再做整体均衡修正(±1s 逐段抹平)保证 +// Σscaled == total 构造即成立;随后按累积偏移重排连续时间码,末镜 endTime 精确对齐 total。 +func normalizeAIDurations(shots []Shot, total int) ([]Shot, error) { + n := len(shots) + if n == 0 { + return nil, pipeErr(ErrEmptyShots, "镜头为空") + } + if total <= 0 { + return shots, nil + } + raw := make([]int, n) + rawSum := 0 + for i, sh := range shots { + d := parseSec(sh.EndTime) - parseSec(sh.StartTime) + if d < 1 { + d = 1 + } + raw[i] = d + rawSum += d + } + if rawSum <= 0 { + return nil, pipeErr(ErrInvalidInput, "AI 时间码时长之和为 0") + } + scaled := make([]int, n) + sum := 0 + for i := 0; i < n; i++ { + s := int(math.Round(float64(raw[i]) * float64(total) / float64(rawSum))) + if s < 1 { + s = 1 + } + scaled[i] = s + sum += s + } + // 均衡修正:把缩放舍入偏差逐段抹平,保证 Σscaled == total(调用方保证 total >= n 可行) + balance := total - sum + for i := n - 1; i >= 0 && balance != 0; i-- { + if balance > 0 { + scaled[i]++ + balance-- + } else if scaled[i] > 1 { + scaled[i]-- + balance++ + } + } + current := 0 + for i := 0; i < n; i++ { + shots[i].StartTime = mmss(current) + current += scaled[i] + shots[i].EndTime = mmss(current) + } + if n > 0 { + shots[n-1].EndTime = mmss(total) + } + return shots, nil +} + +// rebuildFromText 丢弃 AI 时间码、按文本量重建镜头时长(旧 RebuildDurations 算法): +// 有文字镜头按语速算最小时长,纯视觉镜头以 AI 时长意图为基准,盈余按弹性权重分配, +// 超出目标先压视觉再等比压有声最后截尾;整数化 + 整体均衡修正保证 Σ == total 构造即恒等。 +func rebuildFromText(shots []Shot, total int, cfg Config) ([]Shot, error) { + n := len(shots) + base := make([]int, n) + weights := make([]float64, n) + totalBase := 0 + + for i, sh := range shots { + textLen := utf8.RuneCountInString(sh.Dialogue) + utf8.RuneCountInString(sh.Narration) + aiDur := parseSec(sh.EndTime) - parseSec(sh.StartTime) + if aiDur <= 0 { + aiDur = 1 + } + if textLen == 0 { + d := aiDur + if float64(d) < cfg.MinVisualDur { + d = int(cfg.MinVisualDur) + } + if d < 1 { + d = 1 + } + base[i] = d + weights[i] = cfg.VisualWeight + } else { + cps := inferCharPerSecond(sh.Dialogue+" "+sh.Narration, cfg) + speaking := math.Ceil(float64(textLen) / cps) + minDur := speaking + cfg.MinDurBuffer + if minDur < 2 { + minDur = 2 + } + base[i] = int(minDur) + weights[i] = cfg.SpokenWeight + } + totalBase += base[i] + } + + if totalBase <= total { + // 盈余按弹性权重分配(float64),整数化后整体均衡修正 + surplus := total - totalBase + var totalWeight float64 + for _, w := range weights { + totalWeight += w + } + extra := make([]int, n) + sumExtra := 0 + if totalWeight > 0 { + for i := 0; i < n; i++ { + e := int(math.Floor(float64(surplus) * weights[i] / totalWeight)) + extra[i] = e + sumExtra += e + } + } + // 均衡修正:把舍入偏差逐段抹平,保证 sum(extra) == surplus + balance := surplus - sumExtra + for i := n - 1; i >= 0 && balance != 0; i-- { + if balance > 0 { + extra[i]++ + balance-- + } else if extra[i] > 0 { + extra[i]-- + balance++ + } + } + current := 0 + for i := 0; i < n; i++ { + shots[i].StartTime = mmss(current) + current += base[i] + extra[i] + shots[i].EndTime = mmss(current) + } + if n > 0 { + shots[n-1].EndTime = mmss(total) + } + } else { + // 总最小时长超出目标 → 压缩 + overshoot := totalBase - total + + // 1) 压缩纯视觉镜头 + for i := 0; i < n && overshoot > 0; i++ { + if weights[i] >= cfg.VisualWeight && base[i] > 1 { + maxReduce := base[i] - 1 + reduce := overshoot + if reduce > maxReduce { + reduce = maxReduce + } + base[i] -= reduce + overshoot -= reduce + } + } + + if overshoot > 0 { + // 2) 重建有声镜头底线,等比压缩 + reBase := make([]int, n) + newTotal := 0 + for i, sh := range shots { + if weights[i] >= cfg.VisualWeight { + reBase[i] = base[i] + } else { + textLen := utf8.RuneCountInString(sh.Dialogue) + utf8.RuneCountInString(sh.Narration) + cps := inferCharPerSecond(sh.Dialogue+" "+sh.Narration, cfg) + speaking := math.Ceil(float64(textLen) / cps) + minDur := speaking + cfg.MinDurBuffer + if minDur < 2 { + minDur = 2 + } + reBase[i] = int(minDur) + } + newTotal += reBase[i] + } + + if newTotal > total { + ratio := float64(total) / float64(newTotal) + current := 0 + keep := n + for i := 0; i < n; i++ { + d := int(float64(reBase[i]) * ratio) + if d < 1 { + d = 1 + } + if current+d > total { + d = total - current + } + if d < 1 { + d = 1 + } + shots[i].StartTime = mmss(current) + current += d + shots[i].EndTime = mmss(current) + if current >= total { + keep = i + 1 + break + } + } + if keep < n { + shots = shots[:keep] + } + if len(shots) > 0 && current < total { + shots[len(shots)-1].EndTime = mmss(total) + } + } else { + current := 0 + for i := 0; i < n; i++ { + shots[i].StartTime = mmss(current) + current += reBase[i] + shots[i].EndTime = mmss(current) + } + if current < total && n > 0 { + shots[n-1].EndTime = mmss(total) + } + } + } else { + // 仅压缩视觉镜头就够了 + current := 0 + for i := 0; i < n; i++ { + shots[i].StartTime = mmss(current) + current += base[i] + shots[i].EndTime = mmss(current) + } + if current < total && n > 0 { + shots[n-1].EndTime = mmss(total) + } + } + } + + for i := range shots { + shots[i].Index = i + 1 + } + return shots, nil +} + +// inferCharPerSecond 根据文本情绪推断语速:感叹/疑问多→快,低落→慢。 +func inferCharPerSecond(text string, cfg Config) float64 { + runes := utf8.RuneCountInString(text) + if runes == 0 { + return cfg.CharPerSecond + } + excl := strings.Count(text, "!") + strings.Count(text, "!") + ques := strings.Count(text, "?") + strings.Count(text, "?") + emotion := excl + ques + if emotion >= 2 && emotion*100/runes >= 8 { + return cfg.FastCharPerSecond + } + if strings.Contains(text, "…") || strings.Contains(text, "唉") { + return cfg.SlowCharPerSecond + } + return cfg.CharPerSecond +} + +// ---------- ② SplitOversized(设计 §5.2 + 四审 P0-4)---------- + +// SplitOversized 超 MaxSegmentDur 的镜头按句末断句切子镜头,避免"说话说一半"。 +// 每个子镜头按「基础起点 + 子段序号×Max」重算全局 StartTime/EndTime(末段延伸到原终点), +// 保证子镜头全局时间轴连续、无重叠、无缝隙。 +func SplitOversized(shots []Shot, in Input) ([]Shot, error) { + maxDur := in.MaxSegmentDur + if maxDur <= 0 || len(shots) == 0 { + return shots, nil + } + cfg := resolveConfig(in.Cfg, maxDur) + window := cfg.SplitWindow + fragMin := int(cfg.ShortFragmentDur) + if fragMin < 1 { + fragMin = 1 + } + + var result []Shot + for _, sh := range shots { + dur := parseSec(sh.EndTime) - parseSec(sh.StartTime) + if dur <= maxDur { + result = append(result, sh) + continue + } + parts := (dur + maxDur - 1) / maxDur + // 最后一片段 < 短残片阈值时减少拆分段数 + remainder := dur - (parts-1)*maxDur + if remainder < fragMin && parts > 1 { + parts-- + } + dRunes := []rune(sh.Dialogue) + nRunes := []rune(sh.Narration) + baseStart := parseSec(sh.StartTime) + + dPos, nPos := 0, 0 + for p := 0; p < parts; p++ { + sub := sh + pStart := baseStart + p*maxDur + pEnd := pStart + maxDur + if p > 0 { + sub.StartTime = mmss(pStart) + } + if p == parts-1 { + pEnd = baseStart + dur + } + sub.EndTime = mmss(pEnd) + + if p < parts-1 { + dTarget := len(dRunes) * (p + 1) / parts + if dTarget > dPos && dTarget <= len(dRunes) { + dEnd := findBoundary(dRunes, dTarget, true, window) + if dEnd <= dPos { + dEnd = dPos + 1 + } + if dEnd > len(dRunes) { + dEnd = len(dRunes) + } + sub.Dialogue = string(dRunes[dPos:dEnd]) + dPos = dEnd + } + nTarget := len(nRunes) * (p + 1) / parts + if nTarget > nPos && nTarget <= len(nRunes) { + nEnd := findBoundary(nRunes, nTarget, true, window) + if nEnd <= nPos { + nEnd = nPos + 1 + } + if nEnd > len(nRunes) { + nEnd = len(nRunes) + } + sub.Narration = string(nRunes[nPos:nEnd]) + nPos = nEnd + } + } else { + sub.Dialogue = string(dRunes[dPos:]) + sub.Narration = string(nRunes[nPos:]) + } + result = append(result, sub) + } + } + + for i := range result { + result[i].Index = i + 1 + } + return result, nil +} + +// ---------- ③ GroupSegments(2026-08-12 修订,取代 CalcSegmentDurations/AlignSegments)---------- + +// GroupSegments 按镜头边界贪心分组:段内镜头时长之和不超过 MaxSegmentDur;min 为硬约束 +// (段不足 MinSegmentDur 时从下一镜头头部按句末断点咬取补齐,模型不能容忍更短的段)。 +// 镜头时间码已在 RebuildDurations 归一为连续覆盖 [0,total),分组只做切片、不改写时间码; +// 末段为残余可短于 min(无后续镜头可咬)。前置条件:单镜时长 ≤ MaxSegmentDur(由 SplitOversized 保证)。 +func GroupSegments(shots []Shot, in Input) ([][]Shot, []int, error) { + if len(shots) == 0 { + return nil, nil, pipeErr(ErrEmptyShots, "镜头为空") + } + cfg := resolveConfig(in.Cfg, in.MaxSegmentDur) + maxSeg := in.MaxSegmentDur + if maxSeg <= 0 { + maxSeg = 15 + } + minSeg := in.MinSegmentDur + + remaining := append([]Shot(nil), shots...) + var segs [][]Shot + var durs []int + + for len(remaining) > 0 { + var seg []Shot + segDur := 0 + + // 贪心拉入整镜头,直到加入下一镜会超过 max(首镜总是可入;单镜 ≤ max 由 SplitOversized 保证) + for len(remaining) > 0 { + d := shotDur(remaining[0]) + if segDur > 0 && segDur+d > maxSeg { + break + } + seg = append(seg, remaining[0]) + segDur += d + remaining = remaining[1:] + } + + // min 硬约束:段不足 min 且还有镜头时,从下一镜头头部咬取补齐 + if minSeg > 0 && segDur < minSeg && len(remaining) > 0 { + sh := remaining[0] + s := parseSec(sh.StartTime) + e := parseSec(sh.EndTime) + need := minSeg - segDur + // segDur+d > maxSeg ≥ minSeg ⇒ need < shotDur(sh),必走咬取分支 + if need > 0 && need < e-s { + cut := s + need + cutRune := -1 + if c, r, ok := sentenceCut(sh, cut, cfg); ok && c > s && c < e { + cut, cutRune = c, r + } + // 吸附后钳制:min≈max 且句末断点在 need 点之后时,句末吸附会把段推过 max + //(例 max=10,min=9,segDur=8,断点在 +3s → 段 11s>10)。钳回 s+(maxSeg-segDur) 保 max + //(≥ min 恒成立,因 segDur < minSeg ≤ maxSeg),并回退纯时间切,避免 rune 与钳制后时间码错位。 + if maxSeg > segDur && cut > s+(maxSeg-segDur) { + cut = s + (maxSeg - segDur) + cutRune = -1 + } + if cut <= s { + cut = s + 1 + } + if cut >= e { + cut = e - 1 + } + s1, s2 := splitShotAt(sh, cut, cutRune) + seg = append(seg, s1) + segDur += parseSec(s1.EndTime) - parseSec(s1.StartTime) + remaining[0] = s2 + } else if need > 0 { + // 防御:整镜补入(理论不可达,见上) + seg = append(seg, sh) + segDur += shotDur(sh) + remaining = remaining[1:] + } + } + + segs = append(segs, seg) + durs = append(durs, segDur) + } + return segs, durs, nil +} + +// sentenceCut 在镜头内寻找离 target 最近、且位移 ≤ BoundaryTolerance 的句末断点, +// 返回断点时间(秒)与断点 rune 下标(在 "台词\n旁白" 合并串中的位置)。找不到返回 (target,-1,false)。 +func sentenceCut(sh Shot, target int, cfg Config) (int, int, bool) { + s := parseSec(sh.StartTime) + e := parseSec(sh.EndTime) + dur := e - s + if dur <= 0 { + return target, -1, false + } + text := sh.Dialogue + "\n" + sh.Narration + rs := []rune(text) + if len(rs) == 0 { + return target, -1, false + } + tol := int(cfg.BoundaryTolerance) + if tol < 0 { + tol = 0 + } + best := target + bestRune := -1 + bestDist := tol + 1 + found := false + for _, p := range sentenceEnds(text) { + t := s + int(math.Round(float64(p)*float64(dur)/float64(len(rs)))) + if t < s+1 || t > e-1 { + continue + } + dist := absInt(t - target) + if dist < bestDist { + bestDist = dist + best = t + bestRune = p + found = true + } + } + if !found { + return target, -1, false + } + return best, bestRune, true +} + +// splitShotAt 把镜头按时间 cut 切成两个子镜头;cutRune >= 0 时按合并文本 rune 位置切分文本。 +func splitShotAt(sh Shot, cut, cutRune int) (Shot, Shot) { + s1, s2 := sh, sh + s1.EndTime = mmss(cut) + s2.StartTime = mmss(cut) + if cutRune >= 0 { + d := []rune(sh.Dialogue) + n := []rune(sh.Narration) + switch { + case cutRune <= len(d): + s1.Dialogue = string(d[:cutRune]) + s2.Dialogue = string(d[cutRune:]) + s1.Narration = "" + s2.Narration = string(n) + case cutRune <= len(d)+1+len(n): + s1.Dialogue = string(d) + nsplit := cutRune - len(d) - 1 + if nsplit < 0 { + nsplit = 0 + } + if nsplit > len(n) { + nsplit = len(n) + } + s1.Narration = string(n[:nsplit]) + s2.Dialogue = "" + s2.Narration = string(n[nsplit:]) + default: + s1.Dialogue = string(d) + s1.Narration = string(n) + s2.Dialogue = "" + s2.Narration = "" + } + } + return s1, s2 +} + +// BuildTimeline 编排 RebuildDurations → SplitOversized → GroupSegments,收尾做 assertTimeline 不变量校验。 +func BuildTimeline(in Input) ([][]Shot, []int, error) { + in = NormalizeInput(in) + if len(in.Shots) == 0 { + return nil, nil, pipeErr(ErrEmptyShots, "镜头为空") + } + shots, err := RebuildDurations(in) + if err != nil { + return nil, nil, err + } + shots, err = SplitOversized(shots, in) + if err != nil { + return nil, nil, err + } + segs, durs, err := GroupSegments(shots, in) + if err != nil { + return nil, nil, err + } + if err := assertTimeline(segs, durs, in.TotalDuration, in.MinSegmentDur, in.MaxSegmentDur, in.Cfg.StrictInvariant); err != nil { + return nil, nil, err + } + return segs, durs, nil +} + +// ---------- assertTimeline(设计 §5.5 + P0-1)---------- + +// assertTimeline 校验时间线不变量: +// 段时长总和精确等于 total;每个镜头只属于一个段;段内镜头时间码落在段窗口内且连续无重叠; +// 每段时长 ∈ [min, max](strict=false 时该条仅告警,结构性不变量始终强制)。 +func assertTimeline(segShots [][]Shot, segDurs []int, total, minSeg, maxSeg int, strict bool) error { + sum := 0 + for _, d := range segDurs { + sum += d + } + if sum != total { + return pipeErr(ErrTimelineInvariant, fmt.Sprintf("段时长总和 %d != TotalDuration %d", sum, total)) + } + start := 0 + for i, seg := range segShots { + dur := segDurs[i] + if strict && (dur < minSeg || dur > maxSeg) { + return pipeErr(ErrTimelineInvariant, fmt.Sprintf("段%d 时长 %d 不在 [%d,%d] 内", i, dur, minSeg, maxSeg)) + } + if len(seg) == 0 { + start += dur + continue + } + prev := start + for _, sh := range seg { + s := parseSec(sh.StartTime) + e := parseSec(sh.EndTime) + if s != prev || e < s { + return pipeErr(ErrTimelineInvariant, fmt.Sprintf("段%d 镜头时间不连续/重叠(镜头%d %s-%s)", i, sh.Index, sh.StartTime, sh.EndTime)) + } + if s < start || e > start+dur { + return pipeErr(ErrTimelineInvariant, fmt.Sprintf("段%d 镜头%d 时间码超出段窗口", i, sh.Index)) + } + prev = e + } + if prev != start+dur { + return pipeErr(ErrTimelineInvariant, fmt.Sprintf("段%d 镜头未覆盖段窗口 [%d,%d)", i, start, start+dur)) + } + start += dur + } + return nil +} diff --git a/workflow/service/flow/processor/builtin/split_shots_pipeline/split_shots_pipeline.go b/workflow/service/flow/processor/builtin/split_shots_pipeline/split_shots_pipeline.go new file mode 100644 index 0000000..e369504 --- /dev/null +++ b/workflow/service/flow/processor/builtin/split_shots_pipeline/split_shots_pipeline.go @@ -0,0 +1,199 @@ +// Package split_shots_pipeline 工作流前置处理器:用 pipeline 时间线算法拆段,产出各段模型请求参数。 +// 与旧 split_shots 并存:节点 preTool 指向本处理器即灰度启用新算法(设计 §8.2/§10,开关即"用哪个 preTool")。 +// +// 处理器自包含:纯拆段算法随处理器走(pipeline 子包,零外部依赖);唯一 I/O 是按 model_id 查模型网关 +// 推导单段时长约束(模型配置是唯一事实来源)。产出与旧链路一致的 FLAT 请求参数——model-gateway 按 +// requestBodyMapping/requestBusinessFieldMapping 对 flat 业务参数做字段映射,故不产嵌套请求体(会二次嵌套)。 +package split_shots_pipeline + +import ( + "context" + "fmt" + "sort" + "strconv" + "strings" + + "ai-agent/gateway" + "ai-agent/workflow/service/flow/processor" + "ai-agent/workflow/service/flow/processor/builtin/split_shots_pipeline/pipeline" + + "github.com/gogf/gf/v2/util/gconv" +) + +func init() { + processor.Register(SplitShotsPipelineProcessor()) +} + +// SplitShotsInput 视频生成模型节点的请求参数(ModelRequestParams)结构,处理器入参即它。 +// 只含拆段与透传字段:serial/session_id/callback_url/bgm_urls 不参与拆段(串并行由节点 IsBatchExec、 +// session 由 Global.SessionId 提供、合并字段由后置处理器读原始 request),故不入结构。 +// 参考素材是散布在请求参数中的 {refsName,value} 对象,parseRefs 递归提取,类别由 categorizeRefs 按名字推断。 +type SplitShotsInput struct { + ModelID int64 `json:"model_id"` // 视频模型 ID(走网关 modelCall) + Shots []pipeline.Shot `json:"shots"` // 已归一化的镜头脚本 + TotalDuration int `json:"total_duration"` // 目标总时长(秒) + FlatRefs []pipeline.RefItem `json:"flat_refs"` // 参考素材(平铺形态,类别由 categorizeRefs 推断) + Seed int64 `json:"seed"` // 随机种子基数,各段 = baseSeed + 段序号 + NegativePrompt string `json:"negative_prompt,omitempty"` +} + +// SplitShotsPipelineProcessor 新拆段前置处理器。入参 args 即模型请求参数(SplitShotsInput 形状), +// 按 model_id 查模型网关推导单段时长约束 → pipeline 时间线算法拆段 → 输出每段 FLAT 模型请求参数。 +func SplitShotsPipelineProcessor() *processor.Processor { + return &processor.Processor{ + Name: "split_shots_pipeline", + Description: "按 pipeline 时间线算法拆段,产出各段模型请求参数(与 split_shots 并存,灰度用)", + Func: func(ctx context.Context, args map[string]any) (any, error) { + input, err := parseSplitShotsInput(args) + if err != nil { + return nil, err + } + refs := categorizeRefs(input.Shots, input.FlatRefs) + maxSeg, minSeg, err := SegmentBounds(ctx, input.ModelID) + if err != nil { + return nil, err + } + + segs, err := pipeline.PlanSegments(pipeline.NormalizeInput(pipeline.Input{ + Shots: input.Shots, + TotalDuration: input.TotalDuration, + MaxSegmentDur: maxSeg, + MinSegmentDur: minSeg, + Refs: refs, + Seed: input.Seed, + NegativePrompt: input.NegativePrompt, + TokenCfg: pipeline.TokenConfig{}, + })) + if err != nil { + return nil, err + } + + list := make([]map[string]any, 0, len(segs)) + for _, seg := range segs { + list = append(list, segmentParamsMap(input, seg)) + } + return list, nil + }, + } +} + +func parseSplitShotsInput(args map[string]any) (*SplitShotsInput, error) { + if args == nil { + return nil, fmt.Errorf("缺少视频生成请求参数") + } + input := new(SplitShotsInput) + if err := gconv.Struct(args, input); err != nil { + return nil, fmt.Errorf("解析视频生成请求参数失败: %v", err) + } + if input.ModelID <= 0 { + return nil, fmt.Errorf("缺少 model_id") + } + if len(input.Shots) == 0 { + return nil, fmt.Errorf("缺少 shots 镜头脚本") + } + return input, nil +} + +// categorizeRefs 按名字在镜头中出现的字段推断平铺参考素材的类别(角色/场景/道具), +// 都命不中归产品(产品常驻注册表,见 pipeline.NewTokenRegistry)。类别只决定 token 前缀与权重,不改变 URL 绑定。 +func categorizeRefs(shots []pipeline.Shot, items []pipeline.RefItem) pipeline.Refs { + var refs pipeline.Refs + charNames := map[string]bool{} + sceneNames := map[string]bool{} + propNames := map[string]bool{} + for _, sh := range shots { + for _, c := range sh.Characters { + charNames[strings.TrimSpace(c)] = true + } + if sh.Scene != "" { + sceneNames[strings.TrimSpace(sh.Scene)] = true + } + for _, p := range sh.Props { + propNames[strings.TrimSpace(p)] = true + } + } + for _, it := range items { + name := strings.TrimSpace(it.Name) + switch { + case charNames[name]: + refs.Characters = append(refs.Characters, it) + case sceneNames[name]: + refs.Scenes = append(refs.Scenes, it) + case propNames[name]: + refs.Props = append(refs.Props, it) + default: + refs.Products = append(refs.Products, it) + } + } + return refs +} + +// SegmentBounds 查模型网关推导单段时长约束:兜底 max/min = 10/4 秒;模型配置的 +// MaxDuration/MinDuration 字段(>0)可覆盖兜底值,min 收敛到 ≤ max。 +// 导出供脚本转写节点注入"单镜时长 ≤ max"约束(源头预防超长镜头)。 +func SegmentBounds(ctx context.Context, modelID int64) (maxSeg, minSeg int, err error) { + maxSeg, minSeg = 10, 4 + info, err := gateway.GetModelInfoById(ctx, &gateway.GetModelInfoByIdReq{ModelId: modelID}) + if err != nil { + return 0, 0, fmt.Errorf("获取模型信息失败: %v", err) + } + if info.ModelManage.MaxDuration > 0 { + maxSeg = info.ModelManage.MaxDuration + } + if info.ModelManage.MinDuration > 0 { + minSeg = info.ModelManage.MinDuration + } + if minSeg > maxSeg { + minSeg = maxSeg + } + return maxSeg, minSeg, nil +} + +// segmentParamsMap 把一段 pipeline 产出序列化为 FLAT 模型请求参数(对齐旧 plan.SegmentParamsToMap 语义)。 +// reference_urls 按 token 编号排序,保证媒体数组顺序与 prompt 中的 token 编号一致(位置识别模型兼容)。 +func segmentParamsMap(input *SplitShotsInput, seg pipeline.Segment) map[string]any { + m := map[string]any{ + "segment_index": seg.Index, + "prompt": seg.Prompt, + "duration": seg.Duration, + "seed": seg.Seed, + } + if seg.NegativePrompt != "" { + m["negative_prompt"] = seg.NegativePrompt + } + if refs := sortedRefs(seg.Refs); len(refs) > 0 { + urls := make([]string, 0, len(refs)) + labels := make(map[string]string, len(refs)) + for _, rb := range refs { + if rb.URL == "" { + continue + } + urls = append(urls, rb.URL) + labels[rb.Entity] = rb.Token + } + if len(urls) > 0 { + m["reference_urls"] = urls + m["reference_labels"] = labels + } + } + return m +} + +// sortedRefs 按 token 编号(video3→3、image10→10)升序稳定排序, +// 让 reference_urls 顺序尽量贴近 prompt 中 token 的编号顺序。 +func sortedRefs(refs []pipeline.RefBinding) []pipeline.RefBinding { + out := append([]pipeline.RefBinding(nil), refs...) + sort.SliceStable(out, func(i, j int) bool { + return tokenNum(out[i].Token) < tokenNum(out[j].Token) + }) + return out +} + +func tokenNum(tok string) int { + i := 0 + for i < len(tok) && (tok[i] < '0' || tok[i] > '9') { + i++ + } + n, _ := strconv.Atoi(tok[i:]) + return n +} diff --git a/workflow/service/flow/processor/processor.go b/workflow/service/flow/processor/processor.go new file mode 100644 index 0000000..1d28d56 --- /dev/null +++ b/workflow/service/flow/processor/processor.go @@ -0,0 +1,73 @@ +// Package processor 工作流节点的前置/后置处理器注册表。 +// +// 与模型工具(common/tools)区分:处理器是绑定固定业务场景的处理函数,按名注册与分发, +// 供节点配置(preTool/postTool)引用,不由模型 function calling 调用。 +package processor + +import ( + "context" + "fmt" + "sort" +) + +// Processor 工作流前置/后置处理函数。按名注册与分发,供节点配置引用。 +type Processor struct { + Name string + Description string + Func func(ctx context.Context, args map[string]any) (any, error) +} + +// registry 处理器注册表 +var registry = make(map[string]*Processor) + +// Register 注册处理器,同名覆盖 +func Register(list ...*Processor) { + for _, p := range list { + if p == nil || p.Name == "" { + continue + } + registry[p.Name] = p + } +} + +// Call 按名调用处理器。未知处理器返回错误。 +func Call(ctx context.Context, name string, args map[string]any) (any, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + p := registry[name] + if p == nil || p.Func == nil { + return nil, fmt.Errorf("处理器[%s]不存在或未实现", name) + } + return p.Func(ctx, args) +} + +func List(ctx context.Context) ([]*Processor, error) { + list := make([]*Processor, 0, len(registry)) + for _, t := range registry { + list = append(list, t) + } + sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name }) + return list, nil +} + +// ProducedKey 前置处理器已完成模型调用、直接产出最终结果时,在返回列表的每个 map 上打的标记键。 +// 模型调用节点识别到该标记后跳过模型调用循环,把前置处理器的结果直接交给后置处理器。 +// 用于"串行视频生成"这类无法用并发循环表达的前置处理器。 +const ProducedKey = "__produced" + +// IsProduced 判断前置处理器返回列表是否全部带已产出标记(列表中每个 map 的标记值都须为 true) +func IsProduced(list []map[string]any) bool { + if len(list) == 0 { + return false + } + for _, m := range list { + if m == nil { + return false + } + if v, ok := m[ProducedKey].(bool); !ok || !v { + return false + } + } + return true +} diff --git a/workflow/service/flow/react_ws_exec.go b/workflow/service/flow/react_ws_exec.go new file mode 100644 index 0000000..df06c33 --- /dev/null +++ b/workflow/service/flow/react_ws_exec.go @@ -0,0 +1,183 @@ +package flow + +import ( + "ai-agent/gateway" + sessionDao "ai-agent/workflow/dao/session" + sessionDto "ai-agent/workflow/model/dto/session" + "ai-agent/workflow/model/entity" + "context" + "errors" + "fmt" + "time" + + "ai-agent/tools/runner" + + "gitea.redpowerfuture.com/red-future/common/tools" + wsCommon "gitea.redpowerfuture.com/red-future/common/websocket" + + "github.com/gogf/gf/v2/os/glog" + "github.com/gogf/gf/v2/util/gconv" +) + +func init() { + // 普通对话消息处理器(会话服务器 SessionWsService 见 ws_server.go) + SessionWsService.OnMessage("agent", handleToolAgent) + SessionWsService.OnMessage("agent_cancel", handleToolAgentCancel) +} + +// 工具对话默认系统提示词 +const defaultAgentSystemPrompt = "你是一个智能助手,可以调用工具完成任务。请根据任务需要选择合适的工具,参考工具返回结果,最终给出完整回答。" + +// 工具对话 ReAct 最大循环步数 +const defaultAgentMaxStep = 15 + +// handleToolAgent 处理工具对话消息:解析 payload 后异步运行 ReAct 循环,逐步推送过程事件。 +// 首条消息惰性建会话(复用已存在会话),跑完后把问答/token 写入 exec_chat 落库。 +func handleToolAgent(ctx context.Context, conn *wsCommon.WsConnection, payload interface{}) { + var p sessionDto.WebSocketExecChatReq + if err := gconv.Struct(payload, &p); err != nil { + pushAgentEvent(conn, runner.ReActEvent{Type: runner.ReActEventError, Message: "参数解析失败", Error: err.Error()}) + return + } + if p.Question == "" { + pushAgentEvent(conn, runner.ReActEvent{Type: runner.ReActEventError, Message: "内容不能为空", Error: "提问内容不能为空"}) + return + } + + modelTools, err := tools.Default.List(ctx) + if err != nil { + pushAgentEvent(conn, runner.ReActEvent{Type: runner.ReActEventError, Message: "工具列表获取失败", Error: err.Error()}) + return + } + systemPrompt := p.SystemPrompt + if systemPrompt == "" { + systemPrompt = defaultAgentSystemPrompt + } + + // 支持前端终止:agent 上下文可取消;落库用不带取消的 ctx(保留 request 值),保证终止后 token 仍能记录 + agentCtx, agentCancel := context.WithCancel(ctx) + if oldCancel := getToolCancel(conn); oldCancel != nil { + oldCancel() + } + conn.SetMeta("toolCancel", agentCancel) + defer conn.SetMeta("toolCancel", nil) + defer agentCancel() + saveCtx := context.WithoutCancel(ctx) + + // 会话落库:前端临时 sessionId 对应已存在会话则复用,否则新建 + err = ensureSession(saveCtx, conn.SessionId, p.Question) + if err != nil { + pushAgentEvent(conn, runner.ReActEvent{Type: runner.ReActEventError, Message: "会话创建失败", Error: err.Error()}) + } + + agent := runner.NewReActAgent(p.ModelId, conn.SessionId, modelTools, systemPrompt, defaultAgentMaxStep) + agent.OnEvent = func(ev runner.ReActEvent) { + pushAgentEvent(conn, ev) + } + + start := time.Now() + answer, runErr := agent.Run(agentCtx, p.Question) + duration := int64(time.Since(start).Seconds()) + + // 前端终止:结果/错误不推前端,仅把已产生的 token 正常落库(错误记「用户已终止对话」) + terminated := runErr != nil && errors.Is(runErr, context.Canceled) + if terminated { + runErr = errChatTerminated + } else if runErr != nil { + pushAgentEvent(conn, runner.ReActEvent{Type: runner.ReActEventError, Message: "对话运行失败", Error: runErr.Error()}) + } + recordChat(saveCtx, conn.SessionId, p.Question, answer, runErr, agent.TotalTokens, agent.TotalCost, duration) +} + +// handleToolAgentCancel 终止正在运行的对话(前端停止按钮发送 agent_cancel) +func handleToolAgentCancel(ctx context.Context, conn *wsCommon.WsConnection, _ interface{}) { + if cancel := getToolCancel(conn); cancel != nil { + cancel() + } + _ = conn.WriteJSON(&wsCommon.WsPushMsg{Type: "ack", Message: "已终止对话"}) +} + +// getToolCancel 获取当前 agent 运行的取消函数 +func getToolCancel(conn *wsCommon.WsConnection) context.CancelFunc { + cancel, _ := wsCommon.GetMetaT[context.CancelFunc](conn, "toolCancel") + return cancel +} + +// errChatTerminated 前端终止对话的错误标记(写入 exec_chat.error_message) +var errChatTerminated = errors.New("用户已终止对话") + +// recordChat 把一次普通对话写入 exec_chat:答案传 OSS 存 result_file_url,错误写 error_message, +// token 与费用(模型网关返回的累计 cost)落库 +func recordChat(ctx context.Context, sessionId string, question, answer string, runErr error, totalTokens int64, totalCost float64, duration int64) { + var resultFileUrl, errorMessage string + if runErr == nil && answer != "" { + //answerJSON := gjson.New(map[string]any{"answer": answer}).MustToJson() + url, uploadErr := gateway.Upload(ctx, fmt.Sprintf("chat_%v_%d.txt", sessionId, time.Now().UnixMilli()), []byte(answer)) + if uploadErr != nil { + glog.Errorf(ctx, "普通对话答案上传OSS失败: %v", uploadErr) + } else { + resultFileUrl = url + } + } else if runErr != nil { + errorMessage = runErr.Error() + } + _, err := sessionDao.ExecChatDao.Insert(ctx, &sessionDto.CreateExecChatReq{ + SessionId: sessionId, + Duration: duration, + RequestParams: entity.ExecChatRequestParams{Question: question}, + ResultFileUrl: resultFileUrl, + TotalTokens: int(totalTokens), + TotalFee: totalCost, + ErrorMessage: errorMessage, + }) + if err != nil { + glog.Errorf(ctx, "普通对话落库失败: %v", err) + } +} + +// pushAgentEvent 把 ReAct 过程事件转为 WS 推送消息 +func pushAgentEvent(conn *wsCommon.WsConnection, ev runner.ReActEvent) { + if conn.IsClosed() { + return + } + switch ev.Type { + case runner.ReActEventModelCall: + _ = conn.WriteJSON(&wsCommon.WsPushMsg{ + Type: string(ev.Type), + Message: "模型思考中", + Data: map[string]interface{}{"step": ev.Step, "maxStep": ev.MaxStep}, + }) + case runner.ReActEventToolCall: + _ = conn.WriteJSON(&wsCommon.WsPushMsg{ + Type: string(ev.Type), + Message: "调用工具", + Data: map[string]interface{}{"description": ev.Description}, + }) + case runner.ReActEventToolResult: + _ = conn.WriteJSON(&wsCommon.WsPushMsg{ + Type: string(ev.Type), + Message: "工具返回", + Data: map[string]interface{}{"description": ev.Description}, + }) + case runner.ReActEventAnswerChunk: + _ = conn.WriteJSON(&wsCommon.WsPushMsg{ + Type: string(ev.Type), + Message: "思考中", + Data: map[string]interface{}{"delta": ev.Delta}, + }) + case runner.ReActEventReasoningChunk: + _ = conn.WriteJSON(&wsCommon.WsPushMsg{ + Type: string(ev.Type), + Message: "思考中", + Data: map[string]interface{}{"delta": ev.Delta}, + }) + case runner.ReActEventAnswer: + _ = conn.WriteJSON(&wsCommon.WsPushMsg{ + Type: string(ev.Type), + Message: "作答完成", + Data: map[string]interface{}{"answer": ev.Answer}, + }) + case runner.ReActEventError: + _ = conn.WriteJSON(&wsCommon.WsPushMsg{Type: string(ev.Type), Message: ev.Message, Error: ev.Error}) + } +} diff --git a/workflow/service/flow/ws_server.go b/workflow/service/flow/ws_server.go new file mode 100644 index 0000000..0c22140 --- /dev/null +++ b/workflow/service/flow/ws_server.go @@ -0,0 +1,41 @@ +package flow + +import ( + "context" + + sessionDao "ai-agent/workflow/dao/session" + sessionDto "ai-agent/workflow/model/dto/session" + + wsCommon "gitea.redpowerfuture.com/red-future/common/websocket" + "github.com/gogf/gf/v2/net/ghttp" +) + +// SessionWsService 会话 WebSocket 服务器:普通对话与工作流共用一条连接, +// 首次连接仅升级,后续按消息 type 路由到对话/工作流处理器 +// (对话处理器在 react_ws_exec.go 注册,工作流处理器在 flow_ws_exec.go 注册)。 +var SessionWsService = wsCommon.NewWsServer( + wsCommon.WithConnKeyPrefix("ws:session:"), +) + +// WsConnect 控制器统一入口:升级 WebSocket(普通对话/工作流均由消息 type 区分,此处不区分) +func WsConnect(ctx context.Context, r *ghttp.Request, req *sessionDto.WebSocketConnectReq) error { + _, err := SessionWsService.Upgrade(ctx, r, req.SessionId) + return err +} + +// ensureSession 解析前端 sessionId 并确保会话存在:命中已存在会话则复用其 id,否则按 name 新建。 +// 普通对话(react_ws_exec.go)与工作流(flow_ws_exec.go)共用。 +func ensureSession(ctx context.Context, sessionId string, name string) error { + exist, err := sessionDao.SessionDao.GetById(ctx, sessionId) + if err != nil { + return err + } + if exist != nil { + return nil + } + if r := []rune(name); len(r) > 128 { // session_name VARCHAR(128) + name = string(r[:128]) + } + _, err = sessionDao.SessionDao.Insert(ctx, &sessionDto.CreateSessionReq{SessionId: sessionId, SessionName: name}) + return err +} diff --git a/workflow/service/node/node_library_service.go b/workflow/service/node/node_library_service.go index c7c553d..8a01936 100644 --- a/workflow/service/node/node_library_service.go +++ b/workflow/service/node/node_library_service.go @@ -1,14 +1,14 @@ package node import ( + "context" + "strconv" + + "ai-agent/gateway" + "ai-agent/workflow/consts/model" "ai-agent/workflow/consts/node" nodeDto "ai-agent/workflow/model/dto/node" - "context" - "fmt" - "gitea.redpowerfuture.com/red-future/common/beans" - commonHttp "gitea.redpowerfuture.com/red-future/common/http" - "github.com/gogf/gf/v2/encoding/gjson" "github.com/gogf/gf/v2/frame/g" ) @@ -16,370 +16,55 @@ var NodeLibraryService = &nodeLibraryService{} type nodeLibraryService struct{} -func GetModelType(ctx context.Context) (mainTypeMap map[int]string, err error) { - 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] - } - } - } - res := new(nodeDto.ModelTypeResponse) - err = commonHttp.Get(ctx, "model-gateway/model/listType", headers, res, nil) - // 通用过滤:只保留 能被 100 整除的主类型(100/200/300...) - mainTypeMap = make(map[int]string) - for typ, name := range res.Type { - if typ%100 == 0 { - mainTypeMap[typ] = name - } - } - return -} - func (s *nodeLibraryService) GetNodeLibrary(ctx context.Context, req *nodeDto.WorkflowNodeTreeReq) (*nodeDto.WorkflowNodeTreeRes, error) { - WorkflowNodeGroups := []node.NodeGroupItem{ - { - Group: node.NodeGroupComponent, - Label: node.NodeGroupNameComponent, - Items: []node.NodeItem{ - { - NodeCode: node.NodeTypeTextModel, - NodeName: node.NodeNameTextModel, - ModelType: node.ModelTypeText, - SkillOption: false, - PromptOption: true, - IsSaveFile: true, - FormConfig: []node.NodeFormField{}, - ModelConfig: []node.ModelItem{}, - }, - { - NodeCode: node.NodeTypeImageModel, - NodeName: node.NodeNameImageModel, - ModelType: node.ModelTypeImage, - SkillOption: false, - PromptOption: true, - IsSaveFile: true, - FormConfig: []node.NodeFormField{}, - ModelConfig: []node.ModelItem{}, - }, - { - NodeCode: node.NodeTypeVideoModel, - NodeName: node.NodeNameVideoModel, - ModelType: node.ModelTypeVideo, - SkillOption: false, - PromptOption: true, - IsSaveFile: true, - FormConfig: []node.NodeFormField{}, - ModelConfig: []node.ModelItem{}, - }, - { - NodeCode: node.NodeTypeAudioModel, - NodeName: node.NodeNameAudioModel, - ModelType: node.ModelTypeAudio, - SkillOption: false, - PromptOption: true, - IsSaveFile: true, - FormConfig: []node.NodeFormField{}, - ModelConfig: []node.ModelItem{}, - }, - { - NodeCode: node.NodeTypeBatchModel, - NodeName: node.NodeNameBatchModel, - ModelType: node.ModelTypeText, - SkillOption: false, - PromptOption: true, - IsSaveFile: true, - FormConfig: []node.NodeFormField{}, - ModelConfig: []node.ModelItem{}, - }, - }, - }, - { - Group: node.NodeGroupBase, - Label: node.NodeGroupNameBase, - Items: []node.NodeItem{ - { - NodeCode: node.NodeTypeSubFlow, - NodeName: node.NodeSubFlow, - SkillOption: false, - PromptOption: false, - IsSaveFile: false, - FormConfig: []node.NodeFormField{ - {Field: "maxConcurrency", Label: "最大并发数", Type: "input", Required: true}, - }, - ModelConfig: []node.ModelItem{}, - }, - { - NodeCode: node.NodeTypeDataConversionModel, - NodeName: node.NodeNameDataConversionModel, - ModelType: node.ModelTypeText, - SkillOption: false, - PromptOption: true, - FormConfig: []node.NodeFormField{}, - ModelConfig: []node.ModelItem{}, - }, - { - NodeCode: node.NodeTypeMerge, - NodeName: node.NodeNameMerge, - SkillOption: false, - FormConfig: []node.NodeFormField{}, - ModelConfig: []node.ModelItem{}, - }, - { - NodeCode: node.NodeTypeDataMerge, - NodeName: node.NodeNameDataMerge, - SkillOption: false, - FormConfig: []node.NodeFormField{}, - ModelConfig: []node.ModelItem{}, - }, - { - NodeCode: node.NodeTypeJudge, - NodeName: node.NodeNameJudge, - SkillOption: false, - FormConfig: []node.NodeFormField{ - {Field: "condition", Label: node.FormLabelCondition, Type: "input", Required: true}, - }, - ModelConfig: []node.ModelItem{}, - }, - { - NodeCode: node.NodeTypeForm, - NodeName: node.NodeNameForm, - SkillOption: false, - FormConfig: []node.NodeFormField{}, - ModelConfig: []node.ModelItem{}, - }, - { - NodeCode: node.NodeTypeHttp, - NodeName: node.NodeNameHttp, - SkillOption: false, - IsSaveFile: true, - FormConfig: []node.NodeFormField{ - { - Field: "method", - Label: "请求方式", - Type: "select", - Required: true, - Options: []node.SelectOption{ - {Label: "GET", Value: "GET"}, - {Label: "POST", Value: "POST"}, - {Label: "PUT", Value: "PUT"}, - {Label: "DELETE", Value: "DELETE"}, - }, - }, - { - Field: "url", - Label: "请求地址", - Type: "input", - Required: true, - }, - { - Field: "headers", - Label: "请求头(支持Authorization鉴权)", - Type: "keyValue", - Required: false, - }, - { - Field: "bodyType", - Label: "请求体类型", - Type: "select", - Required: true, - Options: []node.SelectOption{ - {Label: "无", Value: "None"}, - {Label: "JSON", Value: "JSON"}, - //{Label: "表单", Value: "FormUrlEncoded"}, - //{Label: "文件上传", Value: "FormData"}, - //{Label: "原生文本", Value: "Raw"}, - }, - }, - { - Field: "body", - Label: "请求体内容", - Type: "keyValue", - Required: false, - }, - { - Field: "response", - Label: "结果返回结构", - Type: "keyValue", - Required: false, - }, - { - Field: "responseType", - Label: "结果返回方式", - Type: "select", - Required: true, - Options: []node.SelectOption{ - {Label: "同步返回", Value: "sync"}, - {Label: "等候回调", Value: "callback"}, - {Label: "主动拉取", Value: "pull"}, - }, - Expand: []node.NodeFormField{ - { - Field: "method", - Label: "请求方式", - Type: "select", - Required: true, - Options: []node.SelectOption{ - {Label: "GET", Value: "GET"}, - {Label: "POST", Value: "POST"}, - {Label: "PUT", Value: "PUT"}, - {Label: "DELETE", Value: "DELETE"}, - }, - }, - { - Field: "url", - Label: "请求地址", - Type: "input", - Required: true, - }, - { - Field: "headers", - Label: "请求头(支持Authorization鉴权)", - Type: "keyValue", - Required: false, - }, - { - Field: "bodyType", - Label: "请求体类型", - Type: "select", - Required: true, - Options: []node.SelectOption{ - {Label: "无", Value: "None"}, - {Label: "JSON", Value: "JSON"}, - //{Label: "表单", Value: "FormUrlEncoded"}, - //{Label: "文件上传", Value: "FormData"}, - //{Label: "原生文本", Value: "Raw"}, - }, - }, - { - Field: "body", - Label: "请求体内容", - Type: "keyValue", - Required: false, - }, - { - Field: "response", - Label: "结果返回结构", - Type: "keyValue", - Required: false, - }, - { - Field: "timeout", - Label: "超时时间(秒)", - Type: "inputNumber", - Required: false, - Default: 30, - }, - { - Field: "insecureSkipVerify", - Label: "跳过HTTPS证书校验", - Type: "switch", - Required: false, - Default: false, - }, - }, - }, - { - Field: "callbackUrl", - Label: "回调地址(只需要填写字段名称)", - Type: "input", - Required: false, - Default: "", - }, - { - Field: "timeout", - Label: "超时时间(秒)", - Type: "inputNumber", - Required: false, - Default: 30, - }, - { - Field: "insecureSkipVerify", - Label: "跳过HTTPS证书校验", - Type: "switch", - Required: false, - Default: false, - }, - }, - ModelConfig: []node.ModelItem{}, - }, - //{ - // NodeCode: node.NodeTypeModel, - // NodeName: node.NodeNameModel, - // SkillOption: true, - // FormConfig: []node.NodeFormField{}, - // ModelConfig: []node.ModelItem{}, - //}, - }, - }, - //{ - // Group: node.NodeGroupCustom, - // Label: node.NodeGroupNameCustom, - // Items: []node.NodeItem{ - // { - // NodeCode: node.NodeTypeCustomNode, - // NodeName: node.NodeNameCustomNode, - // SkillOption: true, - // FormConfig: []node.NodeFormField{ - // {Field: "nodeName", Label: node.FormLabelApiKey, Type: "input", Required: true}, - // {Field: "nodeType", Label: node.FormLabelModel, Type: "input", Required: true}, - // }, - // ModelConfig: []node.ModelItem{}, - // }, - // }, - //}, + tree := node.GetFilterNodeTree([]node.NodeGroup{node.NodeGroupBase}) + if opts, err := videoModelOptions(ctx); err != nil { + g.Log().Warningf(ctx, "加载视频模型选项失败,节点库降级返回: %v", err) + } else { + applyVideoModelOptions(tree, opts) } - tree := &nodeDto.WorkflowNodeTreeRes{ - Groups: WorkflowNodeGroups, - } - - // 3. 遍历分组,根据 typeId=1 给【文本模型节点】追加固定表单 - for gIdx := range tree.Groups { - group := &tree.Groups[gIdx] - - // 遍历分组下的每个节点 - for itemIdx := range group.Items { - item := &group.Items[itemIdx] - if item.NodeCode == node.NodeTypeTextModel || - item.NodeCode == node.NodeTypeImageModel || - item.NodeCode == node.NodeTypeVideoModel || - item.NodeCode == node.NodeTypeAudioModel || - item.NodeCode == node.NodeTypeBatchModel || - item.NodeCode == node.NodeTypeDataConversionModel { - item.ModelConfig = append(item.ModelConfig, node.ModelItem{ - ModelName: "自定义", - }) - } - } - } - - return tree, nil + return &nodeDto.WorkflowNodeTreeRes{ + Groups: tree, + }, nil } -// SetUserInfo 设置用户信息 -func (s *nodeLibraryService) SetUserInfo(ctx context.Context, creator string, tenantId uint64) (headers map[string]string, err error) { - // 创建完整的用户信息 - userInfo := &beans.User{ - UserName: creator, - TenantId: tenantId, - } - ctx = context.WithValue(ctx, "user", *userInfo) - // 提取并保存请求头(在连接升级前) - headers = make(map[string]string) - // 提取其他headers - if r := g.RequestFromCtx(ctx); r != nil { - for k, v := range r.Request.Header { - if len(v) > 0 { - headers[k] = v[0] - } - } - } - // 将完整用户信息序列化为JSON,放到X-User-Info请求头 - userInfoJson, err := gjson.Encode(userInfo) +// videoModelOptions 按视频模型类型(600)查模型网关,转成 modelId 下拉选项:key=模型ID,value=模型名称。 +func videoModelOptions(ctx context.Context) ([]node.SelectOption, error) { + res, err := gateway.ListModelManage(ctx, &gateway.ListModelManageReq{ModelType: model.TypeVideo}) if err != nil { - return nil, fmt.Errorf("用户信息序列化失败: %w", err) + return nil, err + } + opts := make([]node.SelectOption, 0, len(res.List)) + for _, item := range res.List { + if item == nil || item.Id <= 0 || item.ModelName == "" { + continue + } + opts = append(opts, node.SelectOption{ + Key: strconv.FormatInt(item.Id, 10), + Value: item.ModelName, + }) + } + return opts, nil +} + +// applyVideoModelOptions 把 script_transcribe 节点的 modelId 预设下拉替换为视频模型选项。 +// 深拷贝 PresetOption 后再改,避免改写全局 NodeTypeMetaList。 +func applyVideoModelOptions(tree []node.NodeGroupTree, opts []node.SelectOption) { + for gi := range tree { + for ni := range tree[gi].Nodes { + n := &tree[gi].Nodes[ni] + if n.Key != node.NodeTypeScriptTranscribe { + continue + } + presets := append([]node.NodePresetField(nil), n.PresetOption...) + for pi := range presets { + if presets[pi].Field == "modelId" { + presets[pi].Options = opts + break + } + } + n.PresetOption = presets + return + } } - headers["X-User-Info"] = string(userInfoJson) - return } diff --git a/workflow/service/session/session_service.go b/workflow/service/session/session_service.go index 5c7c779..fa96184 100644 --- a/workflow/service/session/session_service.go +++ b/workflow/service/session/session_service.go @@ -1,14 +1,23 @@ package session import ( + "ai-agent/gateway" + "ai-agent/workflow/consts/flow" + flowDao "ai-agent/workflow/dao/flow" sessionDao "ai-agent/workflow/dao/session" + flowDto "ai-agent/workflow/model/dto/flow" sessionDto "ai-agent/workflow/model/dto/session" "ai-agent/workflow/model/entity" + flowService "ai-agent/workflow/service/flow" "context" + "fmt" "sort" "strings" + "gitea.redpowerfuture.com/red-future/common/beans" "gitea.redpowerfuture.com/red-future/common/utils" + "github.com/gogf/gf/v2/os/glog" + "github.com/gogf/gf/v2/util/gconv" ) var SessionService = &sessionService{} @@ -19,6 +28,7 @@ type sessionService struct{} const ( resultStatusSuccess = 2 resultStatusFailed = 3 + resultStatusCancel = 4 ) func (s *sessionService) List(ctx context.Context, req *sessionDto.ListSessionReq) (res *sessionDto.ListSessionRes, err error) { @@ -26,14 +36,18 @@ func (s *sessionService) List(ctx context.Context, req *sessionDto.ListSessionRe if err != nil { return } - list, total, err := sessionDao.SessionDao.List(ctx, user.UserName, req.Page) + var page *beans.Page + if req.PageSize > 0 { + page = &beans.Page{PageNum: req.PageNum, PageSize: req.PageSize} + } + list, total, err := sessionDao.SessionDao.List(ctx, user.UserName, page) if err != nil { return } res = &sessionDto.ListSessionRes{Total: total} for _, item := range list { res.List = append(res.List, &sessionDto.VOSession{ - Id: item.Id, + SessionId: item.SessionId, SessionName: item.SessionName, CreatedAt: item.CreatedAt, }) @@ -46,6 +60,28 @@ func (s *sessionService) Delete(ctx context.Context, req *sessionDto.DeleteSessi return } +func (s *sessionService) DeleteRecord(ctx context.Context, req *sessionDto.DeleteSessionRecordReq) (err error) { + var chatIds, wfIds []int64 + for _, item := range req.Ids { + if item.Type == "chat" { + chatIds = append(chatIds, item.Id) + } else { + wfIds = append(wfIds, item.Id) + } + } + if len(chatIds) > 0 { + if _, e := sessionDao.ExecChatDao.Delete(ctx, &sessionDto.DeleteExecChatReq{Id: chatIds}); e != nil { + return e + } + } + if len(wfIds) > 0 { + if _, e := sessionDao.ExecWorkflowDao.Delete(ctx, &sessionDto.DeleteExecWorkflowReq{Id: wfIds}); e != nil { + return e + } + } + return +} + // Get 会话内结果:普通对话 + 工作流执行混排,按创建时间倒序,分页 func (s *sessionService) Get(ctx context.Context, req *sessionDto.GetSessionInfoReq) (res *sessionDto.GetSessionInfoRes, err error) { chatList, err := sessionDao.ExecChatDao.ListBySession(ctx, req.SessionId) @@ -60,16 +96,18 @@ func (s *sessionService) Get(ctx context.Context, req *sessionDto.GetSessionInfo if err != nil { return } + prefix, _ := utils.GetFileAddressPrefix(ctx) // 工作流结果按 exec_id 分组,合并到对应执行记录的结果文件URL resultByExec := make(map[int64][]string) for _, wr := range wfResultList { if wr.ResultFileUrl != "" { - resultByExec[wr.ExecId] = append(resultByExec[wr.ExecId], wr.ResultFileUrl) + resultByExec[wr.ExecId] = append(resultByExec[wr.ExecId], prefix+wr.ResultFileUrl) } } res = &sessionDto.GetSessionInfoRes{} for _, c := range chatList { + c.ResultFileUrl = prefix + c.ResultFileUrl res.List = append(res.List, chatExecVO(c)) } for _, w := range wfList { @@ -87,8 +125,8 @@ func (s *sessionService) Get(ctx context.Context, req *sessionDto.GetSessionInfo }) res.Total = len(res.List) - if req.Page != nil && req.Page.PageSize > 0 { - start := int((req.Page.PageNum - 1) * req.Page.PageSize) + if req.PageSize > 0 { + start := int((req.PageNum - 1) * req.PageSize) if start < 0 { start = 0 } @@ -96,15 +134,41 @@ func (s *sessionService) Get(ctx context.Context, req *sessionDto.GetSessionInfo res.List = nil return } - end := start + int(req.Page.PageSize) + end := start + int(req.PageSize) if end > res.Total { end = res.Total } res.List = res.List[start:end] } + + // 读取结果文件内容(仅 .txt)放入 ResultContent(仅当前页),供前端直接展示;路径仍保留在 ResultFileUrl + for _, vo := range res.List { + vo.ResultContent = readResultFileContent(ctx, vo.ResultFileUrl) + } return } +// readResultFileContent 读取结果 txt 文件内容(支持逗号分隔的多个 URL),仅 .txt 文件被读取,多个内容用换行连接 +func readResultFileContent(ctx context.Context, fileUrl string) string { + if fileUrl == "" { + return "" + } + var parts []string + for _, u := range strings.Split(fileUrl, ",") { + u = strings.TrimSpace(u) + if u == "" || !strings.HasSuffix(strings.ToLower(u), ".txt") { + continue + } + fileBytes, err := gateway.GetFileBytesFromURL(ctx, u) + if err != nil { + glog.Warningf(ctx, "读取结果 txt 文件失败: %v", err) + continue + } + parts = append(parts, string(fileBytes)) + } + return strings.Join(parts, "\n") +} + func chatExecVO(c *entity.ExecChat) *sessionDto.VOSessionInfoResult { status := resultStatusSuccess if c.ErrorMessage != "" { @@ -124,12 +188,20 @@ func chatExecVO(c *entity.ExecChat) *sessionDto.VOSessionInfoResult { } func workflowExecVO(w *entity.ExecWorkflow, resultFileUrl string) *sessionDto.VOSessionInfoResult { + status := 1 + if w.Status == flow.FlowExecutionStatusFailed.Code() { + status = resultStatusFailed + } else if w.Status == flow.FlowExecutionStatusSuccess.Code() { + status = resultStatusSuccess + } else if w.Status == flow.FlowExecutionStatusCancel.Code() { + status = resultStatusCancel + } return &sessionDto.VOSessionInfoResult{ Id: w.Id, Type: "workflow", - Status: w.Status, + Status: status, FlowId: w.FlowId, - RequestParams: w.RequestParams, + RequestParams: gconv.Map(w.RequestParams), ResultFileUrl: resultFileUrl, TotalTokens: w.TotalTokens, TotalFee: w.TotalFee, @@ -137,3 +209,120 @@ func workflowExecVO(w *entity.ExecWorkflow, resultFileUrl string) *sessionDto.VO CreatedAt: w.CreatedAt, } } + +// ResultList 工作流执行结果树:按创建人分页查询工作流执行记录,返回按天分组的树结构(日期→流程→结果文件)。 +// 分页单位为"天":每页返回 pageSize 个完整日期,同一天内的流程与文件不会被拆到不同页;pageSize 为 0 时返回全部。 +func (s *sessionService) ResultList(ctx context.Context, req *sessionDto.ListWorkflowResultReq) (res *flowDto.ListFlowExecutionTreeRes, err error) { + user, err := utils.GetUserInfo(ctx) + if err != nil { + return + } + dates, err := sessionDao.ExecWorkflowDao.ListDates(ctx, user.UserName, req.Page) + if err != nil { + return + } + res = &flowDto.ListFlowExecutionTreeRes{} + res.ImgAddressPrefix, _ = utils.GetFileAddressPrefix(ctx) + if len(dates) == 0 { + return + } + + execs, err := sessionDao.ExecWorkflowDao.ListByDates(ctx, user.UserName, dates) + if err != nil { + return + } + + // 汇总本页所有执行的 id 与去重后的流程 id,一次取回结果文件与流程名 + var execIds []int64 + flowIdSet := make(map[int64]struct{}) + for _, e := range execs { + execIds = append(execIds, e.Id) + flowIdSet[e.FlowId] = struct{}{} + } + results, err := sessionDao.ExecWorkflowResultDao.ListByExecIds(ctx, execIds) + if err != nil { + return + } + flowNameMap := make(map[int64]string) + for fid := range flowIdSet { + if fu, e := flowDao.FlowUserDao.Get(ctx, &flowDto.GetFlowUserReq{Id: fid}); e == nil && fu != nil && fu.FlowName != "" { + flowNameMap[fid] = fu.FlowName + } + } + + resultsByExec := make(map[int64][]*entity.ExecWorkflowResult) + for _, r := range results { + resultsByExec[r.ExecId] = append(resultsByExec[r.ExecId], r) + } + execsByDate := make(map[string][]*entity.ExecWorkflow) + for _, e := range execs { + date := "" + if e.CreatedAt != nil { + date = e.CreatedAt.Format("Y-m-d") + } + execsByDate[date] = append(execsByDate[date], e) + } + + prefix := res.ImgAddressPrefix + for _, d := range dates { + execList := execsByDate[d] + if len(execList) == 0 { + continue + } + var flows []flowDto.FlowNode + for _, e := range execList { + flowName := flowNameMap[e.FlowId] + if flowName == "" { + flowName = "工作流" + } + var items []flowDto.OutputItem + suffixCount := make(map[string]int) + for _, rf := range resultsByExec[e.Id] { + if rf.ResultFileUrl == "" { + continue + } + content := prefix + rf.ResultFileUrl + ext := flowService.GetFileTypeByPath(content) + suffix := outputItemSuffix(ext) + suffixCount[suffix]++ + items = append(items, flowDto.OutputItem{ + Content: content, + Type: ext, + Label: fmt.Sprintf("%s_%d", suffix, suffixCount[suffix]), + }) + } + if len(items) == 0 { + continue + } + flows = append(flows, flowDto.FlowNode{ + FlowName: flowName, + Id: e.Id, + SessionId: e.SessionId, + Items: items, + }) + } + if len(flows) == 0 { + continue + } + res.Tree = append(res.Tree, flowDto.DateNode{CreateDate: d, Flows: flows}) + } + return +} + +// outputItemSuffix 按文件类型映射结果项的中文标签前缀(与 flow 侧旧逻辑保持一致) +func outputItemSuffix(ext string) string { + switch ext { + case "image": + return "图片" + case "video": + return "视频" + case "audio": + return "音频" + case "text": + return "文案" + case "html": + return "HTML" + default: + return "内容" + } +} diff --git a/workflow/service/tool/tool_service.go b/workflow/service/tool/tool_service.go new file mode 100644 index 0000000..d7cebc3 --- /dev/null +++ b/workflow/service/tool/tool_service.go @@ -0,0 +1,26 @@ +package tool + +import ( + toolDto "ai-agent/workflow/model/dto/tool" + "ai-agent/workflow/service/flow/processor" + "context" +) + +var ToolService = &toolService{} + +type toolService struct{} + +func (s *toolService) List(ctx context.Context, req *toolDto.ToolListReq) (*toolDto.ToolListRes, error) { + list, err := processor.List(ctx) + if err != nil { + return nil, err + } + voList := make([]*toolDto.ToolVO, 0, len(list)) + for _, t := range list { + voList = append(voList, &toolDto.ToolVO{ + Name: t.Name, + Description: t.Description, + }) + } + return &toolDto.ToolListRes{List: voList}, nil +}