Add AI workflow run management with listing and detail retrieval endpoints, including corresponding response structures and UI integration
This commit is contained in:
@@ -231,6 +231,8 @@ func registerDashboardAIAgentRoutes(group *gin.RouterGroup) {
|
||||
func registerDashboardAIWorkflowRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/node-spec/list", dashboard.AIWorkflowGetNodeSpecList)
|
||||
group.POST("/validate", dashboard.AIWorkflowPostValidate)
|
||||
group.Any("/run/list", dashboard.AIWorkflowAnyRunList)
|
||||
group.GET("/run/:id", dashboard.AIWorkflowGetRunBy)
|
||||
group.Any("/version/list", dashboard.AIWorkflowAnyVersionList)
|
||||
group.GET("/version/:id", dashboard.AIWorkflowGetVersionBy)
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ func TestNewServerRegistersGinRoutes(t *testing.T) {
|
||||
http.MethodGet + " /api/dashboard/user/:id",
|
||||
http.MethodPost + " /api/dashboard/user/create",
|
||||
http.MethodPost + " /api/dashboard/conversation/send_message",
|
||||
http.MethodGet + " /api/dashboard/ai-workflow/run/list",
|
||||
http.MethodGet + " /api/dashboard/ai-workflow/run/:id",
|
||||
http.MethodGet + " /api/ws/dashboard",
|
||||
http.MethodGet + " /api/ws/open",
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package builders
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/ai/workflow/dsl"
|
||||
workflowregistry "agent-desk/internal/ai/workflow/registry"
|
||||
@@ -87,6 +88,71 @@ func BuildAIWorkflowNodeSpecs(list []workflowregistry.NodeSpec) []response.AIWor
|
||||
return ret
|
||||
}
|
||||
|
||||
func BuildAIWorkflowRun(item *models.AIWorkflowRun) response.AIWorkflowRunResponse {
|
||||
if item == nil {
|
||||
return response.AIWorkflowRunResponse{}
|
||||
}
|
||||
return response.AIWorkflowRunResponse{
|
||||
ID: item.ID,
|
||||
WorkflowID: item.WorkflowID,
|
||||
WorkflowVersionID: item.WorkflowVersionID,
|
||||
ConversationID: item.ConversationID,
|
||||
AIAgentID: item.AIAgentID,
|
||||
MessageID: item.MessageID,
|
||||
Status: item.Status,
|
||||
StatusName: workflowRunStatusName(item.Status),
|
||||
StartedAt: formatWorkflowTime(item.StartedAt),
|
||||
EndedAt: formatWorkflowTimePtr(item.EndedAt),
|
||||
InterruptType: item.InterruptType,
|
||||
InterruptNodeID: item.InterruptNodeID,
|
||||
ErrorMessage: item.ErrorMessage,
|
||||
CreatedAt: formatWorkflowTime(item.CreatedAt),
|
||||
UpdatedAt: formatWorkflowTime(item.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func BuildAIWorkflowRunDetail(item *models.AIWorkflowRun, nodes []models.AIWorkflowNodeRun) response.AIWorkflowRunResponse {
|
||||
ret := BuildAIWorkflowRun(item)
|
||||
ret.Nodes = BuildAIWorkflowNodeRunList(nodes)
|
||||
return ret
|
||||
}
|
||||
|
||||
func BuildAIWorkflowRunList(list []models.AIWorkflowRun) []response.AIWorkflowRunResponse {
|
||||
ret := make([]response.AIWorkflowRunResponse, 0, len(list))
|
||||
for i := range list {
|
||||
ret = append(ret, BuildAIWorkflowRun(&list[i]))
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func BuildAIWorkflowNodeRun(item *models.AIWorkflowNodeRun) response.AIWorkflowNodeRunResponse {
|
||||
if item == nil {
|
||||
return response.AIWorkflowNodeRunResponse{}
|
||||
}
|
||||
return response.AIWorkflowNodeRunResponse{
|
||||
ID: item.ID,
|
||||
WorkflowRunID: item.WorkflowRunID,
|
||||
NodeID: item.NodeID,
|
||||
NodeType: item.NodeType,
|
||||
Status: item.Status,
|
||||
StatusName: workflowRunStatusName(item.Status),
|
||||
InputPreview: item.InputPreview,
|
||||
OutputPreview: item.OutputPreview,
|
||||
ErrorMessage: item.ErrorMessage,
|
||||
StartedAt: formatWorkflowTime(item.StartedAt),
|
||||
EndedAt: formatWorkflowTimePtr(item.EndedAt),
|
||||
DurationMS: item.DurationMS,
|
||||
}
|
||||
}
|
||||
|
||||
func BuildAIWorkflowNodeRunList(list []models.AIWorkflowNodeRun) []response.AIWorkflowNodeRunResponse {
|
||||
ret := make([]response.AIWorkflowNodeRunResponse, 0, len(list))
|
||||
for i := range list {
|
||||
ret = append(ret, BuildAIWorkflowNodeRun(&list[i]))
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func parseWorkflowDefinition(raw string) dsl.Definition {
|
||||
var ret dsl.Definition
|
||||
if raw == "" {
|
||||
@@ -95,3 +161,30 @@ func parseWorkflowDefinition(raw string) dsl.Definition {
|
||||
_ = json.Unmarshal([]byte(raw), &ret)
|
||||
return ret
|
||||
}
|
||||
|
||||
func workflowRunStatusName(status int) string {
|
||||
switch status {
|
||||
case 1:
|
||||
return "completed"
|
||||
case 2:
|
||||
return "interrupted"
|
||||
case 3:
|
||||
return "failed"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func formatWorkflowTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return value.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
func formatWorkflowTimePtr(value *time.Time) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return formatWorkflowTime(*value)
|
||||
}
|
||||
|
||||
@@ -227,3 +227,37 @@ func AIWorkflowGetVersionBy(ctx *gin.Context) {
|
||||
}
|
||||
httpx.WriteJSON(ctx, builders.BuildAIWorkflowVersion(item))
|
||||
}
|
||||
|
||||
func AIWorkflowAnyRunList(ctx *gin.Context) {
|
||||
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
cnd := params.NewPagedSqlCnd(ctx,
|
||||
params.QueryFilter{ParamName: "workflowId"},
|
||||
params.QueryFilter{ParamName: "workflowVersionId"},
|
||||
params.QueryFilter{ParamName: "conversationId"},
|
||||
params.QueryFilter{ParamName: "aiAgentId"},
|
||||
params.QueryFilter{ParamName: "messageId"},
|
||||
params.QueryFilter{ParamName: "status"},
|
||||
).Desc("id")
|
||||
list, paging := services.AIWorkflowService.FindRunPageByCnd(cnd)
|
||||
httpx.WriteJSON(ctx, &web.PageResult{Results: builders.BuildAIWorkflowRunList(list), Page: paging})
|
||||
}
|
||||
|
||||
func AIWorkflowGetRunBy(ctx *gin.Context) {
|
||||
id, ok := httpx.GetPathInt64(ctx, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
item, nodes := services.AIWorkflowService.GetRunDetail(id)
|
||||
if item == nil {
|
||||
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0002"))
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, builders.BuildAIWorkflowRunDetail(item, nodes))
|
||||
}
|
||||
|
||||
@@ -53,3 +53,37 @@ type AIWorkflowNodeSpecResponse struct {
|
||||
OutputSchema []workflowregistry.VariableSpec `json:"outputSchema,omitempty"`
|
||||
DefaultInputs map[string]dsl.VariableSelector `json:"defaultInputs,omitempty"`
|
||||
}
|
||||
|
||||
type AIWorkflowRunResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
WorkflowID int64 `json:"workflowId"`
|
||||
WorkflowVersionID int64 `json:"workflowVersionId"`
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
AIAgentID int64 `json:"aiAgentId"`
|
||||
MessageID int64 `json:"messageId"`
|
||||
Status int `json:"status"`
|
||||
StatusName string `json:"statusName"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
EndedAt string `json:"endedAt"`
|
||||
InterruptType string `json:"interruptType"`
|
||||
InterruptNodeID string `json:"interruptNodeId"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Nodes []AIWorkflowNodeRunResponse `json:"nodes,omitempty"`
|
||||
}
|
||||
|
||||
type AIWorkflowNodeRunResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
WorkflowRunID int64 `json:"workflowRunId"`
|
||||
NodeID string `json:"nodeId"`
|
||||
NodeType string `json:"nodeType"`
|
||||
Status int `json:"status"`
|
||||
StatusName string `json:"statusName"`
|
||||
InputPreview string `json:"inputPreview"`
|
||||
OutputPreview string `json:"outputPreview"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
EndedAt string `json:"endedAt"`
|
||||
DurationMS int `json:"durationMs"`
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package repositories
|
||||
|
||||
import (
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/httpx/params"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
@@ -28,6 +29,21 @@ func (r *aiWorkflowRunRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []model
|
||||
return
|
||||
}
|
||||
|
||||
func (r *aiWorkflowRunRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.AIWorkflowRun, paging *sqls.Paging) {
|
||||
return r.FindPageByCnd(db, ¶ms.Cnd)
|
||||
}
|
||||
|
||||
func (r *aiWorkflowRunRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.AIWorkflowRun, paging *sqls.Paging) {
|
||||
cnd.Find(db, &list)
|
||||
count := cnd.Count(db, &models.AIWorkflowRun{})
|
||||
paging = &sqls.Paging{
|
||||
Page: cnd.Paging.Page,
|
||||
Limit: cnd.Paging.Limit,
|
||||
Total: count,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (r *aiWorkflowRunRepository) Create(db *gorm.DB, t *models.AIWorkflowRun) error {
|
||||
return db.Create(t).Error
|
||||
}
|
||||
|
||||
@@ -57,6 +57,22 @@ func (s *aiWorkflowService) FindVersionPageByParams(params *params.QueryParams)
|
||||
return repositories.AIWorkflowVersionRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) FindRunPageByCnd(cnd *sqls.Cnd) (list []models.AIWorkflowRun, paging *sqls.Paging) {
|
||||
return repositories.AIWorkflowRunRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) GetRunDetail(id int64) (*models.AIWorkflowRun, []models.AIWorkflowNodeRun) {
|
||||
if id <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
run := repositories.AIWorkflowRunRepository.Get(sqls.DB(), id)
|
||||
if run == nil {
|
||||
return nil, nil
|
||||
}
|
||||
nodes := repositories.AIWorkflowNodeRunRepository.Find(sqls.DB(), sqls.NewCnd().Eq("workflow_run_id", id).Asc("id"))
|
||||
return run, nodes
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) GetByAgentID(agentID int64) *models.AIWorkflow {
|
||||
if agentID <= 0 {
|
||||
return nil
|
||||
|
||||
@@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/ai/workflow/dsl"
|
||||
"agent-desk/internal/models"
|
||||
@@ -151,6 +152,77 @@ func TestAIWorkflowServicePublishRejectsInvalidDSL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIWorkflowServiceRunListAndDetail(t *testing.T) {
|
||||
setupAIWorkflowTestDB(t)
|
||||
now := time.Now()
|
||||
run := models.AIWorkflowRun{
|
||||
WorkflowID: 101,
|
||||
WorkflowVersionID: 202,
|
||||
ConversationID: 303,
|
||||
AIAgentID: 12,
|
||||
MessageID: 404,
|
||||
Status: 1,
|
||||
StartedAt: now,
|
||||
EndedAt: &now,
|
||||
}
|
||||
if err := sqls.DB().Create(&run).Error; err != nil {
|
||||
t.Fatalf("create workflow run: %v", err)
|
||||
}
|
||||
otherRun := models.AIWorkflowRun{
|
||||
WorkflowID: 101,
|
||||
WorkflowVersionID: 202,
|
||||
ConversationID: 999,
|
||||
AIAgentID: 12,
|
||||
MessageID: 505,
|
||||
Status: 1,
|
||||
StartedAt: now,
|
||||
}
|
||||
if err := sqls.DB().Create(&otherRun).Error; err != nil {
|
||||
t.Fatalf("create other workflow run: %v", err)
|
||||
}
|
||||
nodes := []models.AIWorkflowNodeRun{
|
||||
{
|
||||
WorkflowRunID: run.ID,
|
||||
NodeID: "start_1",
|
||||
NodeType: "start",
|
||||
Status: 1,
|
||||
InputPreview: `{"inputs":{}}`,
|
||||
OutputPreview: `{"messageId":404}`,
|
||||
StartedAt: now,
|
||||
EndedAt: &now,
|
||||
},
|
||||
{
|
||||
WorkflowRunID: run.ID,
|
||||
NodeID: "reply_1",
|
||||
NodeType: "llm_reply",
|
||||
Status: 1,
|
||||
OutputPreview: `{"replyText":"hello"}`,
|
||||
StartedAt: now,
|
||||
EndedAt: &now,
|
||||
DurationMS: 8,
|
||||
},
|
||||
}
|
||||
if err := sqls.DB().Create(&nodes).Error; err != nil {
|
||||
t.Fatalf("create workflow node runs: %v", err)
|
||||
}
|
||||
|
||||
list, paging := AIWorkflowService.FindRunPageByCnd(sqls.NewCnd().Eq("conversation_id", 303).Desc("id").Page(1, 20))
|
||||
if paging.Total != 1 || len(list) != 1 || list[0].ID != run.ID {
|
||||
t.Fatalf("unexpected run list: total=%d list=%#v", paging.Total, list)
|
||||
}
|
||||
|
||||
detail, nodeRuns := AIWorkflowService.GetRunDetail(run.ID)
|
||||
if detail == nil || detail.ID != run.ID {
|
||||
t.Fatalf("unexpected detail run: %#v", detail)
|
||||
}
|
||||
if len(nodeRuns) != 2 || nodeRuns[0].NodeID != "start_1" || nodeRuns[1].NodeID != "reply_1" {
|
||||
t.Fatalf("unexpected detail nodes: %#v", nodeRuns)
|
||||
}
|
||||
if missing, missingNodes := AIWorkflowService.GetRunDetail(999999); missing != nil || len(missingNodes) != 0 {
|
||||
t.Fatalf("expected missing detail to be empty, got run=%#v nodes=%#v", missing, missingNodes)
|
||||
}
|
||||
}
|
||||
|
||||
func setupAIWorkflowTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
|
||||
Reference in New Issue
Block a user