Update node specifications and enhance GenericNode component

- Change the 'addable' property of a specific node type to true, allowing for dynamic addition of nodes.
- Modify the GenericNode component to include a new icon and adjust styles for better visual representation.
- Update node handling logic to prevent deletion of 'startCall' nodes and improve node change handling in the workflow editor.
- Refactor layout and styling in the WorkflowEditor for a more polished user interface.
This commit is contained in:
Xin Wang
2026-06-15 15:49:58 +08:00
parent aae0342a57
commit 09a5ffbdbc
3 changed files with 204 additions and 101 deletions

View File

@@ -56,7 +56,7 @@ NODE_SPECS: list[dict[str, Any]] = [
"description": "终止节点,礼貌结束对话。可有多个,均无出边。", "description": "终止节点,礼貌结束对话。可有多个,均无出边。",
"icon": "Flag", "icon": "Flag",
"accent": "rose", "accent": "rose",
"addable": False, "addable": True,
"constraints": {"minIncoming": 1, "minOutgoing": 0, "maxOutgoing": 0}, "constraints": {"minIncoming": 1, "minOutgoing": 0, "maxOutgoing": 0},
"fields": [ "fields": [
{"key": "name", "label": "节点名称", "type": "text"}, {"key": "name", "label": "节点名称", "type": "text"},

View File

@@ -6,7 +6,7 @@
*/ */
import { Handle, Position, type NodeProps } from "@xyflow/react"; import { Handle, Position, type NodeProps } from "@xyflow/react";
import { Pencil, Trash2 } from "lucide-react"; import { MessageSquareText, Pencil, Trash2 } from "lucide-react";
import { useContext } from "react"; import { useContext } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -35,22 +35,30 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
<div <div
data-node-id={id} data-node-id={id}
className={cn( className={cn(
"group relative w-[228px] rounded-2xl border bg-card p-4 shadow-sm transition-all", "group relative w-[250px] rounded-2xl border bg-card p-4 text-card-foreground shadow-sm transition-[border-color,box-shadow,transform]",
isActive isActive
? "border-success ring-2 ring-success/60" ? "border-success shadow-[0_12px_34px_color-mix(in_srgb,var(--success)_20%,transparent)] ring-2 ring-success/50"
: selected : selected
? "border-primary ring-2 ring-primary/30" ? "border-primary shadow-[0_12px_34px_color-mix(in_srgb,var(--primary)_16%,transparent)]"
: "border-hairline hover:shadow-md", : "border-hairline hover:border-hairline-strong hover:shadow-md",
)} )}
> >
{spec.hasTarget && ( {spec.hasTarget && (
<Handle <Handle
type="target" type="target"
position={Position.Left} position={Position.Left}
className="!h-2.5 !w-2.5 !border-2 !border-card !bg-muted-soft" className="!h-3 !w-3 !border-[3px] !border-card !bg-muted-soft"
/> />
)} )}
<div
aria-hidden
className="absolute left-5 right-5 top-0 h-px"
style={{
background: `linear-gradient(90deg, transparent, var(${accentVar(spec.accent)}), transparent)`,
}}
/>
{isActive && ( {isActive && (
<div className="absolute -top-3 left-3 flex items-center gap-1.5 rounded-full bg-success px-2 py-0.5 text-[10px] font-medium text-on-primary shadow-sm"> <div className="absolute -top-3 left-3 flex items-center gap-1.5 rounded-full bg-success px-2 py-0.5 text-[10px] font-medium text-on-primary shadow-sm">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-on-primary" /> <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-on-primary" />
@@ -78,7 +86,7 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
> >
<Pencil size={13} /> <Pencil size={13} />
</button> </button>
{spec.addable && ( {type !== "startCall" && (
<button <button
type="button" type="button"
title="删除节点" title="删除节点"
@@ -93,30 +101,31 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
)} )}
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-start gap-3">
<div <div
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-ink" className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-foreground"
style={{ style={{
backgroundImage: `radial-gradient(circle at 30% 30%, color-mix(in srgb, var(${accentVar(spec.accent)}) 70%, transparent), color-mix(in srgb, var(${accentVar(spec.accent)}) 35%, transparent))`, background: `color-mix(in srgb, var(${accentVar(spec.accent)}) 28%, var(--surface-strong))`,
}} }}
> >
<Icon size={18} /> <Icon size={17} />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground"> <div className="caption-label text-muted-soft">{spec.displayName}</div>
<div className="mt-1 truncate text-sm font-medium text-foreground">
{nodeData.name || spec.displayName} {nodeData.name || spec.displayName}
</div> </div>
<div className="caption-label text-muted-soft">{spec.displayName}</div>
</div> </div>
<MessageSquareText size={15} className="mt-1 text-muted-soft" />
</div> </div>
{preview ? ( {preview ? (
<p className="mt-3 line-clamp-2 text-xs leading-5 text-muted-foreground"> <p className="mt-4 line-clamp-2 border-t border-hairline-soft pt-3 text-xs leading-5 text-muted-foreground">
{preview} {preview}
</p> </p>
) : ( ) : (
<p className="mt-3 text-xs italic leading-5 text-muted-soft"> <p className="mt-4 border-t border-hairline-soft pt-3 text-xs leading-5 text-muted-foreground">
{spec.description}
</p> </p>
)} )}
@@ -124,7 +133,7 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
<Handle <Handle
type="source" type="source"
position={Position.Right} position={Position.Right}
className="!h-2.5 !w-2.5 !border-2 !border-card !bg-primary" className="!h-3 !w-3 !border-[3px] !border-card !bg-primary"
/> />
)} )}
</div> </div>

View File

@@ -17,6 +17,7 @@ import {
type Edge, type Edge,
MiniMap, MiniMap,
type Node, type Node,
type NodeChange,
Panel, Panel,
ReactFlow, ReactFlow,
ReactFlowProvider, ReactFlowProvider,
@@ -214,11 +215,26 @@ function Canvas({
const deleteNode = useCallback( const deleteNode = useCallback(
(id: string) => { (id: string) => {
if (nodes.find((node) => node.id === id)?.type === "startCall") return;
setNodes((ns) => ns.filter((n) => n.id !== id)); setNodes((ns) => ns.filter((n) => n.id !== id));
setEdges((es) => es.filter((e) => e.source !== id && e.target !== id)); setEdges((es) => es.filter((e) => e.source !== id && e.target !== id));
setEditingId((cur) => (cur === id ? null : cur)); setEditingId((cur) => (cur === id ? null : cur));
}, },
[setNodes, setEdges], [nodes, setNodes, setEdges],
);
const handleNodesChange = useCallback(
(changes: NodeChange[]) => {
const startIds = new Set(
nodes.filter((node) => node.type === "startCall").map((node) => node.id),
);
onNodesChange(
changes.filter(
(change) => change.type !== "remove" || !startIds.has(change.id),
),
);
},
[nodes, onNodesChange],
); );
const updateEdgeData = useCallback( const updateEdgeData = useCallback(
@@ -259,96 +275,151 @@ function Canvas({
<ActiveNodeContext.Provider value={activeNodeId ?? null}> <ActiveNodeContext.Provider value={activeNodeId ?? null}>
<NodeActionContext.Provider value={nodeActions}> <NodeActionContext.Provider value={nodeActions}>
<EdgeActionContext.Provider value={edgeActions}> <EdgeActionContext.Provider value={edgeActions}>
<div className="h-full w-full overflow-hidden rounded-2xl border border-hairline bg-canvas-soft"> <div className="h-full w-full min-h-[560px]">
<ReactFlow <section className="relative h-full w-full overflow-hidden rounded-2xl border border-hairline bg-canvas-soft shadow-sm">
nodes={nodes} <div
edges={edges} aria-hidden
nodeTypes={nodeTypes} className="pointer-events-none absolute -right-24 -top-24 z-0 h-80 w-80 rounded-full opacity-30 blur-3xl"
edgeTypes={edgeTypes} style={{
onNodesChange={onNodesChange} background:
onEdgesChange={onEdgesChange} "radial-gradient(circle, var(--gradient-sky), transparent 68%)",
onConnect={onConnect} }}
isValidConnection={isValidConnection}
onPaneClick={() => {
setEditingId(null);
setEditingEdgeId(null);
}}
fitView
proOptions={{ hideAttribution: true }}
defaultEdgeOptions={{ type: "condition", animated: true }}
className="bg-canvas-soft"
>
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="var(--hairline-strong)"
/> />
<Controls className="!rounded-xl !border !border-hairline !bg-card !shadow-sm [&_button]:!border-hairline [&_button]:!bg-card [&_button]:!text-foreground" /> <div
<MiniMap aria-hidden
pannable className="pointer-events-none absolute -bottom-28 left-1/4 z-0 h-72 w-72 rounded-full opacity-25 blur-3xl"
zoomable style={{
className="!rounded-xl !border !border-hairline !bg-card" background:
maskColor="color-mix(in srgb, var(--canvas-soft) 70%, transparent)" "radial-gradient(circle, var(--gradient-lavender), transparent 68%)",
nodeColor="var(--surface-strong)" }}
/> />
<Panel position="top-left" className="flex gap-2">
<Button size="sm" className="gap-2" onClick={() => setAddOpen(true)}> <ReactFlow
<Plus size={15} /> nodes={nodes}
edges={edges}
</Button> nodeTypes={nodeTypes}
<Button edgeTypes={edgeTypes}
size="sm" onNodesChange={handleNodesChange}
variant="outline" onEdgesChange={onEdgesChange}
className="gap-2 border-hairline-strong bg-card text-foreground hover:bg-surface-strong" onConnect={onConnect}
onClick={() => setSettingsOpen(true)} isValidConnection={isValidConnection}
> onPaneClick={() => {
<Settings2 size={15} /> setEditingId(null);
setEditingEdgeId(null);
</Button> }}
</Panel> fitView
</ReactFlow> proOptions={{ hideAttribution: true }}
defaultEdgeOptions={{ type: "condition", animated: true }}
>
<Background
variant={BackgroundVariant.Dots}
gap={22}
size={1}
color="var(--hairline-strong)"
/>
<Controls
className="!rounded-xl !border !border-hairline !bg-card !shadow-sm [&_button]:!border-hairline [&_button]:!bg-card [&_button]:!text-foreground"
/>
<MiniMap
pannable
zoomable
className="!rounded-xl !border !border-hairline !bg-card"
maskColor="color-mix(in srgb, var(--canvas-soft) 70%, transparent)"
nodeColor="var(--surface-strong)"
/>
<Panel position="top-left" className="flex gap-2">
<Button
size="sm"
className="gap-2"
onClick={() => setAddOpen(true)}
>
<Plus size={15} />
</Button>
<Button
size="sm"
variant="outline"
className="gap-2 border-hairline-strong bg-card text-foreground hover:bg-surface-strong"
onClick={() => setSettingsOpen(true)}
>
<Settings2 size={15} />
</Button>
</Panel>
</ReactFlow>
</section>
{/* 添加节点弹窗 */} {/* 添加节点弹窗 */}
<Dialog open={addOpen} onOpenChange={setAddOpen}> <Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent className="sm:max-w-[460px]"> <DialogContent className="gap-0 overflow-hidden border border-hairline bg-card p-0 shadow-2xl sm:max-w-[500px]">
<DialogHeader> <DialogHeader className="relative overflow-hidden border-b border-hairline px-6 py-6 pr-16">
<DialogTitle className="font-display text-ink"> <div
aria-hidden
</DialogTitle> className="pointer-events-none absolute -right-14 -top-16 h-40 w-40 rounded-full opacity-40 blur-3xl"
<DialogDescription> style={{
background:
</DialogDescription> "radial-gradient(circle, var(--gradient-sky), transparent 68%)",
}}
/>
<div className="relative flex items-start gap-4">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
<Plus size={18} />
</div>
<div>
<div className="caption-label text-muted-soft">
</div>
<DialogTitle className="font-display display-sm mt-1 text-ink">
</DialogTitle>
<DialogDescription className="mt-2 leading-6">
线
</DialogDescription>
</div>
</div>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-2 py-2"> <div className="flex max-h-[440px] flex-col gap-3 overflow-y-auto bg-canvas-soft/70 p-4">
{addableSpecs.length === 0 && ( {addableSpecs.length === 0 ? (
<p className="text-sm text-muted-soft"></p> <p className="rounded-2xl border border-dashed border-hairline-strong bg-card px-4 py-8 text-center text-sm text-muted-soft">
)}
</p>
) : null}
{addableSpecs.map((spec) => { {addableSpecs.map((spec) => {
const Icon = spec.icon; const Icon = spec.icon;
return ( return (
<button <button
key={spec.type} key={spec.type}
type="button" type="button"
className="flex items-start gap-3 rounded-xl border border-hairline bg-card p-3 text-left transition-colors hover:border-primary hover:bg-surface-strong/40" className="group relative flex items-start gap-4 overflow-hidden rounded-2xl border border-hairline bg-card p-4 text-left shadow-sm transition-[border-color,box-shadow,transform] hover:-translate-y-0.5 hover:border-hairline-strong hover:shadow-md"
onClick={() => addNode(spec)} onClick={() => addNode(spec)}
> >
<div <span
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-ink" aria-hidden
className="absolute left-5 right-5 top-0 h-px"
style={{ style={{
backgroundImage: `radial-gradient(circle at 30% 30%, color-mix(in srgb, var(${accentVar(spec.accent)}) 70%, transparent), color-mix(in srgb, var(${accentVar(spec.accent)}) 35%, transparent))`, background: `linear-gradient(90deg, transparent, var(${accentVar(spec.accent)}), transparent)`,
}}
/>
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-foreground transition-transform group-hover:scale-105"
style={{
background: `color-mix(in srgb, var(${accentVar(spec.accent)}) 28%, var(--surface-strong))`,
}} }}
> >
<Icon size={18} /> <Icon size={17} />
</div> </div>
<div className="min-w-0"> <div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground"> <div className="text-sm font-medium text-foreground">
{spec.displayName} {spec.displayName}
</div> </div>
<div className="mt-0.5 text-xs leading-5 text-muted-foreground"> <div className="mt-1 text-xs leading-5 text-muted-foreground">
{spec.description} {spec.description}
</div> </div>
</div> </div>
<Plus
size={15}
className="mt-1 shrink-0 text-muted-soft transition-colors group-hover:text-foreground"
/>
</button> </button>
); );
})} })}
@@ -358,16 +429,34 @@ function Canvas({
{/* 工作流设置弹窗 */} {/* 工作流设置弹窗 */}
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}> <Dialog open={settingsOpen} onOpenChange={setSettingsOpen}>
<DialogContent className="sm:max-w-[460px]"> <DialogContent className="gap-0 overflow-hidden border border-hairline bg-card p-0 shadow-2xl sm:max-w-[500px]">
<DialogHeader> <DialogHeader className="relative overflow-hidden border-b border-hairline px-6 py-6 pr-16">
<DialogTitle className="font-display text-ink"> <div
aria-hidden
</DialogTitle> className="pointer-events-none absolute -right-14 -top-16 h-40 w-40 rounded-full opacity-40 blur-3xl"
<DialogDescription> style={{
使, background:
</DialogDescription> "radial-gradient(circle, var(--gradient-lavender), transparent 68%)",
}}
/>
<div className="relative flex items-start gap-4">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
<Settings2 size={18} />
</div>
<div>
<div className="caption-label text-muted-soft">
</div>
<DialogTitle className="font-display display-sm mt-1 text-ink">
</DialogTitle>
<DialogDescription className="mt-2 leading-6">
使
</DialogDescription>
</div>
</div>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-5 py-2"> <div className="flex max-h-[62vh] flex-col gap-3 overflow-y-auto bg-canvas-soft/70 p-4">
<ModelSelect <ModelSelect
label="大模型(LLM)" label="大模型(LLM)"
value={settings.llm} value={settings.llm}
@@ -386,9 +475,14 @@ function Canvas({
options={modelOptions.tts} options={modelOptions.tts}
onChange={(v) => onSettingsChange({ ...settings, tts: v })} onChange={(v) => onSettingsChange({ ...settings, tts: v })}
/> />
<label className="flex items-center justify-between gap-3 pt-1"> <label className="flex items-center justify-between gap-4 rounded-2xl border border-hairline bg-card p-4 shadow-sm">
<span className="text-sm font-medium text-foreground"> <span>
<span className="block text-sm font-medium text-foreground">
</span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
</span>
</span> </span>
<Switch <Switch
checked={settings.allowInterrupt} checked={settings.allowInterrupt}
@@ -398,7 +492,7 @@ function Canvas({
/> />
</label> </label>
</div> </div>
<DialogFooter> <DialogFooter className="border-t border-hairline bg-card px-5 py-4">
<Button onClick={() => setSettingsOpen(false)}></Button> <Button onClick={() => setSettingsOpen(false)}></Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -420,7 +514,7 @@ function Canvas({
setEditingId(null); setEditingId(null);
}} }}
onDelete={ onDelete={
editingSpec.addable editingNode.type !== "startCall"
? () => deleteNode(editingNode.id) ? () => deleteNode(editingNode.id)
: undefined : undefined
} }
@@ -468,13 +562,13 @@ function ModelSelect({
onChange: (value: string | undefined) => void; onChange: (value: string | undefined) => void;
}) { }) {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2 rounded-2xl border border-hairline bg-card p-4 shadow-sm">
<label className="text-sm font-medium text-foreground">{label}</label> <label className="text-sm font-medium text-foreground">{label}</label>
<Select <Select
value={value ?? NONE} value={value ?? NONE}
onValueChange={(v) => onChange(v === NONE ? undefined : v)} onValueChange={(v) => onChange(v === NONE ? undefined : v)}
> >
<SelectTrigger className="border-hairline-strong bg-background text-foreground"> <SelectTrigger className="w-full border-hairline-strong bg-canvas-soft text-foreground">
<SelectValue placeholder="选择模型资源" /> <SelectValue placeholder="选择模型资源" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>