Add workflow editor and node types support in frontend and backend

- Introduce a new workflow editor component for visualizing and managing workflows, allowing users to add nodes and define connections.
- Implement backend support for node types, including validation and constraints for workflow graphs.
- Add new API endpoints for retrieving node types and their specifications.
- Enhance the AssistantPage to integrate the workflow editor, enabling users to create and edit workflows directly.
- Update frontend components to support new workflow functionalities, including condition edges and generic nodes.
- Refactor existing code to accommodate the new workflow features and improve overall structure.
This commit is contained in:
Xin Wang
2026-06-15 10:12:41 +08:00
parent 0309c154b5
commit c2a39257ff
15 changed files with 1733 additions and 54 deletions

View File

@@ -31,6 +31,7 @@ import {
PhoneOff,
Orbit,
Waves,
Bug,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -38,6 +39,12 @@ import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
DropdownMenu,
DropdownMenuContent,
@@ -89,6 +96,14 @@ import {
type ChatMessage,
type VoicePreviewStatus,
} from "@/hooks/use-voice-preview";
import {
WorkflowEditor,
type WorkflowSettings,
} from "@/components/workflow/WorkflowEditor";
import {
defaultGraph,
type WorkflowGraph,
} from "@/components/workflow/specs";
type RuntimeMode = "pipeline" | "realtime";
@@ -167,7 +182,7 @@ const typeToView = {
dify: "create-dify",
fastgpt: "create-fastgpt",
opencode: "create-opencode",
workflow: "placeholder",
workflow: "workflow",
} as const;
type View = "list" | "choose" | "loading" | (typeof typeToView)[ApiAssistantType];
@@ -348,6 +363,16 @@ export function AssistantPage(props: AssistantPageProps) {
// 已保存基线(当前类型表单的 JSON);与表单不一致时保存按钮才可点击
const [savedSnapshot, setSavedSnapshot] = useState<string | null>(null);
// workflow 编辑器:名称 + 图(nodes/edges)。graph 实时由画布回传。
const [workflowName, setWorkflowName] = useState("");
const [workflowGraph, setWorkflowGraph] = useState<WorkflowGraph>(() =>
defaultGraph(),
);
const [workflowSettings, setWorkflowSettings] = useState<WorkflowSettings>({
allowInterrupt: true,
});
const [debugOpen, setDebugOpen] = useState(false);
const loadAssistants = useCallback(async () => {
setListLoading(true);
setListError(null);
@@ -507,6 +532,14 @@ export function AssistantPage(props: AssistantPageProps) {
const next = { ...openCodeForm, apiKey: "" };
setOpenCodeForm(next);
setSavedSnapshot(JSON.stringify(next));
} else if (view === "workflow") {
setSavedSnapshot(
JSON.stringify({
name: workflowName,
graph: workflowGraph,
settings: workflowSettings,
}),
);
}
} catch (error) {
setSaveError(error instanceof Error ? error.message : "保存失败");
@@ -635,9 +668,29 @@ export function AssistantPage(props: AssistantPageProps) {
} else if (assistant.type === "opencode") {
setSavedSnapshot(JSON.stringify(fillOpenCodeForm(assistant)));
} else {
// 工作流:暂时显示占位页
setDraftName(assistant.name);
setDraftType(typeToLabel[assistant.type]);
// 工作流:回填名称与图(空图回落到默认 开始→智能体→结束)
const graph =
assistant.graph &&
Array.isArray((assistant.graph as WorkflowGraph).nodes) &&
(assistant.graph as WorkflowGraph).nodes.length > 0
? (assistant.graph as WorkflowGraph)
: defaultGraph();
const wfSettings: WorkflowSettings = {
llm: assistant.modelResourceIds.LLM,
asr: assistant.modelResourceIds.ASR,
tts: assistant.modelResourceIds.TTS,
allowInterrupt: assistant.enableInterrupt,
};
setWorkflowName(assistant.name);
setWorkflowGraph(graph);
setWorkflowSettings(wfSettings);
setSavedSnapshot(
JSON.stringify({
name: assistant.name,
graph,
settings: wfSettings,
}),
);
}
setView(typeToView[assistant.type]);
} catch (error) {
@@ -672,6 +725,22 @@ export function AssistantPage(props: AssistantPageProps) {
);
}
function handleSaveWorkflow() {
void save(
baseUpsert({
name: workflowName.trim(),
type: "workflow",
enableInterrupt: workflowSettings.allowInterrupt,
modelResourceIds: {
...(workflowSettings.llm ? { LLM: workflowSettings.llm } : {}),
...(workflowSettings.asr ? { ASR: workflowSettings.asr } : {}),
...(workflowSettings.tts ? { TTS: workflowSettings.tts } : {}),
},
graph: workflowGraph as unknown as Record<string, unknown>,
}),
);
}
// 当前编辑器表单相对已保存基线是否有改动(决定保存按钮是否可点)
const activeFormJson =
view === "create"
@@ -682,7 +751,13 @@ export function AssistantPage(props: AssistantPageProps) {
? JSON.stringify(fastGptForm)
: view === "create-opencode"
? JSON.stringify(openCodeForm)
: null;
: view === "workflow"
? JSON.stringify({
name: workflowName,
graph: workflowGraph,
settings: workflowSettings,
})
: null;
const dirty =
activeFormJson !== null &&
savedSnapshot !== null &&
@@ -1114,59 +1189,73 @@ export function AssistantPage(props: AssistantPageProps) {
);
}
if (view === "placeholder") {
if (view === "workflow") {
return (
<div className="mx-auto flex w-full max-w-[1180px] flex-col gap-6">
<div className="flex items-start justify-between gap-6">
<div>
<h1 className="font-display display-lg text-ink">
{draftName.trim() || "创建助手"}
</h1>
<p className="mt-3 max-w-2xl text-[15px] leading-7 text-muted-foreground">
{draftType}
</p>
<div className="-mt-6 flex h-full flex-col gap-4">
<div className="flex shrink-0 items-center justify-between gap-6 border-b border-hairline pb-3 pt-1">
<div className="flex min-w-0 items-center gap-2">
<EditorBackButton onClick={() => router.push("/assistants")} />
<EditableTitle value={workflowName} onChange={setWorkflowName} />
<AssistantIdentity assistantId={editingId} />
</div>
<Button
variant="outline"
size="lg"
className="shrink-0 gap-2 border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={() => router.push("/assistants")}
>
<ChevronLeft size={16} />
</Button>
<div className="flex shrink-0 gap-2">
{saveError && (
<span className="self-center text-xs text-destructive">
{saveError}
</span>
)}
<Button
variant="outline"
className="gap-2 border-hairline-strong text-foreground hover:bg-surface-strong"
disabled={!editingId}
onClick={() => setDebugOpen(true)}
>
<Bug size={16} />
</Button>
<Button
className="gap-2"
disabled={saving || !dirty || !workflowName.trim()}
onClick={() => handleSaveWorkflow()}
>
{saving ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Save size={16} />
)}
</Button>
</div>
</div>
<section className="relative overflow-hidden rounded-3xl border border-hairline bg-canvas-soft px-10 py-20 text-center">
<div
aria-hidden
className="pointer-events-none absolute -right-24 top-0 h-72 w-72 rounded-full opacity-50 blur-3xl"
style={{
backgroundImage:
"radial-gradient(circle, color-mix(in srgb, var(--gradient-sky) 50%, transparent), transparent 70%)",
<div className="min-h-0 flex-1">
<WorkflowEditor
value={workflowGraph}
onChange={setWorkflowGraph}
settings={workflowSettings}
onSettingsChange={setWorkflowSettings}
modelOptions={{
llm: credOptions("LLM"),
asr: credOptions("ASR"),
tts: credOptions("TTS"),
}}
/>
<div
aria-hidden
className="pointer-events-none absolute -left-20 bottom-0 h-64 w-64 rounded-full opacity-45 blur-3xl"
style={{
backgroundImage:
"radial-gradient(circle, color-mix(in srgb, var(--gradient-lavender) 50%, transparent), transparent 70%)",
}}
/>
<div className="relative">
<div className="caption-label inline-flex rounded-full bg-surface-strong px-3 py-1 text-muted-foreground">
</div>
<p className="font-display display-sm mx-auto mt-5 max-w-md text-ink">
{draftType}
</p>
<p className="mx-auto mt-3 max-w-md text-sm leading-7 text-body">
</p>
</div>
</section>
</div>
<Sheet open={debugOpen} onOpenChange={setDebugOpen} modal={false}>
<SheetContent
side="right"
showOverlay={false}
onInteractOutside={(e) => e.preventDefault()}
className="w-[440px] gap-0 border-l border-hairline bg-card p-0 sm:max-w-[440px]"
>
<SheetHeader className="sr-only">
<SheetTitle></SheetTitle>
</SheetHeader>
<DebugDrawer assistantId={editingId} asSheet />
</SheetContent>
</Sheet>
</div>
);
}
@@ -1767,12 +1856,23 @@ function SegmentedIconButton({
);
}
function DebugDrawer({ assistantId }: { assistantId: string | null }) {
function DebugDrawer({
assistantId,
asSheet = false,
}: {
assistantId: string | null;
asSheet?: boolean;
}) {
const [showTranscript, setShowTranscript] = useState(false);
const [vizStyle, setVizStyle] = useState<VizStyle>("aura");
// 内联(prompt 编辑器右栏)用 aside + 圆角边框;抽屉模式占满 Sheet。
const containerClass = asSheet
? "flex h-full min-w-0 flex-1 flex-col overflow-hidden"
: "hidden min-w-0 flex-1 flex-col overflow-hidden rounded-2xl border border-hairline bg-card shadow-sm lg:flex";
return (
<aside className="hidden min-w-0 flex-1 flex-col overflow-hidden rounded-2xl border border-hairline bg-card shadow-sm lg:flex">
<aside className={containerClass}>
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-hairline px-5 py-3">
<div className="text-sm font-medium text-foreground"></div>
{SHOW_VOICE_VIZ && (