Files
ai-agent/workflow/service/session/session_service.go
T
19904408334 cef837a35c feat: 支持工作流断点续跑并拆分错误信息存储
- 新增同会话+同工作流最近执行失败且参数一致时断点续跑逻辑
- exec_workflow/exec_chat 新增 error 字段存储原始错误,error_message 仅存友好提示
- 新增 UpdateExecChatReq 与 exec_chat_dao Update 方法
- 新增 GetLatestBySessionAndFlow 查询最近执行记录
- 修正 ListDates 分组与排序 SQL 表达式
- 新增 pipeline 配置结构,删除旧设计文档
2026-08-21 09:54:06 +08:00

336 lines
9.4 KiB
Go

package session
import (
"ai-agent/gateway"
"ai-agent/workflow/consts/flow"
flowDao "ai-agent/workflow/dao/flow"
sessionDao "ai-agent/workflow/dao/session"
flowDto "ai-agent/workflow/model/dto/flow"
sessionDto "ai-agent/workflow/model/dto/session"
"ai-agent/workflow/model/entity"
flowService "ai-agent/workflow/service/flow"
"context"
"fmt"
"sort"
"strings"
"gitea.redpowerfuture.com/red-future/common/beans"
"gitea.redpowerfuture.com/red-future/common/utils"
"github.com/gogf/gf/v2/os/glog"
"github.com/gogf/gf/v2/util/gconv"
)
var SessionService = &sessionService{}
type sessionService struct{}
// 结果状态(与 workflow_session_result.status / VOSessionInfoResult.Status 一致)
const (
resultStatusSuccess = 2
resultStatusFailed = 3
resultStatusCancel = 4
)
func (s *sessionService) List(ctx context.Context, req *sessionDto.ListSessionReq) (res *sessionDto.ListSessionRes, err error) {
user, err := utils.GetUserInfo(ctx)
if err != nil {
return
}
var page *beans.Page
if req.PageSize > 0 {
page = &beans.Page{PageNum: req.PageNum, PageSize: req.PageSize}
}
list, total, err := sessionDao.SessionDao.List(ctx, user.UserName, page)
if err != nil {
return
}
res = &sessionDto.ListSessionRes{Total: total}
for _, item := range list {
res.List = append(res.List, &sessionDto.VOSession{
SessionId: item.SessionId,
SessionName: item.SessionName,
CreatedAt: item.CreatedAt,
})
}
return
}
func (s *sessionService) Delete(ctx context.Context, req *sessionDto.DeleteSessionReq) (err error) {
_, err = sessionDao.SessionDao.Delete(ctx, req)
return
}
func (s *sessionService) DeleteRecord(ctx context.Context, req *sessionDto.DeleteSessionRecordReq) (err error) {
var chatIds, wfIds []int64
for _, item := range req.Ids {
if item.Type == "chat" {
chatIds = append(chatIds, item.Id)
} else {
wfIds = append(wfIds, item.Id)
}
}
if len(chatIds) > 0 {
if _, e := sessionDao.ExecChatDao.Delete(ctx, &sessionDto.DeleteExecChatReq{Id: chatIds}); e != nil {
return e
}
}
if len(wfIds) > 0 {
if _, e := sessionDao.ExecWorkflowDao.Delete(ctx, &sessionDto.DeleteExecWorkflowReq{Id: wfIds}); e != nil {
return e
}
}
return
}
// Get 会话内结果:普通对话 + 工作流执行混排,按创建时间倒序,分页
func (s *sessionService) Get(ctx context.Context, req *sessionDto.GetSessionInfoReq) (res *sessionDto.GetSessionInfoRes, err error) {
chatList, err := sessionDao.ExecChatDao.ListBySession(ctx, req.SessionId)
if err != nil {
return
}
wfList, err := sessionDao.ExecWorkflowDao.ListBySession(ctx, req.SessionId)
if err != nil {
return
}
wfResultList, err := sessionDao.ExecWorkflowResultDao.ListBySession(ctx, req.SessionId)
if err != nil {
return
}
prefix, _ := utils.GetFileAddressPrefix(ctx)
// 工作流结果按 exec_id 分组,合并到对应执行记录的结果文件URL
resultByExec := make(map[int64][]string)
for _, wr := range wfResultList {
if wr.ResultFileUrl != "" {
resultByExec[wr.ExecId] = append(resultByExec[wr.ExecId], prefix+wr.ResultFileUrl)
}
}
res = &sessionDto.GetSessionInfoRes{}
for _, c := range chatList {
c.ResultFileUrl = prefix + c.ResultFileUrl
res.List = append(res.List, chatExecVO(c))
}
for _, w := range wfList {
res.List = append(res.List, workflowExecVO(w, strings.Join(resultByExec[w.Id], ",")))
}
sort.Slice(res.List, func(i, j int) bool {
ci, cj := res.List[i].CreatedAt, res.List[j].CreatedAt
if ci == nil {
return false
}
if cj == nil {
return true
}
return ci.After(cj)
})
res.Total = len(res.List)
if req.PageSize > 0 {
start := int((req.PageNum - 1) * req.PageSize)
if start < 0 {
start = 0
}
if start >= res.Total {
res.List = nil
return
}
end := start + int(req.PageSize)
if end > res.Total {
end = res.Total
}
res.List = res.List[start:end]
}
// 读取结果文件内容(仅 .txt)放入 ResultContent(仅当前页),供前端直接展示;路径仍保留在 ResultFileUrl
for _, vo := range res.List {
vo.ResultContent = readResultFileContent(ctx, vo.ResultFileUrl)
}
return
}
// readResultFileContent 读取结果 txt 文件内容(支持逗号分隔的多个 URL),仅 .txt 文件被读取,多个内容用换行连接
func readResultFileContent(ctx context.Context, fileUrl string) string {
if fileUrl == "" {
return ""
}
var parts []string
for _, u := range strings.Split(fileUrl, ",") {
u = strings.TrimSpace(u)
if u == "" || !strings.HasSuffix(strings.ToLower(u), ".txt") {
continue
}
fileBytes, err := gateway.GetFileBytesFromURL(ctx, u)
if err != nil {
glog.Warningf(ctx, "读取结果 txt 文件失败: %v", err)
continue
}
parts = append(parts, string(fileBytes))
}
return strings.Join(parts, "\n")
}
func chatExecVO(c *entity.ExecChat) *sessionDto.VOSessionInfoResult {
status := resultStatusSuccess
if c.ErrorMessage != "" {
status = resultStatusFailed
}
return &sessionDto.VOSessionInfoResult{
Id: c.Id,
Type: "chat",
Status: status,
RequestParams: map[string]any{"question": c.RequestParams.Question},
ResultFileUrl: c.ResultFileUrl,
TotalTokens: c.TotalTokens,
TotalFee: c.TotalFee,
ErrorMsg: c.ErrorMessage,
Error: c.Error,
CreatedAt: c.CreatedAt,
}
}
func workflowExecVO(w *entity.ExecWorkflow, resultFileUrl string) *sessionDto.VOSessionInfoResult {
status := 1
if w.Status == flow.FlowExecutionStatusFailed.Code() {
status = resultStatusFailed
} else if w.Status == flow.FlowExecutionStatusSuccess.Code() {
status = resultStatusSuccess
} else if w.Status == flow.FlowExecutionStatusCancel.Code() {
status = resultStatusCancel
}
return &sessionDto.VOSessionInfoResult{
Id: w.Id,
Type: "workflow",
Status: status,
FlowId: w.FlowId,
RequestParams: gconv.Map(w.RequestParams),
ResultFileUrl: resultFileUrl,
TotalTokens: w.TotalTokens,
TotalFee: w.TotalFee,
ErrorMsg: w.ErrorMessage,
Error: w.Error,
CreatedAt: w.CreatedAt,
}
}
// ResultList 工作流执行结果树:按创建人分页查询工作流执行记录,返回按天分组的树结构(日期→流程→结果文件)。
// 分页单位为"天":每页返回 pageSize 个完整日期,同一天内的流程与文件不会被拆到不同页;pageSize 为 0 时返回全部。
func (s *sessionService) ResultList(ctx context.Context, req *sessionDto.ListWorkflowResultReq) (res *flowDto.ListFlowExecutionTreeRes, err error) {
user, err := utils.GetUserInfo(ctx)
if err != nil {
return
}
dates, err := sessionDao.ExecWorkflowDao.ListDates(ctx, user.UserName, req.Page)
if err != nil {
return
}
res = &flowDto.ListFlowExecutionTreeRes{}
res.ImgAddressPrefix, _ = utils.GetFileAddressPrefix(ctx)
if len(dates) == 0 {
return
}
execs, err := sessionDao.ExecWorkflowDao.ListByDates(ctx, user.UserName, dates)
if err != nil {
return
}
// 汇总本页所有执行的 id 与去重后的流程 id,一次取回结果文件与流程名
var execIds []int64
flowIdSet := make(map[int64]struct{})
for _, e := range execs {
execIds = append(execIds, e.Id)
flowIdSet[e.FlowId] = struct{}{}
}
results, err := sessionDao.ExecWorkflowResultDao.ListByExecIds(ctx, execIds)
if err != nil {
return
}
flowNameMap := make(map[int64]string)
for fid := range flowIdSet {
if fu, e := flowDao.FlowUserDao.Get(ctx, &flowDto.GetFlowUserReq{Id: fid}); e == nil && fu != nil && fu.FlowName != "" {
flowNameMap[fid] = fu.FlowName
}
}
resultsByExec := make(map[int64][]*entity.ExecWorkflowResult)
for _, r := range results {
resultsByExec[r.ExecId] = append(resultsByExec[r.ExecId], r)
}
execsByDate := make(map[string][]*entity.ExecWorkflow)
for _, e := range execs {
date := ""
if e.CreatedAt != nil {
date = e.CreatedAt.Format("Y-m-d")
}
execsByDate[date] = append(execsByDate[date], e)
}
prefix := res.ImgAddressPrefix
for _, d := range dates {
execList := execsByDate[d]
if len(execList) == 0 {
continue
}
var flows []flowDto.FlowNode
for _, e := range execList {
flowName := flowNameMap[e.FlowId]
if flowName == "" {
flowName = "工作流"
}
var items []flowDto.OutputItem
suffixCount := make(map[string]int)
for _, rf := range resultsByExec[e.Id] {
if rf.ResultFileUrl == "" {
continue
}
content := prefix + rf.ResultFileUrl
ext := flowService.GetFileTypeByPath(content)
suffix := outputItemSuffix(ext)
suffixCount[suffix]++
items = append(items, flowDto.OutputItem{
Content: content,
Type: ext,
Label: fmt.Sprintf("%s_%d", suffix, suffixCount[suffix]),
})
}
if len(items) == 0 {
continue
}
flows = append(flows, flowDto.FlowNode{
FlowName: flowName,
Id: e.Id,
SessionId: e.SessionId,
Items: items,
})
}
if len(flows) == 0 {
continue
}
res.Tree = append(res.Tree, flowDto.DateNode{CreateDate: d, Flows: flows})
}
return
}
// outputItemSuffix 按文件类型映射结果项的中文标签前缀(与 flow 侧旧逻辑保持一致)
func outputItemSuffix(ext string) string {
switch ext {
case "image":
return "图片"
case "video":
return "视频"
case "audio":
return "音频"
case "text":
return "文案"
case "html":
return "HTML"
default:
return "内容"
}
}
func (s *sessionService) ResultDelete(ctx context.Context, req *sessionDto.DeleteWorkflowResultReq) (err error) {
_, err = sessionDao.ExecWorkflowResultDao.Delete(ctx, req)
return
}