feat: 新增执行记录实体与文件上传能力

This commit is contained in:
2026-08-18 09:47:51 +08:00
parent 155a5cad0c
commit a939c45508
67 changed files with 7341 additions and 4001 deletions
+34
View File
@@ -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
}
+77
View File
@@ -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
}
+211
View File
@@ -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 entityid 在顶层),
// 与 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
}
+157
View File
@@ -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(文本增量转发),
// 流结束返回 ModelCallResContent 为累加全量文本({"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()
}
+24 -7
View File
@@ -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
+218 -28
View File
@@ -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=
+4 -6
View File
@@ -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()
}()
// 保持应用运行
@@ -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
},
}
}
+258
View File
@@ -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 解析同步对话响应 Contentmodel-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
}
+157 -32
View File
@@ -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);
-- 索引(高频查询)
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表语句---------------------------
+40
View File
@@ -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
+27
View File
@@ -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}
}
+427 -97
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
package public
const GmqMsgPluginsName = "gmq_model_msg"
+15 -14
View File
@@ -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"
)
@@ -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)
@@ -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)
}
@@ -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)
}
+60
View File
@@ -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
}
+3
View File
@@ -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))
+1 -1
View File
@@ -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).
+48 -2
View File
@@ -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
}
@@ -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
}
+12 -1
View File
@@ -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
}
+33 -124
View File
@@ -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:"流程名称"`
+1
View File
@@ -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:"关键词搜索"`
}
@@ -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 节点执行记录响应
+1 -17
View File
@@ -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"`
}
@@ -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不能为空"`
}
@@ -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:"错误信息"`
}
@@ -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不能为空"`
}
+55 -63
View File
@@ -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:"工作流IDchat类型为空)"`
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:"工作流IDchat类型为空)"`
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:"创建时间"`
}
+35
View File
@@ -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"`
}
+41
View File
@@ -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",
}
+47
View File
@@ -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",
}
@@ -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",
}
+25
View File
@@ -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",
}
+3
View File
@@ -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",
}
+30 -14
View File
@@ -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
}
+3
View File
@@ -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",
+3 -71
View File
@@ -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",
}
@@ -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))
}
+5 -572
View File
@@ -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
}
+262
View File
@@ -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
}
+160
View File
@@ -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
}
+217
View File
@@ -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(`<p class=['"]image-count['"][^>]*>.*?(\d+).*?</p>`)
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% 删除 <p class='image-count'>
imageTagRegex := regexp.MustCompile(`<p class=['"]image-count['"][^>]*>[\s\S]*?</p>`)
return imageTagRegex.ReplaceAllString(html, "")
}
// StripHtmlTags 去掉所有HTML标签,保留换行和文本结构,并删除配图标记行
func StripHtmlTags(html string) string {
// 1. 替换块级标签为换行,保证排版
blockTags := regexp.MustCompile(`</?(div|p|h1|h2|h3|h4|h5|h6|li|ul|ol|br|tr|td|th)[^>]*>`)
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
// 正则匹配<div class="content-item" id="content-{序号}">包裹的内容
re := regexp.MustCompile(`<div class="content-item" id="content-\d+">([\s\S]*?)</div>`)
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(`<img[^>]*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 {
// 精准替换:找到 <img xxx src="oldSrc" xxx>
re := regexp.MustCompile(`(<img[^>]*src\s*=\s*["'])` + regexp.QuoteMeta(oldSrc) + `(["'])`)
return re.ReplaceAllString(html, `${1}`+newSrc+`${2}`)
}
+23 -50
View File
@@ -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
}
+331
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+140 -801
View File
@@ -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 {
// 直接用 innerKeysubtitle_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(`
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Microsoft YaHei", "PingFang SC", Arial, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.8;
padding: 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.item {
padding: 30px;
}
.image-group img {
width: 100%;
height: auto;
display: block;
margin-bottom: 6px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.image-group img:last-child {
margin-bottom: 0;
}
.image-group {
margin-bottom: 25px;
}
.text {
padding: 0;
font-size: 15px;
line-height: 1.4;
color: #555;
}
.text h2 {
font-size: 28px;
font-weight: bold;
color: #1a1a1a;
margin-bottom: 15px;
line-height: 1.2;
}
.text h3 {
font-size: 20px;
font-weight: 600;
color: #2c3e50;
margin: 20px 0 12px;
padding-left: 12px;
border-left: 4px solid #409eff;
}
.text p {
margin-bottom: 12px;
text-align: justify;
}
.text strong {
color: #e74c3c;
font-weight: 600;
}
.text ul {
list-style: none;
padding: 0;
margin: 8px 0;
}
.text ul li {
padding: 10px 0 10px 30px;
position: relative;
line-height: 1.2;
}
.text ul li:before {
content: "●";
color: #409eff;
font-size: 12px;
position: absolute;
left: 12px;
top: 12px;
}
@media (max-width: 768px) {
body {
padding: 10px;
}
.text h2 {
font-size: 24px;
}
.text h3 {
font-size: 18px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="item">
`)
// 🔥 写入文案前:删除 <p class="image-count">需要配图:X 张</p>
if text != "" {
// 写入清理后的文案
htmlBuilder.WriteString(fmt.Sprintf(`<div class="text">%s</div>`, ImageTagRegex(text)))
}
htmlBuilder.WriteString(`</div>
</div>
</body>
</html>`)
return htmlBuilder.String()
}
func BuildHtml(text string, images []string) string {
var htmlBuilder strings.Builder
htmlBuilder.WriteString(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Microsoft YaHei", sans-serif;
padding: 20px;
background-color: #f6f6f6;
line-height: 1.7;
font-size: 16px;
color: #333;
}
.container {
max-width: 750px;
margin: 0 auto;
background: #fff;
padding: 30px;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
}
</style>
</head>
<body>
<div class="container">
`)
// 写入图片(支持0张、1张、多张)
if len(images) > 0 {
htmlBuilder.WriteString(`<div class="image-group">`)
for _, imgUrl := range images {
htmlBuilder.WriteString(fmt.Sprintf(`<img src="%s" alt="图片"/>`, imgUrl))
}
htmlBuilder.WriteString(`</div>`)
}
htmlBuilder.WriteString(`
<div id="content">加载中...</div>
</div>
<script>
const incUrl = "` + text + `";
fetch(incUrl)
.then(res => {
if (!res.ok) throw new Error("加载失败");
return res.text();
})
.then(text => {
document.getElementById("content").innerHTML = text;
})
.catch(err => {
document.getElementById("content").innerHTML = "加载失败:" + err.message;
});
</script>
</body>
</html>`)
return htmlBuilder.String()
}
// ExtractImageCount 修复:支持单引号/双引号 + 换行 + 空格
func ExtractImageCount(content string) int {
// 🔥 关键:支持 class='image-count' (单引号)
re := regexp.MustCompile(`<p class=['"]image-count['"][^>]*>.*?(\d+).*?</p>`)
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% 删除 <p class='image-count'>
imageTagRegex := regexp.MustCompile(`<p class=['"]image-count['"][^>]*>[\s\S]*?</p>`)
return imageTagRegex.ReplaceAllString(html, "")
}
// StripHtmlTags 去掉所有HTML标签,保留换行和文本结构,并删除配图标记行
func StripHtmlTags(html string) string {
// 1. 替换块级标签为换行,保证排版
blockTags := regexp.MustCompile(`</?(div|p|h1|h2|h3|h4|h5|h6|li|ul|ol|br|tr|td|th)[^>]*>`)
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
// 正则匹配<div class="content-item" id="content-{序号}">包裹的内容
re := regexp.MustCompile(`<div class="content-item" id="content-\d+">([\s\S]*?)</div>`)
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(`<img[^>]*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 {
// 精准替换:找到 <img xxx src="oldSrc" xxx>
re := regexp.MustCompile(`(<img[^>]*src\s*=\s*["'])` + regexp.QuoteMeta(oldSrc) + `(["'])`)
return re.ReplaceAllString(html, `${1}`+newSrc+`${2}`)
return outputRes, nil
}
@@ -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_formatStructuredOutput)即走原生 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)
}
@@ -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: "<jsonType>", 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: <schemaEditorType>} 包装节点
func isSchemaWrapperNode(m map[string]interface{}) bool {
t, ok := m["type"].(string)
return ok && isSchemaEditorType(t)
}
// isSchemaNodeEmpty 判断 schema 节点是否已无有效内容:
// 标量看 value0/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
}
}
@@ -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.SegmentResultplan 包已并入处理器树)。
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字段>: 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 ""
}
@@ -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 即模型请求参数本体(需已解析好 valueSourcevalue 已填充)。
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
}
}
@@ -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}
}
@@ -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")
}
@@ -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
}
@@ -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
}
@@ -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:时间码可用且可容纳时按相对时长整体缩放对齐到
// TotalDurationnormalizeAIDurations,整数化 + 均衡修正,构造即 Σ==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
}
// ---------- ③ GroupSegments2026-08-12 修订,取代 CalcSegmentDurations/AlignSegments----------
// GroupSegments 按镜头边界贪心分组:段内镜头时长之和不超过 MaxSegmentDurmin 为硬约束
// (段不足 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
}
@@ -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
}
@@ -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
}
+183
View File
@@ -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})
}
}
+41
View File
@@ -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
}
+50 -365
View File
@@ -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=模型IDvalue=模型名称。
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
}
+197 -8
View File
@@ -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 "内容"
}
}
+26
View File
@@ -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
}