diff --git a/go.mod b/go.mod index 2c6a4ff..7b42d39 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,10 @@ require ( github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.0 github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.0 github.com/gogf/gf/v2 v2.10.0 + github.com/google/uuid v1.6.0 + github.com/stretchr/testify v1.11.1 + github.com/tidwall/gjson v1.19.0 + github.com/tidwall/sjson v1.2.5 go.opentelemetry.io/otel/trace v1.38.0 ) @@ -28,6 +32,7 @@ require ( github.com/clbanning/mxj/v2 v2.7.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgraph-io/badger/v4 v4.2.0 // indirect github.com/dgraph-io/ristretto v0.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect @@ -49,7 +54,6 @@ require ( github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/flatbuffers v1.12.1 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/goph/emperror v0.17.2 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/grokify/html-strip-tags-go v0.1.0 // indirect @@ -83,11 +87,14 @@ require ( github.com/olekukonko/tablewriter v1.1.0 // indirect github.com/pelletier/go-toml/v2 v2.0.9 // indirect github.com/pkg/errors v0.9.1 // 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.12.1 // 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/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect github.com/tiger1103/gfast-token v1.0.10 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/vcaesar/cedar v0.30.0 // indirect diff --git a/go.sum b/go.sum index 685ac80..a786f26 100644 --- a/go.sum +++ b/go.sum @@ -369,6 +369,15 @@ 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/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/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 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/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/tiger1103/gfast-token v1.0.10 h1:fNiBE/Dq5iTHvTGlCx3DmXa2o4hr0NtumFpffZ39k6s= github.com/tiger1103/gfast-token v1.0.10/go.mod h1:a/21mxmj7zFeNvjhZSC0XpEAFHfb1aT2k6DXnufFU1s= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= diff --git a/main.go b/main.go index 46fb1c6..194f31c 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ func main() { ctx := context.Background() defer jaeger.ShutDown(ctx) // 注册路由 + http.Httpserver.BindHandler("/httpNodeCallback", workflowController.FlowCallBack.HttpNodeCallback) http.RouteRegister([]interface{}{ //digitalhuman相关接口 digitalhumanController.Audio, // 语音相关接口 @@ -30,9 +31,15 @@ func main() { workflowController.FlowUser, workflowController.FlowTemplate, workflowNodeController.NodeLibrary, + workflowNodeController.NodePrompt, workflowSkillController.SkillTemplate, workflowSkillController.SkillUser, }) + //workflow.ExternalInterruptDemo() + //err := activePullService.ActivePullService.AllList(ctx) + //if err != nil { + // g.Log().Error(ctx, "ActivePullService err: %v", err) + //} // 保持应用运行 select {} } diff --git a/update.sql b/update.sql index d11733e..475e3d6 100644 --- a/update.sql +++ b/update.sql @@ -406,6 +406,8 @@ CREATE TABLE IF NOT EXISTS black_deacon_flow_execution ( -- 业务字段 flow_user_id BIGINT NOT NULL, -- 流程ID flow_name VARCHAR(128) NOT NULL DEFAULT '', + node_group_id varchar(64) NOT NULL DEFAULT '', + total_tokens integer NOT NULL DEFAULT 0, trigger_type VARCHAR(32) NOT NULL DEFAULT '', -- 触发类型 duration_ms BIGINT NOT NULL DEFAULT 0, -- 执行时长(毫秒) status SMALLINT NOT NULL DEFAULT 1, -- 状态:1-运行中,2-成功,3-失败 @@ -435,6 +437,8 @@ COMMENT ON COLUMN black_deacon_flow_execution.updated_at IS '更新时间'; COMMENT ON COLUMN black_deacon_flow_execution.deleted_at IS '删除时间(软删)'; COMMENT ON COLUMN black_deacon_flow_execution.flow_user_id IS '流程ID'; COMMENT ON COLUMN black_deacon_flow_execution.flow_name IS '流程名称'; +COMMENT ON COLUMN black_deacon_flow_execution.total_tokens IS '总token消耗'; +COMMENT ON COLUMN black_deacon_flow_execution.node_group_id IS '节点组ID'; COMMENT ON COLUMN black_deacon_flow_execution.trigger_type IS '触发类型'; COMMENT ON COLUMN black_deacon_flow_execution.duration_ms IS '执行时长(毫秒)'; COMMENT ON COLUMN black_deacon_flow_execution.status IS '状态:1-运行中,2-成功,3-失败'; @@ -518,4 +522,139 @@ COMMENT ON COLUMN black_deacon_flow_template.category_name IS '流程分类名 COMMENT ON COLUMN black_deacon_flow_template.flow_content IS '流程内容'; COMMENT ON COLUMN black_deacon_flow_template.node_input_params IS '节点输入参数'; COMMENT ON COLUMN black_deacon_flow_template.status IS '流程状态:1启用/0停用'; ---------------------pgsql创建black_deacon_flow_template表语句--------------------------- \ No newline at end of file +--------------------pgsql创建black_deacon_flow_template表语句--------------------------- + +--------------------pgsql创建black_deacon_active_pull表语句--------------------------- +-- 主动拉取记录表 +CREATE TABLE IF NOT EXISTS black_deacon_active_pull ( + -- 基础字段(完全对齐项目规范) + 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), + + -- 业务字段 + type VARCHAR(64) NOT NULL DEFAULT '', + request_parament JSONB DEFAULT '{}', + response_parament JSONB DEFAULT '{}', + extension JSONB DEFAULT '{}' + ); + +-- 索引 +CREATE INDEX idx_active_pull_tenant_id ON black_deacon_active_pull(tenant_id); +CREATE INDEX idx_active_pull_type ON black_deacon_active_pull("type"); +CREATE INDEX idx_active_pull_deleted_at ON black_deacon_active_pull(deleted_at); + +-- 注释 +COMMENT ON TABLE black_deacon_active_pull IS '主动拉取记录表'; +COMMENT ON COLUMN black_deacon_active_pull.id IS '主键ID'; +COMMENT ON COLUMN black_deacon_active_pull.tenant_id IS '租户ID'; +COMMENT ON COLUMN black_deacon_active_pull.creator IS '创建人'; +COMMENT ON COLUMN black_deacon_active_pull.created_at IS '创建时间'; +COMMENT ON COLUMN black_deacon_active_pull.updater IS '更新人'; +COMMENT ON COLUMN black_deacon_active_pull.updated_at IS '更新时间'; +COMMENT ON COLUMN black_deacon_active_pull.deleted_at IS '删除时间(软删)'; +COMMENT ON COLUMN black_deacon_active_pull.type IS '类型'; +COMMENT ON COLUMN black_deacon_active_pull.request_parament IS '请求参数'; +COMMENT ON COLUMN black_deacon_active_pull.response_parament IS '响应参数'; +COMMENT ON COLUMN black_deacon_active_pull.extension IS '扩展信息'; +--------------------pgsql创建black_deacon_active_pull表语句--------------------------- + +--------------------pgsql创建black_deacon_node_prompt表语句--------------------------- +-- 节点提示词配置表 +CREATE TABLE IF NOT EXISTS black_deacon_node_prompt ( + -- 基础字段(完全对齐项目规范) + 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), + + -- 业务字段 + node_type VARCHAR(64) NOT NULL DEFAULT '', -- 节点类型 + prompt TEXT NOT NULL DEFAULT '', -- 提示词内容 + source_type SMALLINT NOT NULL DEFAULT 1 -- 来源:1=系统初始化,2=用户自定义 + ); + +-- 索引 +CREATE INDEX idx_node_prompt_tenant_id ON black_deacon_node_prompt(tenant_id); +CREATE INDEX idx_node_prompt_node_type ON black_deacon_node_prompt(node_type); +CREATE INDEX idx_node_prompt_source_type ON black_deacon_node_prompt(source_type); +CREATE INDEX idx_node_prompt_deleted_at ON black_deacon_node_prompt(deleted_at); + +-- 注释 +COMMENT ON TABLE black_deacon_node_prompt IS '节点提示词配置表'; +COMMENT ON COLUMN black_deacon_node_prompt.id IS '主键ID'; +COMMENT ON COLUMN black_deacon_node_prompt.tenant_id IS '租户ID'; +COMMENT ON COLUMN black_deacon_node_prompt.creator IS '创建人'; +COMMENT ON COLUMN black_deacon_node_prompt.created_at IS '创建时间'; +COMMENT ON COLUMN black_deacon_node_prompt.updater IS '更新人'; +COMMENT ON COLUMN black_deacon_node_prompt.updated_at IS '更新时间'; +COMMENT ON COLUMN black_deacon_node_prompt.deleted_at IS '删除时间(软删)'; +COMMENT ON COLUMN black_deacon_node_prompt.node_type IS '节点类型'; +COMMENT ON COLUMN black_deacon_node_prompt.prompt IS '提示词内容'; +COMMENT ON COLUMN black_deacon_node_prompt.source_type IS '数据来源:1=系统初始化,2=用户自定义'; +--------------------pgsql创建black_deacon_node_prompt表语句--------------------------- + +--------------------pgsql创建black_deacon_node_execution表语句--------------------------- +-- 节点执行记录表 +-- 记录每个节点的入参、出参、token消耗、执行状态等详细信息 +CREATE TABLE IF NOT EXISTS black_deacon_node_execution ( + -- 基础字段(完全对齐项目规范) + 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), + + -- 业务字段 + flow_execution_id BIGINT NOT NULL, -- 流程执行ID + node_id VARCHAR(64) NOT NULL DEFAULT '', -- 节点ID + node_name VARCHAR(128) NOT NULL DEFAULT '', -- 节点名称 + node_group_id VARCHAR(64) NOT NULL DEFAULT '', -- 节点分组ID + input_params JSONB DEFAULT '{}', -- 节点输入参数 + output_params JSONB DEFAULT '{}', -- 节点输出参数 + prompt_tokens INTEGER NOT NULL DEFAULT 0, -- 提示词token消耗 + completion_tokens INTEGER NOT NULL DEFAULT 0, -- 补全token消耗 + total_tokens INTEGER NOT NULL DEFAULT 0, -- 总token消耗 + status SMALLINT NOT NULL DEFAULT 1, -- 执行状态:1-运行中,2-成功,3-失败,4-暂停,5-等待执行 + duration_ms BIGINT NOT NULL DEFAULT 0, -- 执行时长(毫秒) + error_message TEXT 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); +CREATE INDEX IF NOT EXISTS idx_bne_node_id ON black_deacon_node_execution(node_id); +CREATE INDEX IF NOT EXISTS idx_bne_status ON black_deacon_node_execution(status); +CREATE INDEX IF NOT EXISTS idx_bne_deleted_at ON black_deacon_node_execution(deleted_at); + +-- 表和字段注释 +COMMENT ON TABLE black_deacon_node_execution IS '节点执行记录表'; +COMMENT ON COLUMN black_deacon_node_execution.id IS '主键ID(非自增)'; +COMMENT ON COLUMN black_deacon_node_execution.tenant_id IS '租户ID'; +COMMENT ON COLUMN black_deacon_node_execution.creator IS '创建人'; +COMMENT ON COLUMN black_deacon_node_execution.created_at IS '创建时间'; +COMMENT ON COLUMN black_deacon_node_execution.updater IS '更新人'; +COMMENT ON COLUMN black_deacon_node_execution.updated_at IS '更新时间'; +COMMENT ON COLUMN black_deacon_node_execution.deleted_at IS '删除时间(软删)'; +COMMENT ON COLUMN black_deacon_node_execution.flow_execution_id IS '流程执行ID'; +COMMENT ON COLUMN black_deacon_node_execution.node_id IS '节点ID'; +COMMENT ON COLUMN black_deacon_node_execution.node_name IS '节点名称'; +COMMENT ON COLUMN black_deacon_node_execution.node_group_id IS '节点分组ID'; +COMMENT ON COLUMN black_deacon_node_execution.input_params IS '节点输入参数'; +COMMENT ON COLUMN black_deacon_node_execution.output_params 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.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表语句--------------------------- \ No newline at end of file diff --git a/workflow/consts/node/node_template.go b/workflow/consts/node/node_template.go index 600cae4..ae5a104 100644 --- a/workflow/consts/node/node_template.go +++ b/workflow/consts/node/node_template.go @@ -10,26 +10,28 @@ const ( // 节点名称 const ( - NodeNameTextModel = "生成文案" - NodeNameImageModel = "生成图片" - NodeNameVideoModel = "生成视频" - NodeNameSenseOptimize = "语义优化" - NodeNameStoryOptimize = "分镜优化" - NodeNameScriptOptimize = "剧本优化" - NodeNameAudioModel = "音频" - NodeNameModel = "模型" - NodeNameMerge = "结果合并" - NodeNameJudge = "条件判断" - NodeNameForm = "表单" - NodeNameHttp = "HTTP(S)接口" - NodeNameCustomNode = "自定义节点" + NodeNameTextModel = "生成文案" + NodeNameImageModel = "生成图片" + NodeNameVideoModel = "生成视频" + NodeNameAudioModel = "生成音频" + NodeNameBatchModel = "批量处理一起返回" + NodeNameSenseOptimizeModel = "语义优化" + NodeNameStoryOptimizeModel = "分镜优化" + NodeNameScriptOptimizeModel = "剧本优化" + NodeNameDataConversionModel = "参数转换" + NodeNameModel = "模型" + NodeNameMerge = "结果合并" + NodeNameDataMerge = "结果汇集" + NodeNameJudge = "条件判断" + NodeNameForm = "表单" + NodeNameHttp = "HTTP(S)接口" + NodeNameCustomNode = "自定义节点" ) // 表单字段 Label const ( - FormLabelApiKey = "API Key" - FormLabelModel = "模型名称" - + FormLabelApiKey = "API Key" + FormLabelModel = "模型名称" FormLabelCondition = "判断条件" ) @@ -46,40 +48,47 @@ type NodeType string const ( // 组件 - NodeTypeTextModel NodeType = "text_model" - NodeTypeImageModel NodeType = "image_model" - NodeTypeVideoModel NodeType = "video_model" - NodeTypeSenseOptimize NodeType = "sense_optimize" - NodeTypeStoryOptimize NodeType = "story_optimize" - NodeTypeScriptOptimize NodeType = "script_optimize" - NodeTypeAudioModel NodeType = "audio_model" + NodeTypeTextModel NodeType = "text_model" + NodeTypeImageModel NodeType = "image_model" + NodeTypeVideoModel NodeType = "video_model" + NodeTypeAudioModel NodeType = "audio_model" + NodeTypeBatchModel NodeType = "batch_model" + NodeTypeSenseOptimizeModel NodeType = "sense_optimize_model" + NodeTypeStoryOptimizeModel NodeType = "story_optimize_model" + NodeTypeScriptOptimizeModel NodeType = "script_optimize_model" // 基础 - NodeTypeModel NodeType = "model" - NodeTypeMerge NodeType = "merge" - NodeTypeJudge NodeType = "judge" - NodeTypeForm NodeType = "form" - NodeTypeIntent NodeType = "intent" - NodeTypeHttp NodeType = "http" + NodeTypeDataConversionModel NodeType = "data_conversion_model" + NodeTypeModel NodeType = "model" + NodeTypeMerge NodeType = "merge" + NodeTypeDataMerge NodeType = "data_merge" + NodeTypeJudge NodeType = "judge" + NodeTypeForm NodeType = "form" + NodeTypeIntent NodeType = "intent" + NodeTypeHttp NodeType = "http" // 自定义 NodeTypeCustomNode NodeType = "custom_node" ) const ( - ModelTypeText = 1 - ModelTypeImage = 2 + ModelTypeText = 100 + ModelTypeImage = 200 + ModelTypeAudio = 300 + ModelTypeModality = 500 + ModelTypeVideo = 600 ) // ======================== 结构定义 ======================== type NodeFormField struct { - Value string `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"` + 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"` } type SelectOption struct { @@ -88,20 +97,20 @@ type SelectOption struct { } type ModelItem struct { - ModelApiKey string `json:"modelApiKey"` - ModelName string `json:"modelName"` - ModelForm map[string]any `json:"modelForm"` - ModelResponse map[string]any `json:"modelResponse"` + ModelName string `json:"modelName"` + ModelForm []NodeFormField `json:"modelForm"` } type NodeItem struct { - NodeId string `json:"nodeId"` - NodeCode NodeType `json:"nodeCode"` - ModelType int `json:"modelType"` - NodeName string `json:"nodeName"` // 从常量来 - SkillOption bool `json:"skillOption"` - FormConfig []NodeFormField `json:"formConfig"` - ModelConfig []ModelItem `json:"modelConfig"` + 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"` } type NodeGroupItem struct { diff --git a/workflow/consts/node/source_type.go b/workflow/consts/node/source_type.go new file mode 100644 index 0000000..cee361d --- /dev/null +++ b/workflow/consts/node/source_type.go @@ -0,0 +1,26 @@ +package node + +import "github.com/gogf/gf/v2/util/gconv" + +var ( + SourceTypeSystem = newSourceType(gconv.PtrInt8(1), "系统初始化") + SourceTypeUser = newSourceType(gconv.PtrInt8(2), "用户自定义") +) + +type SourceType *int8 + +type sourceType struct { + code SourceType + desc string +} + +func (s sourceType) Code() SourceType { + return s.code +} +func (s sourceType) Desc() string { + return s.desc +} + +func newSourceType(code SourceType, desc string) sourceType { + return sourceType{code: code, desc: desc} +} diff --git a/workflow/consts/public/table_name.go b/workflow/consts/public/table_name.go index 2242f9b..70b94f5 100644 --- a/workflow/consts/public/table_name.go +++ b/workflow/consts/public/table_name.go @@ -7,11 +7,15 @@ const ( // 数据库表名 const ( - TableNameCreationInfo = "creation_info" - TableNameFlowExecution = "flow_execution" - TableNameFlowTemplate = "flow_template" - TableNameFlowUser = "flow_user" - TableNameSkillTemplate = "skill_template" - TableNameSkillUser = "skill_user" - TableNameFileTemp = "file_temp" + TableNameCreationInfo = "creation_info" + TableNameFlowExecution = "flow_execution" + TableNameFlowTemplate = "flow_template" + TableNameFlowUser = "flow_user" + TableNameSkillTemplate = "skill_template" + TableNameSkillUser = "skill_user" + TableNameFileTemp = "file_temp" + TableNameActivePull = "active_pull" + TableNameWorkflowInterrupt = "workflow_interrupt" + TableNameNodePrompt = "node_prompt" + TableNameNodeExecution = "node_execution" ) diff --git a/workflow/controller/flow/flow_call_back_controller.go b/workflow/controller/flow/flow_call_back_controller.go new file mode 100644 index 0000000..e749030 --- /dev/null +++ b/workflow/controller/flow/flow_call_back_controller.go @@ -0,0 +1,23 @@ +package flow + +import ( + flowService "ai-agent/workflow/service/flow" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" +) + +type flowCallBack struct{} + +var FlowCallBack = new(flowCallBack) + +func (c *flowCallBack) HttpNodeCallback(r *ghttp.Request) { + ctx := r.Context() + err := flowService.FlowExecutionService.HttpNodeCallback(ctx) + if err != nil { + r.Response.WriteJson(g.Map{"code": 500, "message": err.Error()}) + return + } + r.Response.WriteJson(g.Map{"code": 0, "message": "success"}) + return +} diff --git a/workflow/controller/flow/flow_execution_controller.go b/workflow/controller/flow/flow_execution_controller.go index ad52bd7..e2c0d62 100644 --- a/workflow/controller/flow/flow_execution_controller.go +++ b/workflow/controller/flow/flow_execution_controller.go @@ -16,11 +16,21 @@ func (c *flowExecution) Execute(ctx context.Context, req *flowDto.ExecuteReq) (r 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) Get(ctx context.Context, req *flowDto.GetFlowExecutionReq) (res *flowDto.VOFlowExecution, err error) { return flowService.FlowExecutionService.Get(ctx, req) } diff --git a/workflow/controller/node/node_prompt_controller.go b/workflow/controller/node/node_prompt_controller.go new file mode 100644 index 0000000..c0af29e --- /dev/null +++ b/workflow/controller/node/node_prompt_controller.go @@ -0,0 +1,45 @@ +package node + +import ( + nodeDto "ai-agent/workflow/model/dto/node" + nodeService "ai-agent/workflow/service/node" + "context" + + "gitea.com/red-future/common/beans" +) + +type nodePrompt struct{} + +var NodePrompt = new(nodePrompt) + +// Create 创建节点提示词 +func (c *nodePrompt) Create(ctx context.Context, req *nodeDto.CreateNodePromptReq) (res *nodeDto.CreateNodePromptRes, err error) { + return nodeService.NodePromptService.Create(ctx, req) +} + +// Update 更新节点提示词 +func (c *nodePrompt) Update(ctx context.Context, req *nodeDto.UpdateNodePromptReq) (res *beans.ResponseEmpty, err error) { + err = nodeService.NodePromptService.Update(ctx, req) + return +} + +// Delete 删除节点提示词 +func (c *nodePrompt) Delete(ctx context.Context, req *nodeDto.DeleteNodePromptReq) (res *beans.ResponseEmpty, err error) { + err = nodeService.NodePromptService.Delete(ctx, req) + return +} + +// Get 根据ID查询节点提示词详情 +func (c *nodePrompt) Get(ctx context.Context, req *nodeDto.GetNodePromptReq) (res *nodeDto.NodePromptResp, err error) { + return nodeService.NodePromptService.GetById(ctx, req) +} + +// ListMy 查询当前用户自己创建的节点提示词列表 +func (c *nodePrompt) ListMy(ctx context.Context, req *nodeDto.ListMyNodePromptReq) (res *nodeDto.ListNodePromptResp, err error) { + return nodeService.NodePromptService.ListMy(ctx, req) +} + +// List 查询节点提示词列表,包含系统和当前创建人自定义 +func (c *nodePrompt) List(ctx context.Context, req *nodeDto.ListNodePromptReq) (res *nodeDto.ListNodePromptResp, err error) { + return nodeService.NodePromptService.ListWithSystem(ctx, req) +} diff --git a/workflow/dao/node/node_execution_dao.go b/workflow/dao/node/node_execution_dao.go new file mode 100644 index 0000000..465555a --- /dev/null +++ b/workflow/dao/node/node_execution_dao.go @@ -0,0 +1,99 @@ +package node + +import ( + "ai-agent/workflow/consts/public" + nodeDto "ai-agent/workflow/model/dto/node" + "ai-agent/workflow/model/entity" + "context" + + "gitea.com/red-future/common/db/gfdb" + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" +) + +var NodeExecutionDao = &nodeExecutionDao{} + +type nodeExecutionDao struct{} + +// Insert 插入节点执行记录 +func (d *nodeExecutionDao) Insert(ctx context.Context, req *nodeDto.CreateNodeExecutionReq) (id int64, err error) { + nodeExecution := new(entity.NodeExecution) + err = gconv.Struct(req, &nodeExecution) + if err != nil { + return 0, err + } + r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameNodeExecution).Insert(&nodeExecution) + if err != nil { + return 0, err + } + return r.LastInsertId() +} + +// Update 更新节点执行记录 +func (d *nodeExecutionDao) Update(ctx context.Context, req *nodeDto.UpdateNodeExecutionReq) (rows int64, err error) { + model := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameNodeExecution).OmitEmpty() + if !g.IsEmpty(req.CompletionTokens) { + model.Data(entity.NodeExecutionCol.CompletionTokens, &gdb.Counter{ + Field: entity.NodeExecutionCol.CompletionTokens, + Value: gconv.Float64(req.CompletionTokens), + }) + } + if !g.IsEmpty(req.PromptTokens) { + model.Data(entity.NodeExecutionCol.PromptTokens, &gdb.Counter{ + Field: entity.NodeExecutionCol.PromptTokens, + Value: gconv.Float64(req.PromptTokens), + }) + } + if !g.IsEmpty(req.TotalTokens) { + model.Data(entity.NodeExecutionCol.TotalTokens, &gdb.Counter{ + Field: entity.NodeExecutionCol.TotalTokens, + Value: gconv.Float64(req.TotalTokens), + }) + } + r, err := model.Data(&req).Where(entity.NodeExecutionCol.Id, req.Id).Update() + if err != nil { + return 0, err + } + return r.RowsAffected() +} + +// Delete 删除节点执行记录 +func (d *nodeExecutionDao) Delete(ctx context.Context, req *nodeDto.DeleteNodeExecutionReq) (rows int64, err error) { + r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameNodeExecution).Where(entity.NodeExecutionCol.Id, req.Id).Delete() + if err != nil { + return 0, err + } + return r.RowsAffected() +} + +// Get 根据ID查询节点执行记录 +func (d *nodeExecutionDao) Get(ctx context.Context, req *nodeDto.GetNodeExecutionReq, fields ...string) (res *entity.NodeExecution, err error) { + r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameNodeExecution).NoTenantId(ctx).OmitEmpty(). + Where(entity.NodeExecutionCol.Id, req.Id). + Fields(fields).One() + if err != nil { + return nil, err + } + if r.IsEmpty() { + return nil, nil + } + err = r.Struct(&res) + return res, err +} + +// ListByFlowExecutionId 查询指定流程执行下的所有节点执行记录 +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.OrderAsc(entity.NodeExecutionCol.CreatedAt) + if req.Page != nil { + model.Page(int(req.Page.PageNum), int(req.Page.PageSize)) + } + r, total, err := model.AllAndCount(false) + if err != nil { + return nil, 0, err + } + err = r.Structs(&res) + return res, total, err +} diff --git a/workflow/dao/node/node_prompt_dao.go b/workflow/dao/node/node_prompt_dao.go new file mode 100644 index 0000000..ad883a7 --- /dev/null +++ b/workflow/dao/node/node_prompt_dao.go @@ -0,0 +1,95 @@ +package node + +import ( + "ai-agent/workflow/consts/public" + nodeDto "ai-agent/workflow/model/dto/node" + "ai-agent/workflow/model/entity" + "context" + + "gitea.com/red-future/common/db/gfdb" + "github.com/gogf/gf/v2/util/gconv" +) + +var NodePromptDao = &nodePromptDao{} + +type nodePromptDao struct{} + +// Insert 插入节点提示词 +func (d *nodePromptDao) Insert(ctx context.Context, req *nodeDto.CreateNodePromptReq) (id int64, err error) { + nodePrompt := new(entity.NodePrompt) + err = gconv.Struct(req, &nodePrompt) + r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameNodePrompt).Insert(&nodePrompt) + if err != nil { + return 0, err + } + return r.LastInsertId() +} + +// Update 更新节点提示词 +func (d *nodePromptDao) Update(ctx context.Context, req *nodeDto.UpdateNodePromptReq) (rows int64, err error) { + r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameNodePrompt).OmitEmpty().Data(&req).Where(entity.NodePromptCol.Id, req.Id).Update() + if err != nil { + return 0, err + } + return r.RowsAffected() +} + +// Delete 删除节点提示词 +func (d *nodePromptDao) Delete(ctx context.Context, req *nodeDto.DeleteNodePromptReq) (rows int64, err error) { + r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameNodePrompt).Where(entity.NodePromptCol.Id, req.Id).Delete() + if err != nil { + return 0, err + } + return r.RowsAffected() +} + +// Get 根据ID查询节点提示词 +func (d *nodePromptDao) Get(ctx context.Context, req *nodeDto.GetNodePromptReq, fields ...string) (res *entity.NodePrompt, err error) { + r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameNodePrompt).NoTenantId(ctx).OmitEmpty(). + Where(entity.NodePromptCol.Id, req.Id). + Where(entity.NodePromptCol.Prompt, req.Prompt). + Where(entity.NodePromptCol.Creator, req.Creator). + Fields(fields).One() + if err != nil { + return nil, err + } + if r.IsEmpty() { + return nil, nil + } + err = r.Struct(&res) + return res, err +} + +// ListByOnlyCreator 查询仅当前创建人自己创建的提示词 +func (d *nodePromptDao) ListByOnlyCreator(ctx context.Context, req *nodeDto.ListMyNodePromptReq, fields ...string) (res []*entity.NodePrompt, total int, err error) { + model := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameNodePrompt).NoTenantId(ctx).Fields(fields).OmitEmpty() + model.Where(entity.NodePromptCol.Creator, req.Creator) + model.Where(entity.NodePromptCol.NodeType, req.NodeType) + model.OrderDesc(entity.NodePromptCol.CreatedAt) + if req.Page != nil { + model.Page(int(req.Page.PageNum), int(req.Page.PageSize)) + } + r, total, err := model.AllAndCount(false) + if err != nil { + return nil, 0, err + } + err = r.Structs(&res) + return res, total, err +} + +// ListByCreator 查询当前创建人的所有提示词(包含系统和用户) +func (d *nodePromptDao) ListByCreator(ctx context.Context, req *nodeDto.ListNodePromptReq, fields ...string) (res []*entity.NodePrompt, total int, err error) { + // 完整 SQL + sql := ` SELECT * FROM black_deacon_node_prompt WHERE (creator=? OR source_type=1) AND node_type=? AND "deleted_at" IS NULL ORDER BY created_at DESC ` + queryParams := []interface{}{req.Creator, req.NodeType} + if req.Page != nil { + sql += " LIMIT ?,?" + queryParams = append(queryParams, req.Page.PageNum, req.Page.PageSize) + } + r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).GetAll(ctx, sql, queryParams...) + if err != nil { + return nil, 0, err + } + err = r.Structs(&res) + return res, total, err +} diff --git a/workflow/dao/pull/active_pull_dao.go b/workflow/dao/pull/active_pull_dao.go new file mode 100644 index 0000000..18c9bf0 --- /dev/null +++ b/workflow/dao/pull/active_pull_dao.go @@ -0,0 +1,102 @@ +package pull + +import ( + "ai-agent/workflow/consts/public" + pullDto "ai-agent/workflow/model/dto/pull" + "ai-agent/workflow/model/entity" + "context" + "fmt" + "strings" + + "gitea.com/red-future/common/db/gfdb" + "github.com/gogf/gf/v2/util/gconv" +) + +var ActivePullDao = &activePullDao{} + +type activePullDao struct{} + +// Insert 创建执行记录 +func (d *activePullDao) Insert(ctx context.Context, req *pullDto.CreateActivePullReq) (id int64, err error) { + var activePull = new(entity.ActivePull) + err = gconv.Struct(req, &activePull) + if err != nil { + return + } + r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameActivePull).Insert(activePull) + if err != nil { + return + } + return r.LastInsertId() +} + +func (d *activePullDao) Update(ctx context.Context, req *pullDto.UpdateActivePullReq) (rows int64, err error) { + r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameActivePull).OmitEmpty().Data(&req).Where(entity.ActivePullCol.Id, req.Id).Update() + if err != nil { + return + } + return r.RowsAffected() +} + +func (d *activePullDao) Delete(ctx context.Context, req *pullDto.DeleteActivePullReq) (rows int64, err error) { + r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameActivePull).Where(entity.ActivePullCol.Id, req.Id).Delete() + if err != nil { + return + } + return r.RowsAffected() +} + +func (d *activePullDao) List(ctx context.Context, req *pullDto.ListActivePullReq, fields ...string) (res []*entity.ActivePull, total int, err error) { + model := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameActivePull).Fields(fields).OmitEmpty() + model.OrderDesc(entity.ActivePullCol.CreatedAt) + if req.Page != nil { + model.Page(int(req.Page.PageNum), int(req.Page.PageSize)) + } + r, total, err := model.AllAndCount(false) + if err != nil { + return + } + err = r.Structs(&res) + return +} + +func (d *activePullDao) ListNative(ctx context.Context, req *pullDto.ListActivePullReq, fields ...string) (res []*entity.ActivePull, total int, err error) { + db := gfdb.DB(ctx, public.DbNameBlackDeacon) + + // Select fields + selectFields := "*" + if len(fields) > 0 { + selectFields = strings.Join(fields, ",") + } + + // Build count query first for total + countSql := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE deleted_at is null", "black_deacon_"+public.TableNameActivePull) + countResult, err := db.GetAll(ctx, countSql) + if err != nil { + return nil, 0, err + } + if len(countResult) > 0 { + total = countResult[0]["COUNT(*)"].Int() + } + + // Build data query with native SQL + sql := fmt.Sprintf("SELECT %s FROM %s WHERE deleted_at is null ORDER BY created_at DESC", selectFields, "black_deacon_"+public.TableNameActivePull) + if req.Page != nil && req.Page.PageNum > 0 && req.Page.PageSize > 0 { + offset := (req.Page.PageNum - 1) * req.Page.PageSize + sql += fmt.Sprintf(" LIMIT %d OFFSET %d", req.Page.PageSize, offset) + } + + // Execute query with GetAll + result, err := db.GetAll(ctx, sql) + if err != nil { + return nil, total, err + } + + // Scan to entity slice + var models []*entity.ActivePull + if err = result.Structs(&models); err != nil { + return nil, total, err + } + + return models, total, nil +} diff --git a/workflow/model/dto/flow/flow_execution_dto.go b/workflow/model/dto/flow/flow_execution_dto.go index a74eb2f..9d3dbea 100644 --- a/workflow/model/dto/flow/flow_execution_dto.go +++ b/workflow/model/dto/flow/flow_execution_dto.go @@ -2,6 +2,7 @@ package flow import ( "ai-agent/workflow/consts/flow" + "ai-agent/workflow/consts/node" "ai-agent/workflow/model/entity" "gitea.com/red-future/common/beans" @@ -11,12 +12,20 @@ import ( // NodeExecutionInput 节点执行入参(包含配置+表单架构) type NodeExecutionInput struct { - Config *entity.FlowNode // 节点配置 - Global *FlowExecutionInput `json:"-"` + Config *entity.FlowNode `json:"config"` // 节点配置 + Global *FlowExecutionInput `json:"global"` + NodeExecutionId int64 `json:"nodeExecutionId"` +} + +// 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"` @@ -24,27 +33,74 @@ type FlowExecutionInput struct { Desc string `json:"desc"` SkillName string `json:"skillName"` FileUrl []string `json:"fileUrl"` - ExecutedNodes []string `json:"executedNodes"` + 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"` - Form map[string]any `json:"form"` - UserForm map[string]any `json:"userForm"` - UserFiles []string `json:"userFiles"` - SessionId string `json:"sessionId" dc:"会话ID"` - IsBuild bool `json:"isBuild"` - Cause string `json:"cause"` + 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 { - Messages map[string]any `json:"messages"` - EpicycleId int64 `json:"epicycleId" dc:"轮次ID"` + TaskId string `json:"taskId"` } -type CreateTaskReq struct { +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"` @@ -54,83 +110,74 @@ type CreateTaskReq struct { EpicycleId int64 `json:"epicycleId" dc:"轮次ID"` } -type CreateTaskRes struct { +type ModelGatewayRes struct { TaskId string `json:"taskId"` } -type GetIsChatModelRes struct { - ModelName string `json:"modelName"` - ResponseBody map[string]any `json:"responseBody"` +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:"结果文件类型"` - Text string `p:"text" json:"text" dc:"文本结果(可选,最多约 2000 字符)"` + 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:"结果文件类型"` + Messages map[string]any `json:"messages"` + ErrorMsg string `json:"error_msg"` } -type TaskCallback struct { - TaskID string `json:"taskId"` - State int `json:"state"` // 0排队中/1执行中/2成功/3失败/4已下载 - OssFile string `json:"ossFile"` - FileType string `json:"fileType"` - Text string `json:"text"` - //ImgContent *Image `json:"imgContent"` -} - -type Text struct { - Choices []struct { - FinishReason string `json:"finish_reason"` - Index int `json:"index"` - Message struct { - Content string `json:"content"` - Role string `json:"role"` - } `json:"message"` - } `json:"choices"` - Created int `json:"created"` - Id string `json:"id"` - Model string `json:"model"` - Object string `json:"object"` - Usage struct { - CompletionTokens int `json:"completion_tokens"` - PromptTokens int `json:"prompt_tokens"` - PromptTokensDetails struct { - CachedTokens int `json:"cached_tokens"` - } - TotalTokens int `json:"total_tokens"` - } `json:"usage"` -} - -type Image struct { - Output struct { - Choices []struct { - FinishReason string `json:"finish_reason"` - Message struct { - Content []struct { - Image string `json:"image"` - } `json:"content"` - Role string `json:"role"` - } `json:"message"` - } `json:"choices"` - } `json:"output"` - Usage struct { - Height int `json:"height"` - ImageCount int `json:"image_count"` - Width int `json:"width"` - } `json:"usage"` - RequestId string `json:"request_id"` +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"` } //============================================================================= +// 原始入参结构体 +type Word struct { + Confidence float64 `json:"confidence"` + StartTime float64 `json:"startTime"` + EndTime float64 `json:"endTime"` + Word string `json:"word"` +} +type Sentence struct { + EndTime float64 `json:"endTime"` + StartTime float64 `json:"startTime"` + Text string `json:"text"` + Words []Word `json:"words"` +} +type InputData struct { + Data struct { + Sentences []Sentence `json:"sentences"` + } `json:"data"` +} + +// 输出目标结构体(对应截图subtitles格式) +type Subtitle struct { + Start float64 `json:"start"` + End float64 `json:"end"` + Text string `json:"text"` +} + +//============================================================================== + type ExecuteReq struct { g.Meta `path:"/execute" method:"post" tags:"任务管理" summary:"执行任务" dc:"执行任务"` FlowId int64 `json:"flowId" dc:"用户流程ID"` 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"` @@ -153,6 +200,7 @@ type CancelReq struct { type CreateFlowExecutionReq struct { FlowUserId int64 `json:"flowUserId" description:"流程ID"` FlowName string `json:"flowName"` + NodeGroupId string `json:"nodeGroupId"` TriggerType flow.FlowExecutionTriggerType `json:"triggerType" description:"触发类型"` DurationMs int64 `json:"durationMs" description:"执行时长(毫秒)"` Status flow.FlowExecutionStatus `json:"status" description:"状态:1-运行中,2-成功,3-失败"` @@ -170,6 +218,7 @@ type CreateFlowExecutionRes struct { type UpdateFlowExecutionReq struct { Id int64 `json:"id" v:"required#ID不能为空"` + NodeGroupId string `json:"nodeGroupId"` DurationMs int64 `json:"durationMs" description:"执行时长(毫秒)"` Status flow.FlowExecutionStatus `json:"status" description:"状态:1-运行中,2-成功,3-失败"` OutputParams []map[string]interface{} `json:"outputParams" description:"输出参数"` @@ -219,6 +268,7 @@ 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:"后缀+数字标号"` } type FlowNode struct { @@ -232,7 +282,6 @@ type DateNode struct { Flows []FlowNode `json:"flows" description:"流程列表"` } -// 最终树结构返回体 type ListFlowExecutionTreeRes struct { Tree []DateNode `json:"tree"` ImgAddressPrefix string `json:"imgAddressPrefix"` diff --git a/workflow/model/dto/node/node_execution_dto.go b/workflow/model/dto/node/node_execution_dto.go new file mode 100644 index 0000000..cb5c430 --- /dev/null +++ b/workflow/model/dto/node/node_execution_dto.go @@ -0,0 +1,68 @@ +package node + +import ( + "ai-agent/workflow/consts/node" + flowDto "ai-agent/workflow/model/dto/flow" + "ai-agent/workflow/model/entity" + + "gitea.com/red-future/common/beans" + "github.com/gogf/gf/v2/frame/g" +) + +// CreateNodeExecutionReq 创建节点执行记录请求 +type CreateNodeExecutionReq struct { + g.Meta `path:"/create" method:"post" tags:"节点执行记录" summary:"创建节点执行记录" dc:"创建节点执行记录"` + FlowExecutionId int64 `json:"flowExecutionId" v:"required#流程执行ID不能为空"` + NodeId string `json:"nodeId" v:"required#节点ID不能为空"` + NodeName string `json:"nodeName"` + NodeGroupId string `json:"nodeGroupId"` + Status node.NodeExecutionStatus `json:"status"` + InputParams *flowDto.NodeExecutionInput `json:"inputParams"` +} + +type CreateNodeExecutionRes struct { + Id int64 `json:"id,string"` +} + +// UpdateNodeExecutionReq 更新节点执行记录请求 +type UpdateNodeExecutionReq struct { + g.Meta `path:"/update" method:"put" tags:"节点执行记录" summary:"更新节点执行记录" dc:"更新节点执行记录状态和结果"` + Id int64 `json:"id" v:"required#ID不能为空"` + InputParams *flowDto.NodeExecutionInput `json:"inputParams"` + PromptTokens int `json:"promptTokens"` + CompletionTokens int `json:"completionTokens"` + TotalTokens int `json:"totalTokens"` + Status node.NodeExecutionStatus `json:"status"` + DurationMs int64 `json:"durationMs"` + ErrorMessage string `json:"errorMessage"` +} + +// DeleteNodeExecutionReq 删除节点执行记录请求 +type DeleteNodeExecutionReq struct { + g.Meta `path:"/delete" method:"delete" tags:"节点执行记录" summary:"删除节点执行记录" dc:"删除节点执行记录"` + Id int64 `json:"id" v:"required#ID不能为空"` +} + +// GetNodeExecutionReq 根据ID查询节点执行记录请求 +type GetNodeExecutionReq struct { + g.Meta `path:"/get" method:"get" tags:"节点执行记录" summary:"查询节点执行记录详情" dc:"根据ID查询节点执行记录详情"` + Id int64 `json:"id" v:"required#ID不能为空"` +} + +// 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不能为空"` +} + +// NodeExecutionResp 节点执行记录响应 +type NodeExecutionResp struct { + *entity.NodeExecution +} + +// ListNodeExecutionResp 节点执行记录列表响应 +type ListNodeExecutionResp struct { + List []*entity.NodeExecution `json:"list"` + Total int `json:"total"` +} diff --git a/workflow/model/dto/node/node_library_dto.go b/workflow/model/dto/node/node_library_dto.go index 495be55..8b6848a 100644 --- a/workflow/model/dto/node/node_library_dto.go +++ b/workflow/model/dto/node/node_library_dto.go @@ -27,3 +27,7 @@ type ModelItem struct { Name string `json:"name"` Form []node.NodeFormField `json:"form"` } + +type ModelTypeResponse struct { + Type map[int]string `json:"type"` // key 自动解析为整数 100/200/300... +} diff --git a/workflow/model/dto/node/node_prompt_dto.go b/workflow/model/dto/node/node_prompt_dto.go new file mode 100644 index 0000000..63d0c5a --- /dev/null +++ b/workflow/model/dto/node/node_prompt_dto.go @@ -0,0 +1,70 @@ +package node + +import ( + "ai-agent/workflow/consts/node" + "ai-agent/workflow/model/entity" + + "gitea.com/red-future/common/beans" + "github.com/gogf/gf/v2/frame/g" +) + +// CreateNodePromptReq 创建节点提示词请求 +type CreateNodePromptReq struct { + g.Meta `path:"/create" method:"post" tags:"节点提示词管理" summary:"创建节点提示词" dc:"创建用户自定义节点提示词"` + NodeType node.NodeType `json:"nodeType" v:"required#节点类型不能为空"` + Prompt string `json:"prompt" v:"required#提示词不能为空"` + SourceType node.SourceType `json:"sourceType"` +} + +type CreateNodePromptRes struct { + Id int64 `json:"id,string"` +} + +// UpdateNodePromptReq 更新节点提示词请求 +type UpdateNodePromptReq struct { + g.Meta `path:"/update" method:"put" tags:"节点提示词管理" summary:"更新节点提示词" dc:"更新用户自定义节点提示词"` + Id int64 `json:"id" v:"required#ID不能为空"` + NodeType node.NodeType `json:"nodeType"` + Prompt string `json:"prompt"` +} + +// DeleteNodePromptReq 删除节点提示词请求 +type DeleteNodePromptReq struct { + g.Meta `path:"/delete" method:"delete" tags:"节点提示词管理" summary:"删除节点提示词" dc:"删除用户自定义节点提示词"` + Id int64 `json:"id" v:"required#ID不能为空"` +} + +// GetNodePromptReq 根据ID查询节点提示词请求 +type GetNodePromptReq struct { + g.Meta `path:"/get" method:"get" tags:"节点提示词管理" summary:"查询节点提示词详情" dc:"根据ID查询节点提示词详情"` + Id int64 `json:"id"` + Prompt string `json:"prompt"` + Creator string `json:"creator"` +} + +// ListNodePromptReq 查询节点提示词列表请求 +type ListNodePromptReq struct { + g.Meta `path:"/list" method:"get" tags:"节点提示词管理" summary:"查询节点提示词列表" dc:"查询当前创建人的节点提示词,包含系统和用户自定义"` + Page *beans.Page `json:"page"` + Creator string `json:"creator"` + NodeType node.NodeType `json:"nodeType"` +} + +// ListMyNodePromptReq 查询当前用户节点提示词列表请求 +type ListMyNodePromptReq struct { + g.Meta `path:"/listMy" method:"get" tags:"节点提示词管理" summary:"查询当前用户节点提示词列表" dc:"查询当前创建人自己创建的节点提示词列表"` + Page *beans.Page `json:"page"` + NodeType node.NodeType `json:"nodeType"` + Creator string `json:"creator"` +} + +// NodePromptResp 节点提示词响应 +type NodePromptResp struct { + *entity.NodePrompt +} + +// ListNodePromptResp 节点提示词列表响应 +type ListNodePromptResp struct { + List []*entity.NodePrompt `json:"list"` + Total int `json:"total"` +} diff --git a/workflow/model/dto/pull/active_pull_dto.go b/workflow/model/dto/pull/active_pull_dto.go new file mode 100644 index 0000000..718b008 --- /dev/null +++ b/workflow/model/dto/pull/active_pull_dto.go @@ -0,0 +1,54 @@ +package pull + +import ( + "ai-agent/workflow/consts/flow" + "ai-agent/workflow/model/entity" + + "gitea.com/red-future/common/beans" + "github.com/gogf/gf/v2/os/gtime" +) + +type CreateActivePullReq struct { + Type string `json:"type"` + RequestParament map[string]any `json:"requestParament"` + ResponseParament map[string]any `json:"responseParament"` + Extension map[string]any `json:"extension"` +} + +type CreateActivePullRes struct { + Id int64 `json:"id,string"` +} + +type UpdateActivePullReq struct { + Id int64 `json:"id" v:"required#ID不能为空"` + Type string `json:"type"` + RequestParament map[string]any `json:"requestParament"` + ResponseParament map[string]any `json:"responseParament"` + Extension map[string]any `json:"extension"` +} + +type DeleteActivePullReq struct { + Id int64 `json:"id" v:"required#ID不能为空"` +} + +type ListActivePullReq struct { + Page *beans.Page `json:"page"` + Type string `json:"type"` +} + +type ListActivePullRes struct { + List []*ActivePullVO `json:"list"` + Total int `json:"total"` +} + +type ActivePullVO struct { + Id int64 `json:"id,string" dc:"id"` + FlowName string `json:"flowName" description:"流程名称"` + Description string `json:"description" description:"流程描述"` + FlowContent *entity.FlowInfo `json:"flowContent" description:"流程内容"` + NodeInputParams []*entity.FlowNode `json:"nodeInputParams" description:"节点输入参数"` + AccessLevel flow.FlowUserAccessLevel `json:"accessLevel" description:"访问权限:1私有,2团队,3公开"` + SourceFlowTemplateId int64 `json:"sourceFlowTemplateId,string" description:"来源流程模板ID"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` +} diff --git a/workflow/model/entity/active_pull.go b/workflow/model/entity/active_pull.go new file mode 100644 index 0000000..992b66a --- /dev/null +++ b/workflow/model/entity/active_pull.go @@ -0,0 +1,28 @@ +package entity + +import "gitea.com/red-future/common/beans" + +type ActivePull struct { + beans.SQLBaseDO `orm:",inherit"` // 嵌入基础字段:Id, TenantId, Creator, CreatedAt, Updater, UpdatedAt, DeletedAt + + Type string `orm:"type" json:"type"` + RequestParament map[string]any `orm:"request_parament" json:"requestParament"` + ResponseParament map[string]any `orm:"response_parament" json:"responseParament"` + Extension map[string]any `orm:"extension" json:"extension"` +} + +type activePullCol struct { + beans.SQLBaseCol + Type string + RequestParament string + ResponseParament string + Extension string +} + +var ActivePullCol = activePullCol{ + SQLBaseCol: beans.DefSQLBaseCol, + Type: "type", + RequestParament: "request_parament", + ResponseParament: "response_parament", + Extension: "extension", +} diff --git a/workflow/model/entity/flow_execution.go b/workflow/model/entity/flow_execution.go index 9d8961e..bf87c13 100644 --- a/workflow/model/entity/flow_execution.go +++ b/workflow/model/entity/flow_execution.go @@ -11,6 +11,7 @@ type FlowExecution struct { // 业务字段 FlowUserId int64 `orm:"flow_user_id" json:"flowUserId" description:"流程ID"` FlowName string `orm:"flow_name" json:"flowName" description:"流程名称"` + NodeGroupId string `orm:"node_group_id" json:"nodeGroupId" description:"节点组ID"` TriggerType flow.FlowExecutionTriggerType `orm:"trigger_type" json:"triggerType" description:"触发类型"` DurationMs int64 `orm:"duration_ms" json:"durationMs" description:"执行时长(毫秒)"` Status flow.FlowExecutionStatus `orm:"status" json:"status" description:"状态:1-运行中,2-成功,3-失败"` @@ -20,12 +21,14 @@ type FlowExecution struct { ErrorMessage string `orm:"error_message" json:"errorMessage" description:"错误信息"` 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消耗"` } type flowExecutionCol struct { beans.SQLBaseCol FlowUserId string FlowName string + NodeGroupId string TriggerType string DurationMs string Status string @@ -35,12 +38,14 @@ type flowExecutionCol struct { ErrorMessage string TraceId string SessionId string + TotalTokens string } var FlowExecutionCol = flowExecutionCol{ SQLBaseCol: beans.DefSQLBaseCol, FlowUserId: "flow_user_id", FlowName: "flow_name", + NodeGroupId: "node_group_id", TriggerType: "trigger_type", DurationMs: "duration_ms", Status: "status", @@ -50,4 +55,5 @@ var FlowExecutionCol = flowExecutionCol{ ErrorMessage: "error_message", TraceId: "trace_id", SessionId: "session_id", + TotalTokens: "total_tokens", } diff --git a/workflow/model/entity/flow_user.go b/workflow/model/entity/flow_user.go index 21f60ce..bde0c7a 100644 --- a/workflow/model/entity/flow_user.go +++ b/workflow/model/entity/flow_user.go @@ -15,15 +15,18 @@ 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"` - InputSource []FlowNodeInputSource `json:"inputSource"` // 前端指定:来源节点ID - FormConfig []node.NodeFormField `json:"formConfig"` - ModelConfig node.ModelItem `json:"modelConfig"` - OutputResult []node.NodeFormField `json:"outputResult" ds:"节点输出结果"` + 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 + FormConfig []node.NodeFormField `json:"formConfig"` + ModelConfig node.ModelItem `json:"modelConfig"` + OutputConfig []node.NodeFormField `json:"outputConfig"` + OutputResult []node.NodeFormField `json:"outputResult" ds:"节点输出结果"` } type FlowNodeInputSource struct { diff --git a/workflow/model/entity/node_execution.go b/workflow/model/entity/node_execution.go new file mode 100644 index 0000000..1f1e62d --- /dev/null +++ b/workflow/model/entity/node_execution.go @@ -0,0 +1,58 @@ +package entity + +import ( + "ai-agent/workflow/consts/node" + + "gitea.com/red-future/common/beans" +) + +// NodeExecution 节点执行记录 +// 记录每个节点的入参、出参、token消耗、执行状态等信息 +type NodeExecution struct { + beans.SQLBaseDO `orm:",inherit"` // 嵌入基础字段:Id, TenantId, Creator, CreatedAt, Updater, UpdatedAt, DeletedAt + + FlowExecutionId int64 `orm:"flow_execution_id" json:"flowExecutionId" description:"流程执行ID"` + NodeId string `orm:"node_id" json:"nodeId" description:"节点ID"` + NodeName string `orm:"node_name" json:"nodeName" description:"节点名称"` + NodeGroupId string `orm:"node_group_id" json:"nodeGroupId" description:"节点组ID"` + InputParams map[string]interface{} `orm:"input_params" json:"inputParams" description:"节点输入参数"` + OutputParams map[string]interface{} `orm:"output_params" json:"outputParams" description:"节点输出参数"` + 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消耗"` + 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:"错误信息"` +} + +type nodeExecutionCol struct { + beans.SQLBaseCol + FlowExecutionId string + NodeId string + NodeName string + NodeGroupId string + InputParams string + OutputParams string + PromptTokens string + CompletionTokens string + TotalTokens string + Status string + DurationMs string + ErrorMessage string +} + +var NodeExecutionCol = nodeExecutionCol{ + SQLBaseCol: beans.DefSQLBaseCol, + FlowExecutionId: "flow_execution_id", + NodeId: "node_id", + NodeName: "node_name", + NodeGroupId: "node_group_id", + InputParams: "input_params", + OutputParams: "output_params", + PromptTokens: "prompt_tokens", + CompletionTokens: "completion_tokens", + TotalTokens: "total_tokens", + Status: "status", + DurationMs: "duration_ms", + ErrorMessage: "error_message", +} diff --git a/workflow/model/entity/node_prompt.go b/workflow/model/entity/node_prompt.go new file mode 100644 index 0000000..9220ae5 --- /dev/null +++ b/workflow/model/entity/node_prompt.go @@ -0,0 +1,29 @@ +package entity + +import ( + "ai-agent/workflow/consts/node" + + "gitea.com/red-future/common/beans" +) + +type NodePrompt struct { + beans.SQLBaseDO `orm:",inherit"` // 嵌入基础字段:Id, TenantId, Creator, CreatedAt, Updater, UpdatedAt, DeletedAt + + NodeType node.NodeType `orm:"node_type" json:"nodeType"` + Prompt string `orm:"prompt" json:"prompt"` + SourceType node.SourceType `orm:"source_type" json:"sourceType"` +} + +type nodePromptCol struct { + beans.SQLBaseCol + NodeType string + Prompt string + SourceType string +} + +var NodePromptCol = nodePromptCol{ + SQLBaseCol: beans.DefSQLBaseCol, + NodeType: "node_type", + Prompt: "prompt", + SourceType: "source_type", +} diff --git a/workflow/service/active_pull/active_pull_service.go b/workflow/service/active_pull/active_pull_service.go new file mode 100644 index 0000000..ab60499 --- /dev/null +++ b/workflow/service/active_pull/active_pull_service.go @@ -0,0 +1,101 @@ +package pull + +import ( + pullDao "ai-agent/workflow/dao/pull" + pullDto "ai-agent/workflow/model/dto/pull" + "context" + + "github.com/gogf/gf/v2/util/gconv" +) + +var ActivePullService = &activePullService{} + +type activePullService struct{} + +func (s *activePullService) Create(ctx context.Context, req *pullDto.CreateActivePullReq) (res *pullDto.CreateActivePullRes, err error) { + id, err := pullDao.ActivePullDao.Insert(ctx, req) + if err != nil { + return + } + return &pullDto.CreateActivePullRes{Id: id}, nil +} + +func (s *activePullService) Update(ctx context.Context, req *pullDto.UpdateActivePullReq) (err error) { + _, err = pullDao.ActivePullDao.Update(ctx, req) + return +} + +func (s *activePullService) Delete(ctx context.Context, req *pullDto.DeleteActivePullReq) (err error) { + _, err = pullDao.ActivePullDao.Delete(ctx, req) + return +} + +//func (s *activePullService) AllList(ctx context.Context) (err error) { +// ctx = context.WithValue(ctx, "user", &beans.User{ +// UserName: "admin", +// }) +// for { +// select { +// case <-ctx.Done(): +// return ctx.Err() +// default: +// } +// +// var list []*entity.ActivePull +// list, _, err = pullDao.ActivePullDao.ListNative(ctx, &pullDto.ListActivePullReq{}) +// if err != nil { +// g.Log().Error(ctx, "AllList query failed: %v", err) +// time.Sleep(time.Second * 3) +// continue +// } +// +// // Get all active pull tasks and check each one for results +// for _, item := range list { +// var result map[string]any +// result, err = flow.PullTaskResult(ctx, item.RequestParament, item.Extension) +// if err != nil { +// g.Log().Error(ctx, "PullTaskResult failed for item %d: %v", item.Id, err) +// continue +// } +// if !g.IsEmpty(result) { +// // Find the task ID that matches the creation pattern +// // When created in CreateGatewayTask (flow/lambda_node_util.go), +// // the last parameter value extracted from the response becomes the waiting task ID +// var id string +// if taskId, ok := item.RequestParament["task_id"]; ok { +// id = gconv.String(taskId) +// } else if requestId, ok := item.RequestParament["id"]; ok { +// id = gconv.String(requestId) +// } else if jobId, ok := item.RequestParament["job_id"]; ok { +// id = gconv.String(jobId) +// } else { +// // Fallback to original behavior: use last value (matches creation logic) +// for _, v := range item.RequestParament { +// id = gconv.String(v) +// } +// } +// if id != "" { +// flow.Notify(id, result) +// // Delete after successful notification +// _, _ = pullDao.ActivePullDao.Delete(ctx, &pullDto.DeleteActivePullReq{Id: item.Id}) +// } else { +// g.Log().Warning(ctx, "AllList: could not extract task ID for item %d", item.Id) +// } +// } +// } +// +// time.Sleep(time.Second * 10) +// } +//} + +func (s *activePullService) List(ctx context.Context, req *pullDto.ListActivePullReq) (res *pullDto.ListActivePullRes, err error) { + list, total, err := pullDao.ActivePullDao.List(ctx, req) + if err != nil { + return nil, err + } + res = &pullDto.ListActivePullRes{ + Total: total, + } + err = gconv.Struct(list, &res.List) + return +} diff --git a/workflow/service/flow/flow_execution_service.go b/workflow/service/flow/flow_execution_service.go index 63bb0df..616a47c 100644 --- a/workflow/service/flow/flow_execution_service.go +++ b/workflow/service/flow/flow_execution_service.go @@ -5,8 +5,10 @@ import ( "ai-agent/workflow/consts/node" fileDao "ai-agent/workflow/dao/file" flowDao "ai-agent/workflow/dao/flow" + nodeDao "ai-agent/workflow/dao/node" 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" @@ -15,12 +17,14 @@ import ( "strconv" "strings" "sync" + "time" "gitea.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" ) @@ -121,22 +125,30 @@ func (s *flowExecutionService) List(ctx context.Context, req *flowDto.ListFlowEx item := &tempItems[idx] val := item.Content suffix := "内容" - - switch { - case strings.Contains(val, "img") || strings.Contains(val, "png") || strings.Contains(val, "jpg"): + ext := "" + ext = GetFileTypeByPath(val) + if ext == "image" { suffix = "图片" - case strings.Contains(val, "html") || strings.Contains(val, "HTML"): - suffix = "HTML" - case strings.Contains(val, "inc") || len(val) > 50: + } + if ext == "video" { + suffix = "视频" + } + if ext == "audio" { + suffix = "音频" + } + if ext == "text" { suffix = "文案" } - + if ext == "html" { + suffix = "HTML" + } suffixCount[suffix]++ + item.Type = ext item.Label = fmt.Sprintf("%s_%d", suffix, suffixCount[suffix]) } // 组装节点 - node := flowDto.FlowNode{ + flowNode := flowDto.FlowNode{ FlowName: displayFlowName, Id: execution.Id, SessionId: gconv.String(execution.SessionId), @@ -147,7 +159,7 @@ func (s *flowExecutionService) List(ctx context.Context, req *flowDto.ListFlowEx dateMap[createDate] = &[]flowWrap{} } *dateMap[createDate] = append(*dateMap[createDate], flowWrap{ - flowNode: node, + flowNode: flowNode, createdAt: execution.CreatedAt, }) } @@ -188,6 +200,12 @@ 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) { // 唤醒等待的任务 @@ -195,43 +213,19 @@ func (s *flowExecutionService) ModelCallback(ctx context.Context, req *flowDto.M return nil } -// 全局等待任务回调的工具 -var ( - asyncMu sync.Mutex - asyncTasks = make(map[string]chan any) -) - -// Wait 阻塞等待回调结果 -// 调用后会一直卡住,直到 Notify 唤醒 或 超时/取消 -func Wait(ctx context.Context, taskId string) (any, error) { - asyncMu.Lock() - ch := make(chan any, 1) - asyncTasks[taskId] = ch - asyncMu.Unlock() - - select { - case result := <-ch: - return result, nil - case <-ctx.Done(): - asyncMu.Lock() - delete(asyncTasks, taskId) - asyncMu.Unlock() - return nil, ctx.Err() - } +// VideoCallback 视频拼接回调接口 +func (s *flowExecutionService) VideoCallback(ctx context.Context, req *flowDto.VideoCallbackReq) (err error) { + // 唤醒等待的任务 + Notify(req.TaskId, req) + return nil } -// Notify 回调时调用,唤醒等待的任务 -func Notify(taskId string, result any) { - asyncMu.Lock() - defer asyncMu.Unlock() - - ch, exist := asyncTasks[taskId] - if !exist { - return - } - - ch <- result - delete(asyncTasks, taskId) +// HttpNodeCallback http节点回调接口 +func (s *flowExecutionService) HttpNodeCallback(ctx context.Context) (err error) { + r := g.RequestFromCtx(ctx) + taskId := r.Get("task_id").String() + Notify(taskId, r) + return nil } // ===================== 核心改造:替换为 sync.Map 存储取消上下文 ===================== @@ -298,11 +292,13 @@ func (s *flowExecutionService) Execute(ctx context.Context, req *flowDto.Execute } 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 = req.NodeInputParams @@ -327,9 +323,10 @@ func (s *flowExecutionService) Execute(ctx context.Context, req *flowDto.Execute cancelMap.Store(traceId, cancel) } executionReq := flowDto.UpdateFlowExecutionReq{ - Id: executionId, - Status: flow.FlowExecutionStatusRunning.Code(), - TraceId: traceId, + Id: executionId, + NodeGroupId: nodeGroupId, + Status: flow.FlowExecutionStatusRunning.Code(), + TraceId: traceId, } _, err = flowDao.FlowExecutionDao.Update(ctx, &executionReq) if err != nil { @@ -352,6 +349,7 @@ func (s *flowExecutionService) Execute(ctx context.Context, req *flowDto.Execute } 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 @@ -440,6 +438,7 @@ func (s *flowExecutionService) Execute(ctx context.Context, req *flowDto.Execute // ✅【第4步】构建全局执行入参(现在 schemaMap 是有值的!) // ========================================================================= execInput := &flowDto.FlowExecutionInput{ + NodeGroupId: nodeGroupId, IsDialogue: isDialogue, ExecutionId: executionId, ConfigMap: configMap, @@ -476,6 +475,17 @@ func (s *flowExecutionService) Execute(ctx context.Context, req *flowDto.Execute // BuildGraphFromFlowContent 根据前端保存的工作流JSON,自动构建执行图 func BuildGraphFromFlowContent(ctx context.Context, flowContent *entity.FlowInfo, judge2IntentNodeMap map[string]string, summaryNodeID string) (compose.Runnable[any, any], error) { + // 注册自定义合并函数:处理 *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]() nodeMap := make(map[string]entity.FlowNode) @@ -582,7 +592,7 @@ func BuildGraphFromFlowContent(ctx context.Context, flowContent *entity.FlowInfo } _ = graph.AddEdge(summaryNodeID, compose.END) - return graph.Compile(ctx, compose.WithGraphName("auto_build_workflow")) + return graph.Compile(ctx, compose.WithGraphName("auto_build_workflow"), compose.WithNodeTriggerMode(compose.AllPredecessor)) } // -------------------------- 节点自动注册器(核心分发) -------------------------- @@ -606,7 +616,7 @@ func registerNodeToGraph(graph *compose.Graph[any, any], flowNode entity.FlowNod } // 获取入参 - 适配切片类型:遍历所有来源节点 - var realInput any + realInput := new(flowDto.NodeExecutionInput) if len(flowNode.InputSource) > 0 { // 改为判断切片长度 // 遍历所有指定的来源节点,聚合输出结果 for _, inputSource := range flowNode.InputSource { // 遍历切片 @@ -621,19 +631,54 @@ func registerNodeToGraph(graph *compose.Graph[any, any], flowNode entity.FlowNod Config: currentConfig, Global: execInput, // ✅ 把【全部节点】的对象直接塞进来 } - - // 执行节点 - output, err := lambda(ctx, realInput) + // ✅ 插入节点执行记录,初始状态为运行中 + startTime := time.Now() + nodeExecutionId, err := nodeDao.NodeExecutionDao.Insert(ctx, &nodeDto.CreateNodeExecutionReq{ + FlowExecutionId: execInput.ExecutionId, + NodeId: nodeID, + NodeName: flowNode.Name, + NodeGroupId: execInput.NodeGroupId, + InputParams: realInput, + Status: node.NodeExecutionStatusRunning.Code(), + }) if err != nil { + // 记录失败到已执行列表 + execInput.ExecutedNodes = append(execInput.ExecutedNodes, flowDto.ExecutedNode{ + NodeId: nodeID, + Status: node.NodeExecutionStatusFailed.Code(), + }) return nil, err } - // ✅ 自动把当前节点ID 加入已执行列表 - execInput.ExecutedNodes = append(execInput.ExecutedNodes, nodeID) - - // 输出存入 FlowNodeConfig - if outConfig, ok := output.(*entity.FlowNode); ok { - currentConfig.OutputResult = outConfig.OutputResult + realInput.NodeExecutionId = nodeExecutionId + // 执行节点 + _, err = lambda(ctx, realInput) + durationMs := time.Since(startTime).Milliseconds() + updateReq := &nodeDto.UpdateNodeExecutionReq{ + Id: nodeExecutionId, + InputParams: realInput, + 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: nodeID, + Status: node.NodeExecutionStatusFailed.Code(), + }) + return nil, err + } + + // 执行成功,更新状态 + updateReq.Status = node.NodeExecutionStatusSuccess.Code() + _, _ = nodeDao.NodeExecutionDao.Update(ctx, updateReq) + // 记录成功到已执行列表 + execInput.ExecutedNodes = append(execInput.ExecutedNodes, flowDto.ExecutedNode{ + NodeId: nodeID, + Status: node.NodeExecutionStatusSuccess.Code(), + }) // ✅ 关键:返回整个 execInput,让下一个节点继续用! return execInput, nil @@ -654,6 +699,16 @@ func registerNodeToGraph(graph *compose.Graph[any, any], flowNode entity.FlowNod _ = graph.AddLambdaNode(nodeID, compose.InvokableLambda(wrapLambda(VideoModelLambda))) case node.NodeTypeAudioModel: _ = graph.AddLambdaNode(nodeID, compose.InvokableLambda(wrapLambda(AudioModelLambda))) + case node.NodeTypeBatchModel: + _ = graph.AddLambdaNode(nodeID, compose.InvokableLambda(wrapLambda(BatchModelLambda))) + case node.NodeTypeDataConversionModel: + _ = graph.AddLambdaNode(nodeID, compose.InvokableLambda(wrapLambda(DataConversionLambda))) + //case node.NodeTypeSenseOptimizeModel: + // _ = graph.AddLambdaNode(nodeID, compose.InvokableLambda(wrapLambda(SenseOptimizeModelLambda))) + //case node.NodeTypeStoryOptimizeModel: + // _ = graph.AddLambdaNode(nodeID, compose.InvokableLambda(wrapLambda(StoryOptimizeModelLambda))) + //case node.NodeTypeScriptOptimizeModel: + // _ = graph.AddLambdaNode(nodeID, compose.InvokableLambda(wrapLambda(ScriptOptimizeModelLambda))) case node.NodeTypeCustomNode: _ = graph.AddLambdaNode(nodeID, compose.InvokableLambda(wrapLambda(CustomLambda))) case node.NodeTypeForm: @@ -662,6 +717,10 @@ func registerNodeToGraph(graph *compose.Graph[any, any], flowNode entity.FlowNod _ = graph.AddLambdaNode(nodeID, compose.InvokableLambda(wrapLambda(IntentLambda))) case node.NodeTypeMerge: _ = graph.AddLambdaNode(nodeID, compose.InvokableLambda(wrapLambda(MergeLambda))) + case node.NodeTypeDataMerge: + _ = graph.AddLambdaNode(nodeID, compose.InvokableLambda(wrapLambda(DataMergeLambda))) + case node.NodeTypeHttp: + _ = graph.AddLambdaNode(nodeID, compose.InvokableLambda(wrapLambda(HttpLambda))) } } diff --git a/workflow/service/flow/flow_user_service.go b/workflow/service/flow/flow_user_service.go index 3681fac..eae620d 100644 --- a/workflow/service/flow/flow_user_service.go +++ b/workflow/service/flow/flow_user_service.go @@ -2,13 +2,14 @@ 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" + "ai-agent/workflow/service" "context" "gitea.com/red-future/common/beans" - commonHttp "gitea.com/red-future/common/http" "gitea.com/red-future/common/utils" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/util/gconv" @@ -18,25 +19,8 @@ var FlowUserService = &flowUserService{} type flowUserService struct{} -// IsAdmin 调用admin-go服务检查是否是管理员 -func IsAdmin(ctx context.Context) (res bool, 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] - } - } - } - var r = make(map[string]bool) - if err = commonHttp.Get(ctx, "admin-go/api/v1/system/user/checkIsSuperAdmin", headers, &r); err != nil { - return false, err - } - return r["isSuperAdmin"], err -} - func (s *flowUserService) Create(ctx context.Context, req *flowDto.CreateFlowUserReq) (res *flowDto.CreateFlowUserRes, err error) { - admin, err := IsAdmin(ctx) + admin, err := service.UtilService.IsAdmin(ctx) if err != nil { return } @@ -57,7 +41,7 @@ func (s *flowUserService) Create(ctx context.Context, req *flowDto.CreateFlowUse } func (s *flowUserService) Update(ctx context.Context, req *flowDto.UpdateFlowUserReq) (err error) { - admin, err := IsAdmin(ctx) + admin, err := service.UtilService.IsAdmin(ctx) if err != nil { return } @@ -97,6 +81,26 @@ func (s *flowUserService) Update(ctx context.Context, req *flowDto.UpdateFlowUse } 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) @@ -105,7 +109,7 @@ func ExtractFlowNodeFrom(flowContent *entity.FlowInfo) []*entity.FlowNode { } func (s *flowUserService) Delete(ctx context.Context, req *flowDto.DeleteFlowUserReq) (err error) { - admin, err := IsAdmin(ctx) + admin, err := service.UtilService.IsAdmin(ctx) if err != nil { return } @@ -146,7 +150,7 @@ func (s *flowUserService) Get(ctx context.Context, req *flowDto.GetFlowUserReq) } func (s *flowUserService) List(ctx context.Context, req *flowDto.ListFlowUserReq) (res *flowDto.ListFlowRes, err error) { - admin, err := IsAdmin(ctx) + admin, err := service.UtilService.IsAdmin(ctx) if err != nil { return } diff --git a/workflow/service/flow/lambda_node.go b/workflow/service/flow/lambda_node.go index 411d1f9..2928158 100644 --- a/workflow/service/flow/lambda_node.go +++ b/workflow/service/flow/lambda_node.go @@ -13,6 +13,7 @@ import ( "fmt" "strconv" "strings" + "sync" "time" "gitea.com/red-future/common/db/gfdb" @@ -58,7 +59,6 @@ func JudgeLambda(ctx context.Context, input any) (string, error) { outputResult = append(outputResult, field) } } - contextParts := "" for _, v := range nodeInput.Config.FormConfig { contextParts = fmt.Sprintf("%s,%s:%s", contextParts, v.Label, v.Value) @@ -68,51 +68,128 @@ func JudgeLambda(ctx context.Context, input any) (string, error) { contextParts = fmt.Sprintf("%s,%s:%s", contextParts, v.Label, v.Value) } } - if !g.IsEmpty(nodeInput.Global.Desc) { contextParts = fmt.Sprintf("%s,%s:%s", contextParts, "描述", nodeInput.Global.Desc) } configMap := gconv.Map(nodeInput.Config.Config) ids := gconv.Strings(configMap["branch_ids"]) branchIdNameMap := gconv.Map(configMap["branch_id_name_map"]) - - // 【重构】构建提示词:展示ID和对应的名称 var branchIdNameLines []string for _, id := range ids { name := gconv.String(branchIdNameMap[id]) branchIdNameLines = append(branchIdNameLines, fmt.Sprintf("%s: %s", id, name)) } - getIsChatModel, err := GetIsChatModel(ctx) if err != nil { return "", err } - req := flowDto.ComposeMessagesReq{ - BuildType: 2, - ModelName: getIsChatModel.ModelName, - SkillName: "", - Cause: "判断节点", - Form: map[string]any{"prompt": strings.Join(branchIdNameLines, "\n")}, - UserForm: map[string]any{"prompt": contextParts}, - UserFiles: nodeInput.Global.FileUrl, - SessionId: nodeInput.Global.SessionId, - } - msg, err := ComposeMessages(ctx, &req) + composeResult, err := GetComposeResult(ctx, 2, getIsChatModel.Model.ModelName, "", "", []map[string]any{{"prompt": strings.Join(branchIdNameLines, "\n")}}, []map[string]any{{"prompt": contextParts}}, nodeInput.Global.FileUrl, nodeInput.Global.SessionId, nodeInput.Config.Id, "判断节点") if err != nil { return "", err } - if g.IsEmpty(msg.Messages) { + if g.IsEmpty(composeResult.TaskId) { return "", fmt.Errorf("msg is empty") } - content := "" - for key, _ := range getIsChatModel.ResponseBody { - content = gconv.String(msg.Messages[key]) + for key, _ := range getIsChatModel.Model.ResponseBody { + content = gconv.String(composeResult.Messages.Rounds[0][key]) + } + fmt.Printf("JudgeLambda路由:目标节点ID=%s\n", gconv.String(content)) + return content, nil +} + +func BatchModelLambda(ctx context.Context, input any) (any, error) { + nodeInput, ok := input.(*flowDto.NodeExecutionInput) + if !ok { + return nil, fmt.Errorf("入参类型错误") + } + skillName, from, userFrom := BuildParam(nodeInput) + reqMap := make([]map[string]any, 0) + for _, userItem := range userFrom { + m := gconv.Map(userItem) + for _, i := range nodeInput.Config.InputSource { + for _, f := range i.Field { + val := m[f] + if !g.IsEmpty(val) { + if g.NewVar(val).IsSlice() { + slice := gconv.SliceAny(val) + for _, item := range slice { + reqMap = append(reqMap, map[string]any{f: item}) + } + } else { + reqMap = append(reqMap, map[string]any{f: val}) + } + } + } + } + } + // 结果按索引存放,保证顺序 + res := make([][]node.NodeFormField, len(reqMap)) + var wg sync.WaitGroup + // 用一个通道标记是否完成 + done := make(chan struct{}) + // 错误只存一个 + var execErr error + + // 并发执行 + for idx, item := range reqMap { + wg.Add(1) + go func(idx int, userItem map[string]any) { + defer wg.Done() + + singleUserFrom := []map[string]any{userItem} + output, err := TextNode(ctx, nodeInput, skillName, from, singleUserFrom) + if err != nil { + // 并发安全赋值错误 + if execErr == nil { + execErr = err + } + return + } + + // 直接按原索引写,顺序绝对正确 + res[idx] = output + }(idx, item) } - fmt.Printf("JudgeLambda路由:目标节点ID=%s\n", gconv.String(content)) + // 后台等待所有协程完成,然后关闭 done 通道 + go func() { + wg.Wait() + close(done) + }() - return content, nil + // 等待全部完成 + <-done + + // 如果有错误,直接返回 + if execErr != nil { + return nil, execErr + } + + // 全局自增 i + var globalIndex int + var outputRes []node.NodeFormField + for _, items := range res { + for _, item := range items { + // 1. 拿到原来的 Field:例如 "text_content:2:0" + oldField := item.Field + // 2. 找到最后一个 : 的位置 + if idx := strings.LastIndex(oldField, ":"); idx != -1 { + // 3. 截断前面部分,拼接上新的 globalIndex + item.Field = oldField[:idx+1] + fmt.Sprint(globalIndex) + } + // Label 同理 + oldLabel := item.Label + if idx := strings.LastIndex(oldLabel, ":"); idx != -1 { + item.Label = oldLabel[:idx+1] + fmt.Sprint(globalIndex) + } + outputRes = append(outputRes, item) + } + globalIndex++ + } + + nodeInput.Config.OutputResult = outputRes + return nodeInput, nil } // TextModelLambda 构建文案 @@ -122,7 +199,7 @@ func TextModelLambda(ctx context.Context, input any) (any, error) { return nil, fmt.Errorf("入参类型错误") } skillName, from, userFrom := BuildParam(nodeInput) - outputRes, err := TextNode(ctx, nodeInput.Global.SessionId, nodeInput.Config.ModelConfig.ModelName, skillName, from, userFrom, nodeInput.Config.ModelConfig.ModelResponse, nodeInput.Global.FileUrl) + outputRes, err := TextNode(ctx, nodeInput, skillName, from, userFrom) if err != nil { return nil, err } @@ -137,7 +214,7 @@ func ImageModelLambda(ctx context.Context, input any) (any, error) { return nil, fmt.Errorf("入参类型错误") } skillName, from, userFrom := BuildParam(nodeInput) - outputRes, err := ImgNode(ctx, nodeInput.Global.SessionId, nodeInput.Config.ModelConfig.ModelName, skillName, from, userFrom, nodeInput.Config.ModelConfig.ModelResponse, nodeInput.Global.FileUrl) + outputRes, err := ImgNode(ctx, nodeInput, skillName, from, userFrom) if err != nil { return nil, err } @@ -145,7 +222,213 @@ func ImageModelLambda(ctx context.Context, input any) (any, error) { return nodeInput, nil } -func MergeLambda(ctx context.Context, input any) (any, error) { +// AudioModelLambda 构建音频 +func AudioModelLambda(ctx context.Context, input any) (any, error) { + nodeInput, ok := input.(*flowDto.NodeExecutionInput) + if !ok { + return nil, fmt.Errorf("入参类型错误") + } + skillName, from, userFrom := BuildParam(nodeInput) + outputRes, err := AudioOptimizeNode(ctx, nodeInput, skillName, from, userFrom) + if err != nil { + return nil, err + } + nodeInput.Config.OutputResult = outputRes + return nodeInput, nil +} + +// VideoModelLambda 构建视频 +func VideoModelLambda(ctx context.Context, input any) (any, error) { + nodeInput, ok := input.(*flowDto.NodeExecutionInput) + if !ok { + return nil, fmt.Errorf("入参类型错误") + } + + skillName, from, userFrom := BuildParam(nodeInput) + res, err := VideoOptimizeNode(ctx, nodeInput, skillName, from, userFrom) + if err != nil { + return nil, err + } + + videoURL := make([]string, 0) + for _, v := range res { + if strings.Contains(v.Field, "content") { + videoURL = append(videoURL, gconv.String(v.Value)) + } + } + if g.IsEmpty(videoURL) { + return nil, fmt.Errorf("视频合成失败:模型生成视频失败") + } + waitRes, err := VideoConcat(ctx, videoURL) + if err != nil { + return nil, err + } + msg := new(flowDto.VideoCallbackReq) + if err = gconv.Struct(waitRes, msg); err != nil { + return nil, err + } + + urlPrefix, err := utils.GetFileAddressPrefix(ctx) + if err != nil { + return nil, err + } + + outputRes := make([]node.NodeFormField, 0) + if nodeInput.Config.IsSaveFile { + outputRes = append(outputRes, node.NodeFormField{ + Field: fmt.Sprintf("video_oss_url:content:%d", 0), + Value: msg.FileURL, + Label: fmt.Sprintf("video_oss_url:content:%d", 0), + Type: "string", + }) + } else { + outputRes = append(outputRes, node.NodeFormField{ + Field: fmt.Sprintf("concat_video_url:content:%d", 0), + Value: urlPrefix + msg.FileURL, + Label: fmt.Sprintf("concat_video_url:content:%d", 0), + Type: "string", + }) + } + nodeInput.Config.OutputResult = outputRes + + return nodeInput, nil +} + +// HttpLambda 构建HTTP(S)接口 +func HttpLambda(ctx context.Context, input any) (any, error) { + nodeInput, ok := input.(*flowDto.NodeExecutionInput) + if !ok { + return nil, fmt.Errorf("入参类型错误") + } + outputRes := make([]node.NodeFormField, 0) + var err error + outputRes, err = HttpNode(ctx, nodeInput) + if err != nil { + return nil, err + } + nodeInput.Config.OutputResult = outputRes + return nodeInput, nil +} + +// DataConversionLambda 构建数据转换 +func DataConversionLambda(ctx context.Context, input any) (any, error) { + nodeInput, ok := input.(*flowDto.NodeExecutionInput) + if !ok { + return nil, fmt.Errorf("入参类型错误") + } + skillName, from, userFrom := BuildParam(nodeInput) + outputRes, err := DataConversionNode(ctx, nodeInput, skillName, from, userFrom) + if err != nil { + return nil, err + } + nodeInput.Config.OutputResult = outputRes + return nodeInput, nil +} + +func DataMergeLambda(ctx context.Context, input any) (res any, err error) { + nodeInput, ok := input.(*flowDto.NodeExecutionInput) + if !ok { + return nil, fmt.Errorf("参数合并入参类型错误") + } + + // var nodeIds []string + // for _, item := range nodeInput.Config.InputSource { + // nodeIds = append(nodeIds, item.NodeId) + // } + // + // // 检查是否所有输入节点都执行完成,并且检查是否有节点失败 + // checkAllExecuted := func() (allExecuted bool, hasFailed bool, failedNode string) { + // executedCount := 0 + // for _, executedNode := range nodeInput.Global.ExecutedNodes { + // // 检查是否是我们需要的输入节点,并且它失败了 + // for _, targetId := range nodeIds { + // if executedNode.NodeId == targetId { + // if executedNode.Status == node.NodeExecutionStatusFailed.Code() { + // return false, true, targetId + // } + // executedCount++ + // break + // } + // } + // } + // return executedCount == len(nodeIds), false, "" + // } + // + // // 初次检查 + // allExecuted, hasFailed, failedNode := checkAllExecuted() + // if hasFailed { + // return nil, fmt.Errorf("输入节点[%s]执行失败", failedNode) + // } + // + // // 如果不是全部都已执行,阻塞等待直到全部完成、上下文取消或有节点失败 + // if !allExecuted { + // // 轮询检查,每500ms检查一次,依赖ctx超时控制 + // ticker := time.NewTicker(500 * time.Millisecond) + // defer ticker.Stop() + // + // for { + // select { + // case <-ctx.Done(): + // // 如果上下文已经取消,说明已有节点报错,直接退出 + // return nil, ctx.Err() + // case <-ticker.C: + // // 重新检查所有节点 + // allExecuted, hasFailed, failedNode := checkAllExecuted() + // if hasFailed { + // // 有一个输入节点失败,直接退出 + // return nil, fmt.Errorf("输入节点[%s]执行失败", failedNode) + // } + // if allExecuted { + // // 全部执行完成,退出循环继续执行 + // goto allDone + // } + // + // // 再次检查上下文是否已经取消,如果已经取消则立即退出 + // select { + // case <-ctx.Done(): + // return nil, ctx.Err() + // default: + // } + // } + // } + // } + //allDone: + // + // // 最终检查:所有输入节点都成功了吗 + // _, hasFailed, failedNode = checkAllExecuted() + // if hasFailed { + // // 有一个输入节点失败,直接退出 + // return nil, fmt.Errorf("输入节点[%s]执行失败", failedNode) + // } + // + // // 构建已执行节点ID的map,方便合并时查找 + // executedMap := make(map[string]*flowDto.ExecutedNode, len(nodeInput.Global.ExecutedNodes)) + // for _, en := range nodeInput.Global.ExecutedNodes { + // executedMap[en.NodeId] = &en + // } + // + // // 合并所有输入源节点的输出结果 + // for _, inputSource := range nodeInput.Config.InputSource { + // // 每次循环都检查上下文是否已取消,提前退出 + // select { + // case <-ctx.Done(): + // return nil, ctx.Err() + // default: + // } + // // 再次检查该节点是否失败 + // if en, ok := executedMap[inputSource.NodeId]; ok && en.Status == node.NodeExecutionStatusFailed.Code() { + // return nil, fmt.Errorf("输入节点[%s]执行失败", inputSource.NodeId) + // } + // sourceNodeConfig := nodeInput.Global.ConfigMap[inputSource.NodeId] + // if sourceNodeConfig != nil && len(sourceNodeConfig.OutputResult) > 0 { + // nodeInput.Config.OutputResult = append(nodeInput.Config.OutputResult, sourceNodeConfig.OutputResult...) + // } + // } + + return nodeInput, nil +} + +func MergeLambda(ctx context.Context, input any) (res any, err error) { nodeInput, ok := input.(*flowDto.NodeExecutionInput) if !ok { return nil, fmt.Errorf("汇总节点入参类型错误") @@ -155,7 +438,8 @@ func MergeLambda(ctx context.Context, input any) (any, error) { dataMap := make(map[string]node.NodeFormField) _, outputMap, _ := GetNodeContextContent(nodeInput.Global, nodeInput.Config) for _, valueAny := range outputMap { - if field, ok := valueAny.(node.NodeFormField); ok { + field := node.NodeFormField{} + if field, ok = valueAny.(node.NodeFormField); ok { dataMap[field.Field] = field } } @@ -163,7 +447,7 @@ func MergeLambda(ctx context.Context, input any) (any, error) { // 2. 提取所有文案:text_content_0,1,2... var contents []node.NodeFormField for i := 0; ; i++ { - key := fmt.Sprintf("text_url:%d", i) + key := fmt.Sprintf("text_content:%d", i) val, has := dataMap[key] if !has || val.Value == "" { break @@ -179,7 +463,7 @@ func MergeLambda(ctx context.Context, input any) (any, error) { if !has || val.Value == "" { break } - images = append(images, val.Value) + images = append(images, gconv.String(val.Value)) } // 4. 🔥 核心算法:图片按顺序连续归属给每条文案 @@ -232,8 +516,8 @@ func MergeLambda(ctx context.Context, input any) (any, error) { if len(contents) > 0 { for i, val := range contents { item := Item{ - Content: url + val.Value, // 文案 - Images: textImgMap[i], // 自动绑定该条目的图片(没有则为空切片) + Content: url + gconv.String(val.Value), // 文案 + Images: textImgMap[i], // 自动绑定该条目的图片(没有则为空切片) } allItems = append(allItems, item) } @@ -254,24 +538,8 @@ func MergeLambda(ctx context.Context, input any) (any, error) { // 遍历所有【独立图文条目】 → 每条生成独立HTML、独立上传OSS、独立输出记录 for idx, item := range allItems { - // item 结构包含:Content(string) + Images([]string) - // 支持任意来源:文生图、图生文、单独文、单独图、文图合并 - // 生成单条HTML htmlContent := BuildHtml(item.Content, item.Images) - - // 上传OSS(每条独立上传) - fileName := fmt.Sprintf("item_%d_%d.html", idx, time.Now().UnixMilli()) - ossResult, err := Upload(ctx, &dto.UploadFileBytesReq{ - FileBytes: []byte(htmlContent), - FileName: fileName, - }) - if err != nil { - return nil, err - } - - // 拼接成一条输出记录 - // 每条记录包含:HTML内容 + 访问URL + 文案 + 图片列表 outputRecords = append(outputRecords, node.NodeFormField{ Field: fmt.Sprintf("item_html_%d", idx), @@ -279,25 +547,26 @@ func MergeLambda(ctx context.Context, input any) (any, error) { Label: fmt.Sprintf("条目%d HTML", idx+1), Type: "textarea", }, - node.NodeFormField{ - Field: fmt.Sprintf("item_html_url_%d", idx), - Value: ossResult.FileURL, - Label: fmt.Sprintf("条目%d 地址", idx+1), - Type: "text", - }, - node.NodeFormField{ - Field: fmt.Sprintf("item_txt_url_%d", idx), - Value: item.Content, - Label: fmt.Sprintf("条目%d 文案", idx+1), - Type: "text", - }, - node.NodeFormField{ - Field: fmt.Sprintf("item_image_url_%d", idx), - Value: strings.Join(item.Images, ","), - Label: fmt.Sprintf("条目%d 图片", idx+1), - Type: "text", - }, ) + if nodeInput.Config.IsSaveFile { + // 上传OSS(每条独立上传) + fileName := fmt.Sprintf("item_%d_%d.html", idx, time.Now().UnixMilli()) + ossResult, err := Upload(ctx, &dto.UploadFileBytesReq{ + FileBytes: []byte(htmlContent), + FileName: fileName, + }) + if err != nil { + return nil, err + } + outputRecords = append(outputRecords, + node.NodeFormField{ + Field: fmt.Sprintf("item_html_url_%d", idx), + Value: ossResult.FileURL, + Label: fmt.Sprintf("条目%d 地址", idx+1), + Type: "text", + }, + ) + } } // 最终输出多条记录 @@ -313,11 +582,12 @@ func SummaryLambda(ctx context.Context, input any) (any, error) { // 聚合所有已执行节点的输出结果 var summaryResult []map[string]interface{} - for _, nodeID := range execInput.Global.ExecutedNodes { + for _, executedNode := range execInput.Global.ExecutedNodes { + nodeID := executedNode.NodeId nodeConfig := execInput.Global.ConfigMap[nodeID] if nodeConfig != nil && len(nodeConfig.OutputResult) > 0 { for _, field := range nodeConfig.OutputResult { - if strings.Contains(field.Field, "item_html_url") || strings.Contains(field.Field, "img_url") || strings.Contains(field.Field, "text_url") { + if strings.Contains(field.Field, "http_file_url") || strings.Contains(field.Field, "audio_oss_url") || strings.Contains(field.Field, "video_oss_url") || strings.Contains(field.Field, "item_html_url") || strings.Contains(field.Field, "img_oss_url") || strings.Contains(field.Field, "text_url") { // 生成 毫秒时间戳 作为 KEY timeKey := strconv.FormatInt(time.Now().UnixMilli(), 10) item := make(map[string]interface{}) @@ -376,18 +646,6 @@ func SummaryLambda(ctx context.Context, input any) (any, error) { return execInput, err } -// VideoModelLambda 构建视频 -func VideoModelLambda(ctx context.Context, input any) (any, error) { - fmt.Println("VideoModelLambda:", input) - return input, nil -} - -// AudioModelLambda 构建音频 -func AudioModelLambda(ctx context.Context, input any) (any, error) { - fmt.Println("AudioModelLambda:", input) - return input, nil -} - // CustomLambda 构建自定义 func CustomLambda(ctx context.Context, input any) (any, error) { fmt.Println("CustomLambda:", input) diff --git a/workflow/service/flow/lambda_node_imp.go b/workflow/service/flow/lambda_node_imp.go index c7ae430..dc1f429 100644 --- a/workflow/service/flow/lambda_node_imp.go +++ b/workflow/service/flow/lambda_node_imp.go @@ -4,8 +4,10 @@ import ( "ai-agent/workflow/consts/flow" "ai-agent/workflow/consts/node" flowDao "ai-agent/workflow/dao/flow" + nodeDao "ai-agent/workflow/dao/node" "ai-agent/workflow/model/dto" flowDto "ai-agent/workflow/model/dto/flow" + nodeDto "ai-agent/workflow/model/dto/node" "ai-agent/workflow/model/entity" "context" "fmt" @@ -14,18 +16,25 @@ import ( "strings" "time" + commonHttp "gitea.com/red-future/common/http" "gitea.com/red-future/common/utils" "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" "github.com/gogf/gf/v2/util/gconv" + "github.com/google/uuid" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" ) -func getNodeInfo(flowInfo *entity.FlowExecution) (htmlUrl []string, textModelName string, textResultFrom map[string]any, textModelResponse map[string]any, imgModelName string, imgResultFrom map[string]any, imgModelResponse map[string]any) { +func getNodeInfo(flowInfo *entity.FlowExecution) (htmlUrl []string, textIsSaveFile bool, textPromptContent, textModelName string, textResultFrom []map[string]any, imgIsSaveFile bool, imgPromptContent, imgModelName string, imgResultFrom []map[string]any) { + textPromptContent = "" + textIsSaveFile = false textModelName = "" - textResultFrom = make(map[string]any) - textModelResponse = make(map[string]any) + textResultFrom = []map[string]any{} + imgPromptContent = "" + imgIsSaveFile = false imgModelName = "" - imgResultFrom = make(map[string]any) - imgModelResponse = make(map[string]any) + imgResultFrom = []map[string]any{} // 查询节点中是否包含结果合并节点 for _, item := range flowInfo.NodeInputParams { if item.NodeCode == node.NodeTypeMerge { @@ -39,8 +48,9 @@ func getNodeInfo(flowInfo *entity.FlowExecution) (htmlUrl []string, textModelNam } } if item.NodeCode == node.NodeTypeTextModel { + textPromptContent = item.PromptContent + textIsSaveFile = item.IsSaveFile textModelName = item.ModelConfig.ModelName - textModelResponse = item.ModelConfig.ModelResponse for key, modelFormItem := range item.ModelConfig.ModelForm { textResultFrom[key] = map[string]any{ "value": modelFormItem, @@ -48,8 +58,9 @@ func getNodeInfo(flowInfo *entity.FlowExecution) (htmlUrl []string, textModelNam } } if item.NodeCode == node.NodeTypeImageModel { + imgPromptContent = item.PromptContent + imgIsSaveFile = item.IsSaveFile imgModelName = item.ModelConfig.ModelName - imgModelResponse = item.ModelConfig.ModelResponse for key, modelFormItem := range item.ModelConfig.ModelForm { imgResultFrom[key] = map[string]any{ "value": modelFormItem, @@ -58,42 +69,107 @@ func getNodeInfo(flowInfo *entity.FlowExecution) (htmlUrl []string, textModelNam } } - return htmlUrl, textModelName, textResultFrom, textModelResponse, imgModelName, imgResultFrom, imgModelResponse + return htmlUrl, textIsSaveFile, textPromptContent, textModelName, textResultFrom, imgIsSaveFile, imgPromptContent, imgModelName, imgResultFrom } func TextImgModelSingleLambda(ctx context.Context, req *flowDto.ExecuteReq, flowInfo *entity.FlowExecution) (err error) { - _, textModelName, textResultFrom, textModelResponse, imgModelName, imgResultFrom, imgModelResponse := getNodeInfo(flowInfo) + textStartTime := time.Now() - resultUserFrom := make(map[string]any) - resultUserFrom["desc"] = req.Desc + _, textIsSaveFile, textPromptContent, textModelName, textResultFrom, imgIsSaveFile, imgPromptContent, imgModelName, imgResultFrom := getNodeInfo(flowInfo) + resultUserFrom := []map[string]any{ + { + "desc": req.Desc, + }, + } var textNode []node.NodeFormField - textNode, err = TextNode(ctx, req.SessionId, textModelName, req.SkillName, textResultFrom, resultUserFrom, textModelResponse, req.FileUrl) + textNodeInput := new(flowDto.NodeExecutionInput) + textNodeInput.Global.SessionId = req.SessionId + textNodeInput.Global.NodeGroupId = req.NodeGroupId + textNodeInput.Global.Desc = req.Desc + textNodeInput.Global.FileUrl = req.FileUrl + textNodeInput.Config.IsSaveFile = textIsSaveFile + textNodeInput.Config.PromptContent = textPromptContent + textNodeInput.Config.ModelConfig.ModelName = textModelName + var textNodeExecutionId int64 + textNodeExecutionId, err = nodeDao.NodeExecutionDao.Insert(ctx, &nodeDto.CreateNodeExecutionReq{ + FlowExecutionId: textNodeInput.Global.ExecutionId, + NodeId: textNodeInput.Config.Id, + NodeName: textNodeInput.Config.Name, + NodeGroupId: textNodeInput.Global.NodeGroupId, + InputParams: textNodeInput, + Status: node.NodeExecutionStatusRunning.Code(), + }) if err != nil { return } + + textNode, err = TextNode(ctx, textNodeInput, req.SkillName, textResultFrom, resultUserFrom) + textUpdateReq := &nodeDto.UpdateNodeExecutionReq{ + Id: textNodeExecutionId, + InputParams: textNodeInput, + } + if err != nil { + textUpdateReq.Status = node.NodeExecutionStatusFailed.Code() + textUpdateReq.ErrorMessage = err.Error() + _, _ = nodeDao.NodeExecutionDao.Update(ctx, textUpdateReq) + return + } + textUpdateReq.DurationMs = time.Since(textStartTime).Milliseconds() + textUpdateReq.Status = node.NodeExecutionStatusSuccess.Code() + _, err = nodeDao.NodeExecutionDao.Update(ctx, textUpdateReq) var textContent string var textUrl string for _, item := range textNode { - if strings.Contains(item.Field, "text_content") { - textContent = StripHtmlTags(item.Value) - } if strings.Contains(item.Field, "text_url") { - textUrl = item.Value + textUrl = gconv.String(item.Value) } } - resultUserFrom["text_content"] = textContent + imgStartTime := time.Now() + + resultUserFrom = append(resultUserFrom, map[string]any{ + "text_content": textContent, + }) var imgNode []node.NodeFormField - imgNode, err = ImgNode(ctx, req.SessionId, imgModelName, req.SkillName, imgResultFrom, resultUserFrom, imgModelResponse, req.FileUrl) + + imgNodeInput := new(flowDto.NodeExecutionInput) + imgNodeInput.Global.SessionId = req.SessionId + imgNodeInput.Global.NodeGroupId = req.NodeGroupId + imgNodeInput.Global.Desc = req.Desc + imgNodeInput.Global.FileUrl = req.FileUrl + imgNodeInput.Config.IsSaveFile = imgIsSaveFile + imgNodeInput.Config.PromptContent = imgPromptContent + imgNodeInput.Config.ModelConfig.ModelName = imgModelName + var imgNodeExecutionId int64 + imgNodeExecutionId, err = nodeDao.NodeExecutionDao.Insert(ctx, &nodeDto.CreateNodeExecutionReq{ + FlowExecutionId: imgNodeInput.Global.ExecutionId, + NodeId: imgNodeInput.Config.Id, + NodeName: imgNodeInput.Config.Name, + NodeGroupId: imgNodeInput.Global.NodeGroupId, + InputParams: imgNodeInput, + Status: node.NodeExecutionStatusRunning.Code(), + }) if err != nil { return } + + imgNode, err = ImgNode(ctx, imgNodeInput, req.SkillName, imgResultFrom, resultUserFrom) + imgUpdateReq := &nodeDto.UpdateNodeExecutionReq{ + Id: imgNodeExecutionId, + InputParams: imgNodeInput, + } + if err != nil { + imgUpdateReq.Status = node.NodeExecutionStatusFailed.Code() + imgUpdateReq.ErrorMessage = err.Error() + _, _ = nodeDao.NodeExecutionDao.Update(ctx, imgUpdateReq) + return + } var imgUrl []string for _, item := range imgNode { if strings.Contains(item.Field, "img_url") { - imgUrl = append(imgUrl, item.Value) + imgUrl = append(imgUrl, gconv.String(item.Value)) } } @@ -107,6 +183,9 @@ func TextImgModelSingleLambda(ctx context.Context, req *flowDto.ExecuteReq, flow FileName: fileName, }) if err != nil { + imgUpdateReq.Status = node.NodeExecutionStatusFailed.Code() + imgUpdateReq.ErrorMessage = err.Error() + _, _ = nodeDao.NodeExecutionDao.Update(ctx, imgUpdateReq) return } fmt.Printf("上传OSS成功:%s", ossResult.FileURL) @@ -133,32 +212,68 @@ func TextImgModelSingleLambda(ctx context.Context, req *flowDto.ExecuteReq, flow OutputParams: summaryResult, } _, err = flowDao.FlowExecutionDao.Update(ctx, &executionReq) + + imgUpdateReq.DurationMs = time.Since(imgStartTime).Milliseconds() + imgUpdateReq.Status = node.NodeExecutionStatusSuccess.Code() + _, err = nodeDao.NodeExecutionDao.Update(ctx, imgUpdateReq) } return } func ImgModelSingleLambda(ctx context.Context, req *flowDto.ExecuteReq, flowInfo *entity.FlowExecution) (err error) { + startTime := time.Now() + var url string url, err = utils.GetFileAddressPrefix(ctx) if err != nil { return } - htmlUrl, _, _, _, imgModelName, imgResultFrom, imgModelResponse := getNodeInfo(flowInfo) + htmlUrl, _, _, _, _, imgIsSaveFile, imgPromptContent, imgModelName, imgResultFrom := getNodeInfo(flowInfo) - resultUserFrom := make(map[string]any) - resultUserFrom["desc"] = req.Desc + resultUserFrom := []map[string]any{ + { + "desc": req.Desc, + }, + } var imgNode []node.NodeFormField - imgNode, err = ImgNode(ctx, req.SessionId, imgModelName, req.SkillName, imgResultFrom, resultUserFrom, imgModelResponse, req.FileUrl) + imgNodeInput := new(flowDto.NodeExecutionInput) + imgNodeInput.Global.SessionId = req.SessionId + imgNodeInput.Global.NodeGroupId = req.NodeGroupId + imgNodeInput.Global.Desc = req.Desc + imgNodeInput.Global.FileUrl = req.FileUrl + imgNodeInput.Config.IsSaveFile = imgIsSaveFile + imgNodeInput.Config.PromptContent = imgPromptContent + imgNodeInput.Config.ModelConfig.ModelName = imgModelName + var nodeExecutionId int64 + nodeExecutionId, err = nodeDao.NodeExecutionDao.Insert(ctx, &nodeDto.CreateNodeExecutionReq{ + FlowExecutionId: imgNodeInput.Global.ExecutionId, + NodeId: imgNodeInput.Config.Id, + NodeName: imgNodeInput.Config.Name, + NodeGroupId: imgNodeInput.Global.NodeGroupId, + InputParams: imgNodeInput, + Status: node.NodeExecutionStatusRunning.Code(), + }) if err != nil { return } + imgNode, err = ImgNode(ctx, imgNodeInput, req.SkillName, imgResultFrom, resultUserFrom) + updateReq := &nodeDto.UpdateNodeExecutionReq{ + Id: nodeExecutionId, + InputParams: imgNodeInput, + } + if err != nil { + updateReq.Status = node.NodeExecutionStatusFailed.Code() + updateReq.ErrorMessage = err.Error() + _, _ = nodeDao.NodeExecutionDao.Update(ctx, updateReq) + return + } var imgUrl string for _, item := range imgNode { if strings.Contains(item.Field, "img_url") { - imgUrl = item.Value + imgUrl = gconv.String(item.Value) } } @@ -167,8 +282,11 @@ func ImgModelSingleLambda(ctx context.Context, req *flowDto.ExecuteReq, flowInfo if !g.IsEmpty(htmlUrl) { for i, item := range htmlUrl { var htmlBytes []byte - htmlBytes, err = GetFileBytesFromURL(url + item) + htmlBytes, err = GetFileBytesFromURL(ctx, url+item) if err != nil { + updateReq.Status = node.NodeExecutionStatusFailed.Code() + updateReq.ErrorMessage = err.Error() + _, _ = nodeDao.NodeExecutionDao.Update(ctx, updateReq) return } htmlContent := string(htmlBytes) @@ -195,6 +313,9 @@ func ImgModelSingleLambda(ctx context.Context, req *flowDto.ExecuteReq, flowInfo FileName: fileName, }) if err != nil { + updateReq.Status = node.NodeExecutionStatusFailed.Code() + updateReq.ErrorMessage = err.Error() + _, _ = nodeDao.NodeExecutionDao.Update(ctx, updateReq) return } fmt.Printf("上传OSS成功:%s", ossResult.FileURL) @@ -238,30 +359,66 @@ func ImgModelSingleLambda(ctx context.Context, req *flowDto.ExecuteReq, flowInfo OutputParams: summaryResult, } _, err = flowDao.FlowExecutionDao.Update(ctx, &executionReq) + + updateReq.DurationMs = time.Since(startTime).Milliseconds() + updateReq.Status = node.NodeExecutionStatusSuccess.Code() + _, err = nodeDao.NodeExecutionDao.Update(ctx, updateReq) } return } func TextModelSingleLambda(ctx context.Context, req *flowDto.ExecuteReq, flowInfo *entity.FlowExecution) (err error) { + startTime := time.Now() + var url string url, err = utils.GetFileAddressPrefix(ctx) if err != nil { return } - htmlUrl, textModelName, textResultFrom, textModelResponse, _, _, _ := getNodeInfo(flowInfo) + htmlUrl, textIsSaveFile, textPromptContent, textModelName, textResultFrom, _, _, _, _ := getNodeInfo(flowInfo) - resultUserFrom := make(map[string]any) - resultUserFrom["desc"] = req.Desc + resultUserFrom := []map[string]any{ + { + "desc": req.Desc, + }, + } var textNode []node.NodeFormField - textNode, err = TextNode(ctx, req.SessionId, textModelName, req.SkillName, textResultFrom, resultUserFrom, textModelResponse, req.FileUrl) + nodeInput := new(flowDto.NodeExecutionInput) + nodeInput.Global.SessionId = req.SessionId + nodeInput.Global.NodeGroupId = req.NodeGroupId + nodeInput.Global.Desc = req.Desc + nodeInput.Global.FileUrl = req.FileUrl + nodeInput.Config.IsSaveFile = textIsSaveFile + nodeInput.Config.PromptContent = textPromptContent + nodeInput.Config.ModelConfig.ModelName = textModelName + var nodeExecutionId int64 + nodeExecutionId, err = nodeDao.NodeExecutionDao.Insert(ctx, &nodeDto.CreateNodeExecutionReq{ + FlowExecutionId: nodeInput.Global.ExecutionId, + NodeId: nodeInput.Config.Id, + NodeName: nodeInput.Config.Name, + NodeGroupId: nodeInput.Global.NodeGroupId, + InputParams: nodeInput, + Status: node.NodeExecutionStatusRunning.Code(), + }) if err != nil { return } + textNode, err = TextNode(ctx, nodeInput, req.SkillName, textResultFrom, resultUserFrom) + updateReq := &nodeDto.UpdateNodeExecutionReq{ + Id: nodeExecutionId, + InputParams: nodeInput, + } + if err != nil { + updateReq.Status = node.NodeExecutionStatusFailed.Code() + updateReq.ErrorMessage = err.Error() + _, _ = nodeDao.NodeExecutionDao.Update(ctx, updateReq) + return + } var textUrl string for _, item := range textNode { if strings.Contains(item.Field, "text_url") { - textUrl = item.Value + textUrl = gconv.String(item.Value) } } @@ -270,8 +427,11 @@ func TextModelSingleLambda(ctx context.Context, req *flowDto.ExecuteReq, flowInf if !g.IsEmpty(htmlUrl) { for i, item := range htmlUrl { var htmlBytes []byte - htmlBytes, err = GetFileBytesFromURL(url + item) + htmlBytes, err = GetFileBytesFromURL(ctx, url+item) if err != nil { + updateReq.Status = node.NodeExecutionStatusFailed.Code() + updateReq.ErrorMessage = err.Error() + _, _ = nodeDao.NodeExecutionDao.Update(ctx, updateReq) return } htmlContent := string(htmlBytes) @@ -297,6 +457,9 @@ func TextModelSingleLambda(ctx context.Context, req *flowDto.ExecuteReq, flowInf FileName: fileName, }) if err != nil { + updateReq.Status = node.NodeExecutionStatusFailed.Code() + updateReq.ErrorMessage = err.Error() + _, _ = nodeDao.NodeExecutionDao.Update(ctx, updateReq) return } fmt.Printf("上传OSS成功:%s", ossResult.FileURL) @@ -338,122 +501,434 @@ func TextModelSingleLambda(ctx context.Context, req *flowDto.ExecuteReq, flowInf OutputParams: summaryResult, } _, err = flowDao.FlowExecutionDao.Update(ctx, &executionReq) + + updateReq.DurationMs = time.Since(startTime).Milliseconds() + updateReq.Status = node.NodeExecutionStatusSuccess.Code() + _, err = nodeDao.NodeExecutionDao.Update(ctx, updateReq) } return } -func TextNode(ctx context.Context, sessionId, modelName, skillName string, form, userForm, modelResponse map[string]any, fileUrl []string) ([]node.NodeFormField, error) { - contentStr := "你是专业内容生成助手,请严格按以下规则输出内容:1、输出标准 HTML 片段,不要 Markdown,不要 ``` 符号,不要多余解释,2、整体用
,6、列表使用
需要配图:N 张
N 是这条文案需要的图片数量,只能是数字,不能是其他文字,11、只输出 HTML 结构,不输出任何额外文字" - userForm["prompt"] = contentStr +func TextNode(ctx context.Context, nodeInput *flowDto.NodeExecutionInput, skillName string, form []map[string]any, userForm []map[string]any) ([]node.NodeFormField, error) { + //contentStr := "你是专业内容生成助手,请严格按以下规则输出内容:1、输出标准 HTML 片段,不要 Markdown,不要 ``` 符号,不要多余解释,2、整体用,6、列表使用
需要配图:N 张
N 是这条文案需要的图片数量,只能是数字,不能是其他文字,11、只输出 HTML 结构,不输出任何额外文字" - mapTaskResult, err := GetModelResult(ctx, modelName, skillName, form, userForm, fileUrl, sessionId, "文案生成") + mapTaskResult, err := GetModelResult(ctx, nodeInput.Global.SessionId, nodeInput, skillName, form, userForm) + if err != nil { + return nil, err + } + if g.IsEmpty(mapTaskResult) { + return nil, fmt.Errorf("生成内容为空") + } + + outputRes := make([]node.NodeFormField, 0) + for _, item := range mapTaskResult { + for k, v := range item { + // 拆分多条文案 + contentList := SplitMultiContents(gconv.String(v)) + for i, contentItem := range contentList { + if nodeInput.Config.IsSaveFile { + // 1. 构建html文本 + plainText := BuildText(contentItem) + // 2. 上传纯文本到 OSS + textFileName := fmt.Sprintf("ai_text_%d_%d.inc", time.Now().UnixMilli(), i) + var textUrl *dto.UploadFileBytesRes + textUrl, err = Upload(ctx, &dto.UploadFileBytesReq{ + FileBytes: []byte(plainText), + FileName: textFileName, + }) + if err != nil { + return nil, err + } + // 3. 把纯文本地址存入输出 + outputRes = append(outputRes, node.NodeFormField{ + Field: fmt.Sprintf("text_url:%v:%d", k, i), + Value: textUrl.FileURL, + Label: fmt.Sprintf("text_url:%v:%d", k, i), + Type: "string", + Expand: ExtractImageCount(contentItem), + }) + } + outputRes = append(outputRes, node.NodeFormField{ + Field: fmt.Sprintf("text_content:%v:%d", k, i), + Value: contentItem, + Label: fmt.Sprintf("文案内容%v:%d", k, i), + Type: "string", + Expand: ExtractImageCount(gconv.String(v)), + }) + } + } + } + return outputRes, nil +} + +func ImgNode(ctx context.Context, nodeInput *flowDto.NodeExecutionInput, skillName string, form []map[string]any, userForm []map[string]any) ([]node.NodeFormField, error) { + mapTaskResult, err := GetModelResult(ctx, nodeInput.Global.SessionId, nodeInput, skillName, form, userForm) + if err != nil { + return nil, err + } + if g.IsEmpty(mapTaskResult) { + return nil, fmt.Errorf("生成内容为空") + } + outputRes := make([]node.NodeFormField, 0) + for i, item := range mapTaskResult { + for k, v := range item { + if nodeInput.Config.IsSaveFile { + outputRes = append(outputRes, node.NodeFormField{ + Field: fmt.Sprintf("img_oss_url:%v:%d", k, i), + Value: v, + Label: fmt.Sprintf("img_oss_url%v:%d", k, i), + Type: "string", + }) + } + outputRes = append(outputRes, node.NodeFormField{ + Field: fmt.Sprintf("img_url:%v:%d", k, i), + Value: v, + Label: fmt.Sprintf("img_url%v:%d", k, i), + Type: "string", + }) + } + } + + //var resultContent []string + //for _, item := range mapTaskResult { + // for _, i := range gconv.Strings(item[modelInfo.Model.ResponseBody]) { + // resultContent = append(resultContent, i) + // } + //} + //var images []string + //for _, item := range resultContent { + // mapItem := gconv.Map(item) + // for _, value := range mapItem { + // values, ok := value.(string) + // if !ok { + // return nil, fmt.Errorf("图片地址类型错误") + // } + // // 下载官方临时图片 + // var imgBytes []byte + // imgBytes, err = GetFileBytesFromURL(ctx, values) + // if err != nil { + // return nil, fmt.Errorf("下载图片失败: %w", err) + // } + // // 构造文件名 + // fileName := fmt.Sprintf("ai_image_%d.png", time.Now().UnixMilli()) + // // 上传到你的OSS(你项目已有的Upload方法) + // var upResp *dto.UploadFileBytesRes + // upResp, err = Upload(ctx, &dto.UploadFileBytesReq{ + // FileName: fileName, + // FileBytes: imgBytes, + // }) + // if err != nil { + // return nil, fmt.Errorf("上传OSS失败: %w", err) + // } + // images = append(images, upResp.FileURL) + // } + //} + // + //var url string + //url, err = utils.GetFileAddressPrefix(ctx) + //if err != nil { + // return nil, err + //} + //outputRes := make([]node.NodeFormField, 0) + // + //for i, item := range images { + // // 额外存储关联关系 + // outputRes = append(outputRes, node.NodeFormField{ + // Field: fmt.Sprintf("img_url:%d", i), + // Value: fmt.Sprintf("%s%s", url, item), + // Label: fmt.Sprintf("图片路径:%d", i), + // Type: "string", + // }) + //} + + return outputRes, nil +} + +func AudioOptimizeNode(ctx context.Context, nodeInput *flowDto.NodeExecutionInput, skillName string, form []map[string]any, userForm []map[string]any) ([]node.NodeFormField, error) { + mapTaskResult, err := GetModelResult(ctx, "", nodeInput, skillName, form, userForm) + if err != nil { + return nil, err + } + if g.IsEmpty(mapTaskResult) { + return nil, fmt.Errorf("生成内容为空") + } + outputRes := make([]node.NodeFormField, 0) + for i, item := range mapTaskResult { + for k, v := range item { + if nodeInput.Config.IsSaveFile { + outputRes = append(outputRes, node.NodeFormField{ + Field: fmt.Sprintf("audio_oss_url:%v:%d", k, i), + Value: v, + Label: fmt.Sprintf("audio_oss_url:%v:%d", k, i), + Type: "string", + }) + } + if k == "sentences" { + a := new([]flowDto.Sentence) + err = gconv.Structs(v, a) + v, err = BuildSubtitles(a) + if err != nil { + return nil, err + } + } + outputRes = append(outputRes, node.NodeFormField{ + Field: fmt.Sprintf("audio_url:%v:%d", k, i), + Value: v, + Label: fmt.Sprintf("audio_url:%v:%d", k, i), + Type: "string", + }) + } + } + + return outputRes, nil +} + +func splitTextByPunct(raw string) []string { + // 按标点切分+拼接标点 + slice := regexp.MustCompile(`([,。;!?])`).Split(raw, -1) + var res []string + var builder strings.Builder + for idx, s := range slice { + if s == "" { + continue + } + builder.WriteString(s) + // 偶数位是分隔标点(split后规律:文本、标点、文本、标点...) + if idx%2 == 1 { + res = append(res, builder.String()) + builder.Reset() + } + } + if builder.Len() > 0 { + res = append(res, builder.String()) + } + return res +} + +// BuildSubtitles 核心工具:单个sentence生成多条subtitle +func BuildSubtitles(sents *[]flowDto.Sentence) ([]flowDto.Subtitle, error) { + var subtitles []flowDto.Subtitle + for _, sent := range *sents { + segList := splitTextByPunct(sent.Text) + if len(segList) == 0 { + return nil, nil + } + + var subs []flowDto.Subtitle + wordIdx := 0 + allWords := sent.Words + + for _, seg := range segList { + var collectWords []flowDto.Word + currentText := "" + // 循环取 word,直到拼接内容 包含/匹配 seg + for { + if wordIdx >= len(allWords) { + break + } + word := allWords[wordIdx] + currentText += word.Word + collectWords = append(collectWords, word) + wordIdx++ + + // 只要包含分段文本,就认为匹配(无视末尾标点差异) + if strings.Contains(currentText, seg) { + break + } + } + if len(collectWords) == 0 { + continue + } + // 生成字幕 + sub := flowDto.Subtitle{ + Start: collectWords[0].StartTime, + End: collectWords[len(collectWords)-1].EndTime, + Text: seg, + } + subs = append(subs, sub) + } + subtitles = append(subtitles, subs...) + } + + return subtitles, nil +} + +func VideoOptimizeNode(ctx context.Context, nodeInput *flowDto.NodeExecutionInput, skillName string, form []map[string]any, userForm []map[string]any) ([]node.NodeFormField, error) { + mapTaskResult, err := GetModelResult(ctx, nodeInput.Global.SessionId, nodeInput, skillName, form, userForm) + if err != nil { + return nil, err + } + if g.IsEmpty(mapTaskResult) { + return nil, fmt.Errorf("生成内容为空") + } + outputRes := make([]node.NodeFormField, 0) + for i, item := range mapTaskResult { + for k, v := range item { + outputRes = append(outputRes, node.NodeFormField{ + Field: fmt.Sprintf("video_url:%v:%d", k, i), + Value: v, + Label: fmt.Sprintf("video_url:%v:%d", k, i), + Type: "string", + }) + } + } + return outputRes, nil +} + +func DataConversionNode(ctx context.Context, nodeInput *flowDto.NodeExecutionInput, skillName string, form []map[string]any, userForm []map[string]any) ([]node.NodeFormField, error) { + jsonStr := `` + jsonVal := "输出字段规范:" + for _, field := range nodeInput.Config.OutputConfig { + jsonStr, _ = sjson.Set(jsonStr, field.Field, "") + jsonVal += fmt.Sprintf("%s:%s;", field.Field, field.Value) + } + jsonVal += fmt.Sprintf("输出模板结构,仅修改每个字段对应数值:%s", jsonStr) + nodeInput.Config.PromptContent = fmt.Sprintf("%s;%s", nodeInput.Config.PromptContent, jsonVal) + + mapTaskResult, err := GetModelResult(ctx, "", nodeInput, skillName, form, userForm) + if err != nil { + return nil, err + } + if g.IsEmpty(mapTaskResult) { + return nil, fmt.Errorf("生成内容为空") + } + outputRes := make([]node.NodeFormField, 0) + for i, item := range mapTaskResult { + for k, v := range item { + outputRes = append(outputRes, node.NodeFormField{ + Field: fmt.Sprintf("data_conversion:%v:%d", k, i), + Value: v, + Label: fmt.Sprintf("data_conversion:%v:%d", k, i), + Type: "string", + }) + } + } + return outputRes, nil +} + +func HttpNode(ctx context.Context, nodeInput *flowDto.NodeExecutionInput) ([]node.NodeFormField, error) { + var method, url, responseType, callbackUrl string + var headers map[string]string + var body map[string]any + var responseMapping map[string]any + for _, item := range nodeInput.Config.FormConfig { + switch item.Field { + case "method": + method = gconv.String(item.Value) + case "url": + url = gconv.String(item.Value) + case "headers": + headers = gconv.MapStrStr(item.Value) + case "body": + body = gconv.Map(item.Value) + case "response": + responseMapping = gconv.Map(item.Value) + case "responseType": + responseType = gconv.String(item.Value) + case "callbackUrl": + callbackUrl = gconv.String(item.Value) + } + } + + if method == "" { + return nil, fmt.Errorf("method为空") + } + if url == "" { + return nil, fmt.Errorf("url为空") + } + + if headers == nil { + headers = make(map[string]string) + if r := g.RequestFromCtx(ctx); r != nil { + for k, v := range r.Request.Header { + if len(v) > 0 { + headers[k] = v[0] + } + } + } + } + + // 构建请求参数 + newBody := BuildNestedJson(body, nodeInput.Global.ConfigMap) + // 1. 自己生成唯一 taskId(不用前端给) + taskId := "my_task_" + uuid.New().String() // 自己生成唯一ID + if responseType == "callback" { + newBody[callbackUrl] = utils.GetCallbackURL(ctx, "/httpNodeCallback?task_id="+taskId) + } + // ====================== 核心改动 ====================== + // 1. 定义一个空map接收原始HTTP返回结果 + var rawHttpResult map[string]any + // 2. 发送请求(不变) + var err error + if method == "GET" { + err = commonHttp.Get(ctx, url, headers, &rawHttpResult, newBody) + } else if method == "POST" { + err = commonHttp.Post(ctx, url, headers, &rawHttpResult, newBody) + } else if method == "PUT" { + err = commonHttp.Put(ctx, url, headers, &rawHttpResult, newBody) + } else if method == "DELETE" { + err = commonHttp.Delete(ctx, url, headers, &rawHttpResult, newBody) + } else { + return nil, fmt.Errorf("method 不支持") + } if err != nil { return nil, err } - resultContent := "" - for key, _ := range modelResponse { - resultContent = gconv.String(mapTaskResult[key]) + finalResult := make(map[string]any) + if responseType == "sync" { + httpResultJson := gconv.String(rawHttpResult) + for key, jsonPath := range responseMapping { + path := gconv.String(jsonPath) + if !g.IsEmpty(gjson.Get(httpResultJson, path).Value()) { + finalResult[key] = gjson.Get(httpResultJson, path).Value() + } + } } - - // 拆分多条文案 - contentList := SplitMultiContents(resultContent) - - outputRes := make([]node.NodeFormField, 0) - for i, contentItem := range contentList { - outputRes = append(outputRes, node.NodeFormField{ - Field: fmt.Sprintf("text_content_%d", i), - Value: contentItem, - Label: fmt.Sprintf("文案内容_%d", i), - Type: "string", - Expand: ExtractImageCount(contentItem), - }) - - // 1. 构建html文本 - plainText := BuildText(contentItem) - // 2. 上传纯文本到 OSS - textFileName := fmt.Sprintf("ai_text_%d_%d.inc", time.Now().UnixMilli(), i) - var textUrl *dto.UploadFileBytesRes - textUrl, err = Upload(ctx, &dto.UploadFileBytesReq{ - FileBytes: []byte(plainText), - FileName: textFileName, - }) + if responseType == "callback" { + var waitResult any + waitResult, err = Wait(ctx, taskId) if err != nil { return nil, err } - // 3. 把纯文本地址存入输出 - outputRes = append(outputRes, node.NodeFormField{ - Field: fmt.Sprintf("text_url:%d", i), - Value: textUrl.FileURL, - Label: fmt.Sprintf("文案纯文本_txt_%d", i), - Type: "string", - Expand: ExtractImageCount(contentItem), - }) - } - return outputRes, nil -} + request, ok := waitResult.(*ghttp.Request) + if !ok { + return nil, fmt.Errorf("入参类型错误") + } -func ImgNode(ctx context.Context, sessionId, modelName, skillName string, form, userForm, modelResponse map[string]any, fileUrl []string) ([]node.NodeFormField, error) { - - mapTaskResult, err := GetModelResult(ctx, modelName, skillName, form, userForm, fileUrl, sessionId, "图片生成") - if err != nil { - return nil, err - } - - var resultContent []string - for key, _ := range modelResponse { - resultContent = gconv.Strings(mapTaskResult[key]) - } - - var images []string - for _, item := range resultContent { - mapItem := gconv.Map(item) - for _, value := range mapItem { - values, ok := value.(string) - if !ok { - return nil, fmt.Errorf("图片地址类型错误") + bodyStr := request.GetBodyString() + for key, jsonPath := range responseMapping { + path := gconv.String(jsonPath) + val := gjson.Get(bodyStr, path) + // 如果是数组,直接返回整个数组 + if val.IsArray() { + finalResult[key] = val.Value() + } else { + // 普通值,非空才赋值 + if !g.IsEmpty(val.Value()) { + finalResult[key] = val.Value() + } } - // 下载官方临时图片 - var imgBytes []byte - imgBytes, err = GetFileBytesFromURL(values) - if err != nil { - return nil, fmt.Errorf("下载图片失败: %w", err) - } - // 构造文件名 - fileName := fmt.Sprintf("ai_image_%d.png", time.Now().UnixMilli()) - // 上传到你的OSS(你项目已有的Upload方法) - var upResp *dto.UploadFileBytesRes - upResp, err = Upload(ctx, &dto.UploadFileBytesReq{ - FileName: fileName, - FileBytes: imgBytes, - }) - if err != nil { - return nil, fmt.Errorf("上传OSS失败: %w", err) - } - images = append(images, upResp.FileURL) } } + if responseType == "pull" { - var url string - url, err = utils.GetFileAddressPrefix(ctx) - if err != nil { - return nil, err } - outputRes := make([]node.NodeFormField, 0) - for i, item := range images { - // 图片:image_0, image_1, image_2... + outputRes := make([]node.NodeFormField, 0) + for i, item := range finalResult { + if nodeInput.Config.IsSaveFile { + outputRes = append(outputRes, node.NodeFormField{ + Field: fmt.Sprintf("http_file_url:%v", i), + Value: item, + Label: fmt.Sprintf("http_file_url:%v", i), + Type: "string", + }) + } outputRes = append(outputRes, node.NodeFormField{ - Field: fmt.Sprintf("image_%d", i), - Value: fmt.Sprintf("%s%s", url, item), - Label: fmt.Sprintf("图片_%d", i), - Type: "string", - }) - // 额外存储关联关系 - outputRes = append(outputRes, node.NodeFormField{ - Field: fmt.Sprintf("img_url:%d", i), - Value: fmt.Sprintf("%s%s", url, item), - Label: fmt.Sprintf("图片_img_%d关联文案ID", i), + Field: fmt.Sprintf("%v", i), + Value: item, + Label: fmt.Sprintf("%v", i), Type: "string", }) } @@ -461,8 +936,7 @@ func ImgNode(ctx context.Context, sessionId, modelName, skillName string, form, return outputRes, nil } -func BuildParam(nodeInput *flowDto.NodeExecutionInput) (skillName string, resultFrom, resultUserFrom map[string]any) { - // 1. 直接用你原来的方法(返回两个 map) +func BuildParam(nodeInput *flowDto.NodeExecutionInput) (skillName string, resultFrom []map[string]any, resultUserFrom []map[string]any) { inputMap, outputMap, modelMap := GetNodeContextContent(nodeInput.Global, nodeInput.Config) var outputResult []node.NodeFormField for _, valueAny := range inputMap { @@ -471,14 +945,16 @@ func BuildParam(nodeInput *flowDto.NodeExecutionInput) (skillName string, result } } - resultUserFrom = make(map[string]any) + resultUserFrom = []map[string]any{} for _, valueAny := range outputMap { if field, ok := valueAny.(node.NodeFormField); ok { if !strings.Contains(field.Field, "text_url") && !strings.Contains(field.Field, "img_url") { if strings.Contains(field.Field, "text_content") { - field.Value = StripHtmlTags(field.Value) + field.Value = StripHtmlTags(gconv.String(field.Value)) } - resultUserFrom[field.Label] = field + resultUserFrom = append(resultUserFrom, map[string]any{ + field.Label: field.Value, + }) } } } @@ -487,28 +963,32 @@ func BuildParam(nodeInput *flowDto.NodeExecutionInput) (skillName string, result outputResult = append(outputResult, field) } } - if !nodeInput.Global.IsDialogue { - for _, item := range outputResult { - resultUserFrom[item.Label] = item - } - for _, item := range nodeInput.Config.FormConfig { - resultUserFrom[item.Label] = item - } + //if !nodeInput.Global.IsDialogue { + for _, item := range outputResult { + resultUserFrom = append(resultUserFrom, map[string]any{ + item.Label: item.Value, + }) } + for _, item := range nodeInput.Config.FormConfig { + resultUserFrom = append(resultUserFrom, map[string]any{ + item.Label: item.Value, + }) + } + //} if !g.IsEmpty(nodeInput.Global.Desc) { - resultUserFrom["desc"] = node.NodeFormField{ - Value: nodeInput.Global.Desc, - Field: "desc", - Label: "描述", - Type: "text", - } + resultUserFrom = append(resultUserFrom, map[string]any{ + "desc": nodeInput.Global.Desc, + }) } - resultFrom = make(map[string]any) - for key, item := range nodeInput.Config.ModelConfig.ModelForm { - resultFrom[key] = map[string]any{ - "value": item, + resultFrom = []map[string]any{} + for _, item := range nodeInput.Config.ModelConfig.ModelForm { + if g.IsEmpty(item.Value) { + continue } + resultFrom = append(resultFrom, map[string]any{ + item.Label: item.Value, + }) } skillName = nodeInput.Config.SkillName if g.IsEmpty(nodeInput.Config.SkillName) { @@ -518,15 +998,14 @@ func BuildParam(nodeInput *flowDto.NodeExecutionInput) (skillName string, result return skillName, resultFrom, resultUserFrom } -func GetNodeContextContent(execInput *flowDto.FlowExecutionInput, node *entity.FlowNode) (map[string]any, map[string]any, map[string]any) { +func GetNodeContextContent(execInput *flowDto.FlowExecutionInput, nodeEntity *entity.FlowNode) (map[string]any, map[string]any, map[string]any) { input := make(map[string]any) output := make(map[string]any) model := make(map[string]any) // 1. 有引用 → 取引用节点的字段值 - if len(node.InputSource) > 0 { - for _, source := range node.InputSource { + if len(nodeEntity.InputSource) > 0 { + for _, source := range nodeEntity.InputSource { refNodeID := source.NodeId - isQuoteOutput := source.QuoteOutput fields := source.Field refNode, ok := execInput.ConfigMap[refNodeID] @@ -537,26 +1016,27 @@ func GetNodeContextContent(execInput *flowDto.FlowExecutionInput, node *entity.F inputMap := buildInputMap(refNode) outputMap := mergeOutput(refNode.OutputResult) modelMap := mergeModel(refNode.ModelConfig) - if isQuoteOutput { - for k, v := range outputMap { - output[k] = v - } - } if len(fields) > 0 { // 取指定字段 for _, f := range fields { - if v, ok := inputMap[f]; ok { input[f] = v } if v, ok := modelMap[f]; ok { model[f] = v } + for k, v := range outputMap { + if strings.Contains(k, f) { + model[k] = v + } + } } } else { // 取全部 - for k, v := range inputMap { - input[k] = v + if refNode.NodeCode != node.NodeTypeHttp { + for k, v := range inputMap { + input[k] = v + } } for k, v := range modelMap { model[k] = v @@ -589,11 +1069,12 @@ func mergeOutput(output []node.NodeFormField) map[string]any { func mergeModel(output node.ModelItem) map[string]any { m := make(map[string]any) // 遍历 output.ModelForm 里的每一个 key 和原始值 - for key, rawValue := range output.ModelForm { - // 包装成 { "value": 原始值 } - m[key] = map[string]any{ - "value": rawValue, + for _, rawValue := range output.ModelForm { + if g.IsEmpty(rawValue.Value) { + continue } + // 包装成 { "value": 原始值 } + m[rawValue.Label] = rawValue.Value } return m } diff --git a/workflow/service/flow/lambda_node_util.go b/workflow/service/flow/lambda_node_util.go index 419615e..16688c7 100644 --- a/workflow/service/flow/lambda_node_util.go +++ b/workflow/service/flow/lambda_node_util.go @@ -1,24 +1,74 @@ package flow import ( + "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.com/red-future/common/http" "gitea.com/red-future/common/utils" + "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/util/gconv" + "github.com/tidwall/sjson" ) +// 全局等待任务回调的工具 +var ( + asyncMu sync.Mutex + asyncTasks = make(map[string]chan any) +) + +// Wait 阻塞等待回调结果 +// 调用后会一直卡住,直到 Notify 唤醒 或 超时/取消 +func Wait(ctx context.Context, taskId string) (any, error) { + asyncMu.Lock() + ch := make(chan any, 1) + asyncTasks[taskId] = ch + asyncMu.Unlock() + + defer close(ch) + for { + select { + case result := <-ch: + return result, nil + case <-ctx.Done(): + asyncMu.Lock() + delete(asyncTasks, taskId) + asyncMu.Unlock() + return nil, ctx.Err() + } + } +} + +// Notify 回调时调用,唤醒等待的任务 +func Notify(taskId string, result any) { + asyncMu.Lock() + defer asyncMu.Unlock() + + ch, exist := asyncTasks[taskId] + if !exist { + return + } + ch <- result + delete(asyncTasks, taskId) +} + func GetIsChatModel(ctx context.Context) (res *flowDto.GetIsChatModelRes, err error) { headers := make(map[string]string) if r := g.RequestFromCtx(ctx); r != nil { @@ -33,7 +83,7 @@ func GetIsChatModel(ctx context.Context) (res *flowDto.GetIsChatModelRes, err er return } -func ComposeMessages(ctx context.Context, req *flowDto.ComposeMessagesReq) (res *flowDto.ComposeMessagesRes, err error) { +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 { @@ -42,58 +92,71 @@ func ComposeMessages(ctx context.Context, req *flowDto.ComposeMessagesReq) (res } } } - res = new(flowDto.ComposeMessagesRes) - err = commonHttp.Post(ctx, "prompts-core/prompt/composeMessages", headers, res, &req) + res = new(flowDto.GetModelInfoRes) + err = commonHttp.Get(ctx, "model-gateway/model/getModel", headers, res, req) return } -func GetModelResult(ctx context.Context, modelName, skillName string, form, userFrom map[string]any, fileUrl []string, sessionId string, cause string) (mapTaskResult map[string]any, err error) { +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, + }) + } + var callbackUrl = utils.GetCallbackURL(ctx, "/flow/execution/composeCallBack") + var consult = make([]flowDto.Consult, 0) + var collectFileUrls func(val any) + collectFileUrls = func(val any) { + switch { + case g.NewVar(val).IsSlice(): + slice := gconv.SliceAny(val) + for _, item := range slice { + collectFileUrls(item) + } + case g.NewVar(val).IsMap(): + m := gconv.Map(val) + for _, item := range m { + collectFileUrls(item) + } + default: + s := gconv.String(val) + if s != "" { + getFileTypeByPath := GetFileTypeByPath(s) + if getFileTypeByPath != "" { + consult = append(consult, flowDto.Consult{ + Type: getFileTypeByPath, + Url: s, + }) + } + } + } + } + for _, m := range userForm { + for _, v := range gconv.Map(m) { + collectFileUrls(v) + } + } + for _, v := range fileUrl { + getFileTypeByPath := GetFileTypeByPath(gconv.String(v)) + if getFileTypeByPath != "" { + consult = append(consult, flowDto.Consult{ + Type: getFileTypeByPath, + Url: gconv.String(v), + }) + } + } msgReq := flowDto.ComposeMessagesReq{ - BuildType: 1, - ModelName: modelName, - SkillName: skillName, - Cause: cause, - Form: form, - UserForm: userFrom, - UserFiles: fileUrl, - SessionId: sessionId, + BuildType: buildType, + ModelName: modelName, + SkillName: skillName, + CallbackUrl: callbackUrl, + Cause: cause, + Form: form, + UserForm: userForm, + Consult: consult, + SessionId: sessionId, + NodeId: nodeId, } - msg, err := ComposeMessages(ctx, &msgReq) - if err != nil { - return - } - if g.IsEmpty(msg.Messages) { - return nil, fmt.Errorf("msg is empty") - } - var taskResult any - taskResult, err = GatewayTask(ctx, msg.EpicycleId, modelName, msg.Messages) - if err != nil { - return - } - var getTaskResult *flowDto.TaskCallback - getTaskResult, err = GetTaskResult(ctx, taskResult) - if err != nil { - return - } - mapTaskResult = gconv.Map(getTaskResult.Text) - return mapTaskResult, nil -} - -func GatewayTask(ctx context.Context, epicycleId int64, model string, content map[string]any) (any, error) { - modelTaskId, err := CreateGatewayTask(ctx, &flowDto.CreateTaskReq{ - ModelName: model, - BizName: g.Cfg().MustGet(ctx, "server.name").String(), - CallbackUrl: "/flow/execution/modelCallback", - RequestPayload: content, - EpicycleId: epicycleId, - }) - if err != nil { - return nil, err - } - return Wait(ctx, modelTaskId) -} - -func CreateGatewayTask(ctx context.Context, req *flowDto.CreateTaskReq) (string, error) { headers := make(map[string]string) if r := g.RequestFromCtx(ctx); r != nil { for k, v := range r.Request.Header { @@ -102,69 +165,330 @@ func CreateGatewayTask(ctx context.Context, req *flowDto.CreateTaskReq) (string, } } } - res := new(flowDto.CreateTaskRes) + 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 } -func GetTaskResult(ctx context.Context, result any) (*flowDto.TaskCallback, error) { - task := new(flowDto.TaskCallback) - if err := gconv.Struct(result, task); err != nil { - return nil, err - } - - url, err := utils.GetFileAddressPrefix(ctx) +// 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 } - // 获取远程文件内容 - file, err := FetchRemoteJsonFile(ctx, url+task.OssFile) - if err != nil { + task := new(flowDto.ModelCallbackReq) + if err := gconv.Struct(waitRes, task); err != nil { return nil, err } - task.Text = gconv.String(file) + if task.State == 3 || !g.IsEmpty(task.ErrorMsg) { + return nil, fmt.Errorf("模型执行失败:%s", task.ErrorMsg) + } + if g.IsEmpty(task.Messages) { + return nil, fmt.Errorf("模型返回结果为空") + } - return task, nil + return task.Messages, nil } -func FetchRemoteJsonFile(ctx context.Context, fileUrl string) ([]byte, error) { - // 1. 下载文件 +// 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 + } + + 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 + } + + 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("模型返回结果为空") + } + + // Update taskResultMap for next round (used by VideoModel) + 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 { + var taskId string + taskId, err = createGatewayTaskOnly(ctx, composeResult.EpicycleId, nodeInput.Config.ModelConfig.ModelName, item) + if err != nil { + return nil, err + } + taskIdList[idx] = taskId + } + + // Step 2: Wait for all tasks in parallel + var wg sync.WaitGroup + errChan := make(chan error, len(taskIdList)) + + for idx, taskId := range taskIdList { + wg.Add(1) + + // Pass idx and taskId as parameters to avoid loop variable capture bug + // This guarantees results are stored in the correct order matching original requests + go func(idx int, taskId string) { + defer wg.Done() + + var taskResult map[string]any + taskResult, err = waitGatewayResult(ctx, taskId) + if err != nil { + errChan <- err + return + } + + mapTaskResult[idx] = taskResult + updateTokenCount(ctx, nodeInput.NodeExecutionId, modelInfo.Model.ResponseTokenField, taskResult) + }(idx, taskId) + } + + wg.Wait() + close(errChan) + + if len(errChan) > 0 { + return nil, <-errChan + } + } + } 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) { + // 使用 GoFrame 客户端(自带超时、追踪、日志等能力) resp, err := g.Client().Get(ctx, fileUrl) if err != nil { - return nil, fmt.Errorf("get file failed: %w", err) + return nil, gerror.Wrapf(err, "failed to request url: %s", fileUrl) } defer resp.Close() + // 校验状态码 if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("http status error: %d", resp.StatusCode) + return nil, gerror.Newf("request failed with status code: %d, url: %s", resp.StatusCode, fileUrl) } - return io.ReadAll(resp.Body) -} + // 读取全部内容 + allBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, gerror.Wrapf(err, "failed to read response body, url: %s", fileUrl) + } -func GetFileBytesFromURL(url string) (all []byte, err error) { - resp, err := http.Get(url) - if err != nil { - fmt.Printf("请求失败 %s: %v", url, err) - return - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - fmt.Printf("请求失败,状态码: %d\n", resp.StatusCode) - return - } - all, err = io.ReadAll(resp.Body) - if err != nil { - fmt.Printf("读取内容失败 %s: %v", url, err) - return - } - return + return allBytes, nil } func Upload(ctx context.Context, req *dto.UploadFileBytesReq) (*dto.UploadFileBytesRes, error) { @@ -192,8 +516,8 @@ func Upload(ctx context.Context, req *dto.UploadFileBytesReq) (*dto.UploadFileBy // 发起上传请求 res := &dto.UploadFileBytesRes{} - url := "oss/file/uploadFile" - if err = commonHttp.Post(ctx, url, headers, res, body.Bytes()); err != nil { + httpUrl := "oss/file/uploadFile" + if err = commonHttp.Post(ctx, httpUrl, headers, res, body.Bytes()); err != nil { return nil, err } @@ -201,6 +525,40 @@ func Upload(ctx context.Context, req *dto.UploadFileBytesReq) (*dto.UploadFileBy 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 @@ -354,7 +712,7 @@ func BuildHtml(text string, images []string) string { border-radius: 12px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06); } - + @@ -457,8 +815,8 @@ func SplitMultiContents(htmlContent string) []string { func GetAllImgSrcFromHtml(html string) []string { var imgSrcList []string re := regexp.MustCompile(`