refactor: make AI workflows agent-centric

This commit is contained in:
mlogclub
2026-06-22 10:08:51 +08:00
parent 14f50e064f
commit 32ab43e7c2
18 changed files with 436 additions and 400 deletions
@@ -41,14 +41,12 @@ 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,
@@ -96,8 +94,6 @@ type EditForm = {
description: string;
aiConfigId: string;
serviceMode: string;
runtimeMode: string;
workflowVersionId: string;
systemPrompt: string;
welcomeMessage: string;
replyTimeoutSeconds: number;
@@ -106,9 +102,6 @@ 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") },
@@ -139,8 +132,6 @@ 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,
@@ -154,8 +145,6 @@ 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,
@@ -178,11 +167,6 @@ 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),
@@ -236,8 +220,6 @@ 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
@@ -246,18 +228,6 @@ 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],
);
@@ -266,19 +236,6 @@ 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>({
@@ -305,7 +262,6 @@ 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[]>([]);
@@ -390,23 +346,6 @@ 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 {
@@ -497,15 +436,6 @@ 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) => ({
@@ -626,7 +556,6 @@ function EditDialogBody({
);
const handoffMode = watch("handoffMode");
const runtimeMode = watch("runtimeMode");
const selectedHandoffModeLabel =
handoffModeOptions.find((item) => item.value === handoffMode)?.label ??
t("aiAgent.notSelected");
@@ -818,56 +747,6 @@ 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>
+11 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { BotMessageSquareIcon, PowerIcon } from "lucide-react";
import { BotMessageSquareIcon, GitBranchIcon, PowerIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import {
@@ -62,6 +63,7 @@ function getNextStatus(item: AIAgent) {
export default function DashboardAIAgentsPage() {
const t = useI18n();
const router = useRouter();
const statusOptions = useMemo(() => getStatusOptions(t), [t]);
const filters = useMemo<DashboardCrudFilter[]>(
@@ -235,6 +237,14 @@ export default function DashboardAIAgentsPage() {
updateItem={(item, payload) => updateAIAgent({ id: item.id, ...payload })}
deleteItem={(item) => deleteAIAgent(item.id)}
rowActions={[
{
key: "workflow",
icon: <GitBranchIcon />,
label: t("aiAgent.workflow"),
run: ({ item }) => {
router.push(`/dashboard/ai-agents/workflow?agentId=${item.id}`);
},
},
createDashboardStatusToggleAction<AIAgent, number>({
icon: <PowerIcon />,
label: (item) =>
@@ -1,7 +1,8 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { CheckCircle2Icon, GitBranchIcon, SaveIcon, SendIcon } from "lucide-react"
import { useRouter } from "next/navigation"
import { ArrowLeftIcon, CheckCircle2Icon, GitBranchIcon, SaveIcon, SendIcon } from "lucide-react"
import { toast } from "sonner"
import { Badge } from "@/components/ui/badge"
@@ -10,18 +11,19 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import {
createAIWorkflow,
fetchAIAgent,
fetchAIAgentWorkflow,
fetchAIWorkflowNodeSpecs,
fetchAIWorkflows,
publishAIWorkflow,
updateAIWorkflow,
publishAIAgentWorkflow,
saveAIAgentWorkflow,
validateAIWorkflow,
type AIAgent,
type AIWorkflow,
type AIWorkflowDefinition,
type AIWorkflowNodeSpec,
type AIWorkflowValidationResult,
} from "@/lib/api/admin"
import { WorkflowEditor } from "./_components/workflow-editor"
import { WorkflowEditor } from "../../ai-workflows/_components/workflow-editor"
const emptyDefinition: AIWorkflowDefinition = {
schemaVersion: 1,
@@ -45,73 +47,68 @@ const emptyDefinition: AIWorkflowDefinition = {
edges: [{ id: "edge_start_end", source: "start_1", target: "end_1" }],
}
export default function DashboardAIWorkflowsPage() {
const [workflows, setWorkflows] = useState<AIWorkflow[]>([])
function readAgentIdFromLocation() {
if (typeof window === "undefined") {
return 0
}
return Number(new URLSearchParams(window.location.search).get("agentId"))
}
export default function DashboardAIAgentWorkflowPage() {
const router = useRouter()
const [agentId] = useState(() => readAgentIdFromLocation())
const [agent, setAgent] = useState<AIAgent | null>(null)
const [workflow, setWorkflow] = useState<AIWorkflow | null>(null)
const [nodeSpecs, setNodeSpecs] = useState<AIWorkflowNodeSpec[]>([])
const [selected, setSelected] = useState<AIWorkflow | null>(null)
const [name, setName] = useState("Customer support flow")
const [name, setName] = useState("")
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]
() => `${workflow?.id ?? "new"}-${workflow?.updatedAt ?? ""}`,
[workflow?.id, workflow?.updatedAt]
)
const loadData = useCallback(async () => {
const [workflowPage, specs] = await Promise.all([
fetchAIWorkflows({ page: 1, limit: 50, status: 0 }),
if (!Number.isFinite(agentId) || agentId <= 0) {
return
}
const [agentDetail, workflowDetail, specs] = await Promise.all([
fetchAIAgent(agentId),
fetchAIAgentWorkflow(agentId),
fetchAIWorkflowNodeSpecs(),
])
setWorkflows(workflowPage?.results ?? [])
setAgent(agentDetail)
setWorkflow(workflowDetail)
setNodeSpecs(specs ?? [])
}, [])
setName(workflowDetail.name || `${agentDetail.name} 会话流程`)
setDescription(workflowDetail.description || "")
setDefinition(workflowDetail.draftDefinition ?? emptyDefinition)
setValidation(null)
}, [agentId])
useEffect(() => {
void loadData().catch((error) => {
toast.error(error instanceof Error ? error.message : "Failed to load workflows")
toast.error(error instanceof Error ? error.message : "Failed to load workflow")
})
}, [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 () => {
if (!Number.isFinite(agentId) || agentId <= 0) {
toast.error("Invalid AI Agent.")
return
}
setLoading(true)
try {
const payload = {
const saved = await saveAIAgentWorkflow({
agentId,
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()
})
setWorkflow(saved)
toast.success("Draft saved")
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to save workflow")
} finally {
@@ -135,13 +132,20 @@ export default function DashboardAIWorkflowsPage() {
}
const publish = async () => {
if (!selected) {
toast.error("Save the workflow before publishing.")
if (!Number.isFinite(agentId) || agentId <= 0) {
toast.error("Invalid AI Agent.")
return
}
setLoading(true)
try {
const version = await publishAIWorkflow(selected.id, definition)
const saved = await saveAIAgentWorkflow({
agentId,
name,
description,
definition,
})
setWorkflow(saved)
const version = await publishAIAgentWorkflow(agentId, definition)
toast.success(`Published version ${version.version}`)
await loadData()
} catch (error) {
@@ -154,16 +158,20 @@ export default function DashboardAIWorkflowsPage() {
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 className="flex min-w-0 items-center gap-3">
<Button variant="outline" size="icon-sm" onClick={() => router.push("/dashboard/ai-agents")}>
<ArrowLeftIcon />
</Button>
<div className="min-w-0">
<h1 className="truncate text-base font-semibold">
{agent ? `${agent.name} · 会话流程` : "AI Agent Workflow"}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Edit and publish this Agent&apos;s customer-service conversation flow.
</p>
</div>
</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
@@ -172,7 +180,7 @@ export default function DashboardAIWorkflowsPage() {
<SaveIcon className="size-4" />
Save draft
</Button>
<Button disabled={loading || !selected} onClick={publish}>
<Button disabled={loading} onClick={publish}>
<SendIcon className="size-4" />
Publish
</Button>
@@ -189,16 +197,6 @@ export default function DashboardAIWorkflowsPage() {
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
@@ -209,41 +207,25 @@ export default function DashboardAIWorkflowsPage() {
/>
</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 className="space-y-3 p-4 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">Agent</span>
<span className="truncate font-medium">{agent?.name ?? `#${agentId || "-"}`}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">Published</span>
{workflow?.publishedVersionId ? (
<Badge variant="secondary">Version linked</Badge>
) : (
<span className="text-muted-foreground">Not published</span>
)}
</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>
<span className="font-medium">{name || "Conversation workflow"}</span>
{validation ? (
<Badge variant={validation.valid ? "default" : "destructive"}>
{validation.valid ? "Backend valid" : `${validation.errors.length} backend errors`}