feat: 支持工作流断点续跑并拆分错误信息存储
- 新增同会话+同工作流最近执行失败且参数一致时断点续跑逻辑 - exec_workflow/exec_chat 新增 error 字段存储原始错误,error_message 仅存友好提示 - 新增 UpdateExecChatReq 与 exec_chat_dao Update 方法 - 新增 GetLatestBySessionAndFlow 查询最近执行记录 - 修正 ListDates 分组与排序 SQL 表达式 - 新增 pipeline 配置结构,删除旧设计文档
This commit is contained in:
+9
-2
@@ -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 前缀策略:可配置,默认全用 characterN(video-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 // 实体名 → token(character1...)
|
||||
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。
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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表语句---------------------------
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"` // 执行状态:成功/失败
|
||||
}
|
||||
|
||||
//=======================================================================================
|
||||
|
||||
//=============================================================================
|
||||
|
||||
// 原始入参结构体
|
||||
|
||||
@@ -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不能为空"`
|
||||
}
|
||||
|
||||
@@ -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不能为空"`
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 才入库;原始响应体 key(respBody)恒入库
|
||||
// 第二层: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 URI:data:<mime>;base64,<payload>
|
||||
if b, mime, ok := parseDataURI(s); ok {
|
||||
return false, "", b, extOfMime(mime)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.Number(gconv 反序列化数字的运行时类型)与字符串 "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 为 Path,value 为参数值;保留 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 即模型请求参数本体(需已解析好 valueSource,value 已填充)。
|
||||
// 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 校验失败返回 ErrTimelineInvariant;false 仅记录告警
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,原始错误写 error,token 与费用(模型网关返回的累计 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=模型ID,value=模型名称。
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user