diff --git a/internal/ai/application/runtime/service.go b/internal/ai/application/runtime/service.go index c87a83a..343a9fa 100644 --- a/internal/ai/application/runtime/service.go +++ b/internal/ai/application/runtime/service.go @@ -24,6 +24,7 @@ func NewService() *Service { func (s *Service) Run(ctx context.Context, req Request) (*Summary, error) { req.UserMessage.Content = utils.BuildRuntimeMessageText(req.UserMessage.MessageType, req.UserMessage.Content) + req.AIAgent = applyWorkflowInstruction(req.AIAgent) toolSet, err := s.prepare.prepareToolsForRun(req) if err != nil { return nil, err @@ -44,6 +45,7 @@ func (s *Service) Run(ctx context.Context, req Request) (*Summary, error) { } func (s *Service) Resume(ctx context.Context, req ResumeRequest) (*Summary, error) { + req.AIAgent = applyWorkflowInstruction(req.AIAgent) toolSet, err := s.prepare.prepareToolsForResume(req) if err != nil { return nil, err diff --git a/internal/ai/application/runtime/tool_catalog.go b/internal/ai/application/runtime/tool_catalog.go index 74b430e..ac31a81 100644 --- a/internal/ai/application/runtime/tool_catalog.go +++ b/internal/ai/application/runtime/tool_catalog.go @@ -82,6 +82,9 @@ func (c *toolCatalog) parseAgentAllowedToolCodes(aiAgent models.AIAgent) []strin ret = append(ret, graphTools...) } } + if result, ok := resolveAgentWorkflow(aiAgent); ok { + ret = append(ret, result.ToolCodes...) + } return toolx.NormalizeToolCodes(ret) } diff --git a/internal/ai/application/runtime/tool_catalog_test.go b/internal/ai/application/runtime/tool_catalog_test.go index e156172..8b0c29e 100644 --- a/internal/ai/application/runtime/tool_catalog_test.go +++ b/internal/ai/application/runtime/tool_catalog_test.go @@ -1,10 +1,19 @@ package runtime import ( + "encoding/json" + "strings" "testing" + "agent-desk/internal/ai/workflow/dsl" + workflowregistry "agent-desk/internal/ai/workflow/registry" "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/toolx" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" ) func TestNormalizeAllowedToolCodes(t *testing.T) { @@ -60,3 +69,91 @@ func TestBuildRuntimeStaticTools(t *testing.T) { t.Fatalf("expected %d runtime static tools, got %d", len(toolx.ListRuntimeStaticToolSpecs()), len(ret)) } } + +func TestToolCatalogIncludesPublishedWorkflowGraphTools(t *testing.T) { + setupWorkflowRuntimeTestDB(t) + version := createWorkflowRuntimeTestVersion(t, dsl.Definition{ + SchemaVersion: 1, + EntryNodeID: "start", + Nodes: []dsl.Node{ + {ID: "start", Type: workflowregistry.NodeTypeStart, Name: "Start"}, + {ID: "draft", Type: workflowregistry.NodeTypePrepareTicketDraft, Name: "Draft Ticket"}, + {ID: "create", Type: workflowregistry.NodeTypeCreateTicket, Name: "Create Ticket"}, + {ID: "handoff", Type: workflowregistry.NodeTypeHandoffToHuman, Name: "Handoff"}, + }, + }) + + catalog := newToolCatalog() + ret := catalog.parseAgentAllowedToolCodes(models.AIAgent{ + RuntimeMode: enums.AIAgentRuntimeModeWorkflow, + WorkflowVersionID: version.ID, + }) + + assertContainsToolCode(t, ret, toolx.GraphPrepareTicketDraft.Code) + assertContainsToolCode(t, ret, toolx.GraphCreateTicketConfirm.Code) + assertContainsToolCode(t, ret, toolx.GraphHandoffConversation.Code) +} + +func TestApplyWorkflowInstructionAppendsPublishedWorkflow(t *testing.T) { + setupWorkflowRuntimeTestDB(t) + version := createWorkflowRuntimeTestVersion(t, dsl.Definition{ + SchemaVersion: 1, + EntryNodeID: "start", + Nodes: []dsl.Node{ + {ID: "start", Type: workflowregistry.NodeTypeStart, Name: "Start"}, + {ID: "handoff", Type: workflowregistry.NodeTypeHandoffToHuman, Name: "Handoff"}, + }, + }) + + agent := applyWorkflowInstruction(models.AIAgent{ + SystemPrompt: "Base prompt.", + RuntimeMode: enums.AIAgentRuntimeModeWorkflow, + WorkflowVersionID: version.ID, + }) + if agent.SystemPrompt == "Base prompt." { + t.Fatalf("expected workflow appendix to be appended") + } + if !strings.Contains(agent.SystemPrompt, "Published customer-service workflow") { + t.Fatalf("missing workflow appendix: %s", agent.SystemPrompt) + } +} + +func setupWorkflowRuntimeTestDB(t *testing.T) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + if err := db.AutoMigrate(&models.AIWorkflowVersion{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) +} + +func createWorkflowRuntimeTestVersion(t *testing.T, def dsl.Definition) *models.AIWorkflowVersion { + t.Helper() + definition, err := json.Marshal(def) + if err != nil { + t.Fatalf("marshal definition: %v", err) + } + version := &models.AIWorkflowVersion{ + WorkflowID: 1, + Version: 1, + Status: enums.StatusOk, + Definition: string(definition), + } + if err := sqls.DB().Create(version).Error; err != nil { + t.Fatalf("create workflow version: %v", err) + } + return version +} + +func assertContainsToolCode(t *testing.T, items []string, want string) { + t.Helper() + for _, item := range items { + if item == want { + return + } + } + t.Fatalf("expected tool code %s in %#v", want, items) +} diff --git a/internal/ai/application/runtime/workflow_runtime.go b/internal/ai/application/runtime/workflow_runtime.go new file mode 100644 index 0000000..2042972 --- /dev/null +++ b/internal/ai/application/runtime/workflow_runtime.go @@ -0,0 +1,44 @@ +package runtime + +import ( + "encoding/json" + "strings" + + "agent-desk/internal/ai/workflow/compiler" + "agent-desk/internal/ai/workflow/dsl" + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" +) + +func resolveAgentWorkflow(aiAgent models.AIAgent) (compiler.Result, bool) { + if aiAgent.RuntimeMode != enums.AIAgentRuntimeModeWorkflow || aiAgent.WorkflowVersionID <= 0 { + return compiler.Result{}, false + } + version := repositories.AIWorkflowVersionRepository.Get(sqls.DB(), aiAgent.WorkflowVersionID) + if version == nil || version.Status != enums.StatusOk { + return compiler.Result{}, false + } + var def dsl.Definition + if err := json.Unmarshal([]byte(version.Definition), &def); err != nil { + return compiler.Result{}, false + } + return compiler.Compile(def), true +} + +func applyWorkflowInstruction(aiAgent models.AIAgent) models.AIAgent { + result, ok := resolveAgentWorkflow(aiAgent) + if !ok || strings.TrimSpace(result.Appendix) == "" { + return aiAgent + } + prompt := strings.TrimSpace(aiAgent.SystemPrompt) + appendix := strings.TrimSpace(result.Appendix) + if prompt == "" { + aiAgent.SystemPrompt = appendix + return aiAgent + } + aiAgent.SystemPrompt = prompt + "\n\n" + appendix + return aiAgent +} diff --git a/internal/ai/workflow/compiler/compiler.go b/internal/ai/workflow/compiler/compiler.go new file mode 100644 index 0000000..d49eafd --- /dev/null +++ b/internal/ai/workflow/compiler/compiler.go @@ -0,0 +1,60 @@ +package compiler + +import ( + "fmt" + "strings" + + "agent-desk/internal/ai/workflow/dsl" + workflowregistry "agent-desk/internal/ai/workflow/registry" + "agent-desk/internal/pkg/toolx" +) + +type Result struct { + ToolCodes []string + Appendix string +} + +func Compile(def dsl.Definition) Result { + toolCodes := make([]string, 0) + lines := make([]string, 0, len(def.Nodes)+2) + if strings.TrimSpace(def.EntryNodeID) != "" { + lines = append(lines, fmt.Sprintf("Workflow entry node: %s.", strings.TrimSpace(def.EntryNodeID))) + } + for _, node := range def.Nodes { + nodeType := strings.TrimSpace(node.Type) + if code := graphToolCodeForNodeType(nodeType); code != "" { + toolCodes = append(toolCodes, code) + } + nodeName := strings.TrimSpace(node.Name) + if nodeName == "" { + nodeName = strings.TrimSpace(node.ID) + } + if nodeName == "" { + continue + } + lines = append(lines, fmt.Sprintf("- %s: %s", nodeName, nodeType)) + } + appendix := "" + if len(lines) > 0 { + appendix = "Published customer-service workflow:\n" + strings.Join(lines, "\n") + } + return Result{ + ToolCodes: toolx.NormalizeToolCodes(toolCodes), + Appendix: appendix, + } +} + +func graphToolCodeForNodeType(nodeType string) string { + switch strings.TrimSpace(nodeType) { + case workflowregistry.NodeTypeAnalyzeConversation: + return toolx.GraphAnalyzeConversation.Code + case workflowregistry.NodeTypePrepareTicketDraft: + return toolx.GraphPrepareTicketDraft.Code + case workflowregistry.NodeTypeCreateTicket: + return toolx.GraphCreateTicketConfirm.Code + case workflowregistry.NodeTypeHandoffToHuman: + return toolx.GraphHandoffConversation.Code + default: + return "" + } +} diff --git a/internal/ai/workflow/compiler/compiler_test.go b/internal/ai/workflow/compiler/compiler_test.go new file mode 100644 index 0000000..f33d67c --- /dev/null +++ b/internal/ai/workflow/compiler/compiler_test.go @@ -0,0 +1,39 @@ +package compiler + +import ( + "testing" + + "agent-desk/internal/ai/workflow/dsl" + workflowregistry "agent-desk/internal/ai/workflow/registry" + "agent-desk/internal/pkg/toolx" +) + +func TestCompileMapsWorkflowNodesToGraphTools(t *testing.T) { + result := Compile(dsl.Definition{ + EntryNodeID: "start", + Nodes: []dsl.Node{ + {ID: "start", Type: workflowregistry.NodeTypeStart, Name: "Start"}, + {ID: "analyze", Type: workflowregistry.NodeTypeAnalyzeConversation, Name: "Analyze"}, + {ID: "draft", Type: workflowregistry.NodeTypePrepareTicketDraft, Name: "Draft"}, + {ID: "create", Type: workflowregistry.NodeTypeCreateTicket, Name: "Create"}, + {ID: "handoff", Type: workflowregistry.NodeTypeHandoffToHuman, Name: "Handoff"}, + }, + }) + want := []string{ + toolx.GraphAnalyzeConversation.Code, + toolx.GraphPrepareTicketDraft.Code, + toolx.GraphCreateTicketConfirm.Code, + toolx.GraphHandoffConversation.Code, + } + if len(result.ToolCodes) != len(want) { + t.Fatalf("expected %d tool codes, got %d: %#v", len(want), len(result.ToolCodes), result.ToolCodes) + } + for i, item := range want { + if result.ToolCodes[i] != item { + t.Fatalf("tool code[%d] = %s, want %s", i, result.ToolCodes[i], item) + } + } + if result.Appendix == "" { + t.Fatalf("expected workflow appendix") + } +} diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index dea7d30..ce19fa0 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -225,7 +225,6 @@ func registerDashboardAIAgentRoutes(group *gin.RouterGroup) { } func registerDashboardAIWorkflowRoutes(group *gin.RouterGroup) { - group.GET("/:id", dashboard.AIWorkflowGetBy) group.Any("/list", dashboard.AIWorkflowAnyList) group.POST("/create", dashboard.AIWorkflowPostCreate) group.POST("/update", dashboard.AIWorkflowPostUpdate) @@ -235,6 +234,7 @@ func registerDashboardAIWorkflowRoutes(group *gin.RouterGroup) { group.POST("/publish", dashboard.AIWorkflowPostPublish) group.Any("/version/list", dashboard.AIWorkflowAnyVersionList) group.GET("/version/:id", dashboard.AIWorkflowGetVersionBy) + group.GET("/:id", dashboard.AIWorkflowGetBy) } func registerDashboardAIConfigRoutes(group *gin.RouterGroup) { diff --git a/web/app/dashboard/ai-agents/_components/edit.tsx b/web/app/dashboard/ai-agents/_components/edit.tsx index 8ac111a..3474fff 100644 --- a/web/app/dashboard/ai-agents/_components/edit.tsx +++ b/web/app/dashboard/ai-agents/_components/edit.tsx @@ -41,12 +41,14 @@ import { Textarea } from "@/components/ui/textarea"; import { fetchAIAgent, fetchAIConfigsAll, + fetchAIWorkflowVersions, fetchAgentTeamsAll, fetchKnowledgeBasesAll, fetchMCPCatalog, fetchSkillDefinitionsAll, type AIAgent, type AIConfig, + type AIWorkflowVersion, type AdminAgentTeam, type CreateAIAgentPayload, type KnowledgeBase, @@ -94,6 +96,8 @@ type EditForm = { description: string; aiConfigId: string; serviceMode: string; + runtimeMode: string; + workflowVersionId: string; systemPrompt: string; welcomeMessage: string; replyTimeoutSeconds: number; @@ -102,6 +106,9 @@ type EditForm = { fallbackMessage: string; }; +const AI_AGENT_RUNTIME_MODE_BUILTIN_GRAPH = 1; +const AI_AGENT_RUNTIME_MODE_WORKFLOW = 2; + function getServiceModeOptions(t: TFunction) { return [ { value: String(IMConversationServiceMode.AIOnly), label: t("aiAgent.serviceAiOnly") }, @@ -132,6 +139,8 @@ function buildForm(item: AIAgent | null): EditForm { description: "", aiConfigId: "", serviceMode: String(IMConversationServiceMode.AIFirst), + runtimeMode: String(AI_AGENT_RUNTIME_MODE_BUILTIN_GRAPH), + workflowVersionId: "", systemPrompt: "", welcomeMessage: "", replyTimeoutSeconds: 180, @@ -145,6 +154,8 @@ function buildForm(item: AIAgent | null): EditForm { description: item.description || "", aiConfigId: item.aiConfigId > 0 ? String(item.aiConfigId) : "", serviceMode: String(item.serviceMode), + runtimeMode: String(item.runtimeMode || AI_AGENT_RUNTIME_MODE_BUILTIN_GRAPH), + workflowVersionId: item.workflowVersionId > 0 ? String(item.workflowVersionId) : "", systemPrompt: item.systemPrompt || "", welcomeMessage: item.welcomeMessage || "", replyTimeoutSeconds: item.replyTimeoutSeconds ?? 180, @@ -167,6 +178,11 @@ function buildPayload( description: form.description.trim(), aiConfigId: Number(form.aiConfigId), serviceMode: Number(form.serviceMode), + runtimeMode: Number(form.runtimeMode), + workflowVersionId: + Number(form.runtimeMode) === AI_AGENT_RUNTIME_MODE_WORKFLOW + ? Number(form.workflowVersionId) + : 0, systemPrompt: form.systemPrompt.trim(), welcomeMessage: form.welcomeMessage.trim(), replyTimeoutSeconds: Number(form.replyTimeoutSeconds), @@ -220,6 +236,8 @@ function EditDialogBody({ description: z.string().trim(), aiConfigId: z.string().trim().regex(/^\d+$/, t("aiAgent.aiConfigRequired")), serviceMode: z.string().trim().min(1, t("aiAgent.serviceModeRequired")), + runtimeMode: z.string().trim().min(1, t("aiAgent.runtimeModeRequired")), + workflowVersionId: z.string().trim(), systemPrompt: z.string().trim(), welcomeMessage: z.string().trim(), replyTimeoutSeconds: z @@ -228,6 +246,18 @@ function EditDialogBody({ handoffMode: z.string().trim().min(1, t("aiAgent.handoffModeRequired")), fallbackMode: z.string().trim().min(1, t("aiAgent.fallbackModeRequired")), fallbackMessage: z.string().trim(), + }).check((ctx) => { + if ( + ctx.value.runtimeMode === String(AI_AGENT_RUNTIME_MODE_WORKFLOW) && + !/^\d+$/.test(ctx.value.workflowVersionId) + ) { + ctx.issues.push({ + code: "custom", + input: ctx.value.workflowVersionId, + message: t("aiAgent.workflowVersionRequired"), + path: ["workflowVersionId"], + }); + } }), [t], ); @@ -236,6 +266,19 @@ function EditDialogBody({ [schema], ); const serviceModeOptions = useMemo(() => getServiceModeOptions(t), [t]); + const runtimeModeOptions = useMemo( + () => [ + { + value: String(AI_AGENT_RUNTIME_MODE_BUILTIN_GRAPH), + label: t("aiAgent.runtimeBuiltinGraph"), + }, + { + value: String(AI_AGENT_RUNTIME_MODE_WORKFLOW), + label: t("aiAgent.runtimeWorkflow"), + }, + ], + [t], + ); const handoffModeOptions = useMemo(() => getHandoffModeOptions(t), [t]); const fallbackModeOptions = useMemo(() => getFallbackModeOptions(t), [t]); const form = useForm({ @@ -262,6 +305,7 @@ function EditDialogBody({ const [directToolToAdd, setDirectToolToAdd] = useState(""); const [graphToolToAdd, setGraphToolToAdd] = useState(""); const [aiConfigs, setAIConfigs] = useState([]); + const [workflowVersions, setWorkflowVersions] = useState([]); const [knowledgeBases, setKnowledgeBases] = useState([]); const [agentTeams, setAgentTeams] = useState([]); const [skills, setSkills] = useState([]); @@ -346,6 +390,23 @@ function EditDialogBody({ void loadAgentTeams(); }, [t]); + useEffect(() => { + async function loadWorkflowVersions() { + try { + const data = await fetchAIWorkflowVersions({ + page: 1, + limit: 1000, + }); + setWorkflowVersions(data.results ?? []); + } catch (error) { + toast.error( + error instanceof Error ? error.message : t("aiAgent.loadWorkflowVersionsFailed"), + ); + } + } + void loadWorkflowVersions(); + }, [t]); + useEffect(() => { async function loadKnowledgeBases() { try { @@ -436,6 +497,15 @@ function EditDialogBody({ [agentTeams], ); + const workflowVersionOptions = useMemo( + () => + workflowVersions.map((item) => ({ + value: String(item.id), + label: `Workflow #${item.workflowId} · v${item.version}`, + })), + [workflowVersions], + ); + const knowledgeOptions = useMemo( () => knowledgeBases.map((item) => ({ @@ -556,6 +626,7 @@ function EditDialogBody({ ); const handoffMode = watch("handoffMode"); + const runtimeMode = watch("runtimeMode"); const selectedHandoffModeLabel = handoffModeOptions.find((item) => item.value === handoffMode)?.label ?? t("aiAgent.notSelected"); @@ -747,6 +818,56 @@ function EditDialogBody({ +
+ + {t("aiAgent.runtimeMode")} + + ( + + )} + /> + + + + + + {t("aiAgent.workflowVersion")} + + ( + + )} + /> + + + +
+ {t("aiAgent.description")} diff --git a/web/app/dashboard/ai-workflows/_components/node-config-panel.tsx b/web/app/dashboard/ai-workflows/_components/node-config-panel.tsx new file mode 100644 index 0000000..7689c12 --- /dev/null +++ b/web/app/dashboard/ai-workflows/_components/node-config-panel.tsx @@ -0,0 +1,87 @@ +"use client" + +import { useState } from "react" +import type { Node } from "@xyflow/react" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" + +type WorkflowNodeData = Record & { + nodeType?: string + name?: string + config?: Record +} + +export function NodeConfigPanel({ + node, + onChange, +}: { + node: Node | null + onChange: (nodeId: string, data: WorkflowNodeData) => void +}) { + if (!node) { + return ( +
+ Select a node to edit its properties. +
+ ) + } + + return +} + +function NodeConfigForm({ + node, + onChange, +}: { + node: Node + onChange: (nodeId: string, data: WorkflowNodeData) => void +}) { + const [name, setName] = useState(node.data.name ?? "") + const [configText, setConfigText] = useState(JSON.stringify(node.data.config ?? {}, null, 2)) + const [error, setError] = useState("") + + const handleApply = () => { + try { + const parsed = JSON.parse(configText || "{}") as Record + setError("") + onChange(node.id, { + ...node.data, + name: name.trim() || node.data.nodeType || node.id, + config: parsed, + }) + } catch { + setError("Config must be valid JSON.") + } + } + + return ( +
+
+
{node.data.nodeType ?? node.id}
+
{node.id}
+
+
+ + setName(event.target.value)} + /> +
+
+ +