feat: add AI workflow editor
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)}`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "运行日志",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+146
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user