feat: add AI workflow editor

This commit is contained in:
mlogclub
2026-06-22 00:26:16 +08:00
parent df7b199c18
commit 14f50e064f
19 changed files with 1588 additions and 1 deletions
@@ -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
@@ -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)
}
@@ -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)
}
@@ -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
}
+60
View File
@@ -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 ""
}
}
@@ -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")
}
}
+1 -1
View File
@@ -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) {
@@ -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<EditForm>({
@@ -262,6 +305,7 @@ function EditDialogBody({
const [directToolToAdd, setDirectToolToAdd] = useState("");
const [graphToolToAdd, setGraphToolToAdd] = useState("");
const [aiConfigs, setAIConfigs] = useState<AIConfig[]>([]);
const [workflowVersions, setWorkflowVersions] = useState<AIWorkflowVersion[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [agentTeams, setAgentTeams] = useState<AdminAgentTeam[]>([]);
const [skills, setSkills] = useState<SkillDefinition[]>([]);
@@ -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({
</Field>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<Field data-invalid={!!errors.runtimeMode}>
<FieldLabel>{t("aiAgent.runtimeMode")}</FieldLabel>
<FieldContent>
<Controller
control={control}
name="runtimeMode"
render={({ field }) => (
<OptionCombobox
value={field.value}
options={runtimeModeOptions}
placeholder={t("aiAgent.selectRuntimeMode")}
searchPlaceholder={t("aiAgent.searchRuntimeMode")}
emptyText={t("aiAgent.emptyRuntimeMode")}
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.runtimeMode]} />
</FieldContent>
</Field>
<Field
data-invalid={
runtimeMode === String(AI_AGENT_RUNTIME_MODE_WORKFLOW) &&
!!errors.workflowVersionId
}
>
<FieldLabel>{t("aiAgent.workflowVersion")}</FieldLabel>
<FieldContent>
<Controller
control={control}
name="workflowVersionId"
render={({ field }) => (
<OptionCombobox
value={field.value}
options={workflowVersionOptions}
placeholder={t("aiAgent.selectWorkflowVersion")}
searchPlaceholder={t("aiAgent.searchWorkflowVersion")}
emptyText={t("aiAgent.emptyWorkflowVersion")}
disabled={runtimeMode !== String(AI_AGENT_RUNTIME_MODE_WORKFLOW)}
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.workflowVersionId]} />
</FieldContent>
</Field>
</div>
<Field data-invalid={!!errors.description}>
<FieldLabel htmlFor="ai-agent-description">{t("aiAgent.description")}</FieldLabel>
<FieldContent>
@@ -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<string, unknown> & {
nodeType?: string
name?: string
config?: Record<string, unknown>
}
export function NodeConfigPanel({
node,
onChange,
}: {
node: Node<WorkflowNodeData> | null
onChange: (nodeId: string, data: WorkflowNodeData) => void
}) {
if (!node) {
return (
<div className="flex h-full items-center justify-center px-4 text-sm text-muted-foreground">
Select a node to edit its properties.
</div>
)
}
return <NodeConfigForm key={node.id} node={node} onChange={onChange} />
}
function NodeConfigForm({
node,
onChange,
}: {
node: Node<WorkflowNodeData>
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<string, unknown>
setError("")
onChange(node.id, {
...node.data,
name: name.trim() || node.data.nodeType || node.id,
config: parsed,
})
} catch {
setError("Config must be valid JSON.")
}
}
return (
<div className="flex h-full min-h-0 flex-col gap-4 p-4">
<div>
<div className="text-sm font-medium">{node.data.nodeType ?? node.id}</div>
<div className="mt-1 text-xs text-muted-foreground">{node.id}</div>
</div>
<div className="space-y-2">
<Label htmlFor="workflow-node-name">Name</Label>
<Input
id="workflow-node-name"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="min-h-0 flex-1 space-y-2">
<Label htmlFor="workflow-node-config">Config JSON</Label>
<Textarea
id="workflow-node-config"
className="h-64 font-mono text-xs"
value={configText}
onChange={(event) => setConfigText(event.target.value)}
/>
</div>
{error ? <div className="text-xs text-destructive">{error}</div> : null}
<Button onClick={handleApply}>Apply</Button>
</div>
)
}
@@ -0,0 +1,241 @@
"use client"
import "@xyflow/react/dist/style.css"
import {
addEdge,
Background,
Controls,
MiniMap,
ReactFlow,
useEdgesState,
useNodesState,
type Connection,
type Edge,
type Node,
} from "@xyflow/react"
import { PlusIcon } from "lucide-react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
import {
fromApiDefinition,
toApiDefinition,
validateWorkflowDraft,
type WorkflowEditorEdge,
type WorkflowEditorNode,
} from "./workflow-utils"
import { NodeConfigPanel } from "./node-config-panel"
type WorkflowNodeData = Record<string, unknown> & {
nodeType?: string
name?: string
config?: Record<string, unknown>
label?: string
}
type WorkflowFlowNode = Node<WorkflowNodeData>
type WorkflowFlowEdge = Edge
function toFlowNodes(definition: AIWorkflowDefinition): WorkflowFlowNode[] {
return fromApiDefinition(definition).nodes.map((node) => ({
id: node.id,
type: "default",
position: node.position,
data: {
nodeType: node.data?.nodeType ?? node.type,
name: node.data?.name ?? node.id,
label: node.data?.name ?? node.type ?? node.id,
config: node.data?.config ?? {},
},
}))
}
function toFlowEdges(definition: AIWorkflowDefinition): WorkflowFlowEdge[] {
return (definition.edges ?? []).map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
data: edge.condition ? { condition: edge.condition } : undefined,
}))
}
function toDraft(nodes: WorkflowFlowNode[], edges: WorkflowFlowEdge[]) {
return {
nodes: nodes.map((node) => ({
id: node.id,
type: node.type,
position: node.position,
data: {
nodeType: node.data.nodeType,
name: node.data.name,
config: node.data.config,
},
})) as WorkflowEditorNode[],
edges: edges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
data: edge.data as WorkflowEditorEdge["data"],
})),
}
}
export function WorkflowEditor({
definition,
nodeSpecs,
onDefinitionChange,
}: {
definition: AIWorkflowDefinition
nodeSpecs: AIWorkflowNodeSpec[]
onDefinitionChange: (definition: AIWorkflowDefinition) => void
}) {
const [nodes, setNodes, onNodesChange] = useNodesState<WorkflowFlowNode>(
toFlowNodes(definition)
)
const [edges, setEdges, onEdgesChange] = useEdgesState<WorkflowFlowEdge>(
toFlowEdges(definition)
)
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const selectedNode = useMemo(
() => nodes.find((node) => node.id === selectedNodeId) ?? null,
[nodes, selectedNodeId]
)
const validation = useMemo(() => validateWorkflowDraft(toDraft(nodes, edges)), [nodes, edges])
useEffect(() => {
onDefinitionChange(toApiDefinition(toDraft(nodes, edges)) as AIWorkflowDefinition)
}, [edges, nodes, onDefinitionChange])
const onConnect = useCallback(
(connection: Connection) => {
setEdges((current) => {
let nextIndex = current.length + 1
let id = `edge_${connection.source}_${connection.target}_${nextIndex}`
while (current.some((edge) => edge.id === id)) {
nextIndex += 1
id = `edge_${connection.source}_${connection.target}_${nextIndex}`
}
return addEdge(
{
...connection,
id,
},
current
)
})
},
[setEdges]
)
const addNode = (spec: AIWorkflowNodeSpec) => {
setNodes((current) => {
let nextIndex = current.length + 1
let id = `${spec.type}_${nextIndex}`
while (current.some((node) => node.id === id)) {
nextIndex += 1
id = `${spec.type}_${nextIndex}`
}
return [
...current,
{
id,
type: "default",
position: { x: 120 + current.length * 28, y: 100 + current.length * 24 },
data: {
nodeType: spec.type,
name: spec.title,
label: spec.title,
config: {},
},
},
]
})
}
const updateNodeData = (nodeId: string, data: WorkflowNodeData) => {
setNodes((current) =>
current.map((node) =>
node.id === nodeId
? {
...node,
data: {
...data,
label: data.name ?? data.nodeType ?? node.id,
},
}
: node
)
)
}
return (
<div className="grid h-full min-h-0 grid-cols-[220px_minmax(0,1fr)_320px] border-t">
<aside className="min-h-0 overflow-y-auto border-r bg-muted/20 p-3">
<div className="mb-3 text-sm font-medium">Nodes</div>
<div className="space-y-2">
{nodeSpecs.map((spec) => (
<button
key={spec.type}
type="button"
onClick={() => addNode(spec)}
className="flex w-full items-start gap-2 rounded-md border bg-background px-3 py-2 text-left text-sm hover:bg-muted"
>
<PlusIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0">
<span className="block truncate font-medium">{spec.title}</span>
<span className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{spec.description}
</span>
</span>
</button>
))}
</div>
</aside>
<section className="relative min-h-0">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={(_, node) => setSelectedNodeId(node.id)}
fitView
>
<Background />
<Controls />
<MiniMap pannable zoomable />
</ReactFlow>
<div className="absolute left-3 top-3 flex gap-2">
<Badge variant={validation.valid ? "default" : "destructive"}>
{validation.valid ? "Valid draft" : `${validation.errors.length} issues`}
</Badge>
</div>
</section>
<aside className="min-h-0 overflow-y-auto border-l bg-muted/10">
<NodeConfigPanel node={selectedNode} onChange={updateNodeData} />
{!validation.valid ? (
<div className="border-t p-4">
<div className="mb-2 text-sm font-medium">Local validation</div>
<ul className="space-y-1 text-xs text-destructive">
{validation.errors.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
</div>
) : null}
<div className="border-t p-4">
<Button
variant="outline"
className="w-full"
onClick={() => onDefinitionChange(toApiDefinition(toDraft(nodes, edges)) as AIWorkflowDefinition)}
>
Sync definition
</Button>
</div>
</aside>
</div>
)
}
@@ -0,0 +1,127 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import ts from "typescript"
import { readFile } from "node:fs/promises"
import vm from "node:vm"
function plain(value) {
return JSON.parse(JSON.stringify(value))
}
async function loadModule() {
const source = await readFile(new URL("./workflow-utils.ts", import.meta.url), "utf8")
const compiled = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2017,
module: ts.ModuleKind.CommonJS,
},
fileName: "workflow-utils.ts",
})
const sandbox = {
exports: {},
module: { exports: {} },
}
sandbox.exports = sandbox.module.exports
vm.runInNewContext(compiled.outputText, sandbox)
return sandbox.module.exports
}
describe("validateWorkflowDraft", () => {
it("rejects missing start", async () => {
const { validateWorkflowDraft } = await loadModule()
const result = validateWorkflowDraft({
nodes: [{ id: "end_1", type: "end", position: { x: 0, y: 0 }, data: {} }],
edges: [],
})
assert.equal(result.valid, false)
assert.match(result.errors.join("\n"), /exactly one start/)
})
it("rejects dangling edge", async () => {
const { validateWorkflowDraft } = await loadModule()
const result = validateWorkflowDraft({
nodes: [
{ id: "start_1", type: "start", position: { x: 0, y: 0 }, data: {} },
{ id: "end_1", type: "end", position: { x: 200, y: 0 }, data: {} },
],
edges: [{ id: "e1", source: "start_1", target: "missing_1" }],
})
assert.equal(result.valid, false)
assert.match(result.errors.join("\n"), /target node does not exist/)
})
})
describe("toApiDefinition", () => {
it("preserves xyflow node positions", async () => {
const { toApiDefinition } = await loadModule()
const definition = toApiDefinition({
nodes: [
{
id: "start_1",
type: "start",
position: { x: 12, y: 34 },
data: { name: "Start", config: { enabled: true } },
},
{
id: "end_1",
type: "end",
position: { x: 240, y: 80 },
data: { name: "End", config: {} },
},
],
edges: [{ id: "e1", source: "start_1", target: "end_1" }],
})
assert.deepEqual(plain(definition), {
schemaVersion: 1,
entryNodeId: "start_1",
nodes: [
{
id: "start_1",
type: "start",
name: "Start",
position: { x: 12, y: 34 },
config: { enabled: true },
},
{
id: "end_1",
type: "end",
name: "End",
position: { x: 240, y: 80 },
config: {},
},
],
edges: [{ id: "e1", source: "start_1", target: "end_1" }],
})
})
it("uses node data type for xyflow default nodes", async () => {
const { toApiDefinition } = await loadModule()
const definition = toApiDefinition({
nodes: [
{
id: "start_1",
type: "default",
position: { x: 0, y: 0 },
data: { nodeType: "start", name: "Start", config: {} },
},
{
id: "end_1",
type: "default",
position: { x: 200, y: 0 },
data: { nodeType: "end", name: "End", config: {} },
},
],
edges: [{ id: "e1", source: "start_1", target: "end_1" }],
})
assert.equal(definition.entryNodeId, "start_1")
assert.equal(definition.nodes[0].type, "start")
})
})
@@ -0,0 +1,162 @@
export type WorkflowNodePosition = {
x: number
y: number
}
export type WorkflowEditorNode = {
id: string
type?: string
position: WorkflowNodePosition
data?: {
nodeType?: string
name?: string
config?: Record<string, unknown>
}
}
export type WorkflowEditorEdge = {
id: string
source: string
target: string
data?: {
condition?: {
expression: string
}
}
}
export type WorkflowDraft = {
nodes: WorkflowEditorNode[]
edges: WorkflowEditorEdge[]
}
export type WorkflowDefinition = {
schemaVersion: number
entryNodeId: string
nodes: {
id: string
type: string
name: string
position: WorkflowNodePosition
config: Record<string, unknown>
}[]
edges: {
id: string
source: string
target: string
condition?: {
expression: string
}
}[]
}
export type WorkflowDraftValidation = {
valid: boolean
errors: string[]
}
export function validateWorkflowDraft(draft: WorkflowDraft): WorkflowDraftValidation {
const errors: string[] = []
const nodeIds = new Set<string>()
let startCount = 0
let endCount = 0
for (const node of draft.nodes) {
const id = node.id.trim()
if (!id) {
errors.push("node id is required")
continue
}
if (nodeIds.has(id)) {
errors.push(`duplicate node id: ${id}`)
}
nodeIds.add(id)
const nodeType = node.data?.nodeType ?? node.type
if (nodeType === "start") {
startCount += 1
}
if (nodeType === "end") {
endCount += 1
}
}
if (startCount !== 1) {
errors.push("workflow must contain exactly one start node")
}
if (endCount < 1) {
errors.push("workflow must contain at least one end node")
}
const edgeIds = new Set<string>()
for (const edge of draft.edges) {
const id = edge.id.trim()
if (!id) {
errors.push("edge id is required")
} else if (edgeIds.has(id)) {
errors.push(`duplicate edge id: ${id}`)
}
edgeIds.add(id)
if (!nodeIds.has(edge.source)) {
errors.push(`edge source node does not exist: ${edge.source}`)
}
if (!nodeIds.has(edge.target)) {
errors.push(`edge target node does not exist: ${edge.target}`)
}
}
return {
valid: errors.length === 0,
errors,
}
}
export function toApiDefinition(draft: WorkflowDraft): WorkflowDefinition {
const startNode = draft.nodes.find((node) => (node.data?.nodeType ?? node.type) === "start")
return {
schemaVersion: 1,
entryNodeId: startNode?.id ?? "",
nodes: draft.nodes.map((node) => ({
id: node.id,
type: node.data?.nodeType ?? node.type ?? "",
name: node.data?.name ?? node.type ?? node.id,
position: {
x: node.position.x,
y: node.position.y,
},
config: node.data?.config ?? {},
})),
edges: draft.edges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
...(edge.data?.condition
? {
condition: {
expression: edge.data.condition.expression,
},
}
: {}),
})),
}
}
export function fromApiDefinition(definition: WorkflowDefinition): WorkflowDraft {
return {
nodes: (definition.nodes ?? []).map((node) => ({
id: node.id,
type: node.type,
position: node.position ?? { x: 0, y: 0 },
data: {
nodeType: node.type,
name: node.name,
config: node.config ?? {},
},
})),
edges: (definition.edges ?? []).map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
data: edge.condition ? { condition: edge.condition } : undefined,
})),
}
}
+270
View File
@@ -0,0 +1,270 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { CheckCircle2Icon, GitBranchIcon, SaveIcon, SendIcon } from "lucide-react"
import { toast } from "sonner"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import {
createAIWorkflow,
fetchAIWorkflowNodeSpecs,
fetchAIWorkflows,
publishAIWorkflow,
updateAIWorkflow,
validateAIWorkflow,
type AIWorkflow,
type AIWorkflowDefinition,
type AIWorkflowNodeSpec,
type AIWorkflowValidationResult,
} from "@/lib/api/admin"
import { WorkflowEditor } from "./_components/workflow-editor"
const emptyDefinition: AIWorkflowDefinition = {
schemaVersion: 1,
entryNodeId: "start_1",
nodes: [
{
id: "start_1",
type: "start",
name: "Start",
position: { x: 0, y: 80 },
config: {},
},
{
id: "end_1",
type: "end",
name: "End",
position: { x: 360, y: 80 },
config: {},
},
],
edges: [{ id: "edge_start_end", source: "start_1", target: "end_1" }],
}
export default function DashboardAIWorkflowsPage() {
const [workflows, setWorkflows] = useState<AIWorkflow[]>([])
const [nodeSpecs, setNodeSpecs] = useState<AIWorkflowNodeSpec[]>([])
const [selected, setSelected] = useState<AIWorkflow | null>(null)
const [name, setName] = useState("Customer support flow")
const [description, setDescription] = useState("")
const [ownerId, setOwnerId] = useState("1")
const [definition, setDefinition] = useState<AIWorkflowDefinition>(emptyDefinition)
const [validation, setValidation] = useState<AIWorkflowValidationResult | null>(null)
const [loading, setLoading] = useState(false)
const editorKey = useMemo(
() => `${selected?.id ?? "new"}-${selected?.updatedAt ?? ""}`,
[selected?.id, selected?.updatedAt]
)
const loadData = useCallback(async () => {
const [workflowPage, specs] = await Promise.all([
fetchAIWorkflows({ page: 1, limit: 50, status: 0 }),
fetchAIWorkflowNodeSpecs(),
])
setWorkflows(workflowPage?.results ?? [])
setNodeSpecs(specs ?? [])
}, [])
useEffect(() => {
void loadData().catch((error) => {
toast.error(error instanceof Error ? error.message : "Failed to load workflows")
})
}, [loadData])
const selectWorkflow = (workflow: AIWorkflow) => {
setSelected(workflow)
setName(workflow.name)
setDescription(workflow.description)
setOwnerId(String(workflow.ownerId || 1))
setDefinition(workflow.draftDefinition ?? emptyDefinition)
setValidation(null)
}
const createNew = () => {
setSelected(null)
setName("Customer support flow")
setDescription("")
setOwnerId("1")
setDefinition(emptyDefinition)
setValidation(null)
}
const saveDraft = async () => {
setLoading(true)
try {
const payload = {
name,
description,
ownerType: "ai_agent",
ownerId: Number(ownerId) || 0,
definition,
}
if (selected) {
await updateAIWorkflow({ id: selected.id, ...payload })
toast.success("Draft saved")
} else {
const created = await createAIWorkflow(payload)
setSelected(created)
toast.success("Workflow created")
}
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to save workflow")
} finally {
setLoading(false)
}
}
const runValidation = async () => {
setLoading(true)
try {
const result = await validateAIWorkflow(definition)
setValidation(result)
toast[result.valid ? "success" : "error"](
result.valid ? "Workflow is valid" : "Workflow has validation errors"
)
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to validate workflow")
} finally {
setLoading(false)
}
}
const publish = async () => {
if (!selected) {
toast.error("Save the workflow before publishing.")
return
}
setLoading(true)
try {
const version = await publishAIWorkflow(selected.id, definition)
toast.success(`Published version ${version.version}`)
await loadData()
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to publish workflow")
} finally {
setLoading(false)
}
}
return (
<div className="flex h-[calc(100vh-var(--header-height))] min-h-0 flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between border-b px-5 py-3">
<div className="min-w-0">
<h1 className="truncate text-base font-semibold">AI Workflows</h1>
<p className="mt-1 text-sm text-muted-foreground">
Edit and publish customer-service conversation flows.
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={createNew}>
New
</Button>
<Button variant="outline" disabled={loading} onClick={runValidation}>
<CheckCircle2Icon className="size-4" />
Validate
</Button>
<Button variant="outline" disabled={loading} onClick={saveDraft}>
<SaveIcon className="size-4" />
Save draft
</Button>
<Button disabled={loading || !selected} onClick={publish}>
<SendIcon className="size-4" />
Publish
</Button>
</div>
</div>
<div className="grid min-h-0 flex-1 grid-cols-[300px_minmax(0,1fr)]">
<aside className="min-h-0 overflow-y-auto border-r bg-muted/20">
<div className="space-y-4 border-b p-4">
<div className="space-y-2">
<Label htmlFor="workflow-name">Name</Label>
<Input
id="workflow-name"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="workflow-owner">AI Agent ID</Label>
<Input
id="workflow-owner"
type="number"
min={1}
value={ownerId}
onChange={(event) => setOwnerId(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="workflow-description">Description</Label>
<Textarea
id="workflow-description"
rows={3}
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
</div>
<div className="p-3">
<div className="mb-2 text-sm font-medium">Workflows</div>
<div className="space-y-2">
{workflows.map((workflow) => (
<button
key={workflow.id}
type="button"
onClick={() => selectWorkflow(workflow)}
className={`w-full rounded-md border px-3 py-2 text-left text-sm hover:bg-muted ${
selected?.id === workflow.id ? "border-primary bg-primary/5" : "bg-background"
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate font-medium">{workflow.name}</span>
{workflow.publishedVersionId ? (
<Badge variant="secondary">Published</Badge>
) : null}
</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
Agent #{workflow.ownerId}
</div>
</button>
))}
{workflows.length === 0 ? (
<div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
No workflows yet.
</div>
) : null}
</div>
</div>
</aside>
<main className="flex min-h-0 flex-col overflow-hidden">
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-2 text-sm">
<GitBranchIcon className="size-4 text-muted-foreground" />
<span className="font-medium">{selected ? selected.name : "Unsaved workflow"}</span>
{validation ? (
<Badge variant={validation.valid ? "default" : "destructive"}>
{validation.valid ? "Backend valid" : `${validation.errors.length} backend errors`}
</Badge>
) : null}
{validation && !validation.valid ? (
<span className="truncate text-xs text-destructive">
{validation.errors.map((item) => item.message).join("; ")}
</span>
) : null}
</div>
<div className="min-h-0 flex-1">
<WorkflowEditor
key={editorKey}
definition={definition}
nodeSpecs={nodeSpecs}
onDefinitionChange={setDefinition}
/>
</div>
</main>
</div>
</div>
)
}
+152
View File
@@ -242,6 +242,9 @@ export type AIAgent = {
arguments?: Record<string, string>
}[]
graphTools: string[]
runtimeMode: number
runtimeModeName: string
workflowVersionId: number
sortNo: number
createdAt: string
updatedAt: string
@@ -272,12 +275,98 @@ export type CreateAIAgentPayload = {
arguments?: Record<string, string>
}[]
graphTools: string[]
runtimeMode: number
workflowVersionId: number
}
export type UpdateAIAgentPayload = CreateAIAgentPayload & {
id: number
}
export type AIWorkflowPosition = {
x: number
y: number
}
export type AIWorkflowDefinition = {
schemaVersion: number
entryNodeId: string
nodes: {
id: string
type: string
name: string
position: AIWorkflowPosition
config: Record<string, unknown>
}[]
edges: {
id: string
source: string
target: string
condition?: {
expression: string
}
}[]
}
export type AIWorkflow = {
id: number
name: string
description: string
ownerType: string
ownerId: number
status: number
draftDefinition: AIWorkflowDefinition
publishedVersionId: number
sortNo: number
createdAt: string
updatedAt: string
createUserName: string
updateUserName: string
}
export type AIWorkflowVersion = {
id: number
workflowId: number
version: number
status: number
definition: AIWorkflowDefinition
definitionHash: string
publishedAt: string
publishedById: number
publishedByName: string
createdAt: string
updatedAt: string
}
export type AIWorkflowNodeSpec = {
type: string
title: string
description: string
riskLevel: "low" | "medium" | "high"
interruptible: boolean
requiresConfirmationPredecessor: boolean
}
export type AIWorkflowValidationResult = {
valid: boolean
errors: {
field: string
message: string
}[]
}
export type CreateAIWorkflowPayload = {
name: string
description: string
ownerType: string
ownerId: number
definition: AIWorkflowDefinition
}
export type UpdateAIWorkflowPayload = CreateAIWorkflowPayload & {
id: number
}
export type CreateAdminQuickReplyPayload = {
groupName: string
title: string
@@ -692,6 +781,69 @@ export function updateAIAgentStatus(id: number, status: number) {
})
}
export function fetchAIWorkflows(
query?: Record<string, string | number | undefined>
) {
return request<PageResult<AIWorkflow>>(
`/api/dashboard/ai-workflow/list${toQueryString(query)}`
)
}
export function fetchAIWorkflow(id: number) {
return request<AIWorkflow>(`/api/dashboard/ai-workflow/${id}`)
}
export function createAIWorkflow(payload: CreateAIWorkflowPayload) {
return request<AIWorkflow>("/api/dashboard/ai-workflow/create", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function updateAIWorkflow(payload: UpdateAIWorkflowPayload) {
return request<void>("/api/dashboard/ai-workflow/update", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function deleteAIWorkflow(id: number) {
return request<void>("/api/dashboard/ai-workflow/delete", {
method: "POST",
body: JSON.stringify({ id }),
})
}
export function fetchAIWorkflowNodeSpecs() {
return request<AIWorkflowNodeSpec[]>("/api/dashboard/ai-workflow/node-spec/list")
}
export function validateAIWorkflow(definition: AIWorkflowDefinition) {
return request<AIWorkflowValidationResult>("/api/dashboard/ai-workflow/validate", {
method: "POST",
body: JSON.stringify({ definition }),
})
}
export function publishAIWorkflow(workflowId: number, definition: AIWorkflowDefinition) {
return request<AIWorkflowVersion>("/api/dashboard/ai-workflow/publish", {
method: "POST",
body: JSON.stringify({ workflowId, definition }),
})
}
export function fetchAIWorkflowVersions(
query?: Record<string, string | number | undefined>
) {
return request<PageResult<AIWorkflowVersion>>(
`/api/dashboard/ai-workflow/version/list${toQueryString(query)}`
)
}
export function fetchAIWorkflowVersion(id: number) {
return request<AIWorkflowVersion>(`/api/dashboard/ai-workflow/version/${id}`)
}
export function fetchUsers(query?: Record<string, string | number | undefined>) {
return request<PageResult<AdminUser>>(
`/api/dashboard/user/list${toQueryString(query)}`
+7
View File
@@ -14,6 +14,7 @@ import {
TagsIcon,
UserCogIcon,
UsersIcon,
WorkflowIcon,
} from "lucide-react";
import type { ReactNode } from "react";
@@ -193,6 +194,12 @@ export const dashboardNavSections: DashboardNavSectionConfig[] = [
icon: <MessageSquareMoreIcon />,
requiredPermission: "aiAgent.view",
},
{
titleKey: "nav.aiWorkflows",
url: "/dashboard/ai-workflows",
icon: <WorkflowIcon />,
requiredPermission: "aiAgent.view",
},
{
titleKey: "nav.skillDefinition",
url: "/dashboard/skill-definition",
+14
View File
@@ -1038,6 +1038,8 @@
"nameRequired": "Enter a name.",
"aiConfigRequired": "Select an AI config.",
"serviceModeRequired": "Select a service mode.",
"runtimeModeRequired": "Select a runtime mode.",
"workflowVersionRequired": "Select a published workflow version.",
"replyTimeoutInvalid": "Reply timeout must be an integer greater than or equal to 0.",
"handoffModeRequired": "Select a human handoff mode.",
"fallbackModeRequired": "Select a fallback strategy.",
@@ -1046,6 +1048,7 @@
"loadTeamsFailed": "Could not load support teams.",
"loadKnowledgeFailed": "Could not load knowledge bases.",
"loadSkillsFailed": "Could not load skills.",
"loadWorkflowVersionsFailed": "Could not load AI workflow versions.",
"loadDirectToolsFailed": "Could not load Direct Tools.",
"builtinTools": "Built-in tools",
"ungrouped": "Ungrouped",
@@ -1065,6 +1068,16 @@
"selectServiceMode": "Select service mode",
"searchServiceMode": "Search service modes",
"emptyServiceMode": "No service modes found",
"runtimeMode": "Runtime Mode",
"runtimeBuiltinGraph": "Built-in Graph Tools",
"runtimeWorkflow": "Published AI Workflow",
"selectRuntimeMode": "Select runtime mode",
"searchRuntimeMode": "Search runtime modes",
"emptyRuntimeMode": "No runtime modes found",
"workflowVersion": "Workflow Version",
"selectWorkflowVersion": "Select published workflow version",
"searchWorkflowVersion": "Search workflow versions",
"emptyWorkflowVersion": "No published workflow versions available",
"description": "Description",
"welcomeMessage": "Welcome Message",
"systemPrompt": "System Prompt",
@@ -2334,6 +2347,7 @@
"knowledge": "Knowledge Base",
"aiConfigs": "Model Settings",
"aiAgents": "Agents",
"aiWorkflows": "AI Workflows",
"skillDefinition": "Skills",
"mcp": "MCP tools",
"agentRunLogs": "Run Logs",
+14
View File
@@ -1038,6 +1038,8 @@
"nameRequired": "名称不能为空",
"aiConfigRequired": "请选择 AI 配置",
"serviceModeRequired": "请选择服务模式",
"runtimeModeRequired": "请选择运行模式",
"workflowVersionRequired": "请选择已发布的流程版本",
"replyTimeoutInvalid": "回复超时秒数必须是大于等于 0 的整数",
"handoffModeRequired": "请选择转人工模式",
"fallbackModeRequired": "请选择兜底策略",
@@ -1046,6 +1048,7 @@
"loadTeamsFailed": "加载客服组失败",
"loadKnowledgeFailed": "加载知识库失败",
"loadSkillsFailed": "加载 Skills 失败",
"loadWorkflowVersionsFailed": "加载 AI 流程版本失败",
"loadDirectToolsFailed": "加载 Direct Tools 失败",
"builtinTools": "内置工具",
"ungrouped": "未分组",
@@ -1065,6 +1068,16 @@
"selectServiceMode": "请选择服务模式",
"searchServiceMode": "搜索服务模式",
"emptyServiceMode": "未找到服务模式",
"runtimeMode": "运行模式",
"runtimeBuiltinGraph": "内置流程工具",
"runtimeWorkflow": "已发布 AI 流程",
"selectRuntimeMode": "请选择运行模式",
"searchRuntimeMode": "搜索运行模式",
"emptyRuntimeMode": "未找到运行模式",
"workflowVersion": "流程版本",
"selectWorkflowVersion": "请选择已发布流程版本",
"searchWorkflowVersion": "搜索流程版本",
"emptyWorkflowVersion": "没有可用的已发布流程版本",
"description": "描述",
"welcomeMessage": "欢迎语",
"systemPrompt": "系统提示词",
@@ -2334,6 +2347,7 @@
"knowledge": "知识库",
"aiConfigs": "AI模型",
"aiAgents": "Agent",
"aiWorkflows": "AI流程",
"skillDefinition": "Skills",
"mcp": "MCP tools",
"agentRunLogs": "运行日志",
+1
View File
@@ -25,6 +25,7 @@
"@tiptap/react": "^3.20.2",
"@tiptap/starter-kit": "^3.20.2",
"@uiw/react-json-view": "2.0.0-alpha.41",
"@xyflow/react": "^12.11.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
+146
View File
@@ -50,6 +50,9 @@ importers:
'@uiw/react-json-view':
specifier: 2.0.0-alpha.41
version: 2.0.0-alpha.41(@babel/runtime@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@xyflow/react':
specifier: ^12.11.0
version: 12.11.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -1485,6 +1488,9 @@ packages:
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-drag@3.0.7':
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
@@ -1497,6 +1503,9 @@ packages:
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-selection@3.0.11':
resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
'@types/d3-shape@3.1.8':
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
@@ -1506,6 +1515,12 @@ packages:
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/d3-transition@3.0.9':
resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
'@types/d3-zoom@3.0.8':
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -1722,6 +1737,22 @@ packages:
'@vavt/util@2.1.2':
resolution: {integrity: sha512-L3UbSJthJwr3wq0x93O5TrCepimrmVZaIl2ciZbeL18G5++gBhJXNhcH7RcVk/6rr3SavWOvwhig0mqRLoR7dw==}
'@xyflow/react@12.11.0':
resolution: {integrity: sha512-na4IO33FSs2OS72hASgZDmTYwFAkef7Z74uBUVrong3ARmQQHfnRUVaCFn1kTt5LbS6pK03TbYjCPGLjLFfziA==}
peerDependencies:
'@types/react': '>=17'
'@types/react-dom': '>=17'
react: '>=17'
react-dom: '>=17'
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@xyflow/system@0.0.77':
resolution: {integrity: sha512-qCDCMCQAAgUu8yHnhloHG9F5mwPX5E+Wl8McpYIOPSSXfzFJJoZcwOcsDiAjitVKIg2de1WmJbCHfpcvxprsgg==}
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@@ -1913,6 +1944,9 @@ packages:
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
classcat@5.0.5:
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
cli-cursor@5.0.0:
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
engines: {node: '>=18'}
@@ -2031,6 +2065,14 @@ packages:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-dispatch@3.0.1:
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
engines: {node: '>=12'}
d3-drag@3.0.0:
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
@@ -2051,6 +2093,10 @@ packages:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-selection@3.0.0:
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
@@ -2067,6 +2113,16 @@ packages:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
d3-transition@3.0.1:
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
engines: {node: '>=12'}
peerDependencies:
d3-selection: 2 - 3
d3-zoom@3.0.0:
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
engines: {node: '>=12'}
damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
@@ -4171,6 +4227,21 @@ packages:
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
zustand@4.5.7:
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
engines: {node: '>=12.7.0'}
peerDependencies:
'@types/react': '>=16.8'
immer: '>=9.0.6'
react: '>=16.8'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
zustand@5.0.12:
resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==}
engines: {node: '>=12.20.0'}
@@ -5657,6 +5728,10 @@ snapshots:
'@types/d3-color@3.1.3': {}
'@types/d3-drag@3.0.7':
dependencies:
'@types/d3-selection': 3.0.11
'@types/d3-ease@3.0.2': {}
'@types/d3-interpolate@3.0.4':
@@ -5669,6 +5744,8 @@ snapshots:
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-selection@3.0.11': {}
'@types/d3-shape@3.1.8':
dependencies:
'@types/d3-path': 3.1.1
@@ -5677,6 +5754,15 @@ snapshots:
'@types/d3-timer@3.0.2': {}
'@types/d3-transition@3.0.9':
dependencies:
'@types/d3-selection': 3.0.11
'@types/d3-zoom@3.0.8':
dependencies:
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.11
'@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {}
@@ -5872,6 +5958,31 @@ snapshots:
'@vavt/util@2.1.2': {}
'@xyflow/react@12.11.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@xyflow/system': 0.0.77
classcat: 5.0.5
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
zustand: 4.5.7(@types/react@19.2.14)(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
transitivePeerDependencies:
- immer
'@xyflow/system@0.0.77':
dependencies:
'@types/d3-drag': 3.0.7
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.11
'@types/d3-transition': 3.0.9
'@types/d3-zoom': 3.0.8
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-zoom: 3.0.0
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -6089,6 +6200,8 @@ snapshots:
dependencies:
clsx: 2.1.1
classcat@5.0.5: {}
cli-cursor@5.0.0:
dependencies:
restore-cursor: 5.1.0
@@ -6191,6 +6304,13 @@ snapshots:
d3-color@3.1.0: {}
d3-dispatch@3.0.1: {}
d3-drag@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-selection: 3.0.0
d3-ease@3.0.1: {}
d3-format@3.1.2: {}
@@ -6209,6 +6329,8 @@ snapshots:
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-selection@3.0.0: {}
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
@@ -6223,6 +6345,23 @@ snapshots:
d3-timer@3.0.1: {}
d3-transition@3.0.1(d3-selection@3.0.0):
dependencies:
d3-color: 3.1.0
d3-dispatch: 3.0.1
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-timer: 3.0.1
d3-zoom@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
damerau-levenshtein@1.0.8: {}
data-uri-to-buffer@4.0.1: {}
@@ -8652,6 +8791,13 @@ snapshots:
zod@4.3.6: {}
zustand@4.5.7(@types/react@19.2.14)(react@19.2.3):
dependencies:
use-sync-external-store: 1.6.0(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.14
react: 19.2.3
zustand@5.0.12(@types/react@19.2.14)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)):
optionalDependencies:
'@types/react': 19.2.14