Refactor workflow agent and routing components for improved functionality
- Introduce WorkflowAgentStage to manage agent stage configurations and enhance interaction with the workflow engine. - Implement WorkflowEdgeEvaluator for priority-aware edge evaluation, improving routing decisions based on conditions and user turns. - Update WorkflowBrain to handle user turns and routing more effectively, ensuring agents cannot have only one default path. - Enhance CallEndCoordinator to track speech events and manage call termination based on queued speech. - Add new models and output handling for workflow interactions, improving clarity and maintainability. - Update tests to validate the new routing logic and agent behavior under various scenarios.
This commit is contained in:
@@ -1,14 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import type { WorkflowSettings } from "@/components/workflow/types";
|
||||
import { defaultGraph, type WorkflowGraph } from "@/components/workflow/specs";
|
||||
import type { DynamicVariableDefinition } from "@/lib/api";
|
||||
import { defaultTurnConfig } from "@/lib/turn-config";
|
||||
|
||||
function initialWorkflowSettings(): WorkflowSettings {
|
||||
const graph = defaultGraph();
|
||||
type WorkflowPanel =
|
||||
| { type: "debug" }
|
||||
| { type: "settings" }
|
||||
| { type: "node"; nodeId: string }
|
||||
| { type: "edge"; edgeId: string }
|
||||
| null;
|
||||
|
||||
function settingsFromGraph(graph: WorkflowGraph): WorkflowSettings {
|
||||
return {
|
||||
globalPrompt: graph.settings.globalPrompt,
|
||||
llm: graph.settings.defaultLlmResourceId,
|
||||
@@ -21,8 +26,8 @@ function initialWorkflowSettings(): WorkflowSettings {
|
||||
topN: graph.settings.knowledgeTopN,
|
||||
scoreThreshold: graph.settings.knowledgeScoreThreshold,
|
||||
},
|
||||
allowInterrupt: true,
|
||||
turnConfig: defaultTurnConfig(),
|
||||
allowInterrupt: graph.settings.enableInterrupt,
|
||||
turnConfig: graph.settings.turnConfig,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,19 +37,67 @@ export function useWorkflowEditorState() {
|
||||
const [workflowGraph, setWorkflowGraph] = useState<WorkflowGraph>(() =>
|
||||
defaultGraph(),
|
||||
);
|
||||
const [workflowSettings, setWorkflowSettings] = useState<WorkflowSettings>(
|
||||
initialWorkflowSettings,
|
||||
const workflowSettings = useMemo(
|
||||
() => settingsFromGraph(workflowGraph),
|
||||
[workflowGraph],
|
||||
);
|
||||
const setWorkflowSettings = useCallback((settings: WorkflowSettings) => {
|
||||
setWorkflowGraph((graph) => ({
|
||||
...graph,
|
||||
settings: {
|
||||
globalPrompt: settings.globalPrompt,
|
||||
defaultLlmResourceId: settings.llm ?? "",
|
||||
defaultAsrResourceId: settings.asr ?? "",
|
||||
defaultTtsResourceId: settings.tts ?? "",
|
||||
toolIds: settings.toolIds,
|
||||
knowledgeBaseId: settings.knowledgeBaseId,
|
||||
knowledgeMode: settings.knowledgeRetrievalConfig.mode,
|
||||
knowledgeTopN: settings.knowledgeRetrievalConfig.topN,
|
||||
knowledgeScoreThreshold:
|
||||
settings.knowledgeRetrievalConfig.scoreThreshold,
|
||||
enableInterrupt: settings.allowInterrupt,
|
||||
turnConfig: settings.turnConfig,
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
const [workflowDynamicVariableDefinitions, setWorkflowDynamicVariableDefinitions] =
|
||||
useState<Record<string, DynamicVariableDefinition>>({});
|
||||
const [workflowDebugOpen, setWorkflowDebugOpen] = useState(false);
|
||||
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
|
||||
const [workflowEditingNodeId, setWorkflowEditingNodeId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [workflowEditingEdgeId, setWorkflowEditingEdgeId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [workflowPanel, setWorkflowPanel] = useState<WorkflowPanel>(null);
|
||||
const workflowDebugOpen = workflowPanel?.type === "debug";
|
||||
const workflowSettingsOpen = workflowPanel?.type === "settings";
|
||||
const workflowEditingNodeId =
|
||||
workflowPanel?.type === "node" ? workflowPanel.nodeId : null;
|
||||
const workflowEditingEdgeId =
|
||||
workflowPanel?.type === "edge" ? workflowPanel.edgeId : null;
|
||||
|
||||
const setWorkflowDebugOpen = useCallback((open: boolean) => {
|
||||
setWorkflowPanel((panel) =>
|
||||
open ? { type: "debug" } : panel?.type === "debug" ? null : panel,
|
||||
);
|
||||
}, []);
|
||||
const setWorkflowSettingsOpen = useCallback((open: boolean) => {
|
||||
setWorkflowPanel((panel) =>
|
||||
open ? { type: "settings" } : panel?.type === "settings" ? null : panel,
|
||||
);
|
||||
}, []);
|
||||
const setWorkflowEditingNodeId = useCallback((nodeId: string | null) => {
|
||||
setWorkflowPanel((panel) =>
|
||||
nodeId
|
||||
? { type: "node", nodeId }
|
||||
: panel?.type === "node"
|
||||
? null
|
||||
: panel,
|
||||
);
|
||||
}, []);
|
||||
const setWorkflowEditingEdgeId = useCallback((edgeId: string | null) => {
|
||||
setWorkflowPanel((panel) =>
|
||||
edgeId
|
||||
? { type: "edge", edgeId }
|
||||
: panel?.type === "edge"
|
||||
? null
|
||||
: panel,
|
||||
);
|
||||
}, []);
|
||||
const [activeNodeId, setActiveNodeId] = useState<string | null>(null);
|
||||
const [dynamicVariablesOpen, setDynamicVariablesOpen] = useState(false);
|
||||
|
||||
@@ -65,6 +118,7 @@ export function useWorkflowEditorState() {
|
||||
setWorkflowEditingNodeId,
|
||||
workflowEditingEdgeId,
|
||||
setWorkflowEditingEdgeId,
|
||||
workflowPanel,
|
||||
activeNodeId,
|
||||
setActiveNodeId,
|
||||
dynamicVariablesOpen,
|
||||
|
||||
116
frontend/src/components/workflow/AddNodeDialog.tsx
Normal file
116
frontend/src/components/workflow/AddNodeDialog.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
import { accentVar, type RuntimeNodeSpec } from "./specs";
|
||||
|
||||
export function AddNodeDialog({
|
||||
open,
|
||||
connectingFromSource,
|
||||
specs,
|
||||
canAdd,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
}: {
|
||||
open: boolean;
|
||||
connectingFromSource: boolean;
|
||||
specs: RuntimeNodeSpec[];
|
||||
canAdd: (spec: RuntimeNodeSpec) => boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (spec: RuntimeNodeSpec) => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="gap-0 overflow-hidden border border-hairline bg-card p-0 shadow-2xl sm:max-w-[500px]">
|
||||
<DialogHeader className="relative overflow-hidden border-b border-hairline px-6 py-6 pr-16">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-14 -top-16 h-40 w-40 rounded-full opacity-40 blur-3xl"
|
||||
style={{
|
||||
background:
|
||||
"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">
|
||||
{connectingFromSource
|
||||
? "选择节点类型,将在当前节点下方创建并自动连接。"
|
||||
: "选择节点类型并添加到画布中央,随后可编辑内容并建立连线。"}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex max-h-[440px] flex-col gap-3 overflow-y-auto bg-canvas-soft/70 p-4">
|
||||
{specs.length === 0 && (
|
||||
<p className="rounded-2xl border border-dashed border-hairline-strong bg-card px-4 py-8 text-center text-sm text-muted-soft">
|
||||
暂无可添加的节点类型。
|
||||
</p>
|
||||
)}
|
||||
{specs.map((spec) => {
|
||||
const Icon = spec.icon;
|
||||
const allowed = canAdd(spec);
|
||||
return (
|
||||
<button
|
||||
key={spec.type}
|
||||
type="button"
|
||||
disabled={!allowed}
|
||||
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 disabled:cursor-not-allowed disabled:opacity-55 disabled:hover:translate-y-0 disabled:hover:border-hairline disabled:hover:shadow-sm"
|
||||
onClick={() => onSelect(spec)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-5 right-5 top-0 h-px"
|
||||
style={{
|
||||
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={17} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<span>{spec.displayName}</span>
|
||||
{!allowed && (
|
||||
<span className="rounded-full bg-surface-strong px-2 py-0.5 text-[10px] font-normal text-muted-foreground">
|
||||
已添加
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{spec.description}
|
||||
</div>
|
||||
</div>
|
||||
<Plus
|
||||
size={15}
|
||||
className="mt-1 shrink-0 text-muted-soft transition-colors group-hover:text-foreground"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
useNodesState,
|
||||
useReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import { Braces, Plus, Settings2, X } from "lucide-react";
|
||||
import { Braces, Plus, Settings2 } from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -26,13 +26,6 @@ import {
|
||||
} from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -40,6 +33,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
import { AddNodeDialog } from "./AddNodeDialog";
|
||||
import { edgeTypes } from "./ConditionEdge";
|
||||
import { hasDefaultPath, newEdgeData } from "./edge-rules";
|
||||
import {
|
||||
@@ -52,8 +46,8 @@ import { nodeTypes } from "./GenericNode";
|
||||
import { EdgeSettingsPanel } from "./panels/EdgeSettingsPanel";
|
||||
import { GlobalSettingsPanel } from "./panels/GlobalSettingsPanel";
|
||||
import { NodeSettingsPanel } from "./panels/NodeSettingsPanel";
|
||||
import { WorkflowSidePanel } from "./WorkflowSidePanel";
|
||||
import {
|
||||
accentVar,
|
||||
defaultGraph,
|
||||
type NodeSpecMap,
|
||||
type RuntimeNodeSpec,
|
||||
@@ -195,7 +189,9 @@ export function WorkflowCanvas({
|
||||
settings.tts,
|
||||
settings.toolIds,
|
||||
settings.knowledgeBaseId,
|
||||
settings.knowledgeRetrievalConfig,
|
||||
settings.knowledgeRetrievalConfig.mode,
|
||||
settings.knowledgeRetrievalConfig.topN,
|
||||
settings.knowledgeRetrievalConfig.scoreThreshold,
|
||||
settings.allowInterrupt,
|
||||
settings.turnConfig,
|
||||
]);
|
||||
@@ -203,6 +199,9 @@ export function WorkflowCanvas({
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
if (!connection.source || !connection.target) return;
|
||||
const sourceType = nodes.find(
|
||||
(node) => node.id === connection.source,
|
||||
)?.type;
|
||||
setEdges((eds) =>
|
||||
addEdge(
|
||||
{
|
||||
@@ -210,13 +209,13 @@ export function WorkflowCanvas({
|
||||
id: `e-${connection.source}-${connection.target}-${Date.now()}`,
|
||||
type: "condition",
|
||||
animated: true,
|
||||
data: newEdgeData(eds, connection.source),
|
||||
data: newEdgeData(eds, connection.source, sourceType),
|
||||
},
|
||||
eds,
|
||||
),
|
||||
);
|
||||
},
|
||||
[setEdges],
|
||||
[nodes, setEdges],
|
||||
);
|
||||
|
||||
// 连线约束:不能连入开始节点(无入边句柄),不能自连。
|
||||
@@ -277,7 +276,7 @@ export function WorkflowCanvas({
|
||||
target: id,
|
||||
type: "condition",
|
||||
animated: true,
|
||||
data: newEdgeData(currentEdges, source.id),
|
||||
data: newEdgeData(currentEdges, source.id, source.type),
|
||||
},
|
||||
currentEdges,
|
||||
);
|
||||
@@ -577,9 +576,11 @@ export function WorkflowCanvas({
|
||||
</ReactFlow>
|
||||
</section>
|
||||
|
||||
{/* 添加节点弹窗 */}
|
||||
<Dialog
|
||||
<AddNodeDialog
|
||||
open={addOpen}
|
||||
connectingFromSource={Boolean(addSourceId)}
|
||||
specs={addableSpecs}
|
||||
canAdd={canAddSpec}
|
||||
onOpenChange={(open) => {
|
||||
setAddOpen(open);
|
||||
if (!open) {
|
||||
@@ -587,131 +588,43 @@ export function WorkflowCanvas({
|
||||
setAddPosition(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="gap-0 overflow-hidden border border-hairline bg-card p-0 shadow-2xl sm:max-w-[500px]">
|
||||
<DialogHeader className="relative overflow-hidden border-b border-hairline px-6 py-6 pr-16">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-14 -top-16 h-40 w-40 rounded-full opacity-40 blur-3xl"
|
||||
style={{
|
||||
background:
|
||||
"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">
|
||||
{addSourceId
|
||||
? "选择节点类型,将在当前节点下方创建并自动连接。"
|
||||
: "选择节点类型并添加到画布中央,随后可编辑内容并建立连线。"}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div className="flex max-h-[440px] flex-col gap-3 overflow-y-auto bg-canvas-soft/70 p-4">
|
||||
{addableSpecs.length === 0 ? (
|
||||
<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) => {
|
||||
const Icon = spec.icon;
|
||||
const canAdd = canAddSpec(spec);
|
||||
return (
|
||||
<button
|
||||
key={spec.type}
|
||||
type="button"
|
||||
disabled={!canAdd}
|
||||
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 disabled:cursor-not-allowed disabled:opacity-55 disabled:hover:translate-y-0 disabled:hover:border-hairline disabled:hover:shadow-sm"
|
||||
onClick={() => addNode(spec)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-5 right-5 top-0 h-px"
|
||||
style={{
|
||||
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={17} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<span>{spec.displayName}</span>
|
||||
{!canAdd && (
|
||||
<span className="rounded-full bg-surface-strong px-2 py-0.5 text-[10px] font-normal text-muted-foreground">
|
||||
已添加
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{spec.description}
|
||||
</div>
|
||||
</div>
|
||||
<Plus
|
||||
size={15}
|
||||
className="mt-1 shrink-0 text-muted-soft transition-colors group-hover:text-foreground"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
onSelect={addNode}
|
||||
/>
|
||||
|
||||
{(debugOpen ||
|
||||
settingsOpen ||
|
||||
(editingNode && editingSpec) ||
|
||||
editingEdge) && (
|
||||
<aside className="absolute inset-y-0 right-0 z-40 flex w-1/2 flex-col overflow-hidden rounded-r-2xl border-l border-hairline bg-card shadow-2xl">
|
||||
<WorkflowSidePanel
|
||||
title={
|
||||
debugOpen
|
||||
? undefined
|
||||
: settingsOpen
|
||||
? "工作流设置"
|
||||
: editingEdge
|
||||
? "编辑连接条件"
|
||||
: `编辑${editingSpec?.displayName ?? "节点"}`
|
||||
}
|
||||
closeLabel={
|
||||
settingsOpen
|
||||
? "关闭工作流设置"
|
||||
: editingEdge
|
||||
? "关闭边编辑"
|
||||
: "关闭节点编辑"
|
||||
}
|
||||
onClose={
|
||||
debugOpen
|
||||
? undefined
|
||||
: () => {
|
||||
if (settingsOpen) setSettingsOpen(false);
|
||||
else if (editingEdge) onEditingEdgeIdChange(null);
|
||||
else onEditingNodeIdChange(null);
|
||||
}
|
||||
}
|
||||
>
|
||||
{debugOpen ? (
|
||||
debugPanel
|
||||
) : settingsOpen ||
|
||||
(editingNode && editingSpec) ||
|
||||
editingEdge ? (
|
||||
<>
|
||||
<div className="flex min-h-14 shrink-0 items-center gap-3 border-b border-hairline px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
settingsOpen
|
||||
? "关闭工作流设置"
|
||||
: editingEdge
|
||||
? "关闭边编辑"
|
||||
: "关闭节点编辑"
|
||||
}
|
||||
title="关闭"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-hairline-strong bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground"
|
||||
onClick={() => {
|
||||
if (settingsOpen) setSettingsOpen(false);
|
||||
else if (editingEdge) onEditingEdgeIdChange(null);
|
||||
else onEditingNodeIdChange(null);
|
||||
}}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
<h2 className="min-w-0 truncate text-sm font-medium text-foreground">
|
||||
{settingsOpen
|
||||
? "工作流设置"
|
||||
: editingEdge
|
||||
? "编辑连接条件"
|
||||
: `编辑${editingSpec?.displayName ?? "节点"}`}
|
||||
</h2>
|
||||
</div>
|
||||
) : (
|
||||
<div className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto bg-canvas-soft px-4 pb-5 pt-4">
|
||||
{settingsOpen ? (
|
||||
<GlobalSettingsPanel
|
||||
@@ -741,6 +654,16 @@ export function WorkflowCanvas({
|
||||
<EdgeSettingsPanel
|
||||
key={editingEdge.id}
|
||||
edge={editingEdge}
|
||||
sourceType={
|
||||
nodes.find(
|
||||
(node) => node.id === editingEdge.source,
|
||||
)?.type as string | undefined
|
||||
}
|
||||
isOnlyOutgoing={
|
||||
edges.filter(
|
||||
(edge) => edge.source === editingEdge.source,
|
||||
).length === 1
|
||||
}
|
||||
hasOtherDefaultPath={hasDefaultPath(
|
||||
edges,
|
||||
editingEdge.source,
|
||||
@@ -752,9 +675,8 @@ export function WorkflowCanvas({
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</aside>
|
||||
)}
|
||||
</WorkflowSidePanel>
|
||||
)}
|
||||
</div>
|
||||
</EdgeActionContext.Provider>
|
||||
|
||||
38
frontend/src/components/workflow/WorkflowSidePanel.tsx
Normal file
38
frontend/src/components/workflow/WorkflowSidePanel.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function WorkflowSidePanel({
|
||||
title,
|
||||
closeLabel,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
title?: string;
|
||||
closeLabel?: string;
|
||||
onClose?: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<aside className="absolute inset-y-0 right-0 z-40 flex w-1/2 flex-col overflow-hidden rounded-r-2xl border-l border-hairline bg-card shadow-2xl">
|
||||
{title && onClose && (
|
||||
<div className="flex min-h-14 shrink-0 items-center gap-3 border-b border-hairline px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={closeLabel ?? "关闭面板"}
|
||||
title="关闭"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-hairline-strong bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
<h2 className="min-w-0 truncate text-sm font-medium text-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -31,11 +31,19 @@ export function nextEdgePriority(edges: Edge[], sourceId: string): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* The first path from a node is deterministic. Further paths start as an
|
||||
* incomplete LLM condition so the graph never silently gains two defaults.
|
||||
* Agent nodes need a real condition before they can leave; a lone default path
|
||||
* would consume the next user turn without letting the Agent answer it.
|
||||
* Automatic nodes may use a default as their first deterministic path.
|
||||
*/
|
||||
export function newEdgeData(edges: Edge[], sourceId: string): WorkflowEdgeData {
|
||||
export function newEdgeData(
|
||||
edges: Edge[],
|
||||
sourceId: string,
|
||||
sourceType?: string,
|
||||
): WorkflowEdgeData {
|
||||
const priority = nextEdgePriority(edges, sourceId);
|
||||
if (sourceType === "agent" && !hasDefaultPath(edges, sourceId)) {
|
||||
return { mode: "llm", priority, condition: "" };
|
||||
}
|
||||
return hasDefaultPath(edges, sourceId)
|
||||
? { mode: "llm", priority, condition: "" }
|
||||
: { mode: "always", priority };
|
||||
|
||||
@@ -27,10 +27,14 @@ import type { ExpressionRule, WorkflowEdgeData } from "../specs";
|
||||
|
||||
export function EdgeSettingsPanel({
|
||||
edge,
|
||||
sourceType,
|
||||
isOnlyOutgoing,
|
||||
hasOtherDefaultPath,
|
||||
onChange,
|
||||
}: {
|
||||
edge: Edge;
|
||||
sourceType?: string;
|
||||
isOnlyOutgoing: boolean;
|
||||
hasOtherDefaultPath: boolean;
|
||||
onChange: (patch: WorkflowEdgeData) => void;
|
||||
}) {
|
||||
@@ -109,7 +113,10 @@ export function EdgeSettingsPanel({
|
||||
{
|
||||
value: "always",
|
||||
label: "默认路径",
|
||||
disabled: mode !== "always" && hasOtherDefaultPath,
|
||||
disabled:
|
||||
mode !== "always" &&
|
||||
(hasOtherDefaultPath ||
|
||||
(sourceType === "agent" && isOnlyOutgoing)),
|
||||
},
|
||||
{ value: "llm", label: "大模型判断" },
|
||||
{ value: "expression", label: "表达式" },
|
||||
@@ -127,24 +134,31 @@ export function EdgeSettingsPanel({
|
||||
当前节点已经有一条默认路径;其它出边需要使用大模型判断或表达式。
|
||||
</span>
|
||||
)}
|
||||
<label className="block">
|
||||
<div className="mb-1.5 text-sm font-medium text-foreground">
|
||||
优先级
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
value={priority}
|
||||
onChange={(event) => {
|
||||
const nextPriority = Number(event.target.value) || 0;
|
||||
setPriority(nextPriority);
|
||||
publish({ nextPriority });
|
||||
}}
|
||||
className="border-hairline-strong bg-background text-foreground"
|
||||
/>
|
||||
<span className="mt-1.5 block text-xs text-muted-foreground">
|
||||
条件路径按数字从小到大判断,默认路径始终作为兜底。
|
||||
{sourceType === "agent" && isOnlyOutgoing && mode === "always" && (
|
||||
<span className="-mt-1 block text-xs text-destructive">
|
||||
Agent 不能只有默认路径;请改为条件路径,或删除连接以持续对话。
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
{mode !== "always" && (
|
||||
<label className="block">
|
||||
<div className="mb-1.5 text-sm font-medium text-foreground">
|
||||
优先级
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
value={priority}
|
||||
onChange={(event) => {
|
||||
const nextPriority = Number(event.target.value) || 0;
|
||||
setPriority(nextPriority);
|
||||
publish({ nextPriority });
|
||||
}}
|
||||
className="border-hairline-strong bg-background text-foreground"
|
||||
/>
|
||||
<span className="mt-1.5 block text-xs text-muted-foreground">
|
||||
条件路径按数字从小到大判断;默认路径不参与优先级。
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
|
||||
Reference in New Issue
Block a user