feat: 支持工作流断点续跑并拆分错误信息存储

- 新增同会话+同工作流最近执行失败且参数一致时断点续跑逻辑
- exec_workflow/exec_chat 新增 error 字段存储原始错误,error_message 仅存友好提示
- 新增 UpdateExecChatReq 与 exec_chat_dao Update 方法
- 新增 GetLatestBySessionAndFlow 查询最近执行记录
- 修正 ListDates 分组与排序 SQL 表达式
- 新增 pipeline 配置结构,删除旧设计文档
This commit is contained in:
2026-08-21 09:54:06 +08:00
parent a939c45508
commit cef837a35c
34 changed files with 1519 additions and 515 deletions
+9 -2
View File
@@ -64,5 +64,12 @@ consul:
jaeger:
addr: 192.168.0.83:4318
# 文件上传服务地址,与oss模块minio中的endpoint一致
filePrefix: "http://192.168.0.83:9000"
nats:
addr: 192.168.0.83
port: 4222
# 文件上传服务地址,cdn访问地址
filePrefix: "http://cdn.redpowerfuture.com"
# 文件上传服务地址,minio内网访问地址
minioPrefix: "http://192.168.0.83:9000"
@@ -1,226 +0,0 @@
# 分镜 → 时间线 → 模型语言 Pipeline 设计
日期:2026-08-11
状态:已评审(用户确认)
范围:ai-agent 新增独立纯函数包 `video/pipeline/`
## 1. 背景与动机
`script_transcribe` 节点产出 `[{"shots":[...]}]` 后,视频生成需要把分镜镜头转成视频模型能理解的输入。video-factory 项目已有完整链路:**时间线分段 → prompt(实体名→characterN 替换)→ 按 schema 嵌套的完整视频 API 请求体**。
ai-agent 现有 `video/plan/` 已覆盖"重建时间线 + 拆段 + 中文 prompt + 扁平 modelCall 参数",但与 video-factory 存在差异,且缺少三件事:
1. **时间线口径**:现有 `NormalizeShots` 丢弃 AI 时间戳、按台词字数重建时长(保留此口径,用户选定"重建但回写对齐");
2. **跨边界镜头不切分**:跨段边界的镜头原样保留、时间码不回写对齐(本方案改为按段边界切分 + 短残片并入);
3. **模型语言转换缺失**
- prompt 内实体名未替换成 `characterN` 等 token`reference_labels` 单独传,靠模型自行理解);
- 无按 schema 嵌套的完整视频 API 请求体(`video-factory``BuildSchemaRequest` 等价物)。
用户决策:**新流程从零设计**,做成**独立纯函数包**(不依赖 `plan`、不碰 workflow、零 I/O),**外部直接传 shots**,目标视频 API **先做通用抽象**schema 可配),模型语言覆盖 **prompt 文本层 + 完整请求体层**
## 2. 目标与非目标
### 目标
- 提供一个纯函数入口:`shots → 时间线(重建+回写对齐)→ 分段 → prompt(token 替换)→ 完整视频 API 请求体`
- 通用抽象:模型 schema 可配置,未配置时用默认火山风格结构
- 可独立单测,零 I/O、零 workflow 依赖
### 非目标(本阶段不做)
- 不接入 workflow 节点 / 前后置处理器(后续可写薄适配层)
- 不调用模型网关 / 媒体服务(`video/plan``video/` 根包负责)
- 不做视频合并(concat/merge)、不做首帧联动(serial
- 不重构现有 `video/plan/``script_transcribe`
## 3. 包结构与依赖
- 新包:`ai-agent/video/pipeline/`
- 依赖:仅 `ai-agent/video/domain`(复用 `domain.Shot` 作为输入契约)+ Go 标准库(`encoding/json``strings``fmt` 等)
- **不 import** `plan`、workflow、gateway、media 等
- 内部自带:时间线重建、超长镜头切分、段时长计算、回写对齐、prompt 构建、schema 请求体构建
文件规划(建议):
```
video/pipeline/
pipeline.go // Input/Segment/Output 契约 + Run 编排入口
timeline.go // ① 时间线构建:rebuildDurations / splitOversized / calcSegmentDurations / alignToSegments
prompt.go // ② prompt 构建:实体名→token 替换、截断
request.go // ③ 请求体构建:schema 嵌套、默认 schema、media 组装
*_test.go // 各阶段单测 + 用户样本 golden test
```
## 4. 数据契约
```go
// Input 外部直接传入的生成请求参数。
type Input struct {
Shots []domain.Shot // 镜头脚本(中文键或英文键均可,调用方已归一为 domain.Shot
TotalDuration int // 目标总时长(秒),<=0 按镜头时间码累加兜底,仍<=0 默认 60
MaxSegmentDur int // 单段最大时长(秒),<=0 默认 15
MinSegmentDur int // 单段最小时长(秒),<=0 默认 5
Refs Refs // 参考素材(角色/场景/道具/产品,具名)
Seed int64 // 随机种子基数,各段 = Seed + 段序号
NegativePrompt string
ModelName string // 写入请求体 body.model
Schema map[string]any // 视频模型 schema(可选),nil 用默认通用结构
MaxRefs int // 参考素材上限,<=0 默认 5
MaxPromptChars int // prompt 截断长度(rune),<=0 不截断
TokenConfig TokenConfig // 实体名替换 token 的生成配置
}
// TokenConfig token 前缀策略:可配置,默认全用 characterNvideo-factory 兼容)。
type TokenConfig struct {
// 是否按类别区分前缀。false=一律 characterN(推荐,模型只按 reference_urls 顺序识别);
// true=character/scene/prop/product 分别编号。
ByCategory bool
// 自定义前缀模板,如 "char%d"。为空时按 ByCategory 决定默认行为。
Template string
}
// Refs 参考素材,与 domain/plan 的 Refs 概念一致(自包含定义,不依赖 plan)。
type Refs struct {
Characters []RefItem `json:"characters,omitempty"`
Scenes []RefItem `json:"scenes,omitempty"`
Props []RefItem `json:"props,omitempty"`
Products []RefItem `json:"products,omitempty"`
}
type RefItem struct {
Name string `json:"name"`
URL string `json:"url"`
}
// Segment 一个视频生成段:时间轴、段内镜头、prompt、参考素材、请求体。
type Segment struct {
Index int // 段序号(从 0 起)
StartSec int // 段在全局时间轴上的起点(秒)
Duration int // 段时长(秒)
Shots []domain.Shot // 段内镜头(已回写对齐,不跨段)
Prompt string // 实体名→token 替换后的 prompt 文本
Labels map[string]string // 实体名 → tokencharacter1...
RefURLs []string // 参考素材 URL,顺序与 token 对应
Seed int64
Request map[string]any // 完整视频 API 请求体(schema 嵌套)
}
// Output Run 的产出。
type Output struct {
Segments []Segment
TotalDuration int // 实际总时长(秒)
}
```
## 5. 阶段① 时间线构建(重建 + 回写对齐)
入口:`BuildTimeline(in Input) (segShots [][]domain.Shot, segDurs []int, err error)`
`segShots[i]` 为第 i 段的已对齐镜头列表,段数量与 `segDurs` 一致)
### 5.1 rebuildDurations
丢弃 AI 的 `startTime/endTime`,按镜头文本量重建每段时长:
- 有文字(台词+旁白)的镜头:按语速(默认 4 字/秒,感叹/疑问多→6,低落→3)算最小时长 = `ceil(字数/语速) + 1`,低弹性;
- 纯视觉镜头:以 AI 原始时长意图为基准(`endTime-startTime`),高弹性;
- 盈余按弹性权重分配;超出目标则先压缩视觉镜头、再等比压缩有声镜头、最后截断尾部;
- 不变量:重建后各镜头时长之和 **精确等于** `TotalDuration`
> 算法与 `video/domain/timeline.go` 的 `rebuildTimeline` 语义一致,但在 `pipeline` 内**自包含实现**(不 import plan)。
### 5.2 splitOversized
`MaxSegmentDur` 的镜头按句末断句(`。!?;\n!?.`)切子镜头,避免"说话说一半";最后一片段 `≤ minSeg` 时减少拆分段数。重排 `index`
### 5.3 calcSegmentDurations
`TotalDuration` 拆为多段:
- `numSegments = ceil(Total / MaxSegmentDur)``base = Total / num`,前 `Total % num` 段各 `+1`
- 每段 `≥ MinSegmentDur`(不足时从后一向前借位);
- 边界:`MinSegmentDur > MaxSegmentDur` 时收敛为相等。
### 5.4 alignToSegments(回写对齐,核心新增)
按段边界把镜头切分并对齐:
1. 以段边界 `segStart`/`segEnd`(秒)为切点;
2. 对每个与段窗口相交的镜头,裁剪到 `[max(shotStart, segStart), min(shotEnd, segEnd))`,时间码写回为段内绝对时间轴上的 `MM:SS`
3. **跨边界镜头切成两个子镜头**,各留各段,剩余部分进入下一段的时间线;
4. **短残片并入**:切出的子镜头时长 `< 1s` 时并入相邻段(并入前一段末尾),避免产生超短镜头;
5. 不变量:每个镜头只属于一个段;段内镜头时间码落在该段窗口内;所有段首尾相接覆盖 `[0, TotalDuration)`
输出:`segShots [][]domain.Shot`(每段一组已对齐镜头)+ `segDurs`,供阶段②逐段构建 prompt。
## 6. 阶段② Prompt 构建(模型语言·文本层)
入口:`BuildSegmentPrompt(segShots []domain.Shot, refs Refs, tc TokenConfig, maxRefs int) (prompt string, labels map[string]string, refURLs []string)`
1. **收集实体**:按 演员→场景→道具(→产品,若产品图固定携带)出现顺序收集段内具名实体,查 `refs` 拿 URL,上限 `MaxRefs`
2. **生成 token**:按 `TokenConfig` 生成 `character1...`(默认,全用同一前缀、按引用顺序编号)或按类别前缀 `character1/scene1/prop1/product1`,或自定义模板;
3. **替换 prompt 文本**:把镜头文本中的实体名替换为 token;**按名字长度降序替换**,避免长名是短名前缀时的部分覆盖;无 URL 的实体保留原名;
4. **截断**`MaxPromptChars > 0` 且超出时截断并追加 `...`(保留至少 50 字符下限);
5. 返回 `prompt`(替换后)、`labels`(实体名→token)、`refURLs`(顺序与 token 对应)。
## 7. 阶段③ 请求体构建(模型语言·API 层)
入口:`BuildVideoRequest(seg Segment, in Input) map[string]any`
1. 组装 flat input
```go
map[string]any{
"prompt": seg.Prompt,
"seed": seg.Seed,
// 以下有值才写
"negative_prompt": in.NegativePrompt,
"duration": seg.Duration,
"media": buildMedia(seg.RefURLs, mediaTypeValue), // 参考图 media 数组
"reference_urls": seg.RefURLs,
}
```
2. **schema 嵌套**:仿 video-factory `BuildSchemaRequest`——遍历 schema,无 `type` 键的节点视为分组递归进入,有 `type` 的字段从 flat input 取值、缺失填 `default`、可校验 required/type`in.Schema == nil` 时用默认通用结构;
3. `body["model"] = in.ModelName`
4. media 组装:参考图 URL 包装为 media 项,type 值取 `reference_image`schema_mapping 可配置,复用路径解析思路);媒体在请求中保持 URL 字符串(base64 转码属 I/O,不在本包做,由调用方处理)。
### 默认通用 schema(火山风格)
```json
{
"body": {
"input": {
"prompt": { "type": "string", "required": true },
"seed": { "type": "integer", "default": -1 },
"negative_prompt": { "type": "string" },
"duration": { "type": "integer" },
"media": { "type": "array" },
"reference_urls": { "type": "array" }
},
"parameters": {
"model": { "type": "string" }
}
}
}
```
## 8. 入口
```go
// Run 编排①→②→③,纯函数,无 I/O。
func Run(in Input) (*Output, error)
```
错误处理:
- `Shots` 为空 → 返回错误(不产出空请求);
- `TotalDuration <= 0` 且镜头无法累加时长 → 兜底 60;
- schema 字段校验失败 → 返回明确错误。
## 9. 测试计划
| 用例 | 断言 |
|------|------|
| 时间线重建恒等 | 重建后镜头时长之和 == TotalDuration |
| 跨边界切分回写 | 段边界处的镜头被切成子镜头,各镜头时间码落在所属段窗口内,不跨段 |
| 短残片并入 | 切出的 `<1s` 子镜头并入相邻段 |
| token 替换 | 实体名替换为 character1...;长名优先替换;无 URL 实体保留原名 |
| token 可配置 | ByCategory=true 时按类别前缀;Template 生效 |
| schema 嵌套/默认值 | flat input 正确嵌套进 `{body:{input,parameters}}`,缺失字段填 default |
| 用户样本 golden test | 用 2026-08-11 实测的 3 镜头中文样本(时间码 00:00-00:07 等)跑通 Run,断言段数量、prompt 含 characterN、请求体结构 |
## 10. 与现有链路的关系(本阶段不实现)
- 新包完全独立,不接 workflow;
- 将来接入:写薄适配层把 `Segment.Request` 交给模型网关 `modelCall`(对应 `video/plan` 的 `SegmentParamsToMap` 语义),媒体 URL 转 base64 在适配层做;
- 现有 `split_shots`/`generate_segments_serial` 保持不动,两者可并存。
## 11. 评审记录
- 2026-08-11:方案 B(完全独立从零写)获用户确认;token 前缀=可配置(默认 characterN);跨边界=切分+短残片并入;目标 API=通用抽象;挂载层=独立纯函数包;输入=外部直接传 shots。
+5 -10
View File
@@ -3,26 +3,18 @@ module ai-agent
go 1.26.0
require (
gitea.redpowerfuture.com/red-future/common v0.0.24
github.com/bjang03/gmq v0.0.1
gitea.redpowerfuture.com/red-future/common v0.0.31
github.com/bjang03/gmq v0.0.2
github.com/cloudwego/eino v0.9.5
github.com/cloudwego/eino-examples v0.0.0-20260611092511-bd64846fbc1d
github.com/cloudwego/eino-ext/components/model/qwen v0.1.9
github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.2
github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.2
github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5
github.com/gogf/gf/v2 v2.10.2
github.com/google/uuid v1.6.0
github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5
go.opentelemetry.io/otel/trace v1.44.0
)
replace gitea.redpowerfuture.com/red-future/common v0.0.24 => ../common
replace github.com/bjang03/gmq v0.0.1 => ../gmq
require (
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/armon/go-metrics v0.4.1 // indirect
@@ -54,6 +46,7 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5 // indirect
github.com/gogf/gf/contrib/trace/otlphttp/v2 v2.9.5 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
@@ -109,6 +102,7 @@ require (
github.com/spf13/cast v1.10.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // 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
@@ -123,6 +117,7 @@ require (
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.1 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/arch v0.19.0 // indirect
+4
View File
@@ -1,6 +1,8 @@
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
gitea.redpowerfuture.com/red-future/common v0.0.31 h1:9H8nL5Drazcv7Hs9d4j+cXhaB+7uOllIUqEOyZy1Eao=
gitea.redpowerfuture.com/red-future/common v0.0.31/go.mod h1:xPU7aaMxn8rtNnWc2LDUXZL+IkaUkpQeLgflqw9FvdU=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
@@ -38,6 +40,8 @@ github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+Ce
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
github.com/bjang03/gmq v0.0.2 h1:3CcVorDXYoRIN65bbzwRuUxzkBCkEpHWmKHOkfXzUo0=
github.com/bjang03/gmq v0.0.2/go.mod h1:Y7TwWGuV4Cw97WUDaM7x+NC4kyFx1z44WAvNwJV3HV8=
github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
+2
View File
@@ -13,7 +13,9 @@ import (
sessionController "ai-agent/workflow/controller/session"
workflowSkillController "ai-agent/workflow/controller/skill"
toolController "ai-agent/workflow/controller/tool"
_ "ai-agent/workflow/service/flow/processor/builtin/media"
_ "ai-agent/workflow/service/flow/processor/builtin/split_batch"
_ "ai-agent/workflow/service/flow/processor/builtin/split_segment"
_ "ai-agent/workflow/service/flow/processor/builtin/split_shots_pipeline"
"context"
"os"
+2 -1
View File
@@ -20,6 +20,7 @@ import (
type ReActEventType string
const (
ReActEventRoundStart ReActEventType = "round_start"
ReActEventModelCall ReActEventType = "model_call" // 模型思考(step 开始)
ReActEventToolCall ReActEventType = "tool_call" // 模型请求调用工具
ReActEventToolResult ReActEventType = "tool_result" // 工具返回结果
@@ -32,6 +33,7 @@ const (
// ReActEvent ReAct 过程事件,按发生顺序回调
type ReActEvent struct {
Type ReActEventType
Id int64
Step int
MaxStep int
Description string // 工具用途说明(tool_call/tool_result 推给前端展示,不暴露工具名/参数)
@@ -140,7 +142,6 @@ func (a *ReActAgent) Run(ctx context.Context, userInput string) (string, error)
}
// 无工具调用 → 最终回答
if len(toolCalls) == 0 {
a.emit(ReActEvent{Type: ReActEventAnswer, Answer: content})
return content, nil
}
messages["assistant_prompt"] = content
+12 -4
View File
@@ -725,7 +725,8 @@ CREATE TABLE IF NOT EXISTS black_deacon_exec_chat (
result_file_url VARCHAR(512) DEFAULT '', -- 结果文件路径(OSS
total_tokens INTEGER NOT NULL DEFAULT 0, -- 总token消耗
total_fee DOUBLE PRECISION NOT NULL DEFAULT 0, -- 总费用
error_message TEXT DEFAULT '' -- 错误信息
error_message TEXT DEFAULT '', -- 错误信息(友好提示)
error TEXT DEFAULT '' -- 错误明细(原始错误)
);
-- 索引(高频查询)
@@ -748,7 +749,10 @@ COMMENT ON COLUMN black_deacon_exec_chat.request_params IS '请求参数';
COMMENT ON COLUMN black_deacon_exec_chat.result_file_url IS '结果文件路径(OSS';
COMMENT ON COLUMN black_deacon_exec_chat.total_tokens IS '总token消耗';
COMMENT ON COLUMN black_deacon_exec_chat.total_fee IS '总费用';
COMMENT ON COLUMN black_deacon_exec_chat.error_message IS '错误信息';
COMMENT ON COLUMN black_deacon_exec_chat.error_message IS '错误信息(友好提示)';
COMMENT ON COLUMN black_deacon_exec_chat.error IS '错误明细(原始错误)';
-- 兼容已有库:错误信息拆分,error_message 存友好提示、error 存原始错误
ALTER TABLE black_deacon_exec_chat ADD COLUMN IF NOT EXISTS error TEXT DEFAULT '';
--------------------pgsql创建black_deacon_exec_chat表语句---------------------------
--------------------pgsql创建black_deacon_exec_workflow表语句---------------------------
@@ -771,7 +775,8 @@ CREATE TABLE IF NOT EXISTS black_deacon_exec_workflow (
status SMALLINT NOT NULL DEFAULT 1, -- 状态:1-运行中,2-成功,3-失败
total_tokens INTEGER NOT NULL DEFAULT 0, -- 总token消耗
total_fee DOUBLE PRECISION NOT NULL DEFAULT 0, -- 总费用
error_message TEXT DEFAULT '' -- 错误信息
error_message TEXT DEFAULT '', -- 错误信息(友好提示)
error TEXT DEFAULT '' -- 错误明细(原始错误)
);
-- 索引(高频查询)
@@ -797,7 +802,10 @@ COMMENT ON COLUMN black_deacon_exec_workflow.request_params IS '请求参数';
COMMENT ON COLUMN black_deacon_exec_workflow.status IS '状态:1-运行中,2-成功,3-失败';
COMMENT ON COLUMN black_deacon_exec_workflow.total_tokens IS '总token消耗';
COMMENT ON COLUMN black_deacon_exec_workflow.total_fee IS '总费用';
COMMENT ON COLUMN black_deacon_exec_workflow.error_message IS '错误信息';
COMMENT ON COLUMN black_deacon_exec_workflow.error_message IS '错误信息(友好提示)';
COMMENT ON COLUMN black_deacon_exec_workflow.error IS '错误明细(原始错误)';
-- 兼容已有库:错误信息拆分,error_message 存友好提示、error 存原始错误
ALTER TABLE black_deacon_exec_workflow ADD COLUMN IF NOT EXISTS error TEXT DEFAULT '';
--------------------pgsql创建black_deacon_exec_workflow表语句---------------------------
--------------------pgsql创建black_deacon_exec_workflow_result表语句---------------------------
+66 -22
View File
@@ -37,9 +37,11 @@ type NodeTypeMeta struct {
Group NodeGroup `json:"group"` // 所属分组
Sort int `json:"sort"` // UI排序字段
Desc string `json:"desc,omitempty"` // 可选:节点简介
PatchLayout bool `json:"patchLayout"`
IsMultiParameter bool `json:"isMultiParameter"`
BatchExecOption bool `json:"batchExecOption"`
PreToolOption bool `json:"preToolOption"`
PostToolOption bool `json:"postToolOption"`
PreToolOption []NodePresetField `json:"preToolOption"`
PostToolOption []NodePresetField `json:"postToolOption"`
IsSaveFileOption bool `json:"isSaveFileOption"`
FormConfigOption bool `json:"formConfigOption"`
ModelConfigOption bool `json:"modelConfigOption"`
@@ -47,11 +49,17 @@ type NodeTypeMeta struct {
PromptOption bool `json:"promptOption"`
NegativePromptOption bool `json:"negativePromptOption"`
PresetOption []NodePresetField `json:"presetOption"`
OutputField []string `json:"outputField"`
OutputField []NodeOutputField `json:"outputField"`
}
type NodeOutputField struct {
Field string `json:"field"`
Label string `json:"label"`
}
type NodePresetField struct {
Value string `json:"value"`
ValueSource []ValueSource `json:"valueSource"`
Field string `json:"field"`
Label string `json:"label"`
Type string `json:"type"`
@@ -61,6 +69,12 @@ type NodePresetField struct {
Options []SelectOption `json:"options"`
}
type ValueSource struct {
NodeId string `json:"nodeId"`
Field string `json:"field"`
Label string `json:"label"`
}
// FieldConstraint 字段约束
type FieldConstraint struct {
// 数字类型:int、float、double、string
@@ -91,20 +105,38 @@ var NodeGroupMetaList = []NodeGroupMeta{
var NodeTypeMetaList = []NodeTypeMeta{
{
Key: NodeTypeModel,
Name: "模型",
Group: NodeGroupBase,
Sort: 1,
Desc: "模型调用节点,可配置模型参数、模型配置、技能、提示语、结果汇集、结果保存、结果返回、结果展示等信息。",
BatchExecOption: true,
PreToolOption: true,
PostToolOption: true,
Key: NodeTypeModel,
Name: "模型",
Group: NodeGroupBase,
Sort: 1,
Desc: "模型调用节点,可配置模型参数、模型配置、技能、提示语、结果汇集、结果保存、结果返回、结果展示等信息。",
PatchLayout: true,
IsMultiParameter: true,
BatchExecOption: true,
PreToolOption: []NodePresetField{
{
Field: "perTool",
Label: "前置方法",
Type: "select",
Required: false,
Options: []SelectOption{},
},
},
PostToolOption: []NodePresetField{
{
Field: "postTool",
Label: "后置方法",
Type: "select",
Required: false,
Options: []SelectOption{},
},
},
IsSaveFileOption: true,
FormConfigOption: false,
ModelConfigOption: true,
SkillOption: false,
PromptOption: true,
NegativePromptOption: true,
NegativePromptOption: false,
},
{
Key: NodeTypeDataMerge,
@@ -112,9 +144,8 @@ var NodeTypeMetaList = []NodeTypeMeta{
Group: NodeGroupBase,
Sort: 2,
Desc: "结果汇集节点,可配置结果汇集方式、结果保存、结果返回、结果展示等信息。",
IsMultiParameter: false,
BatchExecOption: false,
PreToolOption: false,
PostToolOption: false,
IsSaveFileOption: false,
FormConfigOption: false,
ModelConfigOption: false,
@@ -128,9 +159,8 @@ var NodeTypeMetaList = []NodeTypeMeta{
Group: NodeGroupBase,
Sort: 3,
Desc: "表单节点,可配置表单字段、表单配置、结果汇集、结果保存、结果返回、结果展示等信息。",
IsMultiParameter: false,
BatchExecOption: false,
PreToolOption: false,
PostToolOption: false,
IsSaveFileOption: false,
FormConfigOption: true,
ModelConfigOption: false,
@@ -166,9 +196,7 @@ var NodeTypeMetaList = []NodeTypeMeta{
Sort: 5,
Desc: "HTTP(S)接口节点,可配置HTTP(S)接口地址、请求方式、请求头、请求体、结果返回结构、结果返回方式、结果返回结构、结果返回方式等信息。",
BatchExecOption: false,
PreToolOption: true,
PostToolOption: true,
IsSaveFileOption: false,
IsSaveFileOption: true,
FormConfigOption: false,
ModelConfigOption: false,
SkillOption: false,
@@ -305,9 +333,8 @@ var NodeTypeMetaList = []NodeTypeMeta{
Group: NodeGroupBase,
Sort: 6,
Desc: "脚本转写节点,把文案/视频分析结果通过大模型转写为结构化分镜脚本([]Shot),作为视频生成节点的 shots 输入。",
IsMultiParameter: false,
BatchExecOption: false,
PreToolOption: false,
PostToolOption: false,
IsSaveFileOption: false,
FormConfigOption: false,
ModelConfigOption: true,
@@ -334,7 +361,24 @@ var NodeTypeMetaList = []NodeTypeMeta{
Options: []SelectOption{},
},
},
OutputField: []string{"prompt", "duration", "seed", "negative_prompt", "reference_urls"},
OutputField: []NodeOutputField{
{
Field: "prompt",
Label: "转写内容",
},
{
Field: "duration",
Label: "时长",
},
{
Field: "seed",
Label: "种子",
},
{
Field: "reference_urls",
Label: "参考链接",
},
},
},
{
Key: NodeTypeSystemSum,
@@ -54,3 +54,8 @@ func (c *session) DeleteRecord(ctx context.Context, req *sessionDto.DeleteSessio
func (c *session) ResultList(ctx context.Context, req *sessionDto.ListWorkflowResultReq) (res *flowDto.ListFlowExecutionTreeRes, err error) {
return sessionService.SessionService.ResultList(ctx, req)
}
func (c *session) ResultDelete(ctx context.Context, req *sessionDto.DeleteWorkflowResultReq) (res *beans.ResponseEmpty, err error) {
err = sessionService.SessionService.ResultDelete(ctx, req)
return
}
+8
View File
@@ -27,6 +27,14 @@ func (d *execChatDao) Insert(ctx context.Context, req *sessionDto.CreateExecChat
return r.LastInsertId()
}
func (d *execChatDao) Update(ctx context.Context, req *sessionDto.UpdateExecChatReq) (rows int64, err error) {
r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameExecChat).OmitEmpty().Data(&req).Where(entity.ExecChatCol.Id, req.Id).Update()
if err != nil {
return
}
return r.RowsAffected()
}
func (d *execChatDao) Delete(ctx context.Context, req *sessionDto.DeleteExecChatReq) (rows int64, err error) {
r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameExecChat).Where(entity.ExecChatCol.Id, req.Id).Delete()
if err != nil {
+28 -6
View File
@@ -69,6 +69,24 @@ func (d *execWorkflowDao) List(ctx context.Context, creator string, page *beans.
return
}
// GetLatestBySessionAndFlow 查询会话+工作流下最近一次执行记录(按创建时间倒序,无记录返回 nil)
func (d *execWorkflowDao) GetLatestBySessionAndFlow(ctx context.Context, sessionId string, flowId int64) (res *entity.ExecWorkflow, err error) {
r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameExecWorkflow).
Where(entity.ExecWorkflowCol.SessionId, sessionId).
Where(entity.ExecWorkflowCol.FlowId, flowId).
OrderDesc(entity.ExecWorkflowCol.CreatedAt).
Limit(1).
One()
if err != nil {
return
}
if r.IsEmpty() {
return nil, nil
}
err = r.Struct(&res)
return
}
// ListBySession 查询会话下工作流执行记录(按创建时间倒序)
func (d *execWorkflowDao) ListBySession(ctx context.Context, sessionId string) (res []*entity.ExecWorkflow, err error) {
r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameExecWorkflow).
@@ -84,22 +102,26 @@ func (d *execWorkflowDao) ListBySession(ctx context.Context, sessionId string) (
// ListDates 按创建人查询去重后的创建日期(倒序,支持分页;page 为 nil 返回全部日期)
func (d *execWorkflowDao) ListDates(ctx context.Context, creator string, page *beans.Page) (dates []string, err error) {
fieldAlias := "create_date"
m := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameExecWorkflow).
Fields("DATE(created_at) AS create_date").
Fields("DATE("+entity.ExecWorkflowCol.CreatedAt+") AS "+fieldAlias).
Where(entity.ExecWorkflowCol.Creator, creator).
Group("create_date").
OrderDesc("create_date")
Group("DATE(" + entity.ExecWorkflowCol.CreatedAt + ")"). // 和select表达式保持一致,按自然日分组去重
OrderDesc(fieldAlias) // 按日期别名倒序
if page != nil {
m.Page(int(page.PageNum), int(page.PageSize))
}
r, err := m.All()
if err != nil {
return
return nil, err
}
for _, rec := range r {
dates = append(dates, rec["create_date"].String())
dates = append(dates, rec[fieldAlias].String())
}
return
return dates, nil
}
// ListByDates 按创建人查询指定创建日期(DATE(created_at) 命中)内的执行记录,按创建时间倒序
@@ -41,6 +41,8 @@ type ExecutedNode struct {
Status node.NodeExecutionStatus `json:"status"` // 执行状态:成功/失败
}
//=======================================================================================
//=============================================================================
// 原始入参结构体
+10 -5
View File
@@ -6,12 +6,17 @@ import (
type CreateExecChatReq struct {
SessionId string `json:"sessionId" description:"所属会话ID"`
Duration int64 `json:"duration" description:"执行时长(秒)"`
RequestParams entity.ExecChatRequestParams `json:"requestParams" description:"请求参数"`
ResultFileUrl string `json:"resultFileUrl" description:"结果文件路径"`
TotalTokens int `json:"totalTokens" description:"总token消耗"`
TotalFee float64 `json:"totalFee" description:"总费用"`
ErrorMessage string `json:"errorMessage" description:"错误信息"`
}
type UpdateExecChatReq struct {
Id int64 `json:"id" v:"required#会话执行记录ID不能为空"`
Duration int64 `json:"duration" description:"执行时长(秒)"`
ResultFileUrl string `json:"resultFileUrl" description:"结果文件路径"`
TotalTokens int `json:"totalTokens" description:"总token消耗"`
TotalFee float64 `json:"totalFee" description:"总费用"`
ErrorMessage string `json:"errorMessage" description:"错误信息(友好提示)"`
Error string `json:"error" description:"错误明细(原始错误)"`
}
type DeleteExecChatReq struct {
@@ -11,7 +11,8 @@ type CreateWorkflowReq struct {
NodeGroupId string `json:"nodeGroupId" description:"节点组ID"`
Status flow.FlowExecutionStatus `json:"status" description:"状态:1-运行中,2-成功,3-失败"`
RequestParams *entity.FlowInfo `json:"requestParams" description:"请求参数"`
ErrorMessage string `json:"errorMessage" description:"错误信息"`
ErrorMessage string `json:"errorMessage" description:"错误信息(友好提示)"`
Error string `json:"error" description:"错误明细(原始错误)"`
}
type DeleteExecWorkflowReq struct {
@@ -25,5 +26,6 @@ type UpdateWorkflowReq struct {
Duration int64 `json:"duration" description:"执行时长(秒)"`
TotalTokens int `json:"totalTokens" description:"总token消耗"`
TotalFee float64 `json:"totalFee" description:"总费用"`
ErrorMessage string `json:"errorMessage" description:"错误信息"`
ErrorMessage string `json:"errorMessage" description:"错误信息(友好提示)"`
Error string `json:"error" description:"错误明细(原始错误)"`
}
@@ -6,7 +6,3 @@ type CreateWorkflowResultReq struct {
ExecId int64 `json:"execId" description:"执行ID"`
ResultFileUrl string `json:"resultFileUrl" description:"结果文件路径"`
}
type DeleteWorkflowResultReq struct {
Id int64 `json:"id" v:"required#ID不能为空"`
}
+9 -2
View File
@@ -15,6 +15,7 @@ type WebSocketConnectReq struct {
}
type WebSocketExecChatReq struct {
Id int64 `json:"id" dc:"id"`
ModelId int64 `json:"modelId" dc:"模型ID" v:"required#模型ID不能为空"`
Question string `json:"question" dc:"用户提问" v:"required#用户提问不能为空"`
SystemPrompt string `json:"systemPrompt" dc:"系统提示词"`
@@ -48,7 +49,7 @@ type VOSession struct {
}
type DeleteSessionReq struct {
g.Meta `path:"/delete" method:"post" tags:"会话管理" summary:"删除会话" dc:"删除会话"`
g.Meta `path:"/delete" method:"delete" tags:"会话管理" summary:"删除会话" dc:"删除会话"`
SessionId string `json:"sessionId" v:"required#会话ID不能为空"`
}
@@ -83,7 +84,8 @@ type VOSessionInfoResult struct {
ResultContent string `json:"resultContent" description:"结果文件内容(服务端已读取,前端直接展示)"`
TotalTokens int `json:"totalTokens" dc:"总token消耗"`
TotalFee float64 `json:"totalFee" dc:"总费用"`
ErrorMsg string `json:"errorMsg" dc:"错误信息"`
ErrorMsg string `json:"errorMsg" dc:"错误信息(友好提示)"`
Error string `json:"error" dc:"错误明细(原始错误)"`
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
}
@@ -91,3 +93,8 @@ type ListWorkflowResultReq struct {
g.Meta `path:"/resultList" method:"get" tags:"会话管理" summary:"工作流执行结果树" dc:"按创建人分页查询工作流执行结果,按天分组返回树结构(日期→流程→结果文件),pageSize=每页天数,不传返回全部"`
Page *beans.Page `json:"page"`
}
type DeleteWorkflowResultReq struct {
g.Meta `path:"/resultDelete" method:"delete" tags:"会话管理" summary:"删除工作流执行结果" dc:"删除工作流执行结果"`
Id int64 `json:"id" v:"required#ID不能为空"`
}
+4 -1
View File
@@ -11,7 +11,8 @@ type ExecChat struct {
ResultFileUrl string `orm:"result_file_url" json:"resultFileUrl" description:"结果文件路径"`
TotalTokens int `orm:"total_tokens" json:"totalTokens" description:"总token消耗"`
TotalFee float64 `orm:"total_fee" json:"totalFee" description:"总费用"`
ErrorMessage string `orm:"error_message" json:"errorMessage" description:"错误信息"`
ErrorMessage string `orm:"error_message" json:"errorMessage" description:"错误信息(友好提示)"`
Error string `orm:"error" json:"error" description:"错误明细(原始错误)"`
}
type ExecChatRequestParams struct {
@@ -27,6 +28,7 @@ type execChatCol struct {
TotalTokens string
TotalFee string
ErrorMessage string
Error string
}
var ExecChatCol = execChatCol{
@@ -38,4 +40,5 @@ var ExecChatCol = execChatCol{
TotalTokens: "total_tokens",
TotalFee: "total_fee",
ErrorMessage: "error_message",
Error: "error",
}
+4 -1
View File
@@ -17,7 +17,8 @@ type ExecWorkflow struct {
Status flow.FlowExecutionStatus `orm:"status" json:"status" description:"状态:1-运行中,2-成功,3-失败"`
TotalTokens int `orm:"total_tokens" json:"totalTokens" description:"总token消耗"`
TotalFee float64 `orm:"total_fee" json:"totalFee" description:"总费用"`
ErrorMessage string `orm:"error_message" json:"errorMessage" description:"错误信息"`
ErrorMessage string `orm:"error_message" json:"errorMessage" description:"错误信息(友好提示)"`
Error string `orm:"error" json:"error" description:"错误明细(原始错误)"`
}
type execWorkflowCol struct {
@@ -31,6 +32,7 @@ type execWorkflowCol struct {
TotalTokens string
TotalFee string
ErrorMessage string
Error string
}
var ExecWorkflowCol = execWorkflowCol{
@@ -44,4 +46,5 @@ var ExecWorkflowCol = execWorkflowCol{
TotalTokens: "total_tokens",
TotalFee: "total_fee",
ErrorMessage: "error_message",
Error: "error",
}
+24 -19
View File
@@ -29,6 +29,8 @@ type FlowNode struct {
OutputConfig []map[string]any `json:"outputConfig"`
Prompt string `json:"prompt"`
NegativePrompt string `json:"negativePrompt"`
PatchLayout bool `json:"patchLayout"`
Templates []map[string]any `json:"templates"`
//SkillName string `json:"skillName"`
//PromptContent string `json:"promptContent"`
//InputSource []FlowNodeInputSource `json:"inputSource"` // 前端指定:来源节点ID
@@ -37,33 +39,36 @@ type FlowNode struct {
}
type ModelItem struct {
ModelId int64 `json:"modelId,string"`
ModelName string `json:"modelName"`
ModelFormFields []map[string]any `json:"modelFormFields"`
ModelRequestParams map[string]any `json:"modelRequestParams"`
ModelResponseBodyMapping map[string]any `json:"modelResponseBodyMapping"`
ModelId int64 `json:"modelId,string"`
ModelName string `json:"modelName"`
ModelFormFields []map[string]any `json:"modelFormFields"`
ModelRequestParams map[string]any `json:"modelRequestParams"`
ModelRequestParamsPath []FlowModelParams `json:"modelRequestParamsPath"`
ModelResponseBodyMapping map[string]any `json:"modelResponseBodyMapping"`
}
type FlowNodeInputSource struct {
NodeId string `json:"nodeId"`
QuoteOutput bool `json:"quoteOutput"`
Field []string `json:"field"`
FieldMap []FlowField `json:"fieldMap"`
type FlowModelParams struct {
Label string `json:"label"`
Path string `json:"path"`
Type string `json:"type"`
Required bool `json:"required"`
Value any `json:"value"`
ValueSource []ValueSource `json:"valueSource"`
RefsName string `json:"refsName"`
}
type FlowField struct {
Key string `json:"key"`
Value string `json:"value"`
Desc string `json:"desc"`
type ValueSource struct {
NodeId string `json:"nodeId"`
Field string `json:"field"`
Label string `json:"label"`
}
// SubFlowConfig 子流程节点配置
type SubFlowConfig struct {
WorkflowId int64 `json:"workflowId"`
WorkflowName string `json:"workflowName"`
Fields []map[string]any `json:"fields"`
MaxConcurrency int `json:"maxConcurrency"` // 子流程并发数
InputSource []FlowNodeInputSource `json:"inputSource"` // 前端指定:来源节点ID
WorkflowId int64 `json:"workflowId"`
WorkflowName string `json:"workflowName"`
Fields []map[string]any `json:"fields"`
MaxConcurrency int `json:"maxConcurrency"` // 子流程并发数
}
type FlowEdge struct {
@@ -32,8 +32,6 @@ func init() {
// ========== 4. entity 核心链路类型(递归自 *entity.FlowNode ==========
schema.RegisterName[*entity.FlowNode]("entity.FlowNode")
schema.RegisterName[node.NodeType]("node.NodeType")
schema.RegisterName[entity.FlowNodeInputSource]("entity.FlowNodeInputSource")
schema.RegisterName[entity.FlowField]("entity.FlowField")
schema.RegisterName[*entity.SubFlowConfig]("entity.SubFlowConfig")
//schema.RegisterName[node.NodeFormField]("node.NodeFormField")
schema.RegisterName[entity.ModelItem]("node.ModelItem")
+48 -6
View File
@@ -7,7 +7,9 @@ import (
flowDto "ai-agent/workflow/model/dto/flow"
sessionDto "ai-agent/workflow/model/dto/session"
"ai-agent/workflow/model/entity"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"sync"
@@ -99,6 +101,7 @@ func (r *wsProgressReporter) ReportComplete(nodeId, nodeName string, nodeIndex,
func handleExecute(ctx context.Context, conn *wsCommon.WsConnection, payload interface{}) {
execPayload := new(sessionDto.WebSocketExecWorkflowReq)
if err := gconv.Struct(payload, execPayload); err != nil {
glog.Errorf(ctx, "工作流执行参数解析失败: %v", err)
_ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "error", Message: "执行参数解析失败", Error: err.Error()})
return
}
@@ -132,6 +135,7 @@ func handleExecute(ctx context.Context, conn *wsCommon.WsConnection, payload int
flowName = flowUser.FlowName
}
if e := ensureSession(saveCtx, conn.SessionId, flowName); e != nil {
glog.Errorf(saveCtx, "工作流会话创建失败: %v", e)
_ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "error", Message: "工作流会话创建失败", Error: fmt.Sprintf("%v", e)})
}
@@ -142,7 +146,7 @@ func handleExecute(ctx context.Context, conn *wsCommon.WsConnection, payload int
_ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "ack", Message: fmt.Sprintf("开始执行工作流(共 %d 个节点)", len(execPayload.FlowContent.Nodes))})
execId, err := execute(progressCtx, conn.SessionId, execPayload)
execId, err := executeOrResume(progressCtx, conn, execPayload)
recordWorkflow(saveCtx, execId, time.Since(start), err)
if err != nil {
_ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "error", Message: "工作流执行失败", Error: err.Error()})
@@ -163,14 +167,16 @@ func handleExecute(ctx context.Context, conn *wsCommon.WsConnection, payload int
func recordWorkflow(ctx context.Context, id int64, duration time.Duration, runErr error) {
// exec_workflow 状态沿用 1-运行中,2-成功,3-失败;前端结果卡片也只识别 1/2/3
// 4 会误显示为"运行中"),故取消同样记为失败,错误信息写"用户已终止执行"
// error_message 存友好提示,error 存原始错误明细
status := flow.FlowExecutionStatusSuccess
errorMessage := ""
var errorMessage, errorDetail string
if runErr != nil {
status = flow.FlowExecutionStatusFailed
if errors.Is(runErr, context.Canceled) {
errorMessage = errWorkflowTerminated
} else {
errorMessage = runErr.Error()
errorMessage = "工作流执行失败"
errorDetail = runErr.Error()
}
}
_, err := sessionDao.ExecWorkflowDao.Update(ctx, &sessionDto.UpdateWorkflowReq{
@@ -178,6 +184,7 @@ func recordWorkflow(ctx context.Context, id int64, duration time.Duration, runEr
Status: status.Code(),
Duration: int64(duration.Seconds()),
ErrorMessage: errorMessage,
Error: errorDetail,
})
if err != nil {
glog.Errorf(ctx, "exec_workflow 落库失败: %v", err)
@@ -222,11 +229,43 @@ func writeJSON(conn *wsCommon.WsConnection, data interface{}) error {
return conn.WriteJSON(data)
}
// executeOrResume 决策工作流执行方式:
// - 同会话+同工作流的最近一次执行失败,且本次传递参数与上次一致 → 断点续跑(reExecute,复用原执行记录,从失败断点继续)
// - 其余情况(上次成功 / 上次参数与本次不同 / 无历史记录 / 查询出错)→ 全新执行(execute)
func executeOrResume(ctx context.Context, conn *wsCommon.WsConnection, req *sessionDto.WebSocketExecWorkflowReq) (id int64, err error) {
lastExec, err := sessionDao.ExecWorkflowDao.GetLatestBySessionAndFlow(ctx, conn.SessionId, req.FlowId)
if err != nil {
glog.Errorf(ctx, "查询最近工作流执行记录失败: %v", err)
return execute(ctx, conn, req)
}
if lastExec != nil && *lastExec.Status == *flow.FlowExecutionStatusFailed.Code() && flowContentEqual(lastExec.RequestParams, req.FlowContent) {
_ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "round_start", Message: "运行开始", Data: map[string]interface{}{
"id": lastExec.Id,
}})
return reExecute(ctx, lastExec.Id)
}
return execute(ctx, conn, req)
}
// flowContentEqual 判断两次工作流参数是否一致(JSON 序列化后字节比对。
// Go struct 按字段声明序序列化、map 键自动排序,同一内容结果确定,可用于参数等价判断)
func flowContentEqual(a, b *entity.FlowInfo) bool {
if a == nil || b == nil {
return a == b
}
ab, err1 := json.Marshal(a)
bb, err2 := json.Marshal(b)
if err1 != nil || err2 != nil {
return false
}
return bytes.Equal(ab, bb)
}
// execute 执行工作流(首次执行)
func execute(ctx context.Context, sessionId string, req *sessionDto.WebSocketExecWorkflowReq) (id int64, err error) {
func execute(ctx context.Context, conn *wsCommon.WsConnection, req *sessionDto.WebSocketExecWorkflowReq) (id int64, err error) {
var nodeGroupId = uuid.NewString()
id, err = sessionDao.ExecWorkflowDao.Insert(ctx, &sessionDto.CreateWorkflowReq{
SessionId: sessionId,
SessionId: conn.SessionId,
FlowId: req.FlowId,
NodeGroupId: nodeGroupId,
Status: flow.FlowExecutionStatusRunning.Code(),
@@ -235,7 +274,10 @@ func execute(ctx context.Context, sessionId string, req *sessionDto.WebSocketExe
if err != nil {
return
}
err = BuildExecution(ctx, true, req.FlowId, id, nodeGroupId, sessionId, req.FlowContent)
_ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "round_start", Message: "运行开始", Data: map[string]interface{}{
"id": id,
}})
err = BuildExecution(ctx, true, req.FlowId, id, nodeGroupId, conn.SessionId, req.FlowContent)
if err != nil {
return
}
+110 -27
View File
@@ -3,6 +3,7 @@ package flow
import (
"ai-agent/gateway"
"ai-agent/workflow/consts/flow"
"ai-agent/workflow/consts/model"
"ai-agent/workflow/consts/node"
"ai-agent/workflow/consts/public"
nodeDao "ai-agent/workflow/dao/node"
@@ -12,6 +13,7 @@ import (
sessionDto "ai-agent/workflow/model/dto/session"
"ai-agent/workflow/model/entity"
"ai-agent/workflow/service/flow/processor"
"ai-agent/workflow/service/flow/processor/builtin/media"
"context"
"encoding/base64"
"encoding/json"
@@ -20,6 +22,7 @@ import (
"sync"
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
"gitea.redpowerfuture.com/red-future/common/utils"
"github.com/cloudwego/eino-examples/compose/batch/batch"
"github.com/cloudwego/eino/compose"
"github.com/gogf/gf/v2/database/gdb"
@@ -53,14 +56,16 @@ func ModelLambda(ctx context.Context, input any) (any, error) {
return nil, fmt.Errorf("入参类型错误")
}
// 1. 解析 valueSource 引用,填充模型请求参数
ProcessValueSourceRecursive(nodeInput.Config.ModelConfig.ModelRequestParams, nodeInput.Global)
// 1.5 剔除 value 为空的字段;数组/枚举元素整体为空时移除整个元素(0/false 视为有效值)
CleanEmptyModelParams(nodeInput.Config.ModelConfig.ModelRequestParams)
modelParams, err := BuildModelRequestBody(nodeInput.Config.ModelConfig.ModelRequestParamsPath, nodeInput.Global)
if err != nil {
return nil, err
}
// 2. 前置工具:决定模型调用入参(单次/多次)
paramsList, err := invokePreTool(ctx, nodeInput.Config.PreTool, nodeInput.Config.ModelConfig.ModelRequestParams)
// 入参统一为扁平模型请求体(BuildModelRequestBody 输出,key 为点分路径)。
// 分批处理器按默认上限拆分集合字段,其余前置工具(如 split_shots_pipeline)读取扁平参数。
preToolParams := modelParams
paramsList, err := invokePreTool(ctx, nodeInput.Config.PreTool, preToolParams)
if err != nil {
return nil, err
}
@@ -69,21 +74,22 @@ func ModelLambda(ctx context.Context, input any) (any, error) {
var outputRes []map[string]any
var totalTokens int64
var totalCost float64
if nodeInput.Config.IsBatchExec && len(paramsList) > 1 {
if len(paramsList) > 1 {
// 异步批量执行:并发请求模型,等待全部返回后再继续,避免下游读到空结果
results := make([][]map[string]any, len(paramsList))
tokenRes := make([]*gateway.ModelCallRes, len(paramsList))
errs := make([]error, len(paramsList))
isInference := make([]bool, len(paramsList))
var wg sync.WaitGroup
for i, params := range paramsList {
wg.Add(1)
go func(i int, params map[string]any) {
defer wg.Done()
results[i], tokenRes[i], errs[i] = ModelCallResultLambda(ctx, nodeInput.Config.ModelConfig.ModelId, nodeInput.Global.SessionId, params)
results[i], tokenRes[i], isInference[i], errs[i] = ModelCallResultLambda(ctx, nodeInput.Config.ModelConfig.ModelId, nodeInput.Global.SessionId, params, nodeInput.Config.Prompt)
}(i, params)
}
wg.Wait()
for i, res := range results {
for i := range results {
if errs[i] != nil {
return nil, errs[i]
}
@@ -91,11 +97,19 @@ func ModelLambda(ctx context.Context, input any) (any, error) {
totalTokens += tokenRes[i].TotalTokens
totalCost += tokenRes[i].Cost
}
outputRes = append(outputRes, res...)
}
// 推理模型(分批同一模型,isInference 各批一致):分批结果拼到单个字段(单条输出记录);
// 非推理模型保持逐条展平
if isInference[0] {
outputRes = mergeInferenceBatchResults(results)
} else {
for _, res := range results {
outputRes = append(outputRes, res...)
}
}
} else {
for _, params := range paramsList {
res, modelRes, err := ModelCallResultLambda(ctx, nodeInput.Config.ModelConfig.ModelId, nodeInput.Global.SessionId, params)
res, modelRes, _, err := ModelCallResultLambda(ctx, nodeInput.Config.ModelConfig.ModelId, nodeInput.Global.SessionId, params, nodeInput.Config.Prompt)
if err != nil {
return nil, err
}
@@ -109,7 +123,7 @@ func ModelLambda(ctx context.Context, input any) (any, error) {
// 3.5 把本次节点消耗的 token/费用写入节点执行记录,供汇总节点聚合到 exec_workflow
if nodeInput.NodeExecutionId > 0 && (totalTokens > 0 || totalCost > 0) {
if _, err := nodeDao.NodeExecutionDao.Update(ctx, &nodeDto.UpdateNodeExecutionReq{
if _, err = nodeDao.NodeExecutionDao.Update(ctx, &nodeDto.UpdateNodeExecutionReq{
Id: nodeInput.NodeExecutionId,
TokenInfo: []map[string]any{{
"total_tokens": totalTokens,
@@ -120,16 +134,61 @@ func ModelLambda(ctx context.Context, input any) (any, error) {
}
}
// 4. 后置工具:加工模型输出(透传原始请求参数,供后置工具读取合并配置等)
outputRes, err = invokePostTool(ctx, nodeInput.Config.PostTool, outputRes, nodeInput.Config.ModelConfig.ModelRequestParams)
if err != nil {
return nil, err
// 4.5 视频模型节点返回多个视频时,自动调用视频合成工具(concat_videos)合并为单条;
// 已显式配置 concat_videos 后置工具时跳过,避免重复合并
if nodeInput.Config.PostTool != media.ProcessorName && len(outputRes) > 1 && isVideoModel(ctx, nodeInput.Config.ModelConfig.ModelId) {
outputRes, err = invokePostTool(ctx, media.ProcessorName, outputRes, map[string]any{"callback_url": "callback_url", "upload": true})
if err != nil {
return nil, err
}
} else {
// 4. 后置工具:加工模型输出(透传原始请求参数,供后置工具读取合并配置等)
outputRes, err = invokePostTool(ctx, nodeInput.Config.PostTool, outputRes, modelParams)
if err != nil {
return nil, err
}
}
nodeInput.Config.OutputResult = outputRes
return nodeInput, nil
}
// isVideoModel 判断模型是否为视频模型(模型类型 TypeVideo=600),用于视频节点多视频自动合成判断
func isVideoModel(ctx context.Context, modelId int64) bool {
modelInfo, err := gateway.GetModelInfoById(ctx, &gateway.GetModelInfoByIdReq{ModelId: modelId})
if err != nil {
g.Log().Warningf(ctx, "查询模型配置失败,跳过自动视频合成 modelId=%d err=%v", modelId, err)
return false
}
return modelInfo.ModelManage.ModelType != nil && *modelInfo.ModelManage.ModelType == model.TypeVideo
}
// mergeInferenceBatchResults 推理模型分批结果拼接为单条输出记录:
// 各批结果按批序对同名 key 的值做字符串拼接("拼到一个字段"),最终返回单条 {key:值} 记录。
// 非字符串值(如结构/数组字段)取最后一份,避免误拼接。
func mergeInferenceBatchResults(results [][]map[string]any) []map[string]any {
merged := make(map[string]any)
for _, res := range results {
for _, record := range res {
for key, val := range record {
prev, has := merged[key]
if !has {
merged[key] = val
continue
}
sPrev, pOK := prev.(string)
sVal, vOK := val.(string)
if pOK && vOK {
merged[key] = sPrev + "\n" + sVal
continue
}
merged[key] = val
}
}
}
return []map[string]any{merged}
}
// invokePreTool 执行前置处理器,把模型请求参数转换为模型调用入参列表。
// 前置处理器契约:入参即模型请求参数本体;返回值:
// - map[string]any 一次模型调用,入参为返回值
@@ -137,7 +196,7 @@ func ModelLambda(ctx context.Context, input any) (any, error) {
// - nil 视为异常,节点失败(不允许静默跳过模型调用)
func invokePreTool(ctx context.Context, processorName string, modelParams map[string]any) (paramsList []map[string]any, err error) {
if processorName == "" {
return []map[string]any{modelParams}, nil
return []map[string]any{stripInternalKeys(modelParams)}, nil
}
data, err := processor.Call(ctx, processorName, modelParams)
if err != nil {
@@ -147,14 +206,32 @@ func invokePreTool(ctx context.Context, processorName string, modelParams map[st
case nil:
return nil, fmt.Errorf("前置处理器[%s]返回空", processorName)
case map[string]any:
return []map[string]any{v}, nil
return []map[string]any{stripInternalKeys(v)}, nil
case []map[string]any:
return v, nil
list := make([]map[string]any, 0, len(v))
for _, m := range v {
list = append(list, stripInternalKeys(m))
}
return list, nil
default:
return nil, fmt.Errorf("前置处理器[%s]返回类型不支持: %T", processorName, data)
}
}
// stripInternalKeys 剥离 __ 前缀的内部键(如 __segment_fields/__produced),
// 模型网关做参数严格校验(CheckParams strictUnknown)会拒绝未知字段,内部标记不得随请求体下发。
func stripInternalKeys(params map[string]any) map[string]any {
if params == nil {
return params
}
for k := range params {
if strings.HasPrefix(k, "__") {
delete(params, k)
}
}
return params
}
// invokePostTool 执行后置处理器,加工模型调用结果。
// 后置处理器契约:入参 {"output": 模型输出结果列表, "request": 原始模型请求参数}(列表须包成对象传入);返回值:
// - []map[string]any 替换模型输出
@@ -444,9 +521,10 @@ func SummaryLambda(ctx context.Context, input any) (any, error) {
// collectSaveFileResults 按两层规则收集需入库的文件结果:
// 第一层:节点须开启"保存文件"IsSaveFile);
// 第二层:key 取自模型响应内容 respBody(即节点 OutputResult 的各字段,命中
// ModelResponseBodyMapping 才入库;原始响应体 key(respBody)恒入库(不要求映射声明)
// 结果值为 http(s) URL 直接使用;非路径值(base64 图片/文本)先上传 OSS 换取 URL,
// 第二层:key 取自节点 OutputResult 的各字段,命中 ModelResponseBodyMapping 才入库;
// 原始响应体 key(respBody)恒入库(不要求映射声明)HTTP 节点产出以 http_file_url:{key}
// 标记的字段(IsSaveFile 时由 HttpCallResultLambda 生成)恒入库(无模型响应映射可查)。
// 结果值为 http(s) URL 或 MinIO 对象裸路径直接使用;非路径值(base64 图片/文本)先上传 OSS 换取 URL,
// 文本内容以 .inc 扩展名存储。
func collectSaveFileResults(ctx context.Context, execInput *flowDto.FlowExecutionInput) []*sessionDto.CreateWorkflowResultReq {
if execInput == nil {
@@ -458,13 +536,14 @@ func collectSaveFileResults(ctx context.Context, execInput *flowDto.FlowExecutio
if nodeConfig == nil || len(nodeConfig.OutputResult) == 0 || !nodeConfig.IsSaveFile {
continue
}
// 第二层:key 取自模型响应内容 respBody(即节点 OutputResult 的各字段
// 命中 ModelResponseBodyMapping 才入库;原始响应体 keyrespBody恒入库
// 第二层:key 取自节点 OutputResult 的各字段,
// 命中 ModelResponseBodyMapping 才入库;respBody 与 HTTP 节点 http_file_url:{key} 标记恒入库
saveKeys := nodeConfig.ModelConfig.ModelResponseBodyMapping
for _, respBody := range nodeConfig.OutputResult {
for key, val := range gconv.Map(respBody) {
if _, ok := saveKeys[key]; !ok {
if key != "respBody" {
isHTTPFile := strings.HasPrefix(key, "http_file_url:")
if !isHTTPFile {
if _, ok := saveKeys[key]; !ok && key != "respBody" {
continue
}
}
@@ -486,7 +565,7 @@ func collectSaveFileResults(ctx context.Context, execInput *flowDto.FlowExecutio
}
// resolveSaveFileResult 解析结果值为可入库的 URL:
// - 已是 http(s) URL → 直接返回
// - 已是 http(s) URL 或 MinIO 对象裸路径 → 直接返回
// - 非路径(base64 图片/文本)→ 上传 OSS 换取 URL
func resolveSaveFileResult(ctx context.Context, val any) (string, error) {
isPath, path, fileBytes, ext := resolveFileContent(val)
@@ -513,6 +592,10 @@ func resolveFileContent(val any) (isPath bool, path string, fileBytes []byte, ex
if isFileURL(s) {
return true, s, nil, ""
}
// MinIO 对象裸路径(无 http 前缀,模型网关转存 OSS 后返回)
if utils.IsOSSPath(s) {
return true, s, nil, ""
}
// data URIdata:<mime>;base64,<payload>
if b, mime, ok := parseDataURI(s); ok {
return false, "", b, extOfMime(mime)
+177 -8
View File
@@ -2,10 +2,13 @@ package flow
import (
"ai-agent/gateway"
"ai-agent/workflow/consts/model"
"ai-agent/workflow/consts/node"
flowDto "ai-agent/workflow/model/dto/flow"
"context"
"fmt"
"regexp"
"strings"
"sync"
commonHttp "gitea.redpowerfuture.com/red-future/common/http"
@@ -57,20 +60,31 @@ func Notify(taskId string, result any) {
delete(asyncTasks, taskId)
}
// ModelCallResultLambda 调用模型并返回输出内容列表,同时回传本次调用的 token/费用(*gateway.ModelCallRes
// 供调用方(ModelLambda)累计写入节点执行记录 token_info,最后由汇总节点聚合到 exec_workflow。
func ModelCallResultLambda(ctx context.Context, modelId int64, sessionId string, modelRequestParams map[string]any) ([]map[string]any, *gateway.ModelCallRes, error) {
// ModelCallResultLambda 调用模型并返回输出内容列表,同时回传本次调用的 token/费用(*gateway.ModelCallRes
// 与是否推理模型(供 ModelLambda 决定分批结果是否拼接),供调用方(ModelLambda)累计写入节点执行记录
// token_info,最后由汇总节点聚合到 exec_workflow。
func ModelCallResultLambda(ctx context.Context, modelId int64, sessionId string, modelRequestParams map[string]any, prompt string) ([]map[string]any, *gateway.ModelCallRes, bool, error) {
modelInfo, err := gateway.GetModelInfoById(ctx, &gateway.GetModelInfoByIdReq{ModelId: modelId})
if err != nil {
return nil, nil, fmt.Errorf("获取模型配置失败: %w", err)
return nil, nil, false, fmt.Errorf("获取模型配置失败: %w", err)
}
businessParams := make(map[string]any)
if !g.IsEmpty(prompt) {
if modelInfo.ModelManage.ModelType != nil && *modelInfo.ModelManage.ModelType == model.TypeVideo {
businessParams["user_prompt"] = prompt
} else if modelInfo.ModelManage.ModelType != nil && *modelInfo.ModelManage.ModelType == model.TypeInference {
businessParams["system_prompt"] = prompt
}
}
// 推理模型:分批调用结果需拼接为单个字段,模型类型仅网关配置携带,此处顺带判断
isInference := modelInfo.ModelManage.ModelType != nil && *modelInfo.ModelManage.ModelType == model.TypeInference
// 异步模型 msgTopic 由 gateway.ModelCallResult 在为空时自动生成(唯一、带业务标识),调用方无需管理
responseParams, err := gateway.ModelCallResult(ctx, modelId, modelInfo.ModelManage.ResponseType, sessionId, modelRequestParams, nil)
responseParams, err := gateway.ModelCallResult(ctx, modelId, modelInfo.ModelManage.ResponseType, sessionId, modelRequestParams, businessParams)
if err != nil {
return nil, nil, err
return nil, nil, false, err
}
if g.IsEmpty(responseParams) {
return nil, nil, fmt.Errorf("生成内容为空")
return nil, nil, false, fmt.Errorf("生成内容为空")
}
outputRes := make([]map[string]any, 0)
for key, val := range responseParams.Content {
@@ -78,7 +92,7 @@ func ModelCallResultLambda(ctx context.Context, modelId int64, sessionId string,
key: val,
})
}
return outputRes, responseParams, nil
return outputRes, responseParams, isInference, nil
}
func HttpCallResultLambda(ctx context.Context, nodeInput *flowDto.NodeExecutionInput) ([]map[string]any, error) {
@@ -137,6 +151,9 @@ func HttpCallResultLambda(ctx context.Context, nodeInput *flowDto.NodeExecutionI
// 递归剥掉 {type, value/attrs} 包裹层,只保留 key/value
wrapper := UnwrapSchemaWrapper(body)
newBody := gconv.Map(wrapper)
// body 值若为 MinIO 裸路径(模型网关转存 OSS 后返回,无 http 前缀),
// 补上前缀供目标 HTTP 服务直接下载文件
addFilePathPrefix(ctx, url, newBody)
// 1. 自己生成唯一 taskId(不用前端给)
taskId := "my_task_" + uuid.New().String() // 自己生成唯一ID
@@ -209,3 +226,155 @@ func HttpCallResultLambda(ctx context.Context, nodeInput *flowDto.NodeExecutionI
return outputRes, nil
}
// addFilePathPrefix 递归把 body 中的 MinIO 裸路径(模型网关转存 OSS 后返回,无 http 前缀)补上文件前缀,
// 供目标 HTTP 服务直接下载文件;已是完整 URL 的值保持不变
func addFilePathPrefix(ctx context.Context, url string, body map[string]any) {
prefix, err := utils.GetFileAddressPrefix(ctx)
if err != nil {
g.Log().Warningf(ctx, "获取文件前缀失败,保持原路径: %v", err)
return
}
for k, v := range body {
body[k] = prependFilePathPrefix(prefix, v)
}
// template/template 模板接口要求 video_urls 为数组:标量值包装为单元素数组
if strings.Contains(url, "template/template") {
if v, ok := body["video_urls"]; ok {
body["video_urls"] = toVideoURLsArray(v)
}
if v, ok := body["subtitles"]; ok {
a := new([]flowDto.Sentence)
err = gconv.Structs(v, a)
v, err = BuildSubtitles(a)
body["subtitles"] = v
}
}
}
// toVideoURLsArray 把标量 video_urls 包装为数组;已是数组/切片则原样保留
func toVideoURLsArray(v any) any {
switch val := v.(type) {
case string:
if val == "" {
return []string{}
}
return []string{val}
case []string, []any:
return val
default:
return v
}
}
// prependFilePathPrefix 对单个值加前缀,递归处理嵌套 map/切片
func prependFilePathPrefix(prefix string, v any) any {
switch val := v.(type) {
case string:
if utils.IsOSSPath(val) {
return prefix + val
}
return val
case map[string]any:
for k, item := range val {
val[k] = prependFilePathPrefix(prefix, item)
}
return val
case []any:
for i, item := range val {
val[i] = prependFilePathPrefix(prefix, item)
}
return val
case []map[string]any:
for _, m := range val {
for k, item := range m {
m[k] = prependFilePathPrefix(prefix, item)
}
}
return val
default:
return val
}
}
// BuildSubtitles 核心工具:单个sentence生成多条subtitle
func BuildSubtitles(sents *[]flowDto.Sentence) ([]flowDto.Subtitle, error) {
var subtitles []flowDto.Subtitle
for _, sent := range *sents {
// 1. 先按标点把文本拆成多个片段(保留标点)
segList := splitTextByPunct(sent.Text)
if len(segList) == 0 {
continue
}
wordIdx := 0
allWords := sent.Words
// 2. 遍历每个文本片段,匹配对应的Words
for _, seg := range segList {
// 去除文本片段的标点,方便和Word.Word拼接内容匹配
segClean := strings.ReplaceAll(seg, "", "")
segClean = strings.ReplaceAll(segClean, "。", "")
segClean = strings.ReplaceAll(segClean, "", "")
segClean = strings.ReplaceAll(segClean, "", "")
segClean = strings.ReplaceAll(segClean, "", "")
var collectWords []flowDto.Word
var currentText strings.Builder
// 收集Word直到拼接内容覆盖当前分段
for wordIdx < len(allWords) {
word := allWords[wordIdx]
currentText.WriteString(word.Word)
collectWords = append(collectWords, word)
wordIdx++
// 当拼接的文本包含当前分段的纯文本时,停止收集
if strings.Contains(currentText.String(), segClean) {
break
}
}
if len(collectWords) == 0 {
continue
}
// 3. 生成字幕(时间戳取首尾Word的时间)
sub := flowDto.Subtitle{
Start: collectWords[0].StartTime,
End: collectWords[len(collectWords)-1].EndTime,
Text: segClean,
}
subtitles = append(subtitles, sub)
}
}
return subtitles, nil
}
// splitTextByPunct 按中文标点分割句子,同时保留标点在分段内
// 例如:"这个叫高血压调理方,注意是根源调理不是临时缓解,"
// 会变成:["这个叫高血压调理方,", "注意是根源调理不是临时缓解,"]
func splitTextByPunct(raw string) []string {
// 匹配中文标点并保留在文本中,按标点位置切分
re := regexp.MustCompile(`[,。;!?]`)
// 先找到所有标点的位置
indexes := re.FindAllStringIndex(raw, -1)
if len(indexes) == 0 {
return []string{raw}
}
var res []string
prev := 0
for _, idx := range indexes {
end := idx[1] // 标点的结束位置
seg := raw[prev:end]
res = append(res, seg)
prev = end
}
// 处理最后一段没有标点的文本
if prev < len(raw) {
res = append(res, raw[prev:])
}
return res
}
@@ -35,8 +35,9 @@ const defaultScriptTranscribeSystemPrompt = `你是短剧分镜脚本师。请
时间码需前后衔接、覆盖整个内容时长。直接输出 JSON 数组,不要输出其他文字。`
// ScriptTranscribeLambda 脚本转写节点:
// 把节点输入(文案/视频分析结果,经 valueSource 解析)通过大模型转写为固定结构的 []domain.Shot
// 产出为 [{"shots": [...]}],供视频生成节点的 ModelRequestParams.shots 引用。
// 把节点输入(文案/视频分析结果,经 valueSource 解析)通过大模型转写为 []pipeline.Shot
// 再经 split_shots_pipeline 前置处理器拆成各段扁平请求参数列表([{"prompt","duration","seed",...},...]),
// 供下游视频生成节点逐段引用聚合。
func ScriptTranscribeLambda(ctx context.Context, input any) (any, error) {
nodeInput, ok := input.(*flowDto.NodeExecutionInput)
if !ok {
@@ -53,14 +54,31 @@ func ScriptTranscribeLambda(ctx context.Context, input any) (any, error) {
for _, item := range *n {
switch item.Field {
case "totalDuration":
totalDuration = gconv.Int(item.Value)
if !g.IsEmpty(item.Value) {
totalDuration = gconv.Int(item.Value)
} else {
if !g.IsEmpty(item.ValueSource) {
for _, k := range item.ValueSource {
nodeConfig := nodeInput.Global.ConfigMap[k.NodeId]
if nodeConfig != nil {
for _, output := range nodeConfig.OutputResult {
if !g.IsEmpty(output[k.Field]) {
totalDuration = gconv.Int(output[k.Field])
}
}
}
}
}
}
case "modelId":
modelId = gconv.Int64(item.Value)
}
}
// 1. 解析 valueSource 引用,填充节点输入
ProcessValueSourceRecursive(nodeInput.Config.ModelConfig.ModelRequestParams, nodeInput.Global)
modelParams, err := BuildModelRequestBody(nodeInput.Config.ModelConfig.ModelRequestParamsPath, nodeInput.Global)
if err != nil {
return nil, err
}
// 2. 构建系统提示词 + 用户输入
systemPrompt := nodeInput.Config.Prompt
@@ -104,7 +122,7 @@ func ScriptTranscribeLambda(ctx context.Context, input any) (any, error) {
if err != nil {
return "", fmt.Errorf("获取模型配置失败: %w", err)
}
result, err := gateway.ModelCallResult(ctx, nodeInput.Config.ModelConfig.ModelId, modelInfo.ModelManage.ResponseType, nodeInput.Global.SessionId, nodeInput.Config.ModelConfig.ModelRequestParams, params)
result, err := gateway.ModelCallResult(ctx, nodeInput.Config.ModelConfig.ModelId, modelInfo.ModelManage.ResponseType, nodeInput.Global.SessionId, modelParams, params)
if err != nil {
return nil, err
}
+431 -18
View File
@@ -3,9 +3,16 @@ package flow
import (
"ai-agent/workflow/consts/node"
flowDto "ai-agent/workflow/model/dto/flow"
"ai-agent/workflow/model/entity"
"context"
"encoding/json"
"reflect"
"regexp"
"strconv"
"strings"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/glog"
"github.com/gogf/gf/v2/util/gconv"
"github.com/tidwall/gjson"
)
@@ -15,16 +22,32 @@ var (
regNumIndex = regexp.MustCompile(`\[\d+\]`)
// 匹配 .attrs
regAttrs = regexp.MustCompile(`\.attrs`)
// 匹配带捕获组的数组下标,转扁平点分路径用
arrayIndexPath = regexp.MustCompile(`\[(\d+)\]`)
)
// CleanFieldPath 清理字段路径:移除 .attrs、数字下标转为 [*]
// 示例:usage.attrs.total_tokens → usage.total_tokens
// 示例:choices.attrs[0].attrs.message.attrs.content → choices[*].message.content
func CleanFieldPath(path string) string {
//// 1. 替换 [数字] 为 [*]
//s := regNumIndex.ReplaceAllString(path, `.#`)
//// 2. 移除所有 .attrs
//s = regAttrs.ReplaceAllString(s, "")
index := CleanFieldPathReplaceNumIndex(path)
attrs := CleanFieldPathRemoveAttrs(index)
return attrs
}
func CleanFieldPathReplaceNumIndex(path string) string {
// 1. 替换 [数字] 为 [*]
s := regNumIndex.ReplaceAllString(path, `.#`)
return s
}
func CleanFieldPathRemoveAttrs(path string) string {
// 2. 移除所有 .attrs
s = regAttrs.ReplaceAllString(s, "")
s := regAttrs.ReplaceAllString(path, "")
return s
}
@@ -74,7 +97,7 @@ func isSchemaEditorType(t string) bool {
}
// MapResultByTemplate 按 template 定义的结构,从 source 中拷贝对应字段的值。
// 只保留 template 里出现的字段:对象字段按同名字段递归拷贝,标量/数组字段直接拷贝 source 的值。
// 只保留 template 里出现的字段:对象字段按同名字段递归拷贝,数组字段按模板元素结构逐元素过滤,标量字段直接拷贝 source 的值。
func MapResultByTemplate(template map[string]any, source map[string]any) map[string]any {
result := make(map[string]any, len(template))
for key, tmplVal := range template {
@@ -88,11 +111,38 @@ func MapResultByTemplate(template map[string]any, source map[string]any) map[str
}
continue
}
if tmplArr, isArr := tmplVal.([]any); isArr {
result[key] = mapTemplateArray(tmplArr, srcVal)
continue
}
result[key] = srcVal
}
return result
}
// mapTemplateArray 按模板数组的元素结构映射 source 数组:
// 模板首元素为对象时,逐元素按 MapResultByTemplate 过滤只保留模板字段;
// 模板数组为空或首元素非对象(无法确定元素结构)时,原样拷贝 source 数组。
func mapTemplateArray(tmplArr []any, srcVal any) any {
srcList, ok := srcVal.([]any)
if !ok || len(tmplArr) == 0 {
return srcVal
}
elemTmpl, ok := tmplArr[0].(map[string]any)
if !ok {
return srcVal
}
result := make([]any, 0, len(srcList))
for _, srcElem := range srcList {
if srcMap, isMap := srcElem.(map[string]any); isMap {
result = append(result, MapResultByTemplate(elemTmpl, srcMap))
} else {
result = append(result, srcElem)
}
}
return result
}
// ProcessValueSourceRecursive 递归遍历map,同级同时存在value和valueSource则把value设置为"AA"
func ProcessValueSourceRecursive(rawParams map[string]interface{}, globalParams *flowDto.FlowExecutionInput) {
walkMap(rawParams, globalParams)
@@ -102,9 +152,9 @@ func ProcessValueSourceRecursive(rawParams map[string]interface{}, globalParams
// 返回 (value, refsName, ok)ok=false 表示引用节点不存在或引用值仍为空。
// - 开始/表单节点:OutputConfig 平铺条目按 field == fieldName 匹配(前端约定以 field 为主,
// 不兼容 path),直接读 entry 的 value / refsName
// - scriptTranscribe 节点:OutputResult 的 shots
// - scriptTranscribe 节点:OutputResult 是各段扁平请求参数,按段序收集字段为数组(段位留 nil)
// - 其他节点:读 OutputResult 中 fieldName 路径对应的值
func resolveValueSource(global *flowDto.FlowExecutionInput, nodeId, fieldName string) (value any, refsName any, ok bool) {
func resolveValueSource(global *flowDto.FlowExecutionInput, nodeId, field string) (value any, refsName any, ok bool) {
if global == nil || global.ConfigMap == nil {
return nil, nil, false
}
@@ -115,7 +165,7 @@ func resolveValueSource(global *flowDto.FlowExecutionInput, nodeId, fieldName st
switch nodeConfig.NodeCode {
case node.NodeTypeStart, node.NodeTypeForm:
for _, output := range nodeConfig.OutputConfig {
if gconv.String(output["field"]) != fieldName {
if gconv.String(output["field"]) != field {
continue
}
if !g.IsEmpty(output["value"]) {
@@ -123,15 +173,32 @@ func resolveValueSource(global *flowDto.FlowExecutionInput, nodeId, fieldName st
}
}
case node.NodeTypeScriptTranscribe:
// 脚本转写节点 OutputResult 是各段扁平请求参数(split_shots_pipeline 产出,key 为字面量
// prompt/duration/seed 等),按段序读取 output[field] 收集为数组,供分段模型节点整体引用。
// 每段都占一位(字段缺失/为空留 nil),保证数组与段序对齐,供 split_segment 按段取值。
var list []any
for _, output := range nodeConfig.OutputResult {
value := gjson.Get(gconv.String(output), CleanFieldPath("shots")).Value()
if !g.IsEmpty(value) {
return value, gjson.Get(gconv.String(output), CleanFieldPath("refsName")).Value(), true
list = append(list, output[field])
}
for _, v := range list {
if !g.IsEmpty(v) {
return list, "", true
}
}
default:
for _, output := range nodeConfig.OutputResult {
value := gjson.Get(gconv.String(output), CleanFieldPath(fieldName)).Value()
// 模型节点输出记录是单 key 的字面量扁平 key(如 "choices.attrs[0].attrs.delta.attrs.content"),
// gjson 会把 . 和 [0] 当结构路径解析,无法命中字面量 key,故先按字面量 key 直接取值;
// 未命中再回退 gjson 路径查询(兼容真正嵌套的输出结构)。
if v, has := output[field]; has {
value = v
} else {
if field == "templates" {
value = nodeConfig.Templates
} else {
value = gjson.Get(gconv.String(output), field).Value()
}
}
if !g.IsEmpty(value) {
return value, gjson.Get(gconv.String(output), CleanFieldPath("refsName")).Value(), true
}
@@ -144,16 +211,49 @@ func resolveValueSource(global *flowDto.FlowExecutionInput, nodeId, fieldName st
func walkMap(data interface{}, globalParams *flowDto.FlowExecutionInput) {
switch v := data.(type) {
case map[string]interface{}:
// 当前对象同时存在 value 和 valueSource
// valueSource:解析引用节点值
if valueSource, hasSource := v["valueSource"]; hasSource {
mapValueSource := gconv.Map(valueSource)
nodeId := gconv.String(mapValueSource["nodeId"])
fieldName := gconv.String(mapValueSource["fieldName"])
if fieldName == "" {
fieldName = gconv.String(mapValueSource["field"])
}
if nodeId != "" && fieldName != "" {
if value, refsName, ok := resolveValueSource(globalParams, nodeId, fieldName); ok {
sources := new([]entity.ValueSource)
gconv.Structs(valueSource, sources)
// 多个引用源:把各源解析出的值拼成 "label: value"(无 label 只拼值),逗号分隔
if len(*sources) > 1 {
parts := make([]string, 0, len(*sources))
var refsName any
for _, src := range *sources {
value, rn, ok := resolveValueSource(globalParams, src.NodeId, src.Field)
if !ok || schemaValueEmpty(value) {
continue
}
// 引用非模型节点(开始/表单/HTTP/脚本转写等)时,值按当前字段声明的 type 做类型化转换;
// 模型节点值由模型网关处理,复制时不需要转换
if !isModelSourceNode(globalParams, src.NodeId) {
value = assignBySchemaType(v, value)
}
text := toPlainString(value)
if src.Label != "" {
text = src.Label + ": " + text
}
parts = append(parts, text)
if !g.IsEmpty(rn) && g.IsEmpty(refsName) {
refsName = rn
}
}
if len(parts) > 0 {
v["value"] = strings.Join(parts, ", ")
if !g.IsEmpty(refsName) {
v["refsName"] = refsName
}
return
}
} else if len(*sources) == 1 {
// 单个引用源:保持旧行为,值按原样赋值(非模型节点按声明 type 转换)
src := (*sources)[0]
value, refsName, ok := resolveValueSource(globalParams, src.NodeId, src.Field)
if ok && !isModelSourceNode(globalParams, src.NodeId) {
value = assignBySchemaType(v, value)
}
if ok && !schemaValueEmpty(value) {
v["value"] = value
if !g.IsEmpty(refsName) {
v["refsName"] = refsName
@@ -162,6 +262,10 @@ func walkMap(data interface{}, globalParams *flowDto.FlowExecutionInput) {
}
}
}
// 统一兜底:无 valueSource(或解析失败/值为空)时,value 为空或 0 则取 defaultValue
if defaultValue, hasDefault := v["defaultValue"]; hasDefault && isEmptyForFallback(v["value"]) && !schemaValueEmpty(defaultValue) {
v["value"] = defaultValue
}
// 递归遍历所有子元素
for _, child := range v {
walkMap(child, globalParams)
@@ -174,6 +278,168 @@ func walkMap(data interface{}, globalParams *flowDto.FlowExecutionInput) {
}
}
// isEmptyForFallback 兜底场景判空:除 schemaValueEmpty 规则外,数字 0 也视为未填写,
// 便于配置了 defaultValue 的字段在值为 0 时用默认值兜底。
// 覆盖 json.Numbergconv 反序列化数字的运行时类型)与字符串 "0"/"0.0"。
func isEmptyForFallback(v interface{}) bool {
if schemaValueEmpty(v) {
return true
}
switch val := v.(type) {
case float32:
return val == 0
case float64:
return val == 0
case int:
return val == 0
case int8:
return val == 0
case int16:
return val == 0
case int32:
return val == 0
case int64:
return val == 0
case uint:
return val == 0
case uint8:
return val == 0
case uint16:
return val == 0
case uint32:
return val == 0
case uint64:
return val == 0
case json.Number:
if f, err := val.Float64(); err == nil {
return f == 0
}
case string:
if f, err := strconv.ParseFloat(val, 64); err == nil {
return f == 0
}
}
return false
}
// isModelSourceNode 判断引用源节点是否为模型节点(值由模型网关处理,复制时不转换)
func isModelSourceNode(global *flowDto.FlowExecutionInput, nodeId string) bool {
if global == nil || global.ConfigMap == nil {
return false
}
nodeConfig := global.ConfigMap[nodeId]
return nodeConfig != nil && nodeConfig.NodeCode == node.NodeTypeModel
}
// assignBySchemaType 按当前字段声明的 schema 类型把值类型化:
// string 遇数组/对象转 JSON 字符串;number/boolean 解析字符串;object/array 解析 JSON 字符串;其余原样返回
func assignBySchemaType(node map[string]interface{}, value any) any {
t, _ := node["type"].(string)
return assignByType(t, value)
}
// assignByType 按字段声明的 type 把值类型化;walkMap 的 schema 节点与 parseMap 的模型参数共用
func assignByType(t string, value any) any {
switch t {
case "string":
return toSchemaString(value)
case "number":
return toSchemaNumber(value)
case "boolean":
return toSchemaBool(value)
case "object", "array":
return toSchemaStruct(value)
default:
return value
}
}
// toSchemaString 转 string:字符串原样,数组元素拼成字符串(单元素取元素本身,多元素逗号连接),对象序列化为 JSON 字符串
func toSchemaString(v any) any {
switch val := v.(type) {
case []interface{}:
parts := make([]string, 0, len(val))
for _, item := range val {
parts = append(parts, toPlainString(item))
}
return strings.Join(parts, ",")
case map[string]interface{}:
if b, err := json.Marshal(val); err == nil {
return string(b)
}
}
return v
}
// toPlainString 把数组元素转成不带括号的纯字符串:
// 数组([]any / 类型化切片)逐元素取纯字符串,单元素取元素本身,多元素逗号连接;
// 对象序列化为 JSON 字符串;其余原样字符串化。
func toPlainString(v any) string {
if s, ok := v.(string); ok {
return s
}
switch val := v.(type) {
case []interface{}:
parts := make([]string, 0, len(val))
for _, item := range val {
parts = append(parts, toPlainString(item))
}
return strings.Join(parts, ",")
case map[string]interface{}:
if b, err := json.Marshal(val); err == nil {
return string(b)
}
}
rv := reflect.ValueOf(v)
if rv.IsValid() && (rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array) {
parts := make([]string, 0, rv.Len())
for i := 0; i < rv.Len(); i++ {
parts = append(parts, toPlainString(rv.Index(i).Interface()))
}
return strings.Join(parts, ",")
}
if b, err := json.Marshal(v); err == nil {
return string(b)
}
return gconv.String(v)
}
// toSchemaNumber 转 number:数字原样,字符串尝试解析为 float64,失败原样返回
func toSchemaNumber(v any) any {
if s, ok := v.(string); ok {
if f, err := strconv.ParseFloat(s, 64); err == nil {
return f
}
}
return v
}
// toSchemaBool 转 boolean:布尔原样,字符串尝试解析为 bool,失败原样返回
func toSchemaBool(v any) any {
if s, ok := v.(string); ok {
if b, err := strconv.ParseBool(s); err == nil {
return b
}
}
return v
}
// toSchemaStruct 转 object/array:合法 JSON 字符串解析为结构化数据,否则原样返回
func toSchemaStruct(v any) any {
s, ok := v.(string)
if !ok {
return v
}
if !json.Valid([]byte(s)) {
return v
}
var out any
if err := json.Unmarshal([]byte(s), &out); err != nil {
return v
}
return out
}
// CleanEmptyModelParams 剔除模型请求参数中 value 为空的字段;
// 数组/枚举(attrs / enumValues)元素整体为空时移除整个元素。0/false 视为有效值。
func CleanEmptyModelParams(params map[string]interface{}) {
@@ -285,3 +551,150 @@ func schemaValueEmpty(v interface{}) bool {
return false
}
}
// BuildModelRequestBody 从参数定义 + 全局执行上下文构建最终嵌套 JSON 请求体。
func BuildModelRequestBody(params []entity.FlowModelParams, globalParams *flowDto.FlowExecutionInput) (map[string]interface{}, error) {
// 1. 解析引用、过滤空值
resolved := parseMap(params, globalParams)
// 2. 转扁平路径映射
flat := toFlatMap(resolved)
// 3. 引用了脚本转写节点的字段打内部标记 __segment_fields(逗号分隔的扁平路径),
// 供前置处理器 split_segment 按段拆批;__ 前缀内部键由 invokePreTool 统一剥离,不传给模型网关
if seg := segmentFields(resolved, globalParams); len(seg) > 0 {
flat["__segment_fields"] = strings.Join(seg, ",")
}
return flat, nil
}
// segmentFields 收集引用了脚本转写节点的字段扁平路径(分段字段)。
// 脚本转写节点按段产出一份扁平参数列表,下游模型节点单源引用其字段时,
// 值按段序聚合成数组(见 resolveValueSource 的 scriptTranscribe 分支),需随批拆分。
func segmentFields(resolved []entity.FlowModelParams, globalParams *flowDto.FlowExecutionInput) []string {
if globalParams == nil || globalParams.ConfigMap == nil {
return nil
}
var fields []string
for _, p := range resolved {
if g.IsEmpty(p.Path) || len(p.ValueSource) != 1 {
continue
}
src := p.ValueSource[0]
if nodeConfig := globalParams.ConfigMap[src.NodeId]; nodeConfig != nil &&
nodeConfig.NodeCode == node.NodeTypeScriptTranscribe {
fields = append(fields, flatPath(p.Path))
}
}
return fields
}
// parseMap 解析模型请求参数
func parseMap(data []entity.FlowModelParams, globalParams *flowDto.FlowExecutionInput) []entity.FlowModelParams {
newData := make([]entity.FlowModelParams, 0, len(data))
for _, item := range data {
var d entity.FlowModelParams
d.Path = item.Path
d.Type = item.Type
// 无引用源:直接取静态值
if g.IsEmpty(item.ValueSource) {
if isParamEmpty(item.Value) {
continue
}
d = item
newData = append(newData, d)
continue
}
// 有引用源:单源保持旧行为;多源把各源解析出的值拼成 "label: value"(无 label 只拼值),逗号分隔
var value, refsName any
if len(item.ValueSource) > 1 {
parts := make([]string, 0, len(item.ValueSource))
for _, src := range item.ValueSource {
v, rn, ok := resolveValueSource(globalParams, src.NodeId, src.Field)
if !ok || isParamEmpty(v) {
continue
}
// 模型解析时引用其他节点的值不做类型化转换;
// 多引用源需拼接为字符串,用 toPlainString 渲染(数组取元素去括号)
text := toPlainString(v)
if src.Label != "" {
text = src.Label + ": " + text
}
parts = append(parts, text)
if !g.IsEmpty(rn) && g.IsEmpty(refsName) {
refsName = rn
}
}
if len(parts) == 0 {
// 解析失败不静默,留日志便于排查引用丢失
glog.Debugf(context.Background(),
"resolve value source failed, nodeId=%+v path=%s",
item.ValueSource, item.Path)
continue
}
d.Value = strings.Join(parts, ", ")
d.RefsName = gconv.String(refsName)
d.ValueSource = item.ValueSource
newData = append(newData, d)
continue
}
// 单个引用源:解析取非空值;模型解析时引用其他节点的数组值不做类型化转换,整体传给模型
src := item.ValueSource[0]
value, refsName, ok := resolveValueSource(globalParams, src.NodeId, src.Field)
if !ok || isParamEmpty(value) {
// 解析失败不静默,留日志便于排查引用丢失
glog.Debugf(context.Background(),
"resolve value source failed, nodeId=%+v path=%s",
item.ValueSource, item.Path)
continue
}
d.Value = value
d.RefsName = gconv.String(refsName)
d.ValueSource = item.ValueSource
newData = append(newData, d)
}
return newData
}
// toFlatMap 将解析后的参数列表转为 sjson 可用的扁平路径映射。
// key 为 Pathvalue 为参数值;保留 RefsName 供上层追踪引用来源。
func toFlatMap(params []entity.FlowModelParams) map[string]interface{} {
m := make(map[string]interface{}, len(params))
for _, p := range params {
if g.IsEmpty(p.Path) {
continue
}
m[flatPath(p.Path)] = p.Value
}
return m
}
// flatPath 把数组下标路径转扁平点分路径:a[0].b → a.0.b。
func flatPath(path string) string {
return arrayIndexPath.ReplaceAllString(path, `.$1`)
}
// isParamEmpty 判断参数值是否为"空"。
// 仅 nil、空字符串、空切片/映射视为空;0、false 等零值是合法值,保留。
func isParamEmpty(v interface{}) bool {
if v == nil {
return true
}
switch val := v.(type) {
case string:
return val == ""
case []byte:
return len(val) == 0
case []interface{}:
return len(val) == 0
case map[string]interface{}:
return len(val) == 0
default:
// 数字、布尔、结构体等一律视为非空
return false
}
}
@@ -1,4 +1,4 @@
package video
package media
import (
"ai-agent/workflow/service/flow/processor"
@@ -8,6 +8,7 @@ import (
"time"
commonHttp "gitea.redpowerfuture.com/red-future/common/http"
"gitea.redpowerfuture.com/red-future/common/utils"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
@@ -41,6 +42,9 @@ type mergeSubmitRes struct {
TaskID string `json:"taskId"`
}
// ProcessorName 处理器注册名,供模型节点前置工具分发按名判定入参形态(扁平请求体)。
const ProcessorName = "concat_videos"
func init() {
processor.Register(ConcatVideosProcessor())
}
@@ -48,11 +52,12 @@ func init() {
// ConcatVideosProcessor 合并视频
func ConcatVideosProcessor() *processor.Processor {
return &processor.Processor{
Name: "concat_videos",
Name: ProcessorName,
Description: "合并视频",
IsShow: false,
Func: func(ctx context.Context, args map[string]any) (any, error) {
outputRes := parseOutputList(args)
segments, err := collectSegmentResults(outputRes)
segments, err := collectSegmentResults(ctx, outputRes)
if err != nil {
return nil, err
}
@@ -77,10 +82,16 @@ func ConcatVideosProcessor() *processor.Processor {
if err != nil {
return nil, err
}
// 合并结果沿用输入视频的 key 返回,保持 key 不变;
// 否则下游按原 key 引用(值来源/保存文件映射)会失配
retKey := "fileURL"
if len(outputRes) > 0 {
if k := findVideoKey(outputRes[0]); k != "" {
retKey = k
}
}
return map[string]any{
"video_url": merged.FileURL,
"duration_str": merged.DurationStr,
"task_id": merged.TaskID,
retKey: merged.FileURL,
}, nil
},
}
@@ -217,7 +228,7 @@ type segmentResult struct {
// collectSegmentResults 把模型节点/串行工具的产出([]map[string]any)收敛为有序的分段结果列表。
// 兼容两种形状:串行工具产出的 {segment_index, video_url, duration}
// 并行模型调用产出的 {<url字段>: url}(按列表顺序对应各段)。
func collectSegmentResults(outputRes []map[string]any) ([]segmentResult, error) {
func collectSegmentResults(ctx context.Context, outputRes []map[string]any) ([]segmentResult, error) {
if len(outputRes) == 0 {
return nil, fmt.Errorf("没有可合并的分段视频")
}
@@ -226,7 +237,7 @@ func collectSegmentResults(outputRes []map[string]any) ([]segmentResult, error)
seg := segmentResult{
SegmentIndex: i,
Duration: gconv.Int(m["duration"]),
VideoURL: findVideoURL(m),
VideoURL: findVideoURL(ctx, m),
}
if idx := gconv.Int(m["segment_index"]); len(outputRes) > 1 && idx > 0 {
seg.SegmentIndex = idx
@@ -239,23 +250,54 @@ func collectSegmentResults(outputRes []map[string]any) ([]segmentResult, error)
return segs, nil
}
// findVideoURL 从模型返回参数中提取视频 URL:优先命中常见键,再兜底任意含 url 的 http 字段。
func findVideoURL(params map[string]any) string {
// findVideoURL 从模型返回参数中提取视频 URL:优先命中常见键,再兼容扁平点号键(content.attrs.video_url 等)任意含 url 的字段。
func findVideoURL(ctx context.Context, params map[string]any) string {
key := findVideoKey(params)
if key == "" {
return ""
}
return normalizeVideoURL(ctx, gconv.String(params[key]))
}
// findVideoKey 返回视频 URL 所在字段的 key(命中规则与 findVideoURL 一致),
// 供视频合并后以原 key 返回结果,避免下游按原 key 引用(值来源/保存文件映射)失配。
func findVideoKey(params map[string]any) string {
if params == nil {
return ""
}
for _, key := range []string{"video_url", "video_oss_url", "http_file_url", "file_url", "url"} {
if v := gconv.String(params[key]); v != "" {
return v
if gconv.String(params[key]) != "" {
return key
}
}
// 兼容扁平点号键(如 content.attrs.video_url):优先命中含 video 的键,再兜底任意含 url 的键
var fallback string
for k, v := range params {
if !strings.Contains(strings.ToLower(k), "url") {
continue
}
if s := gconv.String(v); s != "" && strings.HasPrefix(s, "http") {
return s
if gconv.String(v) == "" {
continue
}
if strings.Contains(strings.ToLower(k), "video") {
return k
}
if fallback == "" {
fallback = k
}
}
return ""
return fallback
}
// normalizeVideoURL 统一视频地址:已是完整 http(s) 链接原样返回;相对路径(MinIO 对象路径)补上文件前缀,供 media 服务下载
func normalizeVideoURL(ctx context.Context, url string) string {
if url == "" || strings.HasPrefix(url, "http") {
return url
}
prefix, err := utils.GetFileAddressPrefix(ctx)
if err != nil {
g.Log().Warningf(ctx, "获取文件前缀失败,视频地址保持相对路径: %s err=%v", url, err)
return url
}
return prefix + url
}
@@ -1,10 +1,13 @@
// Package split_batch 工作流前置处理器:按各字段 constraint.uploadTotalMaxCount 拆分模型请求参数。
// Package split_batch 工作流前置处理器:按默认上限(uploadTotalMaxCount 默认 15拆分模型请求参数为多批
// 入参为已构建好的扁平模型请求体(BuildModelRequestBody 输出,key 为点分路径,value 已解析填充),
// 集合字段按上限分批、元素对象取 url 为值;返回结构同入参(扁平 map 数组),未超量返回单份。
// 处理器实现自包含(算法随处理器走,不依赖业务包),通过 init 注册进 processor 注册表。
package split_batch
import (
"context"
"fmt"
"reflect"
"sort"
"github.com/gogf/gf/v2/util/gconv"
@@ -12,16 +15,23 @@ import (
"ai-agent/workflow/service/flow/processor"
)
// ProcessorName 处理器注册名,供模型节点前置工具分发按名判定入参形态(扁平请求体)。
const ProcessorName = "split_batch_model_params"
// defaultMaxCount 每批最大元素数(constraint.uploadTotalMaxCount 当前默认值,后续动态传递)。
const defaultMaxCount = 15
func init() {
processor.Register(SplitBatchModelParamsProcessor())
}
// SplitBatchModelParamsProcessor 将模型请求参数按各字段 constraint.uploadTotalMaxCount 分批的前置处理器。
// 入参 args 即模型请求参数本体(需已解析好 valueSourcevalue 已填充)。
// SplitBatchModelParamsProcessor 将模型请求参数按默认上限分批的前置处理器。
// 入参 args 即扁平模型请求体(valueSource 已解析、value 已填充)。
func SplitBatchModelParamsProcessor() *processor.Processor {
return &processor.Processor{
Name: "split_batch_model_params",
Description: "按 constraint.uploadTotalMaxCount 分批模型请求数",
Name: ProcessorName,
Description: "按最大约束构建分批模型请求数",
IsShow: true,
Func: func(ctx context.Context, args map[string]any) (any, error) {
if args == nil {
return nil, fmt.Errorf("缺少模型请求参数")
@@ -31,37 +41,41 @@ func SplitBatchModelParamsProcessor() *processor.Processor {
}
}
// splitBatchField 描述一个需要按 constraint.uploadTotalMaxCount 分批的字段
type splitBatchField struct {
path []any // 从参数根节点到该字段 value 的路径(map 用 key,数组用下标)
items []any // 该字段 value 拆出的待分批元素列表
maxCount int // 每批最大元素数(constraint.uploadTotalMaxCount
// batchField 描述一个需要分批的扁平字段
type batchField struct {
key string // 扁平点分 key
items []any // 分批元素(元素为带 url 字段的对象时已取 url 为值)
}
// SplitBatchModelParams 把模型请求参数拆成多批,供分批请求模型使用。
// SplitBatchModelParams 把扁平模型请求拆成多批,供分批请求模型使用。
// 处理流程:
// 1. 深拷贝入参,避免污染调用方数据;
// 2. 递归解析 valueSource,把引用节点输出的字段值写入对应字段的 value;
// 3. 找出所有带 constraint.uploadTotalMaxCount 且 value 为集合(map/slice)的字段,
// map 按 key 排序取 value 列表作为元素;总批数 = 各字段 (元素数/上限) 向上取整的最大值;
// 4. 每批 = 整份参数深拷贝 + 各分批字段 value 替换为对应切片。
// 2. 遍历扁平字段,值为集合(slice/array/map)的按元素数 / defaultMaxCount 向上取整,
// map 按 key 排序取 value 列表作为元素;元素为带 url 字段的对象时取 url 为值;
// 3. 总批数 = 各字段批数的最大值;每批 = 整份参数深拷贝 + 各分批字段替换为该批切片,
// 元素已耗尽的分批字段替换为切片。
//
// 未超量时返回单份value 已被解析填充)参数。调用方可遍历返回值逐个请求模型。
// 未超量时返回单份参数。调用方可遍历返回值逐个请求模型。
func SplitBatchModelParams(rawParams map[string]any) []map[string]any {
params, ok := deepCopyAny(rawParams).(map[string]any)
if !ok {
params = make(map[string]any)
}
fields := make([]splitBatchField, 0)
collectBatchFields(params, nil, &fields)
fields := make([]batchField, 0)
batchCount := 1
for _, f := range fields {
n := (len(f.items) + f.maxCount - 1) / f.maxCount
for key, v := range params {
items, has := toItems(v)
if !has {
continue
}
n := (len(items) + defaultMaxCount - 1) / defaultMaxCount
if n > batchCount {
batchCount = n
}
if n > 1 {
fields = append(fields, batchField{key: key, items: items})
}
}
if batchCount <= 1 {
return []map[string]any{params}
@@ -71,67 +85,46 @@ func SplitBatchModelParams(rawParams map[string]any) []map[string]any {
for i := 0; i < batchCount; i++ {
batch, _ := deepCopyAny(params).(map[string]any)
for _, f := range fields {
start := i * f.maxCount
start := i * defaultMaxCount
if start >= len(f.items) {
setValueAtPath(batch, f.path, []any{})
batch[f.key] = []any{}
continue
}
end := start + f.maxCount
end := start + defaultMaxCount
if end > len(f.items) {
end = len(f.items)
}
setValueAtPath(batch, f.path, f.items[start:end])
batch[f.key] = f.items[start:end]
}
batches = append(batches, batch)
}
return batches
}
// collectBatchFields 递归收集所有带 constraint.uploadTotalMaxCount 且 value 为集合的分批字段
func collectBatchFields(node any, path []any, out *[]splitBatchField) {
switch v := node.(type) {
case map[string]any:
if maxCount, ok := uploadTotalMaxCountOf(v); ok {
if items, has := toItems(v["value"]); has {
*out = append(*out, splitBatchField{
path: append(append([]any{}, path...), "value"),
items: items,
maxCount: maxCount,
})
}
}
for key, child := range v {
collectBatchFields(child, append(append([]any{}, path...), key), out)
}
case []any:
for i, child := range v {
collectBatchFields(child, append(append([]any{}, path...), i), out)
// itemValue 取集合元素作为分批粒度时的值:元素为带 url 字段的对象时取 url"取url为值"),
// 其余元素原样保留。
func itemValue(e any) any {
if m := gconv.Map(e); m != nil {
if u, ok := m["url"]; ok && u != nil {
return u
}
}
return e
}
// uploadTotalMaxCountOf 读取 schema 节点 constraint.uploadTotalMaxCount
func uploadTotalMaxCountOf(m map[string]any) (int, bool) {
c, ok := m["constraint"]
if !ok {
return 0, false
}
cm := gconv.Map(c)
if cm == nil {
return 0, false
}
n := gconv.Int(cm["uploadTotalMaxCount"])
if n <= 0 {
return 0, false
}
return n, true
}
// toItems 把集合 value 转成元素列表:切片原样返回;map 按 key 排序取 value,保证分批顺序稳定
// toItems 把集合 value 转成元素列表:切片逐元素转换(对象取 url 为值);map 按 key 排序取 value
// 类型化切片([]string 等)经反射逐元素转换。
func toItems(v any) ([]any, bool) {
switch val := v.(type) {
case []any:
return val, len(val) > 0
if len(val) == 0 {
return nil, false
}
items := make([]any, 0, len(val))
for _, e := range val {
items = append(items, itemValue(e))
}
return items, true
case map[string]any:
if len(val) == 0 {
return nil, false
@@ -143,50 +136,26 @@ func toItems(v any) ([]any, bool) {
sort.Strings(keys)
items := make([]any, 0, len(keys))
for _, k := range keys {
items = append(items, val[k])
items = append(items, itemValue(val[k]))
}
return items, true
}
rv := reflect.ValueOf(v)
if rv.IsValid() && (rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array) {
n := rv.Len()
if n == 0 {
return nil, false
}
items := make([]any, 0, n)
for i := 0; i < n; i++ {
items = append(items, itemValue(rv.Index(i).Interface()))
}
return items, true
}
return nil, false
}
// setValueAtPath 沿 path 逐级导航(map 用 key、数组用下标),在最后一级写入 value
func setValueAtPath(root map[string]any, path []any, value any) {
if len(path) == 0 {
return
}
var cur any = root
for i := 0; i < len(path)-1; i++ {
switch step := path[i].(type) {
case string:
m, ok := cur.(map[string]any)
if !ok {
return
}
cur = m[step]
case int:
s, ok := cur.([]any)
if !ok || step < 0 || step >= len(s) {
return
}
cur = s[step]
default:
return
}
}
switch last := path[len(path)-1].(type) {
case string:
if m, ok := cur.(map[string]any); ok {
m[last] = value
}
case int:
if s, ok := cur.([]any); ok && last >= 0 && last < len(s) {
s[last] = value
}
}
}
// deepCopyAny 深拷贝 map[string]any / []any 嵌套结构,避免批次之间互相影响
// deepCopyAny 深拷贝 map[string]any / []any / 类型化切片嵌套结构,避免批次之间互相影响
func deepCopyAny(v any) any {
switch val := v.(type) {
case map[string]any:
@@ -201,7 +170,18 @@ func deepCopyAny(v any) any {
res[i] = deepCopyAny(child)
}
return res
default:
return val
}
rv := reflect.ValueOf(v)
if rv.IsValid() && (rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array) {
n := rv.Len()
out := reflect.MakeSlice(rv.Type(), n, n)
for i := 0; i < n; i++ {
d := deepCopyAny(rv.Index(i).Interface())
if d != nil {
out.Index(i).Set(reflect.ValueOf(d))
}
}
return out.Interface()
}
return v
}
@@ -0,0 +1,161 @@
// Package split_segment 工作流前置处理器:把聚合了脚本转写各段参数的扁平请求体按段拆成多份,
// 供分段模型请求并行执行。
//
// 脚本转写节点(split_shots_pipeline 产出)的下游模型节点单源引用其字段时,
// BuildModelRequestBody 把这些字段的数组值收集进 __segment_fields(逗号分隔的扁平路径),
// 处理器按段序把每份参数拆成独立请求体,段与段之间互不影响(深拷贝)。
// 处理器自包含(算法随处理器走,不依赖业务包),通过 init 注册进 processor 注册表。
package split_segment
import (
"context"
"fmt"
"reflect"
"strings"
"ai-agent/workflow/service/flow/processor"
)
// ProcessorName 处理器注册名,供模型节点前置工具分发按名判定入参形态(扁平请求体 + __segment_fields 标记)。
const ProcessorName = "split_segment"
func init() {
processor.Register(SplitSegmentProcessor())
}
// SplitSegmentProcessor 按段拆分模型请求参数的前置处理器。
// 入参 args 即扁平模型请求体(BuildModelRequestBody 输出,含 __segment_fields 标记)。
func SplitSegmentProcessor() *processor.Processor {
return &processor.Processor{
Name: ProcessorName,
Description: "按段拆分脚本转写聚合的模型请求参数",
IsShow: true,
Func: func(ctx context.Context, args map[string]any) (any, error) {
if args == nil {
return nil, fmt.Errorf("缺少模型请求参数")
}
return SplitSegmentModelParams(args)
},
}
}
// segField 描述一个待拆分的分段字段
type segField struct {
path string // 扁平点分路径
items []any // 按段序排列的元素(元素为 nil 表示该段无此字段)
}
// SplitSegmentModelParams 把扁平模型请求体按段拆成多份,供分段请求模型使用。
// 处理流程:
// 1. 读取 __segment_fields(逗号分隔的扁平路径),无标记则返回单份参数;
// 2. 逐个解析分段字段的数组值,要求非空且各字段长度一致(不一致直接报错,避免按错位拆分);
// 3. 按段数深拷贝整份参数,各段覆盖其分段字段为该段元素;元素为 nil(该段无此字段)时删除该键,
// 避免把 null 传给模型网关。
//
// 返回的每份参数仍保留 __segment_fields(由 invokePreTool 统一剥离 __ 前缀内部键)。
func SplitSegmentModelParams(rawParams map[string]any) ([]map[string]any, error) {
paths := segmentFieldsFromArgs(rawParams)
if len(paths) == 0 {
return []map[string]any{rawParams}, nil
}
fields := make([]segField, 0, len(paths))
segmentCount := 0
for _, path := range paths {
v, has := rawParams[path]
if !has {
return nil, fmt.Errorf("分段字段[%s]缺失", path)
}
items, ok := asItems(v)
if !ok || len(items) == 0 {
return nil, fmt.Errorf("分段字段[%s]值不是数组或为空", path)
}
if segmentCount == 0 {
segmentCount = len(items)
} else if len(items) != segmentCount {
return nil, fmt.Errorf("分段字段长度不一致: %s=%d, 期望 %d", path, len(items), segmentCount)
}
fields = append(fields, segField{path: path, items: items})
}
batches := make([]map[string]any, 0, segmentCount)
for i := 0; i < segmentCount; i++ {
batch, _ := deepCopyAny(rawParams).(map[string]any)
for _, f := range fields {
if f.items[i] == nil {
delete(batch, f.path)
continue
}
batch[f.path] = f.items[i]
}
batches = append(batches, batch)
}
return batches, nil
}
// segmentFieldsFromArgs 解析 __segment_fields 标记为扁平路径列表,空值返回 nil。
func segmentFieldsFromArgs(args map[string]any) []string {
raw, ok := args["__segment_fields"].(string)
if !ok || strings.TrimSpace(raw) == "" {
return nil
}
var paths []string
for _, p := range strings.Split(raw, ",") {
if t := strings.TrimSpace(p); t != "" {
paths = append(paths, t)
}
}
return paths
}
// asItems 取分段字段值作为元素列表:[]any 直接返回(元素保持原样,nil 段位保留);
// 类型化切片([]string 等)经反射逐元素转 any。非切片返回 false。
func asItems(v any) ([]any, bool) {
if list, ok := v.([]any); ok {
return list, true
}
rv := reflect.ValueOf(v)
if rv.IsValid() && (rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array) {
n := rv.Len()
if n == 0 {
return nil, true
}
items := make([]any, 0, n)
for i := 0; i < n; i++ {
items = append(items, rv.Index(i).Interface())
}
return items, true
}
return nil, false
}
// deepCopyAny 深拷贝 map[string]any / []any / 类型化切片嵌套结构,避免批次之间互相影响
func deepCopyAny(v any) any {
switch val := v.(type) {
case map[string]any:
res := make(map[string]any, len(val))
for k, child := range val {
res[k] = deepCopyAny(child)
}
return res
case []any:
res := make([]any, len(val))
for i, child := range val {
res[i] = deepCopyAny(child)
}
return res
}
rv := reflect.ValueOf(v)
if rv.IsValid() && (rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array) {
n := rv.Len()
out := reflect.MakeSlice(rv.Type(), n, n)
for i := 0; i < n; i++ {
d := deepCopyAny(rv.Index(i).Interface())
if d != nil {
out.Index(i).Set(reflect.ValueOf(d))
}
}
return out.Interface()
}
return v
}
@@ -0,0 +1,100 @@
package pipeline
// Config 统一浮点/阈值配置(设计 §4)。所有阈值唯一出处,阶段函数内不出现硬编码字面量。
// 零值字段在 resolveConfig 统一由 DefaultConfig() 补齐。
type Config struct {
// 时长计算(秒,float 允许更精细的弹性分配)
CharPerSecond float64 // 语速(字/秒),默认 4
FastCharPerSecond float64 // 感叹/疑问多时的语速,默认 6
SlowCharPerSecond float64 // 低落语气时的语速,默认 3
MinDurBuffer float64 // 有声镜头时长缓冲(秒),默认 1
MinVisualDur float64 // 纯视觉镜头最小时长(秒),默认 1
VisualWeight float64 // 纯视觉镜头弹性权重,默认 2.0
SpokenWeight float64 // 有声镜头弹性权重,默认 0.5
// 切分/断句
SplitWindow int // 断句搜索窗口(rune),默认 60
ShortFragmentDur float64 // 短残片阈值(秒),默认 1;时间线为整数秒,残片 ≤ 该值(通常即 1s)走并入逻辑
BoundaryTolerance float64 // 语义切点容差(秒),<=0 时按 max(MaxSegmentDur*0.2, 2) 派生
MaxSplitIter int // 残片并入上限重切的迭代上限,默认 10;超限返回 ErrSegmentInfeasible
// 参考素材与截断
MaxRefs int // 单段参考素材上限,默认 5
MaxPromptChars int // prompt 截断长度(rune),<=0 不截断
MinPromptFloor int // prompt 截断保底长度(rune),默认 50
StrictInvariant bool // 时间线不变量校验:true 校验失败返回 ErrTimelineInvariantfalse 仅记录告警
}
// DefaultConfig 返回默认配置。BoundaryTolerance 依赖 MaxSegmentDur,由 resolveConfig 派生。
func DefaultConfig() Config {
return Config{
CharPerSecond: 4,
FastCharPerSecond: 6,
SlowCharPerSecond: 3,
MinDurBuffer: 1,
MinVisualDur: 1,
VisualWeight: 2.0,
SpokenWeight: 0.5,
SplitWindow: 60,
ShortFragmentDur: 1,
BoundaryTolerance: 0, // 派生子:max(MaxSegmentDur*0.2, 2)
MaxSplitIter: 10,
MaxRefs: 5,
MaxPromptChars: 0,
MinPromptFloor: 50,
}
}
// resolveConfig 用默认值补齐 cfg 的零值字段。maxSegmentDur 用于派生 BoundaryTolerance。
func resolveConfig(cfg Config, maxSegmentDur int) Config {
def := DefaultConfig()
if cfg.CharPerSecond <= 0 {
cfg.CharPerSecond = def.CharPerSecond
}
if cfg.FastCharPerSecond <= 0 {
cfg.FastCharPerSecond = def.FastCharPerSecond
}
if cfg.SlowCharPerSecond <= 0 {
cfg.SlowCharPerSecond = def.SlowCharPerSecond
}
if cfg.MinDurBuffer <= 0 {
cfg.MinDurBuffer = def.MinDurBuffer
}
if cfg.MinVisualDur <= 0 {
cfg.MinVisualDur = def.MinVisualDur
}
if cfg.VisualWeight <= 0 {
cfg.VisualWeight = def.VisualWeight
}
if cfg.SpokenWeight <= 0 {
cfg.SpokenWeight = def.SpokenWeight
}
if cfg.SplitWindow <= 0 {
cfg.SplitWindow = def.SplitWindow
}
if cfg.ShortFragmentDur <= 0 {
cfg.ShortFragmentDur = def.ShortFragmentDur
}
if cfg.MaxSplitIter <= 0 {
cfg.MaxSplitIter = def.MaxSplitIter
}
if cfg.MaxRefs <= 0 {
cfg.MaxRefs = def.MaxRefs
}
if cfg.MaxPromptChars <= 0 {
cfg.MaxPromptChars = def.MaxPromptChars
}
if cfg.MinPromptFloor <= 0 {
cfg.MinPromptFloor = def.MinPromptFloor
}
if cfg.BoundaryTolerance <= 0 {
tol := float64(maxSegmentDur) * 0.2
if tol < 2 {
tol = 2
}
cfg.BoundaryTolerance = tol
}
return cfg
}
@@ -43,6 +43,7 @@ func SplitShotsPipelineProcessor() *processor.Processor {
return &processor.Processor{
Name: "split_shots_pipeline",
Description: "按 pipeline 时间线算法拆段,产出各段模型请求参数(与 split_shots 并存,灰度用)",
IsShow: false,
Func: func(ctx context.Context, args map[string]any) (any, error) {
input, err := parseSplitShotsInput(args)
if err != nil {
@@ -14,6 +14,7 @@ import (
type Processor struct {
Name string
Description string
IsShow bool
Func func(ctx context.Context, args map[string]any) (any, error)
}
+64 -25
View File
@@ -14,6 +14,7 @@ import (
"gitea.redpowerfuture.com/red-future/common/tools"
wsCommon "gitea.redpowerfuture.com/red-future/common/websocket"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/glog"
"github.com/gogf/gf/v2/util/gconv"
@@ -44,9 +45,41 @@ func handleToolAgent(ctx context.Context, conn *wsCommon.WsConnection, payload i
return
}
saveCtx := context.WithoutCancel(ctx)
id := p.Id
if g.IsEmpty(id) {
// 会话落库:前端临时 sessionId 对应已存在会话则复用,否则新建
err := ensureSession(saveCtx, conn.SessionId, p.Question)
if err != nil {
glog.Errorf(ctx, "会话创建失败: %v", err)
pushAgentEvent(conn, runner.ReActEvent{Type: runner.ReActEventError, Message: "会话创建失败", Error: err.Error()})
return
}
// 问答落库:
chatId, err := sessionDao.ExecChatDao.Insert(ctx, &sessionDto.CreateExecChatReq{
SessionId: conn.SessionId,
RequestParams: entity.ExecChatRequestParams{Question: p.Question},
})
if err != nil {
glog.Errorf(ctx, "问答创建失败: %v", err)
pushAgentEvent(conn, runner.ReActEvent{Type: runner.ReActEventError, Message: "问答创建失败", Error: err.Error()})
return
}
id = chatId
pushAgentEvent(conn, runner.ReActEvent{Type: runner.ReActEventRoundStart, Id: chatId})
}
modelTools, err := tools.Default.List(ctx)
if err != nil {
pushAgentEvent(conn, runner.ReActEvent{Type: runner.ReActEventError, Message: "工具列表获取失败", Error: err.Error()})
errChat := recordChat(saveCtx, id, "", "工具列表获取失败", err, 0, 0, 0)
if errChat != nil {
glog.Errorf(ctx, "普通对话落库失败: %v", errChat)
pushAgentEvent(conn, runner.ReActEvent{Type: runner.ReActEventError, Message: "对话落库失败", Error: errChat.Error()})
}
return
}
systemPrompt := p.SystemPrompt
@@ -62,13 +95,6 @@ func handleToolAgent(ctx context.Context, conn *wsCommon.WsConnection, payload i
conn.SetMeta("toolCancel", agentCancel)
defer conn.SetMeta("toolCancel", nil)
defer agentCancel()
saveCtx := context.WithoutCancel(ctx)
// 会话落库:前端临时 sessionId 对应已存在会话则复用,否则新建
err = ensureSession(saveCtx, conn.SessionId, p.Question)
if err != nil {
pushAgentEvent(conn, runner.ReActEvent{Type: runner.ReActEventError, Message: "会话创建失败", Error: err.Error()})
}
agent := runner.NewReActAgent(p.ModelId, conn.SessionId, modelTools, systemPrompt, defaultAgentMaxStep)
agent.OnEvent = func(ev runner.ReActEvent) {
@@ -79,14 +105,22 @@ func handleToolAgent(ctx context.Context, conn *wsCommon.WsConnection, payload i
answer, runErr := agent.Run(agentCtx, p.Question)
duration := int64(time.Since(start).Seconds())
// 前端终止:结果/错误不推前端,仅把已产生的 token 正常落库(错误记「用户已终止对话」)
// 前端终止:结果/错误不推前端,仅把已产生的 token 正常落库(友好提示记「用户已终止对话」,不记原始错误
var errMsg string
terminated := runErr != nil && errors.Is(runErr, context.Canceled)
if terminated {
runErr = errChatTerminated
errMsg = errChatTerminated.Error()
runErr = nil
} else if runErr != nil {
errMsg = "对话运行失败"
pushAgentEvent(conn, runner.ReActEvent{Type: runner.ReActEventError, Message: "对话运行失败", Error: runErr.Error()})
}
recordChat(saveCtx, conn.SessionId, p.Question, answer, runErr, agent.TotalTokens, agent.TotalCost, duration)
err = recordChat(saveCtx, id, answer, errMsg, runErr, agent.TotalTokens, agent.TotalCost, duration)
if err != nil {
glog.Errorf(ctx, "普通对话落库失败: %v", err)
pushAgentEvent(conn, runner.ReActEvent{Type: runner.ReActEventError, Message: "对话落库失败", Error: err.Error()})
}
pushAgentEvent(conn, runner.ReActEvent{Type: runner.ReActEventAnswer, Answer: answer})
}
// handleToolAgentCancel 终止正在运行的对话(前端停止按钮发送 agent_cancel
@@ -106,33 +140,32 @@ func getToolCancel(conn *wsCommon.WsConnection) context.CancelFunc {
// errChatTerminated 前端终止对话的错误标记(写入 exec_chat.error_message
var errChatTerminated = errors.New("用户已终止对话")
// recordChat 把一次普通对话写入 exec_chat:答案传 OSS 存 result_file_url错误写 error_message
// token 与费用(模型网关返回的累计 cost)落库
func recordChat(ctx context.Context, sessionId string, question, answer string, runErr error, totalTokens int64, totalCost float64, duration int64) {
var resultFileUrl, errorMessage string
// recordChat 把一次普通对话写入 exec_chat:答案传 OSS 存 result_file_url
// 友好提示写 error_message,原始错误写 errortoken 与费用(模型网关返回的累计 cost)落库
func recordChat(ctx context.Context, id int64, answer string, msg string, runErr error, totalTokens int64, totalCost float64, duration int64) error {
var resultFileUrl string
if runErr == nil && answer != "" {
//answerJSON := gjson.New(map[string]any{"answer": answer}).MustToJson()
url, uploadErr := gateway.Upload(ctx, fmt.Sprintf("chat_%v_%d.txt", sessionId, time.Now().UnixMilli()), []byte(answer))
url, uploadErr := gateway.Upload(ctx, fmt.Sprintf("chat_%v_%d.txt", id, time.Now().UnixMilli()), []byte(answer))
if uploadErr != nil {
glog.Errorf(ctx, "普通对话答案上传OSS失败: %v", uploadErr)
} else {
resultFileUrl = url
}
} else if runErr != nil {
errorMessage = runErr.Error()
}
_, err := sessionDao.ExecChatDao.Insert(ctx, &sessionDto.CreateExecChatReq{
SessionId: sessionId,
var errorDetail string
if runErr != nil {
errorDetail = runErr.Error()
}
_, err := sessionDao.ExecChatDao.Update(ctx, &sessionDto.UpdateExecChatReq{
Id: id,
Duration: duration,
RequestParams: entity.ExecChatRequestParams{Question: question},
ResultFileUrl: resultFileUrl,
TotalTokens: int(totalTokens),
TotalFee: totalCost,
ErrorMessage: errorMessage,
ErrorMessage: msg,
Error: errorDetail,
})
if err != nil {
glog.Errorf(ctx, "普通对话落库失败: %v", err)
}
return err
}
// pushAgentEvent 把 ReAct 过程事件转为 WS 推送消息
@@ -141,6 +174,12 @@ func pushAgentEvent(conn *wsCommon.WsConnection, ev runner.ReActEvent) {
return
}
switch ev.Type {
case runner.ReActEventRoundStart:
_ = conn.WriteJSON(&wsCommon.WsPushMsg{
Type: string(ev.Type),
Message: "运行开始",
Data: map[string]interface{}{"recordId": ev.Id},
})
case runner.ReActEventModelCall:
_ = conn.WriteJSON(&wsCommon.WsPushMsg{
Type: string(ev.Type),
@@ -8,6 +8,7 @@ import (
"ai-agent/workflow/consts/model"
"ai-agent/workflow/consts/node"
nodeDto "ai-agent/workflow/model/dto/node"
"ai-agent/workflow/service/flow/processor"
"github.com/gogf/gf/v2/frame/g"
)
@@ -23,11 +24,67 @@ func (s *nodeLibraryService) GetNodeLibrary(ctx context.Context, req *nodeDto.Wo
} else {
applyVideoModelOptions(tree, opts)
}
// 前置/后置方法下拉填充处理器注册表数据
if opts, err := processorOptions(ctx); err != nil {
g.Log().Warningf(ctx, "加载工作流前后置处理器选项失败,节点库降级返回: %v", err)
} else {
applyProcessorOptions(tree, opts)
}
return &nodeDto.WorkflowNodeTreeRes{
Groups: tree,
}, nil
}
// processorOptions 从处理器注册表构建前置/后置方法下拉选项:key=处理器名,value=描述(无描述回落名称)。
func processorOptions(ctx context.Context) ([]node.SelectOption, error) {
list, err := processor.List(ctx)
if err != nil {
return nil, err
}
opts := make([]node.SelectOption, 0, len(list))
for _, p := range list {
if p == nil || p.Name == "" || !p.IsShow {
continue
}
value := p.Description
if value == "" {
value = p.Name
}
opts = append(opts, node.SelectOption{
Key: p.Name,
Value: value,
})
}
return opts, nil
}
// applyProcessorOptions 把处理器选项填充进模型节点的 preTool/postTool 下拉。
// 深拷贝 PreToolOption/PostToolOption 后再改,避免改写全局 NodeTypeMetaList。
func applyProcessorOptions(tree []node.NodeGroupTree, opts []node.SelectOption) {
for gi := range tree {
for ni := range tree[gi].Nodes {
n := &tree[gi].Nodes[ni]
if n.Key != node.NodeTypeModel {
continue
}
n.PreToolOption = withProcessorOptions(n.PreToolOption, opts)
n.PostToolOption = withProcessorOptions(n.PostToolOption, opts)
return
}
}
}
// withProcessorOptions 返回 preTool/postTool 字段列表的深拷贝,并把 select 类型字段的 Options 替换为处理器选项。
func withProcessorOptions(fields []node.NodePresetField, opts []node.SelectOption) []node.NodePresetField {
out := append([]node.NodePresetField(nil), fields...)
for i := range out {
if out[i].Type == "select" {
out[i].Options = opts
}
}
return out
}
// videoModelOptions 按视频模型类型(600)查模型网关,转成 modelId 下拉选项:key=模型IDvalue=模型名称。
func videoModelOptions(ctx context.Context) ([]node.SelectOption, error) {
res, err := gateway.ListModelManage(ctx, &gateway.ListModelManageReq{ModelType: model.TypeVideo})
@@ -183,6 +183,7 @@ func chatExecVO(c *entity.ExecChat) *sessionDto.VOSessionInfoResult {
TotalTokens: c.TotalTokens,
TotalFee: c.TotalFee,
ErrorMsg: c.ErrorMessage,
Error: c.Error,
CreatedAt: c.CreatedAt,
}
}
@@ -206,6 +207,7 @@ func workflowExecVO(w *entity.ExecWorkflow, resultFileUrl string) *sessionDto.VO
TotalTokens: w.TotalTokens,
TotalFee: w.TotalFee,
ErrorMsg: w.ErrorMessage,
Error: w.Error,
CreatedAt: w.CreatedAt,
}
}
@@ -326,3 +328,8 @@ func outputItemSuffix(ext string) string {
return "内容"
}
}
func (s *sessionService) ResultDelete(ctx context.Context, req *sessionDto.DeleteWorkflowResultReq) (err error) {
_, err = sessionDao.ExecWorkflowResultDao.Delete(ctx, req)
return
}