feat: 新增执行记录实体与文件上传能力
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// Package gateway ai-agent 对外部服务的调用层:模型网关(model-gateway)的查询/提交/聊天调用,以及文件下载/上传(OSS)。
|
||||
// 从 workflow/service/flow 抽离为独立目录,供工作流节点与视频管线(video/)共用,业务侧不再直接依赖 workflow 引擎。
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"ai-agent/workflow/consts/model"
|
||||
"ai-agent/workflow/consts/public"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
commonHttp "gitea.redpowerfuture.com/red-future/common/http"
|
||||
gmq "github.com/bjang03/gmq/core/gmq"
|
||||
"github.com/bjang03/gmq/mq"
|
||||
"github.com/bjang03/gmq/types"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ListModelManageReq 配置列表
|
||||
type ListModelManageReq struct {
|
||||
*beans.Page `json:"page"`
|
||||
ModelName string `p:"modelName" json:"modelName" dc:"模型名称(模糊查询,可选)"`
|
||||
ModelType int `p:"modelType" json:"modelType" dc:"模型类型"`
|
||||
}
|
||||
|
||||
// ModelManageListItem 模型配置列表项。listModelManage 返回平铺的 model-gateway entity(id 在顶层),
|
||||
// 与 getModelManage 的 {modelManage:{...}} 嵌套不同,故独立成结构而非复用 GetModelInfoByIdRes。
|
||||
type ModelManageListItem struct {
|
||||
Id int64 `json:"id" dc:"配置ID"`
|
||||
ModelName string `json:"modelName" dc:"模型名称"`
|
||||
}
|
||||
|
||||
type ListModelManageRes struct {
|
||||
List []*ModelManageListItem `json:"list" dc:"列表数据"`
|
||||
Total int `json:"total" dc:"总数"`
|
||||
}
|
||||
|
||||
// GetModelInfoByIdReq 查询模型配置
|
||||
type GetModelInfoByIdReq struct {
|
||||
ModelId int64 `json:"id" dc:"模型ID"`
|
||||
}
|
||||
|
||||
type GetModelInfoByIdRes struct {
|
||||
ModelManage struct {
|
||||
Id int64 `json:"id" dc:"模型ID"`
|
||||
ModelType model.ModelType `json:"modelType" description:"模型类型"`
|
||||
ModelName string `json:"modelName" description:"模型名称"`
|
||||
MaxTokens int `json:"maxTokens" description:"最大token数"`
|
||||
ChatModel *bool `json:"chatModel" description:"是否聊天模型"`
|
||||
ResponseType model.ResponseType `json:"responseType" description:"返回类型:1同步,2异步,3流"`
|
||||
Enabled *bool `json:"enabled" description:"是否启用"`
|
||||
RequestBodyMapping map[string]any `json:"requestBodyMapping" description:"请求体映射(约等于视频模型 schema)"`
|
||||
RequestBusinessFieldMapping map[string]string `json:"requestBusinessFieldMapping" description:"请求业务字段映射"`
|
||||
MinDuration int `json:"minDuration" description:"最小时长(秒)"`
|
||||
MaxDuration int `json:"maxDuration" description:"最大时长(秒)"`
|
||||
} `json:"modelManage" dc:"模型配置"`
|
||||
}
|
||||
|
||||
// modelCallTopic 生成异步模型消息主题:带业务标识(bizName/modelId/sessionId)便于排查,
|
||||
// uuid 保证每次调用唯一,避免并发调用共用同一主题导致结果串扰
|
||||
func modelCallTopic(bizName string, modelId int64, sessionId string) string {
|
||||
return fmt.Sprintf("model-call-%s-%d-%s-%s", bizName, modelId, sessionId, uuid.NewString())
|
||||
}
|
||||
|
||||
// ModelCallReq 调用模型网关
|
||||
type ModelCallReq struct {
|
||||
ModelId int64 `json:"modelId" dc:"模型ID"`
|
||||
BizName string `json:"bizName" dc:"业务名称(调用方模块/系统,用于统计)"`
|
||||
SessionId string `json:"sessionId" dc:"会话ID"`
|
||||
RequestParams map[string]any `json:"requestParams" dc:"请求参数"`
|
||||
BusinessParams map[string]any `json:"businessParams" dc:"业务参数"`
|
||||
MsgTopic string `json:"msgTopic" dc:"消息主题(异步必要参数)"`
|
||||
}
|
||||
|
||||
type ModelCallRes struct {
|
||||
TaskId int64 `json:"id" dc:"任务ID"`
|
||||
State int8 `json:"state" dc:"状态"`
|
||||
TotalTokens int64 `json:"totalTokens" dc:"总token"`
|
||||
PromptTokens int64 `json:"promptTokens" dc:"输入token"`
|
||||
CompletionTokens int64 `json:"completionTokens" dc:"输出token"`
|
||||
Tools []ModelTool `json:"tools" dc:"工具"`
|
||||
Content map[string]any `json:"content" dc:"内容"`
|
||||
Cost float64 `json:"cost" dc:"费用(元)"`
|
||||
ErrorMsg string `json:"errorMsg" dc:"错误消息"`
|
||||
}
|
||||
|
||||
type ModelTool struct {
|
||||
Id string `json:"id" dc:"工具ID"`
|
||||
Type string `json:"type" dc:"工具类型"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
}
|
||||
|
||||
// requestHeaders 透传当前 HTTP 请求头(鉴权/链路信息)。
|
||||
// 浏览器 WebSocket 握手无法携带 Authorization 头,前端把 token 放在握手 URL query(?token=)里;
|
||||
// 若请求头没有 Authorization,则从 query 补回,保证下游(model-gateway → admin-go)能拿到用户 token。
|
||||
func requestHeaders(ctx context.Context) map[string]string {
|
||||
headers := make(map[string]string)
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
for k, v := range r.Request.Header {
|
||||
if len(v) > 0 {
|
||||
headers[k] = v[0]
|
||||
}
|
||||
}
|
||||
if headers["Authorization"] == "" {
|
||||
if t := r.Request.URL.Query().Get("token"); t != "" {
|
||||
headers["Authorization"] = "Bearer " + t
|
||||
}
|
||||
}
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
// ListModelManage 配置列表
|
||||
func ListModelManage(ctx context.Context, req *ListModelManageReq) (res *ListModelManageRes, err error) {
|
||||
res = new(ListModelManageRes)
|
||||
err = commonHttp.Get(ctx, "model-gateway/model/manage/listModelManage", requestHeaders(ctx), res, req)
|
||||
return
|
||||
}
|
||||
|
||||
// GetModelInfoById 查询模型配置
|
||||
func GetModelInfoById(ctx context.Context, req *GetModelInfoByIdReq) (res *GetModelInfoByIdRes, err error) {
|
||||
res = new(GetModelInfoByIdRes)
|
||||
err = commonHttp.Get(ctx, "model-gateway/model/manage/getModelManage", requestHeaders(ctx), res, req)
|
||||
return
|
||||
}
|
||||
|
||||
// ModelCallResult 调模型网关生成一段内容并等待结果。
|
||||
// 视频等异步模型为"提交+等待"一体:内部订阅 GMQ 直到结果返回。
|
||||
func ModelCallResult(ctx context.Context, modelId int64, responseType model.ResponseType, sessionId string, requestParams map[string]any, businessParams map[string]any) (responseParams *ModelCallRes, err error) {
|
||||
// 异步模型必须绑定消息主题接收结果:自动生成唯一主题,
|
||||
// 带业务标识(bizName/modelId/sessionId)便于排查,每次调用唯一避免并发串结果
|
||||
msgTopic := ""
|
||||
if *responseType == *model.ResponseTypeAsync.Code() {
|
||||
msgTopic = modelCallTopic(g.Cfg().MustGet(ctx, "server.name").String(), modelId, sessionId)
|
||||
}
|
||||
|
||||
req := ModelCallReq{
|
||||
ModelId: modelId,
|
||||
BizName: g.Cfg().MustGet(ctx, "server.name").String(),
|
||||
SessionId: sessionId,
|
||||
RequestParams: requestParams,
|
||||
BusinessParams: businessParams,
|
||||
MsgTopic: msgTopic,
|
||||
}
|
||||
// 2. 克隆 commonHttp 客户端(保留 Consul 服务发现),显式设置超时和 ResponseHeaderTimeout
|
||||
client := commonHttp.Httpclient.Clone()
|
||||
client.SetTimeout(30 * time.Minute)
|
||||
if tr, ok := client.Transport.(*http.Transport); ok {
|
||||
tr.ResponseHeaderTimeout = 30 * time.Minute
|
||||
}
|
||||
|
||||
res := new(ModelCallRes)
|
||||
err = commonHttp.Post(ctx, "model-gateway/model/call/modelCall", requestHeaders(ctx), res, &req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if g.IsEmpty(res.TaskId) || !g.IsEmpty(res.ErrorMsg) {
|
||||
return nil, fmt.Errorf("创建模型任务失败:%v", res.ErrorMsg)
|
||||
}
|
||||
// 3. 订阅模型结果(异步模型)
|
||||
if *responseType == *model.ResponseTypeAsync.Code() {
|
||||
resultCh := make(chan *ModelCallRes, 1)
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
_, err = gmq.GetGmq(public.GmqMsgPluginsName).GmqSubscribe(ctx, &mq.NatsSubMessage{
|
||||
SubMessage: types.SubMessage{
|
||||
Topic: msgTopic,
|
||||
ConsumerName: fmt.Sprintf("model-call-result-%s", uuid.NewString()),
|
||||
AutoAck: false,
|
||||
AutoUnsubscribe: true,
|
||||
FetchCount: 1,
|
||||
HandleFunc: func(ctx context.Context, msg any) error {
|
||||
r := new(ModelCallRes)
|
||||
if err := gconv.Struct(msg, r); err != nil {
|
||||
errCh <- err
|
||||
return nil
|
||||
}
|
||||
if g.IsEmpty(r.TaskId) || !g.IsEmpty(r.ErrorMsg) {
|
||||
errCh <- fmt.Errorf("创建模型任务失败:%v", r.ErrorMsg)
|
||||
return nil
|
||||
}
|
||||
resultCh <- r
|
||||
return nil
|
||||
},
|
||||
},
|
||||
Durable: true,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case responseParams = <-resultCh:
|
||||
case err = <-errCh:
|
||||
return nil, err
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
commonHttp "gitea.redpowerfuture.com/red-future/common/http"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// ModelCallStreamReq 调用模型网关流式(client 侧请求体)
|
||||
type ModelCallStreamReq struct {
|
||||
ModelId int64 `json:"modelId" dc:"模型ID"`
|
||||
BizName string `json:"bizName" dc:"业务名称"`
|
||||
SessionId string `json:"sessionId" dc:"会话ID"`
|
||||
RequestParams map[string]any `json:"requestParams" dc:"请求参数(模板字段)"`
|
||||
BusinessParams map[string]any `json:"businessParams" dc:"业务参数"`
|
||||
}
|
||||
|
||||
// ModelCallStream 流式调用模型网关:POST /modelCallStream,逐 chunk 调 onChunk(文本增量转发),
|
||||
// 流结束返回 ModelCallRes:Content 为累加全量文本({"respBody": ...}),Tools 为流末工具列表。
|
||||
func ModelCallStream(ctx context.Context, modelId int64, sessionId string, requestParams, businessParams map[string]any, onChunk func(chunk map[string]any) error) (*ModelCallRes, error) {
|
||||
req := ModelCallStreamReq{
|
||||
ModelId: modelId,
|
||||
BizName: g.Cfg().MustGet(ctx, "server.name").String(),
|
||||
SessionId: sessionId,
|
||||
RequestParams: requestParams,
|
||||
BusinessParams: businessParams,
|
||||
}
|
||||
body, err := commonHttp.PostStream(ctx, "model-gateway/model/call/modelCallStream", requestHeaders(ctx), &req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
res := new(ModelCallRes)
|
||||
var contentBuf strings.Builder
|
||||
scanErr := parseSSEStream(ctx, body, func(chunk map[string]any) error {
|
||||
tools, done := processStreamChunk(chunk)
|
||||
if done {
|
||||
res.Tools = tools
|
||||
// 流末 done 事件携带该次调用累计 token 与费用:纯工具调用步骤无文本 chunk,靠它取数(>0 才覆盖,兼容旧网关无 token/cost 的 done 事件)
|
||||
if total := gconv.Int64(chunk["totalTokens"]); total > 0 {
|
||||
res.TotalTokens = total
|
||||
res.PromptTokens = gconv.Int64(chunk["promptTokens"])
|
||||
res.CompletionTokens = gconv.Int64(chunk["completionTokens"])
|
||||
}
|
||||
if cost := gconv.Float64(chunk["cost"]); cost > 0 {
|
||||
res.Cost = cost
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if onChunk != nil {
|
||||
if err := onChunk(chunk); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
contentBuf.WriteString(streamTextDelta(chunk))
|
||||
// 网关端每个 chunk 携带累计 token 值,取最后一个文本 chunk 即为该步总消耗
|
||||
res.TotalTokens = gconv.Int64(chunk["totalTokens"])
|
||||
res.PromptTokens = gconv.Int64(chunk["promptTokens"])
|
||||
res.CompletionTokens = gconv.Int64(chunk["completionTokens"])
|
||||
res.Cost = gconv.Float64(chunk["cost"])
|
||||
return nil
|
||||
})
|
||||
if scanErr != nil {
|
||||
// 流被中断(如 ctx 取消)时仍返回已累计的 res,调用方可读取已产生的 token 正常落库
|
||||
return res, scanErr
|
||||
}
|
||||
if contentBuf.Len() > 0 {
|
||||
res.Content = map[string]any{"respBody": contentBuf.String()}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// processStreamChunk 处理单个流式 chunk。done 事件({"type":"done","tools":[...]})返回解析出的
|
||||
// tools(可为空)且 done=true;其余为文本增量 chunk,返回 done=false。
|
||||
func processStreamChunk(chunk map[string]any) (tools []ModelTool, done bool) {
|
||||
if typ, _ := chunk["type"].(string); typ == "done" {
|
||||
raw, _ := chunk["tools"].([]any)
|
||||
for _, t := range raw {
|
||||
m := gconv.Map(t)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
var tool ModelTool
|
||||
tool.Id = gconv.String(m["id"])
|
||||
tool.Type = gconv.String(m["type"])
|
||||
if fn := gconv.Map(m["function"]); fn != nil {
|
||||
tool.Function.Name = gconv.String(fn["name"])
|
||||
tool.Function.Arguments = gconv.String(fn["arguments"])
|
||||
}
|
||||
tools = append(tools, tool)
|
||||
}
|
||||
return tools, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// streamTextDelta 提取文本增量 chunk 的文本(读 content 子对象,字段名由网关端结构体统一管理)
|
||||
func streamTextDelta(chunk map[string]any) string {
|
||||
var sb strings.Builder
|
||||
if content, ok := chunk["content"].(map[string]any); ok {
|
||||
for _, v := range content {
|
||||
if s := gconv.String(v); s != "" {
|
||||
sb.WriteString(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// parseSSEStream 标准 SSE 解析:逐 data 行 JSON 回调,支持 [DONE] 与上下文取消。
|
||||
// 对齐 model-gateway ParseSSEStream 语义:跳过空行、[DONE]、非 data 行。
|
||||
func parseSSEStream(ctx context.Context, reader io.Reader, onChunk func(chunk map[string]any) error) error {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
var sb strings.Builder
|
||||
for scanner.Scan() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
line := scanner.Text()
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
if sb.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
data := sb.String()
|
||||
sb.Reset()
|
||||
if data == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
var chunk map[string]any
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
continue
|
||||
}
|
||||
if onChunk != nil {
|
||||
if err := onChunk(chunk); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
sb.WriteString(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
|
||||
}
|
||||
}
|
||||
return scanner.Err()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Package runner 工具框架的"使用面":ReAct 执行循环。
|
||||
// 模型调用按模型响应类型分流:流式走 gateway.ModelCallStream(逐 chunk 推增量),同步/异步走 gateway.ModelCallResult;
|
||||
// 本包只保留"模型思考→调用工具→观察结果"的循环编排,工具的"使用"统一走 tools.Default.Call。
|
||||
package runner
|
||||
|
||||
import (
|
||||
"ai-agent/gateway"
|
||||
"ai-agent/workflow/consts/model"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/tools"
|
||||
)
|
||||
|
||||
// ReActEventType ReAct 过程事件类型(WebSocket 场景逐条推送,HTTP 同步场景不设置 OnEvent 则忽略)
|
||||
type ReActEventType string
|
||||
|
||||
const (
|
||||
ReActEventModelCall ReActEventType = "model_call" // 模型思考(step 开始)
|
||||
ReActEventToolCall ReActEventType = "tool_call" // 模型请求调用工具
|
||||
ReActEventToolResult ReActEventType = "tool_result" // 工具返回结果
|
||||
ReActEventAnswer ReActEventType = "answer" // 最终回答
|
||||
ReActEventAnswerChunk ReActEventType = "answer_chunk" // 回答内容增量(逐 chunk)
|
||||
ReActEventReasoningChunk ReActEventType = "reasoning_chunk" // 思考内容增量(逐 chunk)
|
||||
ReActEventError ReActEventType = "error"
|
||||
)
|
||||
|
||||
// ReActEvent ReAct 过程事件,按发生顺序回调
|
||||
type ReActEvent struct {
|
||||
Type ReActEventType
|
||||
Step int
|
||||
MaxStep int
|
||||
Description string // 工具用途说明(tool_call/tool_result 推给前端展示,不暴露工具名/参数)
|
||||
Answer string
|
||||
Delta string // 流式文本增量(ReActEventAnswerChunk / ReActEventReasoningChunk 用)
|
||||
Message string
|
||||
Error string
|
||||
}
|
||||
|
||||
// ReActAgent 实现 ReAct 模式的智能体:模型思考→调用工具→观察工具结果→重复→最终回答。
|
||||
// 工具的"使用"统一走 tools.Default.Call:未知工具由 Server 返回结构化 not_found,
|
||||
// 无需在 runner 内维护工具查找表。
|
||||
type ReActAgent struct {
|
||||
ModelId int64
|
||||
SessionId string
|
||||
ToolList []*tools.Tool
|
||||
SystemPrompt string
|
||||
MaxStep int
|
||||
// OnEvent 过程回调(WebSocket 流式推送用;HTTP 同步调用不设置,忽略事件)
|
||||
OnEvent func(ReActEvent)
|
||||
// TotalTokens 本次 ReAct 循环累计 token 消耗(供调用方落库)
|
||||
TotalTokens int64
|
||||
// TotalCost 本次 ReAct 循环累计费用(元,各步模型调用返回的 cost 求和,供调用方落库)
|
||||
TotalCost float64
|
||||
}
|
||||
|
||||
// NewReActAgent 创建 ReAct 智能体
|
||||
func NewReActAgent(modelId int64, sessionId string, toolList []*tools.Tool, systemPrompt string, maxStep int) *ReActAgent {
|
||||
return &ReActAgent{
|
||||
ModelId: modelId,
|
||||
SessionId: sessionId,
|
||||
ToolList: toolList,
|
||||
SystemPrompt: systemPrompt,
|
||||
MaxStep: maxStep,
|
||||
}
|
||||
}
|
||||
|
||||
// Run 执行 ReAct 循环
|
||||
// 标准流程: 思考 → 行动(调用工具) → 观察(工具结果) → 重复 → 最终回答
|
||||
func (a *ReActAgent) Run(ctx context.Context, userInput string) (string, error) {
|
||||
// 模型须为已启用且勾选「聊天模型」的对话模型,再按其响应类型分流调用:
|
||||
// 流式走 ModelCallStream(逐 chunk 推增量),同步/异步走 ModelCallResult(一次返回)
|
||||
modelInfo, err := gateway.GetModelInfoById(ctx, &gateway.GetModelInfoByIdReq{ModelId: a.ModelId})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取模型配置失败: %w", err)
|
||||
}
|
||||
if modelInfo.ModelManage.Enabled == nil || !*modelInfo.ModelManage.Enabled {
|
||||
return "", fmt.Errorf("模型 [%s] 未启用", modelInfo.ModelManage.ModelName)
|
||||
}
|
||||
if modelInfo.ModelManage.ChatModel == nil || !*modelInfo.ModelManage.ChatModel {
|
||||
return "", fmt.Errorf("模型 [%s] 不是对话模型,请在模型配置中勾选「聊天模型」", modelInfo.ModelManage.ModelName)
|
||||
}
|
||||
isStream := *modelInfo.ModelManage.ResponseType == *model.ResponseTypeStream.Code()
|
||||
|
||||
messages := map[string]any{
|
||||
"user_prompt": userInput,
|
||||
"system_prompt": a.SystemPrompt,
|
||||
"tools": a.rawTools(a.ToolList),
|
||||
}
|
||||
for step := 0; step < a.MaxStep; step++ {
|
||||
a.emit(ReActEvent{Type: ReActEventModelCall, Step: step + 1, MaxStep: a.MaxStep})
|
||||
var result *gateway.ModelCallRes
|
||||
if isStream {
|
||||
result, err = gateway.ModelCallStream(ctx, a.ModelId, a.SessionId, nil, messages, func(chunk map[string]any) error {
|
||||
// 文本增量从 content 子对象取,字段名由网关端结构体统一管理
|
||||
if content, ok := chunk["content"].(map[string]any); ok {
|
||||
for _, v := range content {
|
||||
if s := gconv.String(v); s != "" {
|
||||
a.emit(ReActEvent{Type: ReActEventAnswerChunk, Delta: s})
|
||||
}
|
||||
}
|
||||
}
|
||||
// 思考内容增量从 reasoningContent 字段取(model-gateway 按 ResponseBusinessFieldMapping 配置返回)
|
||||
if s := gconv.String(chunk["reasoningContent"]); s != "" {
|
||||
a.emit(ReActEvent{Type: ReActEventReasoningChunk, Delta: s})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
} else {
|
||||
// 同步/异步对话模型:结果一次返回,无增量事件(异步 msgTopic 为空时由 gateway.ModelCallResult 自动生成)
|
||||
result, err = gateway.ModelCallResult(ctx, a.ModelId, modelInfo.ModelManage.ResponseType, a.SessionId, nil, messages)
|
||||
}
|
||||
// 出错时 result 仍可能携带已累计的 token/cost(流中断返回 res),先累加再判错
|
||||
if result != nil {
|
||||
a.TotalTokens += result.TotalTokens
|
||||
a.TotalCost += result.Cost
|
||||
}
|
||||
if err != nil {
|
||||
// 前端终止/上下文取消:静默停止,不推错误事件
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return "", err
|
||||
}
|
||||
a.emit(ReActEvent{Type: ReActEventError, Message: "模型调用失败", Error: err.Error()})
|
||||
return "", fmt.Errorf("step %d: model call failed: %w", step, err)
|
||||
}
|
||||
var content string
|
||||
var toolCalls []gateway.ModelTool
|
||||
if isStream {
|
||||
for _, v := range gconv.Map(result.Content) {
|
||||
content += v.(string)
|
||||
}
|
||||
toolCalls = result.Tools
|
||||
} else {
|
||||
// 同步响应 Content 按 responseBodyMapping 抽取(content/toolCalls/finishReason),工具在 toolCalls 里
|
||||
content, toolCalls = syncChatResult(result.Content)
|
||||
}
|
||||
// 无工具调用 → 最终回答
|
||||
if len(toolCalls) == 0 {
|
||||
a.emit(ReActEvent{Type: ReActEventAnswer, Answer: content})
|
||||
return content, nil
|
||||
}
|
||||
messages["assistant_prompt"] = content
|
||||
for _, tool := range toolCalls {
|
||||
fn := gconv.Map(tool.Function)
|
||||
name := gconv.String(fn["name"])
|
||||
arguments := gconv.String(fn["arguments"])
|
||||
desc := a.toolDescription(name)
|
||||
a.emit(ReActEvent{Type: ReActEventToolCall, Description: desc})
|
||||
// 执行每个工具调用
|
||||
toolMsg := a.executeToolCall(ctx, name, arguments)
|
||||
a.emit(ReActEvent{Type: ReActEventToolResult, Description: desc})
|
||||
messages["tool_prompt"] = toolMsg
|
||||
messages["tool_id"] = tool.Id
|
||||
}
|
||||
}
|
||||
err = fmt.Errorf("max steps reached %d, generation incomplete", a.MaxStep)
|
||||
a.emit(ReActEvent{Type: ReActEventError, Message: "最大步数达到,生成失败", Error: err.Error()})
|
||||
return "", err
|
||||
}
|
||||
|
||||
// syncChatResult 解析同步对话响应 Content(model-gateway 按 responseBodyMapping 抽取,固定
|
||||
// content/toolCalls/finishReason 三个 key):返回最终文本与工具调用列表(同步响应工具在 toolCalls 里,res.Tools 为空)。
|
||||
func syncChatResult(content map[string]any) (string, []gateway.ModelTool) {
|
||||
text := gconv.String(content["content"])
|
||||
raw := content["toolCalls"]
|
||||
if raw == nil {
|
||||
raw = content["tools"]
|
||||
}
|
||||
var toolCalls []gateway.ModelTool
|
||||
if arr, ok := raw.([]any); ok {
|
||||
for _, t := range arr {
|
||||
m := gconv.Map(t)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
var tool gateway.ModelTool
|
||||
tool.Id = gconv.String(m["id"])
|
||||
tool.Type = gconv.String(m["type"])
|
||||
if fn := gconv.Map(m["function"]); fn != nil {
|
||||
tool.Function.Name = gconv.String(fn["name"])
|
||||
tool.Function.Arguments = gconv.String(fn["arguments"])
|
||||
}
|
||||
toolCalls = append(toolCalls, tool)
|
||||
}
|
||||
}
|
||||
return text, toolCalls
|
||||
}
|
||||
|
||||
// emit 触发过程事件回调;未设置 OnEvent 时忽略(HTTP 同步调用路径)
|
||||
func (a *ReActAgent) emit(ev ReActEvent) {
|
||||
if a.OnEvent != nil {
|
||||
a.OnEvent(ev)
|
||||
}
|
||||
}
|
||||
|
||||
// executeToolCall 执行单个工具调用,返回回灌给模型的工具结果消息。
|
||||
// 未知工具由 tools.Default.Call 返回结构化 not_found;工具业务失败进 ToolResult.Code。
|
||||
func (a *ReActAgent) executeToolCall(ctx context.Context, name, arguments string) string {
|
||||
if arguments == "" {
|
||||
return "工具参数为空"
|
||||
}
|
||||
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(arguments), &args); err != nil {
|
||||
return fmt.Sprintf("参数解析失败: %v", err)
|
||||
}
|
||||
|
||||
res, callErr := tools.Default.Call(ctx, name, args)
|
||||
|
||||
switch {
|
||||
case callErr != nil:
|
||||
msg := fmt.Sprintf("工具执行失败: %v", callErr)
|
||||
return msg
|
||||
case res.Code != 0:
|
||||
return res.Message
|
||||
default:
|
||||
dataJSON, err := json.Marshal(res.Data)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("工具结果序列化失败: %v", err)
|
||||
}
|
||||
return string(dataJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// toolDescription 按工具名从 ToolList 中查找用途说明(推给前端展示用,不暴露工具名/参数)
|
||||
func (a *ReActAgent) toolDescription(name string) string {
|
||||
for _, t := range a.ToolList {
|
||||
if t.Name == name {
|
||||
return t.Description
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RawTools 将工具定义转为 OpenAI 原始数组,parameters 保持 JSON Schema 原样。
|
||||
// businessParams 里传 tools 必须用它转换——直接传 []*tools.Tool 会被 json.Marshal 序列化 Func 字段而失败。
|
||||
func (a *ReActAgent) rawTools(toolList []*tools.Tool) []any {
|
||||
raw := make([]any, 0, len(toolList))
|
||||
for _, t := range toolList {
|
||||
params := t.Parameters
|
||||
if params == nil {
|
||||
params = map[string]any{}
|
||||
}
|
||||
raw = append(raw, map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": t.Name,
|
||||
"description": t.Description,
|
||||
"parameters": params,
|
||||
},
|
||||
})
|
||||
}
|
||||
return raw
|
||||
}
|
||||
+157
-32
@@ -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表语句---------------------------
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
package public
|
||||
|
||||
const GmqMsgPluginsName = "gmq_model_msg"
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:"流程名称"`
|
||||
|
||||
@@ -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 节点执行记录响应
|
||||
|
||||
@@ -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不能为空"`
|
||||
}
|
||||
@@ -1,30 +1,39 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"ai-agent/workflow/model/entity"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// 结果状态(自包含,不复用 flow 的 *int8 指针类型)
|
||||
const (
|
||||
ResultStatusRunning = 1 // 运行中
|
||||
ResultStatusSuccess = 2 // 成功
|
||||
ResultStatusFailed = 3 // 失败
|
||||
)
|
||||
|
||||
type CreateSessionReq struct {
|
||||
g.Meta `path:"/create" method:"post" tags:"会话管理" summary:"新建会话" dc:"新建会话"`
|
||||
SessionName string `json:"sessionName" v:"required#会话名称不能为空" dc:"会话名称"`
|
||||
// WebSocketConnectReq WebSocket 连接请求(query string 参数)
|
||||
type WebSocketConnectReq struct {
|
||||
g.Meta `path:"/wsExecute" method:"get" tags:"工作流执行" summary:"WebSocket执行工作流" dc:"通过WebSocket连接实时执行工作流,支持进度推送和取消"`
|
||||
SessionId string `p:"sessionId" dc:"会话ID"`
|
||||
}
|
||||
|
||||
type CreateSessionRes struct {
|
||||
Id int64 `json:"id,string" dc:"会话ID"`
|
||||
type WebSocketExecChatReq struct {
|
||||
ModelId int64 `json:"modelId" dc:"模型ID" v:"required#模型ID不能为空"`
|
||||
Question string `json:"question" dc:"用户提问" v:"required#用户提问不能为空"`
|
||||
SystemPrompt string `json:"systemPrompt" dc:"系统提示词"`
|
||||
}
|
||||
|
||||
type WebSocketExecWorkflowReq struct {
|
||||
FlowId int64 `json:"flowId" dc:"用户流程ID" v:"required#用户流程ID不能为空"`
|
||||
FlowContent *entity.FlowInfo `json:"flowContent" dc:"用户流程内容" v:"required#用户流程内容不能为空"`
|
||||
}
|
||||
|
||||
type CreateSessionReq struct {
|
||||
SessionId string `json:"sessionId" dc:"会话ID"`
|
||||
SessionName string `json:"sessionName" dc:"会话名称"`
|
||||
}
|
||||
|
||||
type ListSessionReq struct {
|
||||
g.Meta `path:"/list" method:"get" tags:"会话管理" summary:"会话列表" dc:"会话列表"`
|
||||
Page *beans.Page `json:"page"`
|
||||
g.Meta `path:"/list" method:"get" tags:"会话管理" summary:"会话列表" dc:"会话列表"`
|
||||
PageNum int64 `json:"pageNum" dc:"页码,从1开始"`
|
||||
PageSize int64 `json:"pageSize" dc:"每页数量"`
|
||||
}
|
||||
|
||||
type ListSessionRes struct {
|
||||
@@ -33,69 +42,52 @@ type ListSessionRes struct {
|
||||
}
|
||||
|
||||
type VOSession struct {
|
||||
Id int64 `json:"id,string" dc:"会话ID"`
|
||||
SessionId string `json:"sessionId" dc:"会话ID"`
|
||||
SessionName string `json:"sessionName" dc:"会话名称"`
|
||||
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
|
||||
}
|
||||
|
||||
type DeleteSessionReq struct {
|
||||
g.Meta `path:"/delete" method:"post" tags:"会话管理" summary:"删除会话" dc:"删除会话,级联删除该会话下普通对话结果,工作流结果保留"`
|
||||
Id int64 `json:"id" v:"required#会话ID不能为空"`
|
||||
g.Meta `path:"/delete" method:"post" tags:"会话管理" summary:"删除会话" dc:"删除会话"`
|
||||
SessionId string `json:"sessionId" v:"required#会话ID不能为空"`
|
||||
}
|
||||
|
||||
type ListSessionResultsReq struct {
|
||||
g.Meta `path:"/results" method:"get" tags:"会话管理" summary:"会话内结果列表" dc:"会话内结果列表,工作流+普通对话混排按时间倒序"`
|
||||
SessionId int64 `json:"sessionId" v:"required#会话ID不能为空"`
|
||||
type DeleteSessionRecordReq struct {
|
||||
g.Meta `path:"/deleteRecord" method:"post" tags:"会话管理" summary:"删除会话记录" dc:"删除会话记录"`
|
||||
Ids []struct {
|
||||
Id int64 `json:"id" v:"required#ID不能为空"`
|
||||
Type string `json:"type" v:"required#类型不能为空"`
|
||||
}
|
||||
}
|
||||
|
||||
type ListSessionResultsRes struct {
|
||||
List []*VOSessionResult `json:"list"`
|
||||
type GetSessionInfoReq struct {
|
||||
g.Meta `path:"/get" method:"get" tags:"会话管理" summary:"会话内结果列表" dc:"会话内结果列表,工作流+普通对话混排按时间倒序"`
|
||||
PageNum int64 `json:"pageNum" dc:"页码,从1开始"`
|
||||
PageSize int64 `json:"pageSize" dc:"每页数量"`
|
||||
SessionId string `json:"sessionId" v:"required#会话ID不能为空"`
|
||||
}
|
||||
|
||||
// VOSessionResult 会话内单条结果(工作流/普通对话混排)
|
||||
type VOSessionResult struct {
|
||||
ResultId int64 `json:"resultId,string" dc:"结果ID"`
|
||||
Type string `json:"type" dc:"workflow-工作流结果,chat-普通对话结果"`
|
||||
Status int `json:"status" dc:"1-运行中,2-成功,3-失败"`
|
||||
FlowId int64 `json:"flowId,string" dc:"工作流ID(chat类型为空)"`
|
||||
FlowName string `json:"flowName" dc:"工作流名称(chat类型为空)"`
|
||||
Question string `json:"question" dc:"用户问题(workflow类型为空)"`
|
||||
Answer string `json:"answer" dc:"模型返回结果(workflow类型为空)"`
|
||||
RequestParams map[string]any `json:"requestParams" dc:"请求参数(workflow类型)"`
|
||||
ResultParams []map[string]any `json:"resultParams" dc:"返回结果"`
|
||||
TotalTokens int `json:"totalTokens" dc:"总token消耗"`
|
||||
TotalFee float64 `json:"totalFee" dc:"总费用"`
|
||||
ErrorMsg string `json:"errorMsg" dc:"错误信息"`
|
||||
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
|
||||
type GetSessionInfoRes struct {
|
||||
List []*VOSessionInfoResult `json:"list"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type DeleteSessionResultReq struct {
|
||||
g.Meta `path:"/resultDelete" method:"post" tags:"会话管理" summary:"删除单条结果" dc:"删除单条结果,不影响会话"`
|
||||
Type string `json:"type" v:"required#结果类型不能为空" dc:"workflow-工作流结果,chat-普通对话结果"`
|
||||
Id int64 `json:"id" v:"required#结果ID不能为空"`
|
||||
// VOSessionInfoResult 会话内单条结果(工作流/普通对话混排)
|
||||
type VOSessionInfoResult struct {
|
||||
Id int64 `json:"id,string" dc:"结果ID"`
|
||||
Type string `json:"type" dc:"workflow-工作流结果,chat-普通对话结果"`
|
||||
Status int `json:"status" dc:"1-运行中,2-成功,3-失败"`
|
||||
FlowId int64 `json:"flowId,string" dc:"工作流ID(chat类型为空)"`
|
||||
RequestParams map[string]any `json:"requestParams" description:"请求参数"`
|
||||
ResultFileUrl string `json:"resultFileUrl" description:"结果文件路径(供预览/下载)"`
|
||||
ResultContent string `json:"resultContent" description:"结果文件内容(服务端已读取,前端直接展示)"`
|
||||
TotalTokens int `json:"totalTokens" dc:"总token消耗"`
|
||||
TotalFee float64 `json:"totalFee" dc:"总费用"`
|
||||
ErrorMsg string `json:"errorMsg" dc:"错误信息"`
|
||||
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
|
||||
}
|
||||
|
||||
type ListWorkflowResultsReq struct {
|
||||
g.Meta `path:"/workflowResults" method:"get" tags:"会话管理" summary:"工作流结果平铺列表" dc:"工作流维度所有结果平铺列表,含已删除会话下的结果"`
|
||||
type ListWorkflowResultReq struct {
|
||||
g.Meta `path:"/resultList" method:"get" tags:"会话管理" summary:"工作流执行结果树" dc:"按创建人分页查询工作流执行结果,按天分组返回树结构(日期→流程→结果文件),pageSize=每页天数,不传返回全部"`
|
||||
Page *beans.Page `json:"page"`
|
||||
FlowId int64 `json:"flowId" dc:"按工作流ID过滤,可选"`
|
||||
}
|
||||
|
||||
type ListWorkflowResultsRes struct {
|
||||
List []*VOWorkflowResult `json:"list"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type VOWorkflowResult struct {
|
||||
ResultId int64 `json:"resultId,string" dc:"结果ID"`
|
||||
SessionId int64 `json:"sessionId,string" dc:"所属会话ID"`
|
||||
FlowId int64 `json:"flowId,string" dc:"工作流ID"`
|
||||
FlowName string `json:"flowName" dc:"工作流名称"`
|
||||
RequestParams map[string]any `json:"requestParams" dc:"请求参数"`
|
||||
ResultParams []map[string]any `json:"resultParams" dc:"返回结果"`
|
||||
Status int `json:"status" dc:"1-运行中,2-成功,3-失败"`
|
||||
TotalTokens int `json:"totalTokens" dc:"总token消耗"`
|
||||
TotalFee float64 `json:"totalFee" dc:"总费用"`
|
||||
ErrorMsg string `json:"errorMsg" dc:"错误信息"`
|
||||
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,31 +1,19 @@
|
||||
package flow
|
||||
|
||||
import (
|
||||
"ai-agent/gateway"
|
||||
"ai-agent/workflow/consts/node"
|
||||
nodeDao "ai-agent/workflow/dao/node"
|
||||
"ai-agent/workflow/model/dto"
|
||||
flowDto "ai-agent/workflow/model/dto/flow"
|
||||
nodeDto "ai-agent/workflow/model/dto/node"
|
||||
"ai-agent/workflow/model/entity"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
commonHttp "gitea.redpowerfuture.com/red-future/common/http"
|
||||
"gitea.redpowerfuture.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"github.com/tidwall/sjson"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 全局等待任务回调的工具
|
||||
@@ -69,804 +57,155 @@ func Notify(taskId string, result any) {
|
||||
delete(asyncTasks, taskId)
|
||||
}
|
||||
|
||||
func GetIsChatModel(ctx context.Context) (res *flowDto.GetIsChatModelRes, err error) {
|
||||
headers := make(map[string]string)
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
for k, v := range r.Request.Header {
|
||||
if len(v) > 0 {
|
||||
headers[k] = v[0]
|
||||
}
|
||||
}
|
||||
// ModelCallResultLambda 调用模型并返回输出内容列表,同时回传本次调用的 token/费用(*gateway.ModelCallRes),
|
||||
// 供调用方(ModelLambda)累计写入节点执行记录 token_info,最后由汇总节点聚合到 exec_workflow。
|
||||
func ModelCallResultLambda(ctx context.Context, modelId int64, sessionId string, modelRequestParams map[string]any) ([]map[string]any, *gateway.ModelCallRes, error) {
|
||||
modelInfo, err := gateway.GetModelInfoById(ctx, &gateway.GetModelInfoByIdReq{ModelId: modelId})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("获取模型配置失败: %w", err)
|
||||
}
|
||||
res = new(flowDto.GetIsChatModelRes)
|
||||
err = commonHttp.Get(ctx, "model-gateway/model/getIsChatModel", headers, res, nil)
|
||||
return
|
||||
}
|
||||
|
||||
func GetModelInfo(ctx context.Context, req *flowDto.GetModelInfoReq) (res *flowDto.GetModelInfoRes, err error) {
|
||||
headers := make(map[string]string)
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
for k, v := range r.Request.Header {
|
||||
if len(v) > 0 {
|
||||
headers[k] = v[0]
|
||||
}
|
||||
}
|
||||
// 异步模型 msgTopic 由 gateway.ModelCallResult 在为空时自动生成(唯一、带业务标识),调用方无需管理
|
||||
responseParams, err := gateway.ModelCallResult(ctx, modelId, modelInfo.ModelManage.ResponseType, sessionId, modelRequestParams, nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
res = new(flowDto.GetModelInfoRes)
|
||||
err = commonHttp.Get(ctx, "model-gateway/model/getModel", headers, res, req)
|
||||
return
|
||||
}
|
||||
|
||||
func GetComposeResult(ctx context.Context, buildType int, modelName, promptContent, skillName string, form []map[string]any, userForm []map[string]any, fileUrl []string, sessionId, nodeId string, cause string) (res *flowDto.ComposeCallbackReq, err error) {
|
||||
if !g.IsEmpty(promptContent) {
|
||||
userForm = append(userForm, map[string]any{
|
||||
"prompt": promptContent,
|
||||
if g.IsEmpty(responseParams) {
|
||||
return nil, nil, fmt.Errorf("生成内容为空")
|
||||
}
|
||||
outputRes := make([]map[string]any, 0)
|
||||
for key, val := range responseParams.Content {
|
||||
outputRes = append(outputRes, map[string]any{
|
||||
key: val,
|
||||
})
|
||||
}
|
||||
var callbackUrl = utils.GetCallbackURL(ctx, "/flow/execution/composeCallBack")
|
||||
var consult = make([]flowDto.Consult, 0)
|
||||
var collectFileUrls func(val any) (fullyConsumed bool)
|
||||
collectFileUrls = func(val any) (fullyConsumed bool) {
|
||||
switch {
|
||||
case g.NewVar(val).IsSlice():
|
||||
slice := gconv.SliceAny(val)
|
||||
allConsumed := false
|
||||
for _, item := range slice {
|
||||
if collectFileUrls(item) {
|
||||
allConsumed = true
|
||||
}
|
||||
return outputRes, responseParams, nil
|
||||
}
|
||||
|
||||
func HttpCallResultLambda(ctx context.Context, nodeInput *flowDto.NodeExecutionInput) ([]map[string]any, error) {
|
||||
var method, url, responseType, callbackUrl string
|
||||
var headers map[string]string
|
||||
var body map[string]any
|
||||
var responseMapping map[string]any
|
||||
|
||||
n := new([]node.NodePresetField)
|
||||
err := gconv.Structs(nodeInput.Config.OutputConfig, n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range *n {
|
||||
switch item.Field {
|
||||
case "method":
|
||||
method = gconv.String(item.Value)
|
||||
case "url":
|
||||
url = gconv.String(item.Value)
|
||||
case "headers":
|
||||
headers = gconv.MapStrStr(item.Value)
|
||||
case "body":
|
||||
body = gconv.Map(item.Value)
|
||||
case "response":
|
||||
// 先剥掉 {type, value/attrs} 包裹层,得到干净的输出结构模板
|
||||
responseMapping = gconv.Map(UnwrapSchemaWrapper(gconv.Map(item.Value)))
|
||||
case "responseType":
|
||||
responseType = gconv.String(item.Value)
|
||||
if responseType == "callback" {
|
||||
callbackUrl = item.Options[0].Config[0].Value
|
||||
}
|
||||
return allConsumed
|
||||
case g.NewVar(val).IsMap():
|
||||
m := gconv.Map(val)
|
||||
allConsumed := false
|
||||
for _, item := range m {
|
||||
if collectFileUrls(item) {
|
||||
allConsumed = true
|
||||
}
|
||||
}
|
||||
return allConsumed
|
||||
default:
|
||||
s := gconv.String(val)
|
||||
if s != "" {
|
||||
getFileTypeByPath := GetFileTypeByPath(s)
|
||||
if getFileTypeByPath != "" {
|
||||
consult = append(consult, flowDto.Consult{
|
||||
Type: getFileTypeByPath,
|
||||
Url: s,
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
var newUserForm []map[string]any
|
||||
for _, m := range userForm {
|
||||
for k, v := range m {
|
||||
if collectFileUrls(v) {
|
||||
delete(m, k)
|
||||
|
||||
if method == "" {
|
||||
return nil, fmt.Errorf("method为空")
|
||||
}
|
||||
if url == "" {
|
||||
return nil, fmt.Errorf("url为空")
|
||||
}
|
||||
|
||||
if headers == nil {
|
||||
headers = make(map[string]string)
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
for k, v := range r.Request.Header {
|
||||
if len(v) > 0 {
|
||||
headers[k] = v[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(m) > 0 {
|
||||
newUserForm = append(newUserForm, m)
|
||||
}
|
||||
}
|
||||
for _, v := range fileUrl {
|
||||
getFileTypeByPath := GetFileTypeByPath(gconv.String(v))
|
||||
if getFileTypeByPath != "" {
|
||||
consult = append(consult, flowDto.Consult{
|
||||
Type: getFileTypeByPath,
|
||||
Url: gconv.String(v),
|
||||
|
||||
// 构建请求参数
|
||||
ProcessValueSourceRecursive(body, nodeInput.Global)
|
||||
// 递归剥掉 {type, value/attrs} 包裹层,只保留 key/value
|
||||
wrapper := UnwrapSchemaWrapper(body)
|
||||
newBody := gconv.Map(wrapper)
|
||||
|
||||
// 1. 自己生成唯一 taskId(不用前端给)
|
||||
taskId := "my_task_" + uuid.New().String() // 自己生成唯一ID
|
||||
if responseType == "callback" {
|
||||
newBody[callbackUrl] = utils.GetCallbackURL(ctx, "/httpNodeCallback?task_id="+taskId)
|
||||
}
|
||||
// ====================== 核心改动 ======================
|
||||
// 1. 定义一个空map接收原始HTTP返回结果
|
||||
var rawHttpResult map[string]any
|
||||
// 2. 发送请求(不变)
|
||||
if method == "GET" {
|
||||
err = commonHttp.Get(ctx, url, headers, &rawHttpResult, newBody)
|
||||
} else if method == "POST" {
|
||||
err = commonHttp.Post(ctx, url, headers, &rawHttpResult, newBody)
|
||||
} else if method == "PUT" {
|
||||
err = commonHttp.Put(ctx, url, headers, &rawHttpResult, newBody)
|
||||
} else if method == "DELETE" {
|
||||
err = commonHttp.Delete(ctx, url, headers, &rawHttpResult, newBody)
|
||||
} else {
|
||||
return nil, fmt.Errorf("method 不支持")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var e = ""
|
||||
|
||||
finalResult := make(map[string]any)
|
||||
if responseType == "sync" {
|
||||
httpResultJson := gconv.String(rawHttpResult)
|
||||
// 按 responseMapping 定义的结构,从 http 返回结果中拷贝对应字段
|
||||
finalResult = MapResultByTemplate(responseMapping, rawHttpResult)
|
||||
e = httpResultJson
|
||||
}
|
||||
if responseType == "callback" {
|
||||
var waitResult any
|
||||
waitResult, err = Wait(ctx, taskId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request, ok := waitResult.(*ghttp.Request)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("入参类型错误")
|
||||
}
|
||||
|
||||
bodyStr := request.GetBodyString()
|
||||
// 按 responseMapping 定义的结构,从回调结果中拷贝对应字段
|
||||
finalResult = MapResultByTemplate(responseMapping, gconv.Map(bodyStr))
|
||||
e = bodyStr
|
||||
}
|
||||
if responseType == "pull" {
|
||||
return nil, fmt.Errorf("pull 暂不支持")
|
||||
}
|
||||
|
||||
if g.IsEmpty(finalResult) {
|
||||
return nil, fmt.Errorf("http请求异常,返回结果为空:%v", e)
|
||||
}
|
||||
|
||||
outputRes := make([]map[string]any, 0)
|
||||
for i, item := range finalResult {
|
||||
if nodeInput.Config.IsSaveFile {
|
||||
outputRes = append(outputRes, map[string]any{
|
||||
fmt.Sprintf("http_file_url:%v", i): item,
|
||||
})
|
||||
}
|
||||
outputRes = append(outputRes, map[string]any{
|
||||
fmt.Sprintf("%v", i): item,
|
||||
})
|
||||
}
|
||||
msgReq := flowDto.ComposeMessagesReq{
|
||||
BuildType: buildType,
|
||||
ModelName: modelName,
|
||||
SkillName: skillName,
|
||||
CallbackUrl: callbackUrl,
|
||||
Cause: cause,
|
||||
Form: form,
|
||||
UserForm: newUserForm,
|
||||
Consult: consult,
|
||||
SessionId: sessionId,
|
||||
NodeId: nodeId,
|
||||
}
|
||||
headers := make(map[string]string)
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
for k, v := range r.Request.Header {
|
||||
if len(v) > 0 {
|
||||
headers[k] = v[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
msgRes := new(flowDto.ComposeMessagesRes)
|
||||
err = commonHttp.Post(ctx, "prompts-core/prompt/composeMessages", headers, msgRes, &msgReq)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if g.IsEmpty(msgRes.TaskId) {
|
||||
return nil, fmt.Errorf("msg is empty")
|
||||
}
|
||||
waitRes, err := Wait(ctx, msgRes.TaskId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg := new(flowDto.ComposeCallbackReq)
|
||||
if err = gconv.Struct(waitRes, msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !g.IsEmpty(msg.ErrorMsg) {
|
||||
return nil, fmt.Errorf(msg.ErrorMsg)
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func CreateGatewayTask(ctx context.Context, epicycleId int64, model string, content map[string]any) (map[string]any, error) {
|
||||
taskId, err := createGatewayTaskOnly(ctx, epicycleId, model, content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return waitGatewayResult(ctx, taskId)
|
||||
}
|
||||
|
||||
// createGatewayTaskOnly creates a gateway task and returns the taskId only
|
||||
// doesn't wait for completion
|
||||
func createGatewayTaskOnly(ctx context.Context, epicycleId int64, model string, content map[string]any) (string, error) {
|
||||
callbackUrl := utils.GetCallbackURL(ctx, "/flow/execution/modelCallback")
|
||||
req := flowDto.ModelGatewayReq{
|
||||
ModelName: model,
|
||||
BizName: g.Cfg().MustGet(ctx, "server.name").String(),
|
||||
CallbackUrl: callbackUrl,
|
||||
RequestPayload: content,
|
||||
EpicycleId: epicycleId,
|
||||
}
|
||||
|
||||
headers := make(map[string]string)
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
for k, v := range r.Request.Header {
|
||||
if len(v) > 0 {
|
||||
headers[k] = v[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res := new(flowDto.ModelGatewayRes)
|
||||
err := commonHttp.Post(ctx, "model-gateway/task/createTask", headers, res, &req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if g.IsEmpty(res.TaskId) {
|
||||
return "", fmt.Errorf("创建模型任务失败,taskId为空")
|
||||
}
|
||||
|
||||
return res.TaskId, nil
|
||||
}
|
||||
|
||||
// waitGatewayResult waits for a created gateway task to complete and returns the result
|
||||
func waitGatewayResult(ctx context.Context, taskId string) (map[string]any, error) {
|
||||
waitRes, err := Wait(ctx, taskId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
task := new(flowDto.ModelCallbackReq)
|
||||
if err = gconv.Struct(waitRes, task); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if task.State == 3 || !g.IsEmpty(task.ErrorMsg) {
|
||||
return nil, fmt.Errorf("模型执行失败:%s", task.ErrorMsg)
|
||||
}
|
||||
if g.IsEmpty(task.OssFile) {
|
||||
return nil, fmt.Errorf("模型返回结果为空")
|
||||
}
|
||||
// 获取远程文件内容
|
||||
file, err := GetFileBytesFromURL(ctx, task.OssFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gconv.Map(file), nil
|
||||
}
|
||||
|
||||
// updateTokenCount updates the token count in node execution
|
||||
func updateTokenCount(ctx context.Context, nodeExecutionId int64, responseField string, result map[string]any) {
|
||||
if responseField == "" {
|
||||
return
|
||||
}
|
||||
_, _ = nodeDao.NodeExecutionDao.Update(ctx, &nodeDto.UpdateNodeExecutionReq{
|
||||
Id: nodeExecutionId,
|
||||
CompletionTokens: gconv.Int(result[responseField]),
|
||||
TotalTokens: gconv.Int(result[responseField]),
|
||||
})
|
||||
}
|
||||
|
||||
func GetModelResult(ctx context.Context, sessionId string, nodeInput *flowDto.NodeExecutionInput, skillName string, form []map[string]any, userForm []map[string]any) (mapTaskResult []map[string]any, err error) {
|
||||
buildType := 1
|
||||
if nodeInput.Config.NodeCode == node.NodeTypeDataConversionModel {
|
||||
buildType = 3
|
||||
}
|
||||
|
||||
if !nodeInput.Global.IsDialogue {
|
||||
sessionId = ""
|
||||
}
|
||||
|
||||
composeResult, err := GetComposeResult(ctx, buildType, nodeInput.Config.ModelConfig.ModelName, nodeInput.Config.PromptContent, skillName, form, userForm, nodeInput.Global.FileUrl, sessionId, nodeInput.Config.Id, nodeInput.Config.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if composeResult.Status != "success" {
|
||||
return nil, fmt.Errorf("模型提示词构建错误")
|
||||
}
|
||||
|
||||
modelInfo, err := GetModelInfo(ctx, &flowDto.GetModelInfoReq{ModelName: nodeInput.Config.ModelConfig.ModelName})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mapTaskResult = make([]map[string]any, len(composeResult.Messages.Rounds))
|
||||
var taskResultMap map[string]any
|
||||
|
||||
needSequential := false
|
||||
if buildType == 1 {
|
||||
if needSequential {
|
||||
for idx, item := range composeResult.Messages.Rounds {
|
||||
if !g.IsEmpty(taskResultMap) {
|
||||
var set string
|
||||
set, err = sjson.Set(gconv.String(item), modelInfo.Model.LastFrame, gconv.String(taskResultMap[modelInfo.Model.ResponseBody]))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item = gconv.Map(set)
|
||||
}
|
||||
|
||||
var taskResult map[string]any
|
||||
taskResult, err = CreateGatewayTask(ctx, composeResult.EpicycleId, nodeInput.Config.ModelConfig.ModelName, item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if g.IsEmpty(taskResult) {
|
||||
return nil, fmt.Errorf("模型返回结果为空")
|
||||
}
|
||||
|
||||
if nodeInput.Config.NodeCode == node.NodeTypeVideoModel {
|
||||
ext := GetFileTypeByPath(gconv.String(taskResult[modelInfo.Model.ResponseBody]))
|
||||
if ext == "image" {
|
||||
taskResultMap = taskResult
|
||||
} else {
|
||||
taskResultMap = make(map[string]any)
|
||||
}
|
||||
} else {
|
||||
taskResultMap = make(map[string]any)
|
||||
}
|
||||
|
||||
mapTaskResult[idx] = taskResult
|
||||
updateTokenCount(ctx, nodeInput.NodeExecutionId, modelInfo.Model.ResponseTokenField, taskResult)
|
||||
}
|
||||
} else {
|
||||
taskIdList := make([]string, len(composeResult.Messages.Rounds))
|
||||
|
||||
for idx, item := range composeResult.Messages.Rounds {
|
||||
taskId, err := createGatewayTaskOnly(ctx, composeResult.EpicycleId, nodeInput.Config.ModelConfig.ModelName, item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
taskIdList[idx] = taskId
|
||||
}
|
||||
|
||||
// 全局共享子上下文,实现一处报错全部终止
|
||||
subCtx, globalCancel := context.WithCancel(ctx)
|
||||
defer globalCancel() // 函数退出兜底释放
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errChan := make(chan error, len(taskIdList))
|
||||
|
||||
// 加互斥锁保护结果map
|
||||
var mu sync.Mutex
|
||||
|
||||
for idx, taskId := range taskIdList {
|
||||
wg.Add(1)
|
||||
|
||||
go func(idx int, taskId string) {
|
||||
defer wg.Done()
|
||||
|
||||
taskResult, err := waitGatewayResult(subCtx, taskId)
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
globalCancel() // 全局取消,所有协程收到ctx取消信号快速退出
|
||||
return
|
||||
}
|
||||
|
||||
// 加锁写入map,解决并发竞态
|
||||
mu.Lock()
|
||||
mapTaskResult[idx] = taskResult
|
||||
mu.Unlock()
|
||||
|
||||
updateTokenCount(ctx, nodeInput.NodeExecutionId, modelInfo.Model.ResponseTokenField, taskResult)
|
||||
}(idx, taskId)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errChan)
|
||||
|
||||
// 收集全部错误,而非只读一条
|
||||
var errs []error
|
||||
for len(errChan) > 0 {
|
||||
errs = append(errs, <-errChan)
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
// 返回第一个错误;如需汇总所有错误可拼接
|
||||
return nil, errs[0]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for idx, item := range composeResult.Messages.Rounds {
|
||||
mapTaskResult[idx] = item
|
||||
updateTokenCount(ctx, nodeInput.NodeExecutionId, modelInfo.Model.ResponseTokenField, item)
|
||||
}
|
||||
}
|
||||
|
||||
return mapTaskResult, nil
|
||||
}
|
||||
|
||||
func BuildNestedJson(body g.Map, mockConfigMap map[string]*entity.FlowNode) g.Map {
|
||||
jsonStr := "{}"
|
||||
for originKey, originItem := range body {
|
||||
bodyItemMap := gconv.Map(originItem)
|
||||
val := bodyItemMap["value"]
|
||||
if v, ok := bodyItemMap["value"]; ok {
|
||||
jsonStr, _ = sjson.Set(jsonStr, originKey, v)
|
||||
}
|
||||
// 判断 value 是不是引用结构(map)
|
||||
if g.NewVar(val).IsMap() {
|
||||
valMap := gconv.Map(val)
|
||||
nodeId := gconv.String(valMap["nodeId"])
|
||||
fieldName := gconv.String(valMap["field"])
|
||||
if configValue, ok := mockConfigMap[nodeId]; ok {
|
||||
if !g.IsEmpty(configValue.OutputResult) {
|
||||
for _, v := range configValue.OutputResult {
|
||||
if strings.Contains(v.Field, fieldName) {
|
||||
if configValue.NodeCode == node.NodeTypeDataConversionModel {
|
||||
switch {
|
||||
case g.NewVar(v.Value).IsSlice() || g.NewVar(v.Value).IsMap():
|
||||
// 核心:自动判断两种结构,精准赋值
|
||||
vm := gconv.Map(v.Value)
|
||||
// 先判断是否是 单个key包裹的对象(如 {"subtitle_style": {...}})
|
||||
if len(vm) == 1 {
|
||||
// 遍历取出唯一的 key 和 真实值
|
||||
for innerKey, innerVal := range vm {
|
||||
// 直接用 innerKey(subtitle_style)赋值
|
||||
jsonStr, _ = sjson.Set(jsonStr, innerKey, innerVal)
|
||||
}
|
||||
} else {
|
||||
// 直接是对象,用 originKey 赋值
|
||||
jsonStr, _ = sjson.Set(jsonStr, originKey, v.Value)
|
||||
}
|
||||
default:
|
||||
jsonStr, _ = sjson.Set(jsonStr, originKey, v.Value)
|
||||
}
|
||||
} else {
|
||||
jsonStr, _ = sjson.Set(jsonStr, originKey, v.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !g.IsEmpty(configValue.FormConfig) {
|
||||
for _, v := range configValue.FormConfig {
|
||||
if v.Field == fieldName {
|
||||
if v.Type == "uploadMultiple" {
|
||||
if g.NewVar(v.FieldConstraint).IsMap() {
|
||||
mapFieldConstraint := gconv.Map(v.FieldConstraint)
|
||||
for key, value := range mapFieldConstraint {
|
||||
if key == "maxFileCount" {
|
||||
if gconv.Int(value) == 1 {
|
||||
// 如果是单文件上传,则替换成字符串重新赋值给v.Value
|
||||
if g.NewVar(v.Value).IsSlice() {
|
||||
sliceVal := gconv.SliceAny(v.Value)
|
||||
if len(sliceVal) > 0 {
|
||||
v.Value = sliceVal[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
jsonStr, _ = sjson.Set(jsonStr, originKey, v.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return gconv.Map(jsonStr)
|
||||
}
|
||||
|
||||
func VideoConcat(ctx context.Context, videoUrls []string) (r any, err error) {
|
||||
var httpUrl = "media/video/concat/async"
|
||||
headers := make(map[string]string)
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
for k, v := range r.Request.Header {
|
||||
if len(v) > 0 {
|
||||
headers[k] = v[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
var callbackUrl = utils.GetCallbackURL(ctx, "/flow/execution/videoCallback")
|
||||
var newBody = flowDto.VideoConcatReq{
|
||||
VideoUrls: videoUrls,
|
||||
Method: "auto",
|
||||
Upload: true,
|
||||
CallbackUrl: callbackUrl,
|
||||
}
|
||||
res := new(flowDto.VideoConcatRes)
|
||||
err = commonHttp.Post(ctx, httpUrl, headers, &res, newBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Wait(ctx, res.TaskId)
|
||||
}
|
||||
|
||||
func GetFileBytesFromURL(ctx context.Context, fileUrl string) ([]byte, error) {
|
||||
newS := strings.ReplaceAll(fileUrl, g.Cfg().MustGet(ctx, "filePrefix").String(), g.Cfg().MustGet(ctx, "minioPrefix").String())
|
||||
// 使用 GoFrame 客户端(自带超时、追踪、日志等能力)
|
||||
resp, err := g.Client().Get(ctx, newS)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "failed to request url: %s", newS)
|
||||
}
|
||||
defer resp.Close()
|
||||
|
||||
// 校验状态码
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, gerror.Newf("request failed with status code: %d, url: %s", resp.StatusCode, newS)
|
||||
}
|
||||
|
||||
// 读取全部内容
|
||||
allBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "failed to read response body, url: %s", fileUrl)
|
||||
}
|
||||
|
||||
return allBytes, nil
|
||||
}
|
||||
|
||||
func Upload(ctx context.Context, req *dto.UploadFileBytesReq) (*dto.UploadFileBytesRes, error) {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
part, err := writer.CreateFormFile("file", req.FileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = part.Write(req.FileBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = writer.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
headers := make(map[string]string)
|
||||
headers["Content-Type"] = writer.FormDataContentType()
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
if auth := r.Header.Get("Authorization"); auth != "" {
|
||||
headers["Authorization"] = auth
|
||||
}
|
||||
}
|
||||
|
||||
// 发起上传请求
|
||||
res := &dto.UploadFileBytesRes{}
|
||||
httpUrl := "oss/file/uploadFile"
|
||||
if err = commonHttp.Post(ctx, httpUrl, headers, res, body.Bytes()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[Upload] success url=%s size=%d", res.FileURL, res.FileSize)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func GetFileTypeByPath(filePath string) string {
|
||||
if filePath == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 解析 URL,获取真实路径(兼容 http 链接)
|
||||
u, err := url.Parse(filePath)
|
||||
if err == nil {
|
||||
filePath = u.Path
|
||||
}
|
||||
|
||||
// 获取后缀(小写)
|
||||
ext := filepath.Ext(filePath)
|
||||
ext = strings.ToLower(ext)
|
||||
|
||||
// 判断类型
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp":
|
||||
return "image"
|
||||
case ".mp4", ".mov", ".avi", ".flv", ".wmv", ".mkv":
|
||||
return "video"
|
||||
case ".mp3", ".wav", ".m4a", ".flac", ".aac", ".ogg":
|
||||
return "audio"
|
||||
case ".txt", ".md", ".log", ".json", ".xml", ".inc":
|
||||
return "text"
|
||||
case ".html":
|
||||
return "html"
|
||||
case ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx":
|
||||
return "document"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func BuildText(text string) string {
|
||||
// 生成单条HTML
|
||||
var htmlBuilder strings.Builder
|
||||
htmlBuilder.WriteString(`
|
||||
<!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_format(StructuredOutput)即走原生 json_schema 保证结构;
|
||||
// 否则模型 content 直接出 JSON,靠容错解析兜底
|
||||
val, ok := info.ModelManage.RequestBusinessFieldMapping["response_format"]
|
||||
if ok {
|
||||
if !g.IsEmpty(val) {
|
||||
params["response_format"] = pipeline.ShotsStructuredFormat()
|
||||
}
|
||||
}
|
||||
modelInfo, err := gateway.GetModelInfoById(ctx, &gateway.GetModelInfoByIdReq{ModelId: nodeInput.Config.ModelConfig.ModelId})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取模型配置失败: %w", err)
|
||||
}
|
||||
result, err := gateway.ModelCallResult(ctx, nodeInput.Config.ModelConfig.ModelId, modelInfo.ModelManage.ResponseType, nodeInput.Global.SessionId, nodeInput.Config.ModelConfig.ModelRequestParams, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var content string
|
||||
for _, v := range gconv.Map(result.Content) {
|
||||
content += v.(string)
|
||||
}
|
||||
// 4. 解析镜头数组(兼容英文键结构化输出与中文键 content 兜底)
|
||||
shots, err := unmarshalShots(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 5. 产出固定结构 {"shots": [...]}
|
||||
var arr []any
|
||||
if b, err := json.Marshal(shots); err == nil {
|
||||
_ = json.Unmarshal(b, &arr)
|
||||
}
|
||||
|
||||
args := split_shots_pipeline.SplitShotsInput{
|
||||
ModelID: modelId,
|
||||
Shots: shots,
|
||||
TotalDuration: totalDuration,
|
||||
FlatRefs: refsItem,
|
||||
Seed: nodeInput.Global.ExecutionId % 1000000,
|
||||
NegativePrompt: nodeInput.Config.NegativePrompt,
|
||||
}
|
||||
data, err := processor.Call(ctx, "split_shots_pipeline", gconv.Map(args))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodeInput.Config.OutputResult = gconv.Maps(data)
|
||||
return nodeInput, nil
|
||||
}
|
||||
|
||||
// unmarshalShots 解析镜头数组 JSON,容忍 markdown 代码围栏、json_schema 结构化输出的 {"shots":[...]} 包装,
|
||||
// 以及模型按中文键输出(时间码/事件/台词旁白/景别/运镜/出演角色/场景/道具)的容错映射。
|
||||
func unmarshalShots(s string) ([]pipeline.Shot, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasPrefix(s, "```") {
|
||||
s = strings.TrimPrefix(s, "```json")
|
||||
s = strings.TrimPrefix(s, "```")
|
||||
s = strings.TrimSuffix(s, "```")
|
||||
s = strings.TrimSpace(s)
|
||||
}
|
||||
var raw []map[string]any
|
||||
if err := json.Unmarshal([]byte(s), &raw); err == nil && len(raw) > 0 {
|
||||
return parseShots(raw)
|
||||
}
|
||||
var wrapped struct {
|
||||
Shots []map[string]any `json:"shots"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(s), &wrapped); err == nil && len(wrapped.Shots) > 0 {
|
||||
return parseShots(wrapped.Shots)
|
||||
}
|
||||
return nil, fmt.Errorf("解析分镜脚本失败: %v", s)
|
||||
}
|
||||
|
||||
// parseShots 把原始镜头对象数组归一为 domain.Shot,跳过没有内容字段的镜头。
|
||||
func parseShots(raw []map[string]any) ([]pipeline.Shot, error) {
|
||||
shots := make([]pipeline.Shot, 0, len(raw))
|
||||
for i, m := range raw {
|
||||
shot := shotFromMap(m)
|
||||
if shot.Index == 0 {
|
||||
shot.Index = i + 1
|
||||
}
|
||||
if isEmptyShot(shot) {
|
||||
continue
|
||||
}
|
||||
shots = append(shots, shot)
|
||||
}
|
||||
if len(shots) == 0 {
|
||||
return nil, fmt.Errorf("解析分镜脚本失败: 镜头内容为空")
|
||||
}
|
||||
return shots, nil
|
||||
}
|
||||
|
||||
// isEmptyShot 镜头是否没有可用内容(仅有时间码/序号,或字段名对不上导致全空)。
|
||||
func isEmptyShot(s pipeline.Shot) bool {
|
||||
return s.Event == "" && s.Dialogue == "" && s.Narration == "" && s.AmbientSound == "" &&
|
||||
s.CameraMovement == "" && s.ShotSize == "" && s.Scene == "" &&
|
||||
len(s.Characters) == 0 && len(s.Props) == 0
|
||||
}
|
||||
|
||||
// shotFromMap 把单个镜头对象映射为 domain.Shot,兼容英文键(结构化输出)与中文键(提示词兜底输出)。
|
||||
func shotFromMap(m map[string]any) pipeline.Shot {
|
||||
get := func(keys ...string) string {
|
||||
for _, k := range keys {
|
||||
switch v := m[k].(type) {
|
||||
case string:
|
||||
if t := strings.TrimSpace(v); t != "" {
|
||||
return t
|
||||
}
|
||||
case float64:
|
||||
return gconv.String(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
getSlice := func(keys ...string) []string {
|
||||
for _, k := range keys {
|
||||
switch v := m[k].(type) {
|
||||
case []any:
|
||||
var out []string
|
||||
for _, e := range v {
|
||||
if t, ok := e.(string); ok && strings.TrimSpace(t) != "" {
|
||||
out = append(out, strings.TrimSpace(t))
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
case string:
|
||||
if out := splitList(v); len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
shot := pipeline.Shot{
|
||||
Index: gconv.Int(m["index"]),
|
||||
StartTime: get("startTime", "开始时间"),
|
||||
EndTime: get("endTime", "结束时间"),
|
||||
Event: get("event", "事件"),
|
||||
Dialogue: get("dialogue", "台词"),
|
||||
Narration: get("narration", "旁白"),
|
||||
AmbientSound: get("ambientSound", "环境音"),
|
||||
CameraMovement: get("cameraMovement", "运镜"),
|
||||
ShotSize: get("shotSize", "景别"),
|
||||
Scene: get("scene", "场景"),
|
||||
Characters: getSlice("characters", "出演角色"),
|
||||
Props: getSlice("props", "道具"),
|
||||
}
|
||||
if shot.StartTime == "" && shot.EndTime == "" {
|
||||
shot.StartTime, shot.EndTime = splitTimeRange(get("时间码"))
|
||||
}
|
||||
if shot.Dialogue == "" && shot.Narration == "" {
|
||||
shot.Dialogue, shot.Narration = splitDialogueNarration(get("台词/旁白"))
|
||||
}
|
||||
return shot
|
||||
}
|
||||
|
||||
// formatSecondsToMMSS 秒转 MM:SS 时间码。
|
||||
func formatSecondsToMMSS(sec int) string {
|
||||
if sec < 0 {
|
||||
sec = 0
|
||||
}
|
||||
return fmt.Sprintf("%02d:%02d", sec/60, sec%60)
|
||||
}
|
||||
|
||||
// splitTimeRange 解析时间码 "MM:SS-MM:SS"(兼容 "—"/"~"/"到" 等分隔,或单个时间点)。
|
||||
func splitTimeRange(s string) (start, end string) {
|
||||
if s == "" {
|
||||
return "", ""
|
||||
}
|
||||
normalized := strings.NewReplacer("—", "-", "–", "-", "~", "-", "~", "-", "到", "-", "至", "-").Replace(s)
|
||||
parts := strings.Split(normalized, "-")
|
||||
start = strings.TrimSpace(parts[0])
|
||||
if len(parts) > 1 {
|
||||
end = strings.TrimSpace(parts[1])
|
||||
} else {
|
||||
end = start
|
||||
}
|
||||
return start, end
|
||||
}
|
||||
|
||||
// splitDialogueNarration 把"台词/旁白"合字段拆成 dialogue 与 narration:
|
||||
// 以"旁白"/"画外音"开头的内容归为旁白,其余视为角色开口的主台词。
|
||||
func splitDialogueNarration(s string) (dialogue, narration string) {
|
||||
s = strings.TrimSpace(s)
|
||||
switch {
|
||||
case strings.HasPrefix(s, "旁白"):
|
||||
return "", strings.TrimSpace(strings.TrimLeft(strings.TrimPrefix(s, "旁白"), "::"))
|
||||
case strings.HasPrefix(s, "画外音"):
|
||||
return "", strings.TrimSpace(strings.TrimLeft(strings.TrimPrefix(s, "画外音"), "::"))
|
||||
default:
|
||||
return s, ""
|
||||
}
|
||||
}
|
||||
|
||||
// splitList 按常见分隔符拆分人名/道具列表(兼容中英文顿号、逗号、分号、"和""及"等)。
|
||||
func splitList(s string) []string {
|
||||
repl := strings.NewReplacer("、", "|", ",", "|", ",", "|", ";", "|", ";", "|", "和", "|", "及", "|", "&", "|", "/", "|", " ", "|")
|
||||
var out []string
|
||||
for _, p := range strings.Split(repl.Replace(strings.TrimSpace(s)), "|") {
|
||||
if t := strings.TrimSpace(p); t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// shotDurationConstraintPrompt 生成"单个镜头时长不超过 maxSeg 秒"的转写约束提示词片段;maxSeg<=0 返回空串。
|
||||
func shotDurationConstraintPrompt(maxSeg int) string {
|
||||
if maxSeg <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("\n\n单个镜头时长不超过 %d 秒:每镜的 startTime 与 endTime 之差必须 ≤ %d 秒。", maxSeg, maxSeg)
|
||||
}
|
||||
@@ -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 节点是否已无有效内容:
|
||||
// 标量看 value(0/false 有效);object/array 看 value/attrs/enumValues 容器是否都为空
|
||||
func isSchemaNodeEmpty(node map[string]interface{}) bool {
|
||||
t, _ := node["type"].(string)
|
||||
switch t {
|
||||
case "object":
|
||||
return schemaContainerEmpty(node, "value") && schemaContainerEmpty(node, "attrs")
|
||||
case "array":
|
||||
return schemaContainerEmpty(node, "value") && schemaContainerEmpty(node, "attrs") && schemaContainerEmpty(node, "enumValues")
|
||||
default:
|
||||
return schemaValueEmpty(node["value"])
|
||||
}
|
||||
}
|
||||
|
||||
// schemaContainerEmpty 容器(value/attrs/enumValues)是否为空
|
||||
func schemaContainerEmpty(node map[string]interface{}, key string) bool {
|
||||
switch v := node[key].(type) {
|
||||
case []interface{}:
|
||||
return len(v) == 0
|
||||
case map[string]interface{}:
|
||||
return len(v) == 0
|
||||
default:
|
||||
return v == nil
|
||||
}
|
||||
}
|
||||
|
||||
// schemaValueEmpty 值是否为空;0/false 视为有效值不剔除
|
||||
func schemaValueEmpty(v interface{}) bool {
|
||||
switch val := v.(type) {
|
||||
case nil:
|
||||
return true
|
||||
case string:
|
||||
return val == ""
|
||||
case []interface{}:
|
||||
return len(val) == 0
|
||||
case map[string]interface{}:
|
||||
return len(val) == 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"ai-agent/workflow/service/flow/processor"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
commonHttp "gitea.redpowerfuture.com/red-future/common/http"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// TaskKind 媒体任务类型(拼接/拼接+混音),决定提交与查询的接口路径
|
||||
type TaskKind string
|
||||
|
||||
const (
|
||||
TaskKindConcat TaskKind = "concat" // 纯拼接,无 BGM
|
||||
TaskKindMerge TaskKind = "merge" // 拼接+混音,有 BGM
|
||||
)
|
||||
|
||||
// MergeTask media 服务异步任务状态
|
||||
type MergeTask struct {
|
||||
TaskID string `json:"taskId"`
|
||||
Status string `json:"status"` // pending/running/success/failed
|
||||
FileURL string `json:"fileURL,omitempty"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
DurationStr string `json:"durationStr,omitempty"`
|
||||
}
|
||||
|
||||
type mergeSubmitReq struct {
|
||||
VideoURLs []string `json:"video_urls"`
|
||||
AudioURLs []string `json:"audio_urls,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Upload bool `json:"upload"`
|
||||
CallbackURL string `json:"callback_url"`
|
||||
}
|
||||
|
||||
type mergeSubmitRes struct {
|
||||
TaskID string `json:"taskId"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
processor.Register(ConcatVideosProcessor())
|
||||
}
|
||||
|
||||
// ConcatVideosProcessor 合并视频
|
||||
func ConcatVideosProcessor() *processor.Processor {
|
||||
return &processor.Processor{
|
||||
Name: "concat_videos",
|
||||
Description: "合并视频",
|
||||
Func: func(ctx context.Context, args map[string]any) (any, error) {
|
||||
outputRes := parseOutputList(args)
|
||||
segments, err := collectSegmentResults(outputRes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
videoURLs := make([]string, 0, len(segments))
|
||||
for _, seg := range segments {
|
||||
videoURLs = append(videoURLs, seg.VideoURL)
|
||||
}
|
||||
|
||||
reqParams := gconv.Map(args["request"])
|
||||
bgmURLs := gconv.Strings(reqParams["bgm_urls"])
|
||||
if len(bgmURLs) == 0 {
|
||||
// 兼容串行结果把 bgm 带回 output 的情况
|
||||
for _, m := range outputRes {
|
||||
bgmURLs = append(bgmURLs, gconv.Strings(m["bgm_urls"])...)
|
||||
}
|
||||
}
|
||||
upload := gconv.Bool(reqParams["upload"])
|
||||
callback := gconv.String(reqParams["callback_url"])
|
||||
|
||||
merged, err := MergeSegments(ctx, videoURLs, bgmURLs, upload, callback, 30*time.Minute)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"video_url": merged.FileURL,
|
||||
"duration_str": merged.DurationStr,
|
||||
"task_id": merged.TaskID,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// parseOutputList 兼容不同反序列化形态的 output 列表
|
||||
func parseOutputList(args map[string]any) []map[string]any {
|
||||
switch v := args["output"].(type) {
|
||||
case []map[string]any:
|
||||
return v
|
||||
case []any:
|
||||
out := make([]map[string]any, 0, len(v))
|
||||
for _, it := range v {
|
||||
if m, ok := it.(map[string]any); ok {
|
||||
out = append(out, m)
|
||||
} else if m := gconv.Map(it); m != nil {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MergeSegments 把分段视频按序合并:有 BGM 走拼接+混音(merge),否则纯拼接(concat)。
|
||||
// 返回最终合并结果(FileURL)。
|
||||
func MergeSegments(ctx context.Context, videoURLs, bgmURLs []string, upload bool, callbackURL string, timeout time.Duration) (*MergeTask, error) {
|
||||
var kind TaskKind
|
||||
var taskID string
|
||||
var err error
|
||||
if len(bgmURLs) > 0 {
|
||||
kind = TaskKindMerge
|
||||
taskID, err = SubmitMergeAsync(ctx, videoURLs, bgmURLs, upload, callbackURL)
|
||||
} else {
|
||||
kind = TaskKindConcat
|
||||
taskID, err = SubmitConcatAsync(ctx, videoURLs, upload, callbackURL)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return WaitMediaTask(ctx, kind, taskID, timeout)
|
||||
}
|
||||
|
||||
// SubmitConcatAsync 提交纯拼接异步任务
|
||||
func SubmitConcatAsync(ctx context.Context, videoURLs []string, upload bool, callbackURL string) (string, error) {
|
||||
return submitMediaTask(ctx, TaskKindConcat, &mergeSubmitReq{
|
||||
VideoURLs: videoURLs,
|
||||
Method: "auto",
|
||||
Upload: upload,
|
||||
CallbackURL: callbackURL,
|
||||
})
|
||||
}
|
||||
|
||||
// SubmitMergeAsync 提交拼接+混音异步任务
|
||||
func SubmitMergeAsync(ctx context.Context, videoURLs, audioURLs []string, upload bool, callbackURL string) (string, error) {
|
||||
return submitMediaTask(ctx, TaskKindMerge, &mergeSubmitReq{
|
||||
VideoURLs: videoURLs,
|
||||
AudioURLs: audioURLs,
|
||||
Upload: upload,
|
||||
CallbackURL: callbackURL,
|
||||
})
|
||||
}
|
||||
|
||||
func submitMediaTask(ctx context.Context, kind TaskKind, req *mergeSubmitReq) (string, error) {
|
||||
path := "media/video/" + string(kind) + "/async"
|
||||
res := new(mergeSubmitRes)
|
||||
if err := commonHttp.Post(ctx, path, requestHeaders(ctx), res, req); err != nil {
|
||||
return "", fmt.Errorf("提交%s任务失败: %v", kind, err)
|
||||
}
|
||||
if res.TaskID == "" {
|
||||
return "", fmt.Errorf("media 返回空 taskId")
|
||||
}
|
||||
return res.TaskID, nil
|
||||
}
|
||||
|
||||
// WaitMediaTask 轮询媒体任务直到 success/failed,或超时。
|
||||
func WaitMediaTask(ctx context.Context, kind TaskKind, taskID string, timeout time.Duration) (*MergeTask, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
t, err := getMediaTask(ctx, kind, taskID)
|
||||
if err == nil {
|
||||
switch t.Status {
|
||||
case "success":
|
||||
if t.FileURL == "" {
|
||||
return nil, fmt.Errorf("%s任务成功但未返回文件URL", kind)
|
||||
}
|
||||
return t, nil
|
||||
case "failed":
|
||||
return nil, fmt.Errorf("%s任务失败: %s", kind, t.ErrorMessage)
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return nil, fmt.Errorf("%s任务[%s]超时", kind, taskID)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getMediaTask(ctx context.Context, kind TaskKind, taskID string) (*MergeTask, error) {
|
||||
path := "media/video/" + string(kind) + "/task/" + taskID
|
||||
res := new(MergeTask)
|
||||
if err := commonHttp.Get(ctx, path, requestHeaders(ctx), res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// requestHeaders 透传当前请求头(含 Authorization / X-User-Info),供内部服务鉴权使用
|
||||
func requestHeaders(ctx context.Context) map[string]string {
|
||||
headers := make(map[string]string)
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
for k, v := range r.Request.Header {
|
||||
if len(v) > 0 {
|
||||
headers[k] = v[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
// segmentResult 一段视频生成的产出(原 ai-agent/video/plan.SegmentResult,plan 包已并入处理器树)。
|
||||
type segmentResult struct {
|
||||
SegmentIndex int `json:"segment_index"`
|
||||
VideoURL string `json:"video_url"`
|
||||
Duration int `json:"duration"`
|
||||
}
|
||||
|
||||
// collectSegmentResults 把模型节点/串行工具的产出([]map[string]any)收敛为有序的分段结果列表。
|
||||
// 兼容两种形状:串行工具产出的 {segment_index, video_url, duration};
|
||||
// 并行模型调用产出的 {<url字段>: 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 即模型请求参数本体(需已解析好 valueSource,value 已填充)。
|
||||
func SplitBatchModelParamsProcessor() *processor.Processor {
|
||||
return &processor.Processor{
|
||||
Name: "split_batch_model_params",
|
||||
Description: "按 constraint.uploadTotalMaxCount 分批模型请求参数",
|
||||
Func: func(ctx context.Context, args map[string]any) (any, error) {
|
||||
if args == nil {
|
||||
return nil, fmt.Errorf("缺少模型请求参数")
|
||||
}
|
||||
return SplitBatchModelParams(args), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// splitBatchField 描述一个需要按 constraint.uploadTotalMaxCount 分批的字段
|
||||
type splitBatchField struct {
|
||||
path []any // 从参数根节点到该字段 value 的路径(map 用 key,数组用下标)
|
||||
items []any // 该字段 value 拆出的待分批元素列表
|
||||
maxCount int // 每批最大元素数(constraint.uploadTotalMaxCount)
|
||||
}
|
||||
|
||||
// SplitBatchModelParams 把模型请求参数拆成多批,供分批请求模型使用。
|
||||
// 处理流程:
|
||||
// 1. 深拷贝入参,避免污染调用方数据;
|
||||
// 2. 递归解析 valueSource,把引用节点输出的字段值写入对应字段的 value;
|
||||
// 3. 找出所有带 constraint.uploadTotalMaxCount 且 value 为集合(map/slice)的字段,
|
||||
// map 按 key 排序取 value 列表作为元素;总批数 = 各字段 (元素数/上限) 向上取整的最大值;
|
||||
// 4. 每批 = 整份参数深拷贝 + 各分批字段 value 替换为对应切片。
|
||||
//
|
||||
// 未超量时返回单份(value 已被解析填充)参数。调用方可遍历返回值逐个请求模型。
|
||||
func SplitBatchModelParams(rawParams map[string]any) []map[string]any {
|
||||
params, ok := deepCopyAny(rawParams).(map[string]any)
|
||||
if !ok {
|
||||
params = make(map[string]any)
|
||||
}
|
||||
|
||||
fields := make([]splitBatchField, 0)
|
||||
collectBatchFields(params, nil, &fields)
|
||||
|
||||
batchCount := 1
|
||||
for _, f := range fields {
|
||||
n := (len(f.items) + f.maxCount - 1) / f.maxCount
|
||||
if n > batchCount {
|
||||
batchCount = n
|
||||
}
|
||||
}
|
||||
if batchCount <= 1 {
|
||||
return []map[string]any{params}
|
||||
}
|
||||
|
||||
batches := make([]map[string]any, 0, batchCount)
|
||||
for i := 0; i < batchCount; i++ {
|
||||
batch, _ := deepCopyAny(params).(map[string]any)
|
||||
for _, f := range fields {
|
||||
start := i * f.maxCount
|
||||
if start >= len(f.items) {
|
||||
setValueAtPath(batch, f.path, []any{})
|
||||
continue
|
||||
}
|
||||
end := start + f.maxCount
|
||||
if end > len(f.items) {
|
||||
end = len(f.items)
|
||||
}
|
||||
setValueAtPath(batch, f.path, f.items[start:end])
|
||||
}
|
||||
batches = append(batches, batch)
|
||||
}
|
||||
return batches
|
||||
}
|
||||
|
||||
// collectBatchFields 递归收集所有带 constraint.uploadTotalMaxCount 且 value 为集合的分批字段
|
||||
func collectBatchFields(node any, path []any, out *[]splitBatchField) {
|
||||
switch v := node.(type) {
|
||||
case map[string]any:
|
||||
if maxCount, ok := uploadTotalMaxCountOf(v); ok {
|
||||
if items, has := toItems(v["value"]); has {
|
||||
*out = append(*out, splitBatchField{
|
||||
path: append(append([]any{}, path...), "value"),
|
||||
items: items,
|
||||
maxCount: maxCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
for key, child := range v {
|
||||
collectBatchFields(child, append(append([]any{}, path...), key), out)
|
||||
}
|
||||
case []any:
|
||||
for i, child := range v {
|
||||
collectBatchFields(child, append(append([]any{}, path...), i), out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// uploadTotalMaxCountOf 读取 schema 节点 constraint.uploadTotalMaxCount
|
||||
func uploadTotalMaxCountOf(m map[string]any) (int, bool) {
|
||||
c, ok := m["constraint"]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
cm := gconv.Map(c)
|
||||
if cm == nil {
|
||||
return 0, false
|
||||
}
|
||||
n := gconv.Int(cm["uploadTotalMaxCount"])
|
||||
if n <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// toItems 把集合 value 转成元素列表:切片原样返回;map 按 key 排序取 value,保证分批顺序稳定
|
||||
func toItems(v any) ([]any, bool) {
|
||||
switch val := v.(type) {
|
||||
case []any:
|
||||
return val, len(val) > 0
|
||||
case map[string]any:
|
||||
if len(val) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
keys := make([]string, 0, len(val))
|
||||
for k := range val {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
items := make([]any, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
items = append(items, val[k])
|
||||
}
|
||||
return items, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// setValueAtPath 沿 path 逐级导航(map 用 key、数组用下标),在最后一级写入 value
|
||||
func setValueAtPath(root map[string]any, path []any, value any) {
|
||||
if len(path) == 0 {
|
||||
return
|
||||
}
|
||||
var cur any = root
|
||||
for i := 0; i < len(path)-1; i++ {
|
||||
switch step := path[i].(type) {
|
||||
case string:
|
||||
m, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cur = m[step]
|
||||
case int:
|
||||
s, ok := cur.([]any)
|
||||
if !ok || step < 0 || step >= len(s) {
|
||||
return
|
||||
}
|
||||
cur = s[step]
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
switch last := path[len(path)-1].(type) {
|
||||
case string:
|
||||
if m, ok := cur.(map[string]any); ok {
|
||||
m[last] = value
|
||||
}
|
||||
case int:
|
||||
if s, ok := cur.([]any); ok && last >= 0 && last < len(s) {
|
||||
s[last] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deepCopyAny 深拷贝 map[string]any / []any 嵌套结构,避免批次之间互相影响
|
||||
func deepCopyAny(v any) any {
|
||||
switch val := v.(type) {
|
||||
case map[string]any:
|
||||
res := make(map[string]any, len(val))
|
||||
for k, child := range val {
|
||||
res[k] = deepCopyAny(child)
|
||||
}
|
||||
return res
|
||||
case []any:
|
||||
res := make([]any, len(val))
|
||||
for i, child := range val {
|
||||
res[i] = deepCopyAny(child)
|
||||
}
|
||||
return res
|
||||
default:
|
||||
return val
|
||||
}
|
||||
}
|
||||
@@ -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:时间码可用且可容纳时按相对时长整体缩放对齐到
|
||||
// TotalDuration(normalizeAIDurations,整数化 + 均衡修正,构造即 Σ==total);否则回退按文本量重建
|
||||
// (rebuildFromText,旧算法保留)。AI 时间码是模型对镜头节奏的真实意图,文本量重建只作兜底。
|
||||
func RebuildDurations(in Input) ([]Shot, error) {
|
||||
shots := append([]Shot(nil), in.Shots...)
|
||||
if len(shots) == 0 {
|
||||
return nil, pipeErr(ErrEmptyShots, "镜头为空")
|
||||
}
|
||||
total := in.TotalDuration
|
||||
if total <= 0 {
|
||||
total = sumShotDurations(shots)
|
||||
}
|
||||
if total <= 0 {
|
||||
total = 60
|
||||
}
|
||||
if aiDurationsUsable(shots) && total >= len(shots) {
|
||||
return normalizeAIDurations(shots, total)
|
||||
}
|
||||
return rebuildFromText(shots, total, resolveConfig(in.Cfg, in.MaxSegmentDur))
|
||||
}
|
||||
|
||||
// aiDurationsUsable AI 时间码是否值得保留:所有镜头时间码非空且时长之和 > 0。
|
||||
// 单镜零/负时长在 normalizeAIDurations 中防御性钳到 1(设计决策:零时长镜头不应出现)。
|
||||
func aiDurationsUsable(shots []Shot) bool {
|
||||
sum := 0
|
||||
for _, sh := range shots {
|
||||
if strings.TrimSpace(sh.StartTime) == "" || strings.TrimSpace(sh.EndTime) == "" {
|
||||
return false
|
||||
}
|
||||
sum += parseSec(sh.EndTime) - parseSec(sh.StartTime)
|
||||
}
|
||||
return sum > 0
|
||||
}
|
||||
|
||||
// normalizeAIDurations 保留 AI 镜头相对时长,整体缩放对齐到目标总时长:
|
||||
// 各镜时长 d[i] 按 total/Σd 等比缩放并四舍五入整数化,再做整体均衡修正(±1s 逐段抹平)保证
|
||||
// Σscaled == total 构造即成立;随后按累积偏移重排连续时间码,末镜 endTime 精确对齐 total。
|
||||
func normalizeAIDurations(shots []Shot, total int) ([]Shot, error) {
|
||||
n := len(shots)
|
||||
if n == 0 {
|
||||
return nil, pipeErr(ErrEmptyShots, "镜头为空")
|
||||
}
|
||||
if total <= 0 {
|
||||
return shots, nil
|
||||
}
|
||||
raw := make([]int, n)
|
||||
rawSum := 0
|
||||
for i, sh := range shots {
|
||||
d := parseSec(sh.EndTime) - parseSec(sh.StartTime)
|
||||
if d < 1 {
|
||||
d = 1
|
||||
}
|
||||
raw[i] = d
|
||||
rawSum += d
|
||||
}
|
||||
if rawSum <= 0 {
|
||||
return nil, pipeErr(ErrInvalidInput, "AI 时间码时长之和为 0")
|
||||
}
|
||||
scaled := make([]int, n)
|
||||
sum := 0
|
||||
for i := 0; i < n; i++ {
|
||||
s := int(math.Round(float64(raw[i]) * float64(total) / float64(rawSum)))
|
||||
if s < 1 {
|
||||
s = 1
|
||||
}
|
||||
scaled[i] = s
|
||||
sum += s
|
||||
}
|
||||
// 均衡修正:把缩放舍入偏差逐段抹平,保证 Σscaled == total(调用方保证 total >= n 可行)
|
||||
balance := total - sum
|
||||
for i := n - 1; i >= 0 && balance != 0; i-- {
|
||||
if balance > 0 {
|
||||
scaled[i]++
|
||||
balance--
|
||||
} else if scaled[i] > 1 {
|
||||
scaled[i]--
|
||||
balance++
|
||||
}
|
||||
}
|
||||
current := 0
|
||||
for i := 0; i < n; i++ {
|
||||
shots[i].StartTime = mmss(current)
|
||||
current += scaled[i]
|
||||
shots[i].EndTime = mmss(current)
|
||||
}
|
||||
if n > 0 {
|
||||
shots[n-1].EndTime = mmss(total)
|
||||
}
|
||||
return shots, nil
|
||||
}
|
||||
|
||||
// rebuildFromText 丢弃 AI 时间码、按文本量重建镜头时长(旧 RebuildDurations 算法):
|
||||
// 有文字镜头按语速算最小时长,纯视觉镜头以 AI 时长意图为基准,盈余按弹性权重分配,
|
||||
// 超出目标先压视觉再等比压有声最后截尾;整数化 + 整体均衡修正保证 Σ == total 构造即恒等。
|
||||
func rebuildFromText(shots []Shot, total int, cfg Config) ([]Shot, error) {
|
||||
n := len(shots)
|
||||
base := make([]int, n)
|
||||
weights := make([]float64, n)
|
||||
totalBase := 0
|
||||
|
||||
for i, sh := range shots {
|
||||
textLen := utf8.RuneCountInString(sh.Dialogue) + utf8.RuneCountInString(sh.Narration)
|
||||
aiDur := parseSec(sh.EndTime) - parseSec(sh.StartTime)
|
||||
if aiDur <= 0 {
|
||||
aiDur = 1
|
||||
}
|
||||
if textLen == 0 {
|
||||
d := aiDur
|
||||
if float64(d) < cfg.MinVisualDur {
|
||||
d = int(cfg.MinVisualDur)
|
||||
}
|
||||
if d < 1 {
|
||||
d = 1
|
||||
}
|
||||
base[i] = d
|
||||
weights[i] = cfg.VisualWeight
|
||||
} else {
|
||||
cps := inferCharPerSecond(sh.Dialogue+" "+sh.Narration, cfg)
|
||||
speaking := math.Ceil(float64(textLen) / cps)
|
||||
minDur := speaking + cfg.MinDurBuffer
|
||||
if minDur < 2 {
|
||||
minDur = 2
|
||||
}
|
||||
base[i] = int(minDur)
|
||||
weights[i] = cfg.SpokenWeight
|
||||
}
|
||||
totalBase += base[i]
|
||||
}
|
||||
|
||||
if totalBase <= total {
|
||||
// 盈余按弹性权重分配(float64),整数化后整体均衡修正
|
||||
surplus := total - totalBase
|
||||
var totalWeight float64
|
||||
for _, w := range weights {
|
||||
totalWeight += w
|
||||
}
|
||||
extra := make([]int, n)
|
||||
sumExtra := 0
|
||||
if totalWeight > 0 {
|
||||
for i := 0; i < n; i++ {
|
||||
e := int(math.Floor(float64(surplus) * weights[i] / totalWeight))
|
||||
extra[i] = e
|
||||
sumExtra += e
|
||||
}
|
||||
}
|
||||
// 均衡修正:把舍入偏差逐段抹平,保证 sum(extra) == surplus
|
||||
balance := surplus - sumExtra
|
||||
for i := n - 1; i >= 0 && balance != 0; i-- {
|
||||
if balance > 0 {
|
||||
extra[i]++
|
||||
balance--
|
||||
} else if extra[i] > 0 {
|
||||
extra[i]--
|
||||
balance++
|
||||
}
|
||||
}
|
||||
current := 0
|
||||
for i := 0; i < n; i++ {
|
||||
shots[i].StartTime = mmss(current)
|
||||
current += base[i] + extra[i]
|
||||
shots[i].EndTime = mmss(current)
|
||||
}
|
||||
if n > 0 {
|
||||
shots[n-1].EndTime = mmss(total)
|
||||
}
|
||||
} else {
|
||||
// 总最小时长超出目标 → 压缩
|
||||
overshoot := totalBase - total
|
||||
|
||||
// 1) 压缩纯视觉镜头
|
||||
for i := 0; i < n && overshoot > 0; i++ {
|
||||
if weights[i] >= cfg.VisualWeight && base[i] > 1 {
|
||||
maxReduce := base[i] - 1
|
||||
reduce := overshoot
|
||||
if reduce > maxReduce {
|
||||
reduce = maxReduce
|
||||
}
|
||||
base[i] -= reduce
|
||||
overshoot -= reduce
|
||||
}
|
||||
}
|
||||
|
||||
if overshoot > 0 {
|
||||
// 2) 重建有声镜头底线,等比压缩
|
||||
reBase := make([]int, n)
|
||||
newTotal := 0
|
||||
for i, sh := range shots {
|
||||
if weights[i] >= cfg.VisualWeight {
|
||||
reBase[i] = base[i]
|
||||
} else {
|
||||
textLen := utf8.RuneCountInString(sh.Dialogue) + utf8.RuneCountInString(sh.Narration)
|
||||
cps := inferCharPerSecond(sh.Dialogue+" "+sh.Narration, cfg)
|
||||
speaking := math.Ceil(float64(textLen) / cps)
|
||||
minDur := speaking + cfg.MinDurBuffer
|
||||
if minDur < 2 {
|
||||
minDur = 2
|
||||
}
|
||||
reBase[i] = int(minDur)
|
||||
}
|
||||
newTotal += reBase[i]
|
||||
}
|
||||
|
||||
if newTotal > total {
|
||||
ratio := float64(total) / float64(newTotal)
|
||||
current := 0
|
||||
keep := n
|
||||
for i := 0; i < n; i++ {
|
||||
d := int(float64(reBase[i]) * ratio)
|
||||
if d < 1 {
|
||||
d = 1
|
||||
}
|
||||
if current+d > total {
|
||||
d = total - current
|
||||
}
|
||||
if d < 1 {
|
||||
d = 1
|
||||
}
|
||||
shots[i].StartTime = mmss(current)
|
||||
current += d
|
||||
shots[i].EndTime = mmss(current)
|
||||
if current >= total {
|
||||
keep = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
if keep < n {
|
||||
shots = shots[:keep]
|
||||
}
|
||||
if len(shots) > 0 && current < total {
|
||||
shots[len(shots)-1].EndTime = mmss(total)
|
||||
}
|
||||
} else {
|
||||
current := 0
|
||||
for i := 0; i < n; i++ {
|
||||
shots[i].StartTime = mmss(current)
|
||||
current += reBase[i]
|
||||
shots[i].EndTime = mmss(current)
|
||||
}
|
||||
if current < total && n > 0 {
|
||||
shots[n-1].EndTime = mmss(total)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 仅压缩视觉镜头就够了
|
||||
current := 0
|
||||
for i := 0; i < n; i++ {
|
||||
shots[i].StartTime = mmss(current)
|
||||
current += base[i]
|
||||
shots[i].EndTime = mmss(current)
|
||||
}
|
||||
if current < total && n > 0 {
|
||||
shots[n-1].EndTime = mmss(total)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i := range shots {
|
||||
shots[i].Index = i + 1
|
||||
}
|
||||
return shots, nil
|
||||
}
|
||||
|
||||
// inferCharPerSecond 根据文本情绪推断语速:感叹/疑问多→快,低落→慢。
|
||||
func inferCharPerSecond(text string, cfg Config) float64 {
|
||||
runes := utf8.RuneCountInString(text)
|
||||
if runes == 0 {
|
||||
return cfg.CharPerSecond
|
||||
}
|
||||
excl := strings.Count(text, "!") + strings.Count(text, "!")
|
||||
ques := strings.Count(text, "?") + strings.Count(text, "?")
|
||||
emotion := excl + ques
|
||||
if emotion >= 2 && emotion*100/runes >= 8 {
|
||||
return cfg.FastCharPerSecond
|
||||
}
|
||||
if strings.Contains(text, "…") || strings.Contains(text, "唉") {
|
||||
return cfg.SlowCharPerSecond
|
||||
}
|
||||
return cfg.CharPerSecond
|
||||
}
|
||||
|
||||
// ---------- ② SplitOversized(设计 §5.2 + 四审 P0-4)----------
|
||||
|
||||
// SplitOversized 超 MaxSegmentDur 的镜头按句末断句切子镜头,避免"说话说一半"。
|
||||
// 每个子镜头按「基础起点 + 子段序号×Max」重算全局 StartTime/EndTime(末段延伸到原终点),
|
||||
// 保证子镜头全局时间轴连续、无重叠、无缝隙。
|
||||
func SplitOversized(shots []Shot, in Input) ([]Shot, error) {
|
||||
maxDur := in.MaxSegmentDur
|
||||
if maxDur <= 0 || len(shots) == 0 {
|
||||
return shots, nil
|
||||
}
|
||||
cfg := resolveConfig(in.Cfg, maxDur)
|
||||
window := cfg.SplitWindow
|
||||
fragMin := int(cfg.ShortFragmentDur)
|
||||
if fragMin < 1 {
|
||||
fragMin = 1
|
||||
}
|
||||
|
||||
var result []Shot
|
||||
for _, sh := range shots {
|
||||
dur := parseSec(sh.EndTime) - parseSec(sh.StartTime)
|
||||
if dur <= maxDur {
|
||||
result = append(result, sh)
|
||||
continue
|
||||
}
|
||||
parts := (dur + maxDur - 1) / maxDur
|
||||
// 最后一片段 < 短残片阈值时减少拆分段数
|
||||
remainder := dur - (parts-1)*maxDur
|
||||
if remainder < fragMin && parts > 1 {
|
||||
parts--
|
||||
}
|
||||
dRunes := []rune(sh.Dialogue)
|
||||
nRunes := []rune(sh.Narration)
|
||||
baseStart := parseSec(sh.StartTime)
|
||||
|
||||
dPos, nPos := 0, 0
|
||||
for p := 0; p < parts; p++ {
|
||||
sub := sh
|
||||
pStart := baseStart + p*maxDur
|
||||
pEnd := pStart + maxDur
|
||||
if p > 0 {
|
||||
sub.StartTime = mmss(pStart)
|
||||
}
|
||||
if p == parts-1 {
|
||||
pEnd = baseStart + dur
|
||||
}
|
||||
sub.EndTime = mmss(pEnd)
|
||||
|
||||
if p < parts-1 {
|
||||
dTarget := len(dRunes) * (p + 1) / parts
|
||||
if dTarget > dPos && dTarget <= len(dRunes) {
|
||||
dEnd := findBoundary(dRunes, dTarget, true, window)
|
||||
if dEnd <= dPos {
|
||||
dEnd = dPos + 1
|
||||
}
|
||||
if dEnd > len(dRunes) {
|
||||
dEnd = len(dRunes)
|
||||
}
|
||||
sub.Dialogue = string(dRunes[dPos:dEnd])
|
||||
dPos = dEnd
|
||||
}
|
||||
nTarget := len(nRunes) * (p + 1) / parts
|
||||
if nTarget > nPos && nTarget <= len(nRunes) {
|
||||
nEnd := findBoundary(nRunes, nTarget, true, window)
|
||||
if nEnd <= nPos {
|
||||
nEnd = nPos + 1
|
||||
}
|
||||
if nEnd > len(nRunes) {
|
||||
nEnd = len(nRunes)
|
||||
}
|
||||
sub.Narration = string(nRunes[nPos:nEnd])
|
||||
nPos = nEnd
|
||||
}
|
||||
} else {
|
||||
sub.Dialogue = string(dRunes[dPos:])
|
||||
sub.Narration = string(nRunes[nPos:])
|
||||
}
|
||||
result = append(result, sub)
|
||||
}
|
||||
}
|
||||
|
||||
for i := range result {
|
||||
result[i].Index = i + 1
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ---------- ③ GroupSegments(2026-08-12 修订,取代 CalcSegmentDurations/AlignSegments)----------
|
||||
|
||||
// GroupSegments 按镜头边界贪心分组:段内镜头时长之和不超过 MaxSegmentDur;min 为硬约束
|
||||
// (段不足 MinSegmentDur 时从下一镜头头部按句末断点咬取补齐,模型不能容忍更短的段)。
|
||||
// 镜头时间码已在 RebuildDurations 归一为连续覆盖 [0,total),分组只做切片、不改写时间码;
|
||||
// 末段为残余可短于 min(无后续镜头可咬)。前置条件:单镜时长 ≤ MaxSegmentDur(由 SplitOversized 保证)。
|
||||
func GroupSegments(shots []Shot, in Input) ([][]Shot, []int, error) {
|
||||
if len(shots) == 0 {
|
||||
return nil, nil, pipeErr(ErrEmptyShots, "镜头为空")
|
||||
}
|
||||
cfg := resolveConfig(in.Cfg, in.MaxSegmentDur)
|
||||
maxSeg := in.MaxSegmentDur
|
||||
if maxSeg <= 0 {
|
||||
maxSeg = 15
|
||||
}
|
||||
minSeg := in.MinSegmentDur
|
||||
|
||||
remaining := append([]Shot(nil), shots...)
|
||||
var segs [][]Shot
|
||||
var durs []int
|
||||
|
||||
for len(remaining) > 0 {
|
||||
var seg []Shot
|
||||
segDur := 0
|
||||
|
||||
// 贪心拉入整镜头,直到加入下一镜会超过 max(首镜总是可入;单镜 ≤ max 由 SplitOversized 保证)
|
||||
for len(remaining) > 0 {
|
||||
d := shotDur(remaining[0])
|
||||
if segDur > 0 && segDur+d > maxSeg {
|
||||
break
|
||||
}
|
||||
seg = append(seg, remaining[0])
|
||||
segDur += d
|
||||
remaining = remaining[1:]
|
||||
}
|
||||
|
||||
// min 硬约束:段不足 min 且还有镜头时,从下一镜头头部咬取补齐
|
||||
if minSeg > 0 && segDur < minSeg && len(remaining) > 0 {
|
||||
sh := remaining[0]
|
||||
s := parseSec(sh.StartTime)
|
||||
e := parseSec(sh.EndTime)
|
||||
need := minSeg - segDur
|
||||
// segDur+d > maxSeg ≥ minSeg ⇒ need < shotDur(sh),必走咬取分支
|
||||
if need > 0 && need < e-s {
|
||||
cut := s + need
|
||||
cutRune := -1
|
||||
if c, r, ok := sentenceCut(sh, cut, cfg); ok && c > s && c < e {
|
||||
cut, cutRune = c, r
|
||||
}
|
||||
// 吸附后钳制:min≈max 且句末断点在 need 点之后时,句末吸附会把段推过 max
|
||||
//(例 max=10,min=9,segDur=8,断点在 +3s → 段 11s>10)。钳回 s+(maxSeg-segDur) 保 max
|
||||
//(≥ min 恒成立,因 segDur < minSeg ≤ maxSeg),并回退纯时间切,避免 rune 与钳制后时间码错位。
|
||||
if maxSeg > segDur && cut > s+(maxSeg-segDur) {
|
||||
cut = s + (maxSeg - segDur)
|
||||
cutRune = -1
|
||||
}
|
||||
if cut <= s {
|
||||
cut = s + 1
|
||||
}
|
||||
if cut >= e {
|
||||
cut = e - 1
|
||||
}
|
||||
s1, s2 := splitShotAt(sh, cut, cutRune)
|
||||
seg = append(seg, s1)
|
||||
segDur += parseSec(s1.EndTime) - parseSec(s1.StartTime)
|
||||
remaining[0] = s2
|
||||
} else if need > 0 {
|
||||
// 防御:整镜补入(理论不可达,见上)
|
||||
seg = append(seg, sh)
|
||||
segDur += shotDur(sh)
|
||||
remaining = remaining[1:]
|
||||
}
|
||||
}
|
||||
|
||||
segs = append(segs, seg)
|
||||
durs = append(durs, segDur)
|
||||
}
|
||||
return segs, durs, nil
|
||||
}
|
||||
|
||||
// sentenceCut 在镜头内寻找离 target 最近、且位移 ≤ BoundaryTolerance 的句末断点,
|
||||
// 返回断点时间(秒)与断点 rune 下标(在 "台词\n旁白" 合并串中的位置)。找不到返回 (target,-1,false)。
|
||||
func sentenceCut(sh Shot, target int, cfg Config) (int, int, bool) {
|
||||
s := parseSec(sh.StartTime)
|
||||
e := parseSec(sh.EndTime)
|
||||
dur := e - s
|
||||
if dur <= 0 {
|
||||
return target, -1, false
|
||||
}
|
||||
text := sh.Dialogue + "\n" + sh.Narration
|
||||
rs := []rune(text)
|
||||
if len(rs) == 0 {
|
||||
return target, -1, false
|
||||
}
|
||||
tol := int(cfg.BoundaryTolerance)
|
||||
if tol < 0 {
|
||||
tol = 0
|
||||
}
|
||||
best := target
|
||||
bestRune := -1
|
||||
bestDist := tol + 1
|
||||
found := false
|
||||
for _, p := range sentenceEnds(text) {
|
||||
t := s + int(math.Round(float64(p)*float64(dur)/float64(len(rs))))
|
||||
if t < s+1 || t > e-1 {
|
||||
continue
|
||||
}
|
||||
dist := absInt(t - target)
|
||||
if dist < bestDist {
|
||||
bestDist = dist
|
||||
best = t
|
||||
bestRune = p
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return target, -1, false
|
||||
}
|
||||
return best, bestRune, true
|
||||
}
|
||||
|
||||
// splitShotAt 把镜头按时间 cut 切成两个子镜头;cutRune >= 0 时按合并文本 rune 位置切分文本。
|
||||
func splitShotAt(sh Shot, cut, cutRune int) (Shot, Shot) {
|
||||
s1, s2 := sh, sh
|
||||
s1.EndTime = mmss(cut)
|
||||
s2.StartTime = mmss(cut)
|
||||
if cutRune >= 0 {
|
||||
d := []rune(sh.Dialogue)
|
||||
n := []rune(sh.Narration)
|
||||
switch {
|
||||
case cutRune <= len(d):
|
||||
s1.Dialogue = string(d[:cutRune])
|
||||
s2.Dialogue = string(d[cutRune:])
|
||||
s1.Narration = ""
|
||||
s2.Narration = string(n)
|
||||
case cutRune <= len(d)+1+len(n):
|
||||
s1.Dialogue = string(d)
|
||||
nsplit := cutRune - len(d) - 1
|
||||
if nsplit < 0 {
|
||||
nsplit = 0
|
||||
}
|
||||
if nsplit > len(n) {
|
||||
nsplit = len(n)
|
||||
}
|
||||
s1.Narration = string(n[:nsplit])
|
||||
s2.Dialogue = ""
|
||||
s2.Narration = string(n[nsplit:])
|
||||
default:
|
||||
s1.Dialogue = string(d)
|
||||
s1.Narration = string(n)
|
||||
s2.Dialogue = ""
|
||||
s2.Narration = ""
|
||||
}
|
||||
}
|
||||
return s1, s2
|
||||
}
|
||||
|
||||
// BuildTimeline 编排 RebuildDurations → SplitOversized → GroupSegments,收尾做 assertTimeline 不变量校验。
|
||||
func BuildTimeline(in Input) ([][]Shot, []int, error) {
|
||||
in = NormalizeInput(in)
|
||||
if len(in.Shots) == 0 {
|
||||
return nil, nil, pipeErr(ErrEmptyShots, "镜头为空")
|
||||
}
|
||||
shots, err := RebuildDurations(in)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
shots, err = SplitOversized(shots, in)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
segs, durs, err := GroupSegments(shots, in)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := assertTimeline(segs, durs, in.TotalDuration, in.MinSegmentDur, in.MaxSegmentDur, in.Cfg.StrictInvariant); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return segs, durs, nil
|
||||
}
|
||||
|
||||
// ---------- assertTimeline(设计 §5.5 + P0-1)----------
|
||||
|
||||
// assertTimeline 校验时间线不变量:
|
||||
// 段时长总和精确等于 total;每个镜头只属于一个段;段内镜头时间码落在段窗口内且连续无重叠;
|
||||
// 每段时长 ∈ [min, max](strict=false 时该条仅告警,结构性不变量始终强制)。
|
||||
func assertTimeline(segShots [][]Shot, segDurs []int, total, minSeg, maxSeg int, strict bool) error {
|
||||
sum := 0
|
||||
for _, d := range segDurs {
|
||||
sum += d
|
||||
}
|
||||
if sum != total {
|
||||
return pipeErr(ErrTimelineInvariant, fmt.Sprintf("段时长总和 %d != TotalDuration %d", sum, total))
|
||||
}
|
||||
start := 0
|
||||
for i, seg := range segShots {
|
||||
dur := segDurs[i]
|
||||
if strict && (dur < minSeg || dur > maxSeg) {
|
||||
return pipeErr(ErrTimelineInvariant, fmt.Sprintf("段%d 时长 %d 不在 [%d,%d] 内", i, dur, minSeg, maxSeg))
|
||||
}
|
||||
if len(seg) == 0 {
|
||||
start += dur
|
||||
continue
|
||||
}
|
||||
prev := start
|
||||
for _, sh := range seg {
|
||||
s := parseSec(sh.StartTime)
|
||||
e := parseSec(sh.EndTime)
|
||||
if s != prev || e < s {
|
||||
return pipeErr(ErrTimelineInvariant, fmt.Sprintf("段%d 镜头时间不连续/重叠(镜头%d %s-%s)", i, sh.Index, sh.StartTime, sh.EndTime))
|
||||
}
|
||||
if s < start || e > start+dur {
|
||||
return pipeErr(ErrTimelineInvariant, fmt.Sprintf("段%d 镜头%d 时间码超出段窗口", i, sh.Index))
|
||||
}
|
||||
prev = e
|
||||
}
|
||||
if prev != start+dur {
|
||||
return pipeErr(ErrTimelineInvariant, fmt.Sprintf("段%d 镜头未覆盖段窗口 [%d,%d)", i, start, start+dur))
|
||||
}
|
||||
start += dur
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"ai-agent/gateway"
|
||||
"ai-agent/workflow/consts/model"
|
||||
"ai-agent/workflow/consts/node"
|
||||
nodeDto "ai-agent/workflow/model/dto/node"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
commonHttp "gitea.redpowerfuture.com/red-future/common/http"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
@@ -16,370 +16,55 @@ var NodeLibraryService = &nodeLibraryService{}
|
||||
|
||||
type nodeLibraryService struct{}
|
||||
|
||||
func GetModelType(ctx context.Context) (mainTypeMap map[int]string, err error) {
|
||||
headers := make(map[string]string)
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
for k, v := range r.Request.Header {
|
||||
if len(v) > 0 {
|
||||
headers[k] = v[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
res := new(nodeDto.ModelTypeResponse)
|
||||
err = commonHttp.Get(ctx, "model-gateway/model/listType", headers, res, nil)
|
||||
// 通用过滤:只保留 能被 100 整除的主类型(100/200/300...)
|
||||
mainTypeMap = make(map[int]string)
|
||||
for typ, name := range res.Type {
|
||||
if typ%100 == 0 {
|
||||
mainTypeMap[typ] = name
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *nodeLibraryService) GetNodeLibrary(ctx context.Context, req *nodeDto.WorkflowNodeTreeReq) (*nodeDto.WorkflowNodeTreeRes, error) {
|
||||
WorkflowNodeGroups := []node.NodeGroupItem{
|
||||
{
|
||||
Group: node.NodeGroupComponent,
|
||||
Label: node.NodeGroupNameComponent,
|
||||
Items: []node.NodeItem{
|
||||
{
|
||||
NodeCode: node.NodeTypeTextModel,
|
||||
NodeName: node.NodeNameTextModel,
|
||||
ModelType: node.ModelTypeText,
|
||||
SkillOption: false,
|
||||
PromptOption: true,
|
||||
IsSaveFile: true,
|
||||
FormConfig: []node.NodeFormField{},
|
||||
ModelConfig: []node.ModelItem{},
|
||||
},
|
||||
{
|
||||
NodeCode: node.NodeTypeImageModel,
|
||||
NodeName: node.NodeNameImageModel,
|
||||
ModelType: node.ModelTypeImage,
|
||||
SkillOption: false,
|
||||
PromptOption: true,
|
||||
IsSaveFile: true,
|
||||
FormConfig: []node.NodeFormField{},
|
||||
ModelConfig: []node.ModelItem{},
|
||||
},
|
||||
{
|
||||
NodeCode: node.NodeTypeVideoModel,
|
||||
NodeName: node.NodeNameVideoModel,
|
||||
ModelType: node.ModelTypeVideo,
|
||||
SkillOption: false,
|
||||
PromptOption: true,
|
||||
IsSaveFile: true,
|
||||
FormConfig: []node.NodeFormField{},
|
||||
ModelConfig: []node.ModelItem{},
|
||||
},
|
||||
{
|
||||
NodeCode: node.NodeTypeAudioModel,
|
||||
NodeName: node.NodeNameAudioModel,
|
||||
ModelType: node.ModelTypeAudio,
|
||||
SkillOption: false,
|
||||
PromptOption: true,
|
||||
IsSaveFile: true,
|
||||
FormConfig: []node.NodeFormField{},
|
||||
ModelConfig: []node.ModelItem{},
|
||||
},
|
||||
{
|
||||
NodeCode: node.NodeTypeBatchModel,
|
||||
NodeName: node.NodeNameBatchModel,
|
||||
ModelType: node.ModelTypeText,
|
||||
SkillOption: false,
|
||||
PromptOption: true,
|
||||
IsSaveFile: true,
|
||||
FormConfig: []node.NodeFormField{},
|
||||
ModelConfig: []node.ModelItem{},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Group: node.NodeGroupBase,
|
||||
Label: node.NodeGroupNameBase,
|
||||
Items: []node.NodeItem{
|
||||
{
|
||||
NodeCode: node.NodeTypeSubFlow,
|
||||
NodeName: node.NodeSubFlow,
|
||||
SkillOption: false,
|
||||
PromptOption: false,
|
||||
IsSaveFile: false,
|
||||
FormConfig: []node.NodeFormField{
|
||||
{Field: "maxConcurrency", Label: "最大并发数", Type: "input", Required: true},
|
||||
},
|
||||
ModelConfig: []node.ModelItem{},
|
||||
},
|
||||
{
|
||||
NodeCode: node.NodeTypeDataConversionModel,
|
||||
NodeName: node.NodeNameDataConversionModel,
|
||||
ModelType: node.ModelTypeText,
|
||||
SkillOption: false,
|
||||
PromptOption: true,
|
||||
FormConfig: []node.NodeFormField{},
|
||||
ModelConfig: []node.ModelItem{},
|
||||
},
|
||||
{
|
||||
NodeCode: node.NodeTypeMerge,
|
||||
NodeName: node.NodeNameMerge,
|
||||
SkillOption: false,
|
||||
FormConfig: []node.NodeFormField{},
|
||||
ModelConfig: []node.ModelItem{},
|
||||
},
|
||||
{
|
||||
NodeCode: node.NodeTypeDataMerge,
|
||||
NodeName: node.NodeNameDataMerge,
|
||||
SkillOption: false,
|
||||
FormConfig: []node.NodeFormField{},
|
||||
ModelConfig: []node.ModelItem{},
|
||||
},
|
||||
{
|
||||
NodeCode: node.NodeTypeJudge,
|
||||
NodeName: node.NodeNameJudge,
|
||||
SkillOption: false,
|
||||
FormConfig: []node.NodeFormField{
|
||||
{Field: "condition", Label: node.FormLabelCondition, Type: "input", Required: true},
|
||||
},
|
||||
ModelConfig: []node.ModelItem{},
|
||||
},
|
||||
{
|
||||
NodeCode: node.NodeTypeForm,
|
||||
NodeName: node.NodeNameForm,
|
||||
SkillOption: false,
|
||||
FormConfig: []node.NodeFormField{},
|
||||
ModelConfig: []node.ModelItem{},
|
||||
},
|
||||
{
|
||||
NodeCode: node.NodeTypeHttp,
|
||||
NodeName: node.NodeNameHttp,
|
||||
SkillOption: false,
|
||||
IsSaveFile: true,
|
||||
FormConfig: []node.NodeFormField{
|
||||
{
|
||||
Field: "method",
|
||||
Label: "请求方式",
|
||||
Type: "select",
|
||||
Required: true,
|
||||
Options: []node.SelectOption{
|
||||
{Label: "GET", Value: "GET"},
|
||||
{Label: "POST", Value: "POST"},
|
||||
{Label: "PUT", Value: "PUT"},
|
||||
{Label: "DELETE", Value: "DELETE"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Field: "url",
|
||||
Label: "请求地址",
|
||||
Type: "input",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Field: "headers",
|
||||
Label: "请求头(支持Authorization鉴权)",
|
||||
Type: "keyValue",
|
||||
Required: false,
|
||||
},
|
||||
{
|
||||
Field: "bodyType",
|
||||
Label: "请求体类型",
|
||||
Type: "select",
|
||||
Required: true,
|
||||
Options: []node.SelectOption{
|
||||
{Label: "无", Value: "None"},
|
||||
{Label: "JSON", Value: "JSON"},
|
||||
//{Label: "表单", Value: "FormUrlEncoded"},
|
||||
//{Label: "文件上传", Value: "FormData"},
|
||||
//{Label: "原生文本", Value: "Raw"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Field: "body",
|
||||
Label: "请求体内容",
|
||||
Type: "keyValue",
|
||||
Required: false,
|
||||
},
|
||||
{
|
||||
Field: "response",
|
||||
Label: "结果返回结构",
|
||||
Type: "keyValue",
|
||||
Required: false,
|
||||
},
|
||||
{
|
||||
Field: "responseType",
|
||||
Label: "结果返回方式",
|
||||
Type: "select",
|
||||
Required: true,
|
||||
Options: []node.SelectOption{
|
||||
{Label: "同步返回", Value: "sync"},
|
||||
{Label: "等候回调", Value: "callback"},
|
||||
{Label: "主动拉取", Value: "pull"},
|
||||
},
|
||||
Expand: []node.NodeFormField{
|
||||
{
|
||||
Field: "method",
|
||||
Label: "请求方式",
|
||||
Type: "select",
|
||||
Required: true,
|
||||
Options: []node.SelectOption{
|
||||
{Label: "GET", Value: "GET"},
|
||||
{Label: "POST", Value: "POST"},
|
||||
{Label: "PUT", Value: "PUT"},
|
||||
{Label: "DELETE", Value: "DELETE"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Field: "url",
|
||||
Label: "请求地址",
|
||||
Type: "input",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Field: "headers",
|
||||
Label: "请求头(支持Authorization鉴权)",
|
||||
Type: "keyValue",
|
||||
Required: false,
|
||||
},
|
||||
{
|
||||
Field: "bodyType",
|
||||
Label: "请求体类型",
|
||||
Type: "select",
|
||||
Required: true,
|
||||
Options: []node.SelectOption{
|
||||
{Label: "无", Value: "None"},
|
||||
{Label: "JSON", Value: "JSON"},
|
||||
//{Label: "表单", Value: "FormUrlEncoded"},
|
||||
//{Label: "文件上传", Value: "FormData"},
|
||||
//{Label: "原生文本", Value: "Raw"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Field: "body",
|
||||
Label: "请求体内容",
|
||||
Type: "keyValue",
|
||||
Required: false,
|
||||
},
|
||||
{
|
||||
Field: "response",
|
||||
Label: "结果返回结构",
|
||||
Type: "keyValue",
|
||||
Required: false,
|
||||
},
|
||||
{
|
||||
Field: "timeout",
|
||||
Label: "超时时间(秒)",
|
||||
Type: "inputNumber",
|
||||
Required: false,
|
||||
Default: 30,
|
||||
},
|
||||
{
|
||||
Field: "insecureSkipVerify",
|
||||
Label: "跳过HTTPS证书校验",
|
||||
Type: "switch",
|
||||
Required: false,
|
||||
Default: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Field: "callbackUrl",
|
||||
Label: "回调地址(只需要填写字段名称)",
|
||||
Type: "input",
|
||||
Required: false,
|
||||
Default: "",
|
||||
},
|
||||
{
|
||||
Field: "timeout",
|
||||
Label: "超时时间(秒)",
|
||||
Type: "inputNumber",
|
||||
Required: false,
|
||||
Default: 30,
|
||||
},
|
||||
{
|
||||
Field: "insecureSkipVerify",
|
||||
Label: "跳过HTTPS证书校验",
|
||||
Type: "switch",
|
||||
Required: false,
|
||||
Default: false,
|
||||
},
|
||||
},
|
||||
ModelConfig: []node.ModelItem{},
|
||||
},
|
||||
//{
|
||||
// NodeCode: node.NodeTypeModel,
|
||||
// NodeName: node.NodeNameModel,
|
||||
// SkillOption: true,
|
||||
// FormConfig: []node.NodeFormField{},
|
||||
// ModelConfig: []node.ModelItem{},
|
||||
//},
|
||||
},
|
||||
},
|
||||
//{
|
||||
// Group: node.NodeGroupCustom,
|
||||
// Label: node.NodeGroupNameCustom,
|
||||
// Items: []node.NodeItem{
|
||||
// {
|
||||
// NodeCode: node.NodeTypeCustomNode,
|
||||
// NodeName: node.NodeNameCustomNode,
|
||||
// SkillOption: true,
|
||||
// FormConfig: []node.NodeFormField{
|
||||
// {Field: "nodeName", Label: node.FormLabelApiKey, Type: "input", Required: true},
|
||||
// {Field: "nodeType", Label: node.FormLabelModel, Type: "input", Required: true},
|
||||
// },
|
||||
// ModelConfig: []node.ModelItem{},
|
||||
// },
|
||||
// },
|
||||
//},
|
||||
tree := node.GetFilterNodeTree([]node.NodeGroup{node.NodeGroupBase})
|
||||
if opts, err := videoModelOptions(ctx); err != nil {
|
||||
g.Log().Warningf(ctx, "加载视频模型选项失败,节点库降级返回: %v", err)
|
||||
} else {
|
||||
applyVideoModelOptions(tree, opts)
|
||||
}
|
||||
tree := &nodeDto.WorkflowNodeTreeRes{
|
||||
Groups: WorkflowNodeGroups,
|
||||
}
|
||||
|
||||
// 3. 遍历分组,根据 typeId=1 给【文本模型节点】追加固定表单
|
||||
for gIdx := range tree.Groups {
|
||||
group := &tree.Groups[gIdx]
|
||||
|
||||
// 遍历分组下的每个节点
|
||||
for itemIdx := range group.Items {
|
||||
item := &group.Items[itemIdx]
|
||||
if item.NodeCode == node.NodeTypeTextModel ||
|
||||
item.NodeCode == node.NodeTypeImageModel ||
|
||||
item.NodeCode == node.NodeTypeVideoModel ||
|
||||
item.NodeCode == node.NodeTypeAudioModel ||
|
||||
item.NodeCode == node.NodeTypeBatchModel ||
|
||||
item.NodeCode == node.NodeTypeDataConversionModel {
|
||||
item.ModelConfig = append(item.ModelConfig, node.ModelItem{
|
||||
ModelName: "自定义",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tree, nil
|
||||
return &nodeDto.WorkflowNodeTreeRes{
|
||||
Groups: tree,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetUserInfo 设置用户信息
|
||||
func (s *nodeLibraryService) SetUserInfo(ctx context.Context, creator string, tenantId uint64) (headers map[string]string, err error) {
|
||||
// 创建完整的用户信息
|
||||
userInfo := &beans.User{
|
||||
UserName: creator,
|
||||
TenantId: tenantId,
|
||||
}
|
||||
ctx = context.WithValue(ctx, "user", *userInfo)
|
||||
// 提取并保存请求头(在连接升级前)
|
||||
headers = make(map[string]string)
|
||||
// 提取其他headers
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
for k, v := range r.Request.Header {
|
||||
if len(v) > 0 {
|
||||
headers[k] = v[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
// 将完整用户信息序列化为JSON,放到X-User-Info请求头
|
||||
userInfoJson, err := gjson.Encode(userInfo)
|
||||
// videoModelOptions 按视频模型类型(600)查模型网关,转成 modelId 下拉选项:key=模型ID,value=模型名称。
|
||||
func videoModelOptions(ctx context.Context) ([]node.SelectOption, error) {
|
||||
res, err := gateway.ListModelManage(ctx, &gateway.ListModelManageReq{ModelType: model.TypeVideo})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("用户信息序列化失败: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
opts := make([]node.SelectOption, 0, len(res.List))
|
||||
for _, item := range res.List {
|
||||
if item == nil || item.Id <= 0 || item.ModelName == "" {
|
||||
continue
|
||||
}
|
||||
opts = append(opts, node.SelectOption{
|
||||
Key: strconv.FormatInt(item.Id, 10),
|
||||
Value: item.ModelName,
|
||||
})
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
// applyVideoModelOptions 把 script_transcribe 节点的 modelId 预设下拉替换为视频模型选项。
|
||||
// 深拷贝 PresetOption 后再改,避免改写全局 NodeTypeMetaList。
|
||||
func applyVideoModelOptions(tree []node.NodeGroupTree, opts []node.SelectOption) {
|
||||
for gi := range tree {
|
||||
for ni := range tree[gi].Nodes {
|
||||
n := &tree[gi].Nodes[ni]
|
||||
if n.Key != node.NodeTypeScriptTranscribe {
|
||||
continue
|
||||
}
|
||||
presets := append([]node.NodePresetField(nil), n.PresetOption...)
|
||||
for pi := range presets {
|
||||
if presets[pi].Field == "modelId" {
|
||||
presets[pi].Options = opts
|
||||
break
|
||||
}
|
||||
}
|
||||
n.PresetOption = presets
|
||||
return
|
||||
}
|
||||
}
|
||||
headers["X-User-Info"] = string(userInfoJson)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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 "内容"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user