feat(workflow): add drawer anchor navigation

This commit is contained in:
Xin Wang
2026-08-04 09:41:52 +08:00
parent a16ecd8e01
commit caa2ff65fa
7 changed files with 570 additions and 288 deletions

View File

@@ -759,7 +759,7 @@ export function WorkflowCanvas({
) : dynamicVariablesOpen ? ( ) : dynamicVariablesOpen ? (
dynamicVariablesPanel dynamicVariablesPanel
) : ( ) : (
<div className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto bg-canvas-soft px-4 pb-5 pt-4"> <div className="flex min-h-0 flex-1">
{settingsOpen ? ( {settingsOpen ? (
<GlobalSettingsPanel <GlobalSettingsPanel
settings={settings} settings={settings}

View File

@@ -20,6 +20,10 @@ import type { KnowledgeRetrievalConfig } from "@/lib/api";
import { normalizeTurnConfig } from "@/lib/turn-config"; import { normalizeTurnConfig } from "@/lib/turn-config";
import { NodeSelect, ToolOptionPicker } from "./controls"; import { NodeSelect, ToolOptionPicker } from "./controls";
import {
PanelAnchor,
PanelAnchorNavigation,
} from "./PanelAnchorNavigation";
import type { WorkflowNodeData } from "../specs"; import type { WorkflowNodeData } from "../specs";
import type { ModelOption, WorkflowSettings } from "../types"; import type { ModelOption, WorkflowSettings } from "../types";
@@ -99,123 +103,146 @@ export function AgentNodePanel({
}; };
return ( return (
<div className="space-y-3"> <PanelAnchorNavigation
<SectionCard ariaLabel="Agent 节点配置分区"
icon={<Settings2 size={15} />} sections={[
title="配置范围" { id: "scope", label: "配置范围" },
description="默认复用工作流全局的模型、语音、视觉、知识库和工具" { id: "prompt", label: inheritsGlobal ? "任务" : "提示词" },
> { id: "entry", label: "进入行为" },
<div className="flex items-center justify-between gap-4 rounded-xl border border-hairline bg-canvas-soft px-3.5 py-3"> ...(!inheritsGlobal
<div> ? [
<div className="text-sm font-medium text-foreground"> { id: "models", label: "模型与语音" },
{ id: "capabilities", label: "知识与工具" },
{ id: "interaction", label: "交互策略" },
]
: []),
]}
>
<PanelAnchor id="scope">
<SectionCard
icon={<Settings2 size={15} />}
title="配置范围"
description="默认复用工作流全局的模型、语音、视觉、知识库和工具"
>
<div className="flex items-center justify-between gap-4 rounded-xl border border-hairline bg-canvas-soft px-3.5 py-3">
<div>
<div className="text-sm font-medium text-foreground">
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{inheritsGlobal
? "全局提示词会与当前节点提示词合并。"
: "当前节点使用独立的完整助手配置。"}
</p>
</div> </div>
<p className="mt-1 text-xs leading-5 text-muted-foreground"> <Switch checked={inheritsGlobal} onCheckedChange={setInheritance} />
{inheritsGlobal
? "全局提示词会与当前节点提示词合并。"
: "当前节点使用独立的完整助手配置。"}
</p>
</div> </div>
<Switch checked={inheritsGlobal} onCheckedChange={setInheritance} /> </SectionCard>
</div> </PanelAnchor>
</SectionCard>
<SectionCard <PanelAnchor id="prompt">
icon={<MessageSquareText size={15} />} <SectionCard
title={inheritsGlobal ? "任务" : "提示词"} icon={<MessageSquareText size={15} />}
description={ title={inheritsGlobal ? "任务" : "提示词"}
inheritsGlobal description={
? "描述当前阶段要完成的目标;角色、能力和通用规则继承工作流全局配置"
: "描述当前独立助手的角色、能力和回答要求"
}
>
<Textarea
rows={8}
value={draft.prompt ?? ""}
onChange={(event) => set("prompt", event.target.value)}
placeholder={
inheritsGlobal inheritsGlobal
? "例如:确认用户身份,并收集需要查询的订单编号" ? "描述当前阶段要完成的目标;角色、能力和通用规则继承工作流全局配置"
: "请输入提示词,描述助手的角色、能力和回答要求" : "描述当前独立助手的角色、能力和回答要求"
} }
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft" >
/> <Textarea
</SectionCard> rows={8}
value={draft.prompt ?? ""}
onChange={(event) => set("prompt", event.target.value)}
placeholder={
inheritsGlobal
? "例如:确认用户身份,并收集需要查询的订单编号"
: "请输入提示词,描述助手的角色、能力和回答要求"
}
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
/>
</SectionCard>
</PanelAnchor>
<SectionCard <PanelAnchor id="entry">
icon={<Bot size={15} />} <SectionCard
title="进入行为" icon={<Bot size={15} />}
description="进入该节点时的首轮交互方式" title="进入行为"
> description="进入该节点时的首轮交互方式"
<NodeSelect >
label="进入节点时" <NodeSelect
value={(draft.entryMode as string) || "wait_user"} label="进入节点时"
options={[ value={(draft.entryMode as string) || "wait_user"}
{ value: "wait_user", label: "等待下一轮用户输入(默认)" }, options={[
{ value: "generate", label: "进入后立即回复" }, { value: "wait_user", label: "等待下一轮用户输入(默认)" },
]} { value: "generate", label: "进入后立即回复" },
onChange={(value) => set("entryMode", value || "wait_user")} ]}
allowNone={false} onChange={(value) => set("entryMode", value || "wait_user")}
/> allowNone={false}
<p className="text-xs leading-5 text-muted-foreground"> />
使 Message <p className="text-xs leading-5 text-muted-foreground">
</p> 使 Message
</SectionCard> </p>
</SectionCard>
</PanelAnchor>
{!inheritsGlobal && ( {!inheritsGlobal && (
<> <>
<SectionCard <PanelAnchor id="models">
icon={<Brain size={15} />} <SectionCard
title="模型与语音" icon={<Brain size={15} />}
description="当前 Agent 独立使用的推理、语音识别和语音合成资源" title="模型与语音"
> description="当前 Agent 独立使用的推理、语音识别和语音合成资源"
<NodeSelect >
label="大语言模型" <NodeSelect
value={(draft.llmResourceId as string) || ""} label="大语言模型"
options={llmOptions} value={(draft.llmResourceId as string) || ""}
onChange={(llmResourceId) => options={llmOptions}
onChange={(llmResourceId) =>
setPatch({
llmResourceId: llmResourceId || "",
...(draft.visionModelResourceId === llmResourceId
? { visionModelResourceId: "" }
: {}),
})
}
noneLabel="请选择模型"
/>
<NodeSelect
label="语音识别"
value={(draft.asrResourceId as string) || ""}
options={asrOptions}
onChange={(value) => set("asrResourceId", value || "")}
noneLabel="请选择语音识别"
/>
<NodeSelect
label="语音合成"
value={(draft.ttsResourceId as string) || ""}
options={ttsOptions}
onChange={(value) => set("ttsResourceId", value || "")}
noneLabel="请选择语音合成"
/>
</SectionCard>
</PanelAnchor>
<PanelAnchor id="capabilities">
<VisionConfigSection
description="配置当前 Agent 是否可以按需理解用户摄像头画面"
hint="开启后,该 Agent 会获得读取当前视频画面的工具。选择「模型自己」时,当前大语言模型必须支持图片输入。"
enabled={Boolean(draft.visionEnabled)}
modelResourceId={draft.visionModelResourceId ?? ""}
mainModelResourceId={(draft.llmResourceId as string) || ""}
modelOptions={visionOptions}
onEnabledChange={(visionEnabled) =>
setPatch({ setPatch({
llmResourceId: llmResourceId || "", visionEnabled,
...(draft.visionModelResourceId === llmResourceId ...(!visionEnabled ? { visionModelResourceId: "" } : {}),
? { visionModelResourceId: "" }
: {}),
}) })
} }
noneLabel="请选择模型" onModelResourceIdChange={(visionModelResourceId) =>
set("visionModelResourceId", visionModelResourceId)
}
/> />
<NodeSelect
label="语音识别"
value={(draft.asrResourceId as string) || ""}
options={asrOptions}
onChange={(value) => set("asrResourceId", value || "")}
noneLabel="请选择语音识别"
/>
<NodeSelect
label="语音合成"
value={(draft.ttsResourceId as string) || ""}
options={ttsOptions}
onChange={(value) => set("ttsResourceId", value || "")}
noneLabel="请选择语音合成"
/>
</SectionCard>
<VisionConfigSection
description="配置当前 Agent 是否可以按需理解用户摄像头画面"
hint="开启后,该 Agent 会获得读取当前视频画面的工具。选择「模型自己」时,当前大语言模型必须支持图片输入。"
enabled={Boolean(draft.visionEnabled)}
modelResourceId={draft.visionModelResourceId ?? ""}
mainModelResourceId={(draft.llmResourceId as string) || ""}
modelOptions={visionOptions}
onEnabledChange={(visionEnabled) =>
setPatch({
visionEnabled,
...(!visionEnabled ? { visionModelResourceId: "" } : {}),
})
}
onModelResourceIdChange={(visionModelResourceId) =>
set("visionModelResourceId", visionModelResourceId)
}
/>
<SectionCard <SectionCard
icon={<Database size={15} />} icon={<Database size={15} />}
@@ -259,24 +286,28 @@ export function AgentNodePanel({
/> />
</SectionCard> </SectionCard>
<SectionCard </PanelAnchor>
icon={<Sparkles size={15} />}
title="交互策略" <PanelAnchor id="interaction">
description="配置当前 Agent 独立使用的打断和轮次检测策略" <SectionCard
> icon={<Sparkles size={15} />}
<TurnConfigEditor title="交互策略"
enabled={ description="配置当前 Agent 独立使用的打断和轮次检测策略"
draft.enableInterrupt ?? workflowSettings.allowInterrupt >
} <TurnConfigEditor
config={agentTurnConfig} enabled={
onEnabledChange={(enableInterrupt) => draft.enableInterrupt ?? workflowSettings.allowInterrupt
set("enableInterrupt", enableInterrupt) }
} config={agentTurnConfig}
onConfigChange={(turnConfig) => set("turnConfig", turnConfig)} onEnabledChange={(enableInterrupt) =>
/> set("enableInterrupt", enableInterrupt)
</SectionCard> }
onConfigChange={(turnConfig) => set("turnConfig", turnConfig)}
/>
</SectionCard>
</PanelAnchor>
</> </>
)} )}
</div> </PanelAnchorNavigation>
); );
} }

View File

@@ -22,6 +22,10 @@ import {
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { NodeSelect } from "./controls"; import { NodeSelect } from "./controls";
import {
PanelAnchor,
PanelAnchorNavigation,
} from "./PanelAnchorNavigation";
import type { ExpressionRule, WorkflowEdgeData } from "../specs"; import type { ExpressionRule, WorkflowEdgeData } from "../specs";
export function EdgeSettingsPanel({ export function EdgeSettingsPanel({
@@ -85,8 +89,15 @@ export function EdgeSettingsPanel({
}; };
return ( return (
<div className="space-y-3"> <PanelAnchorNavigation
<SectionCard ariaLabel="连接配置分区"
sections={[
{ id: "routing", label: "路由方式" },
{ id: "condition", label: "触发条件" },
]}
>
<PanelAnchor id="routing">
<SectionCard
icon={<GitBranch size={15} />} icon={<GitBranch size={15} />}
title="路由方式" title="路由方式"
description="每个节点最多一条默认路径,也可以配置多条条件路径" description="每个节点最多一条默认路径,也可以配置多条条件路径"
@@ -124,9 +135,11 @@ export function EdgeSettingsPanel({
Agent Agent
</span> </span>
)} )}
</SectionCard> </SectionCard>
</PanelAnchor>
<SectionCard <PanelAnchor id="condition">
<SectionCard
icon={<Braces size={15} />} icon={<Braces size={15} />}
title="触发条件" title="触发条件"
description="配置这条连接被命中的条件" description="配置这条连接被命中的条件"
@@ -271,7 +284,8 @@ export function EdgeSettingsPanel({
沿 沿
</p> </p>
)} )}
</SectionCard> </SectionCard>
</div> </PanelAnchor>
</PanelAnchorNavigation>
); );
} }

View File

@@ -15,6 +15,10 @@ import { TurnConfigEditor } from "@/components/turn-config-editor";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { ModelSelect, NodeSelect, ToolOptionPicker } from "./controls"; import { ModelSelect, NodeSelect, ToolOptionPicker } from "./controls";
import {
PanelAnchor,
PanelAnchorNavigation,
} from "./PanelAnchorNavigation";
import type { import type {
ModelOption, ModelOption,
WorkflowEditorProps, WorkflowEditorProps,
@@ -35,134 +39,150 @@ export function GlobalSettingsPanel({
knowledgeOptions: ModelOption[]; knowledgeOptions: ModelOption[];
}) { }) {
return ( return (
<div className="space-y-3"> <PanelAnchorNavigation
<SectionCard ariaLabel="工作流设置分区"
icon={<MessageSquareText size={15} />} sections={[
title="全局提示词" { id: "prompt", label: "提示词" },
description="与所有选择继承全局配置的 Agent 节点提示词合并" { id: "models", label: "模型与语音" },
> { id: "capabilities", label: "知识与工具" },
<Textarea { id: "interaction", label: "交互策略" },
rows={4} ]}
value={settings.globalPrompt} >
onChange={(event) => <PanelAnchor id="prompt">
onSettingsChange({ <SectionCard
...settings, icon={<MessageSquareText size={15} />}
globalPrompt: event.target.value, title="全局提示词"
}) description="与所有选择继承全局配置的 Agent 节点提示词合并"
} >
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft" <Textarea
/> rows={4}
</SectionCard> value={settings.globalPrompt}
onChange={(event) =>
onSettingsChange({
...settings,
globalPrompt: event.target.value,
})
}
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
/>
</SectionCard>
</PanelAnchor>
<SectionCard <PanelAnchor id="models">
icon={<Brain size={15} />} <SectionCard
title="模型与语音" icon={<Brain size={15} />}
description="继承全局配置的 Agent 共用的推理、语音识别和语音合成资源" title="模型与语音"
> description="继承全局配置的 Agent 共用的推理、语音识别和语音合成资源"
<ModelSelect >
label="大语言模型" <ModelSelect
value={settings.llm} label="大语言模型"
options={modelOptions.llm} value={settings.llm}
onChange={(llm) => options={modelOptions.llm}
onSettingsChange({ onChange={(llm) =>
...settings, onSettingsChange({
llm, ...settings,
...(settings.visionModelResourceId === llm llm,
? { visionModelResourceId: "" } ...(settings.visionModelResourceId === llm
: {}), ? { visionModelResourceId: "" }
}) : {}),
} })
/>
<ModelSelect
label="语音识别"
value={settings.asr}
options={modelOptions.asr}
onChange={(asr) => onSettingsChange({ ...settings, asr })}
/>
<ModelSelect
label="语音合成"
value={settings.tts}
options={modelOptions.tts}
onChange={(tts) => onSettingsChange({ ...settings, tts })}
/>
</SectionCard>
<VisionConfigSection
description="配置继承全局设置的 Agent 是否可以按需理解用户摄像头画面"
hint="开启后,继承全局配置的 Agent 会获得读取当前视频画面的工具。选择「模型自己」时,全局大语言模型必须支持图片输入。"
enabled={settings.visionEnabled}
modelResourceId={settings.visionModelResourceId}
mainModelResourceId={settings.llm ?? ""}
modelOptions={modelOptions.vision}
onEnabledChange={(visionEnabled) =>
onSettingsChange({
...settings,
visionEnabled,
...(!visionEnabled ? { visionModelResourceId: "" } : {}),
})
}
onModelResourceIdChange={(visionModelResourceId) =>
onSettingsChange({ ...settings, visionModelResourceId })
}
/>
<SectionCard
icon={<Database size={15} />}
title="知识库配置"
description="所有继承全局配置的 Agent 都可以使用该知识库"
>
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-foreground"></span>
<KnowledgeRetrievalConfigDialog
disabled={!settings.knowledgeBaseId}
value={settings.knowledgeRetrievalConfig}
onChange={(knowledgeRetrievalConfig) =>
onSettingsChange({ ...settings, knowledgeRetrievalConfig })
} }
/> />
</div> <ModelSelect
<NodeSelect label="语音识别"
label="" value={settings.asr}
value={settings.knowledgeBaseId} options={modelOptions.asr}
options={knowledgeOptions} onChange={(asr) => onSettingsChange({ ...settings, asr })}
onChange={(knowledgeBaseId) => />
<ModelSelect
label="语音合成"
value={settings.tts}
options={modelOptions.tts}
onChange={(tts) => onSettingsChange({ ...settings, tts })}
/>
</SectionCard>
</PanelAnchor>
<PanelAnchor id="capabilities">
<VisionConfigSection
description="配置继承全局设置的 Agent 是否可以按需理解用户摄像头画面"
hint="开启后,继承全局配置的 Agent 会获得读取当前视频画面的工具。选择「模型自己」时,全局大语言模型必须支持图片输入。"
enabled={settings.visionEnabled}
modelResourceId={settings.visionModelResourceId}
mainModelResourceId={settings.llm ?? ""}
modelOptions={modelOptions.vision}
onEnabledChange={(visionEnabled) =>
onSettingsChange({ onSettingsChange({
...settings, ...settings,
knowledgeBaseId: knowledgeBaseId || "", visionEnabled,
...(!visionEnabled ? { visionModelResourceId: "" } : {}),
}) })
} }
noneLabel="无" onModelResourceIdChange={(visionModelResourceId) =>
/> onSettingsChange({ ...settings, visionModelResourceId })
</SectionCard>
<SectionCard
icon={<Wrench size={15} />}
title="工具"
description="所有继承全局配置的 Agent 都可以调用这些工具"
>
<ToolOptionPicker
options={toolOptions}
selectedIds={settings.toolIds}
onChange={(toolIds) => onSettingsChange({ ...settings, toolIds })}
/>
</SectionCard>
<SectionCard
icon={<Sparkles size={15} />}
title="交互策略"
description="设置实时视频对话时的交互体验"
>
<TurnConfigEditor
enabled={settings.allowInterrupt}
config={settings.turnConfig}
onEnabledChange={(allowInterrupt) =>
onSettingsChange({ ...settings, allowInterrupt })
}
onConfigChange={(turnConfig) =>
onSettingsChange({ ...settings, turnConfig })
} }
/> />
</SectionCard>
</div> <SectionCard
icon={<Database size={15} />}
title="知识库配置"
description="所有继承全局配置的 Agent 都可以使用该知识库"
>
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-foreground"></span>
<KnowledgeRetrievalConfigDialog
disabled={!settings.knowledgeBaseId}
value={settings.knowledgeRetrievalConfig}
onChange={(knowledgeRetrievalConfig) =>
onSettingsChange({ ...settings, knowledgeRetrievalConfig })
}
/>
</div>
<NodeSelect
label=""
value={settings.knowledgeBaseId}
options={knowledgeOptions}
onChange={(knowledgeBaseId) =>
onSettingsChange({
...settings,
knowledgeBaseId: knowledgeBaseId || "",
})
}
noneLabel="无"
/>
</SectionCard>
<SectionCard
icon={<Wrench size={15} />}
title="工具"
description="所有继承全局配置的 Agent 都可以调用这些工具"
>
<ToolOptionPicker
options={toolOptions}
selectedIds={settings.toolIds}
onChange={(toolIds) => onSettingsChange({ ...settings, toolIds })}
/>
</SectionCard>
</PanelAnchor>
<PanelAnchor id="interaction">
<SectionCard
icon={<Sparkles size={15} />}
title="交互策略"
description="设置实时视频对话时的交互体验"
>
<TurnConfigEditor
enabled={settings.allowInterrupt}
config={settings.turnConfig}
onEnabledChange={(allowInterrupt) =>
onSettingsChange({ ...settings, allowInterrupt })
}
onConfigChange={(turnConfig) =>
onSettingsChange({ ...settings, turnConfig })
}
/>
</SectionCard>
</PanelAnchor>
</PanelAnchorNavigation>
); );
} }

View File

@@ -18,6 +18,7 @@ import {
type MessageCompletionPolicy, type MessageCompletionPolicy,
type WorkflowNodeData, type WorkflowNodeData,
} from "../specs"; } from "../specs";
import { PanelAnchor } from "./PanelAnchorNavigation";
const COMPLETION_OPTIONS: Array<{ const COMPLETION_OPTIONS: Array<{
value: MessageCompletionPolicy; value: MessageCompletionPolicy;
@@ -65,7 +66,8 @@ export function MessageNodePanel({
return ( return (
<> <>
<SectionCard <PanelAnchor id="speech">
<SectionCard
icon={<MessageSquareText size={15} />} icon={<MessageSquareText size={15} />}
title="固定播报" title="固定播报"
description="配置进入 Message 节点后播放的固定话术" description="配置进入 Message 节点后播放的固定话术"
@@ -91,9 +93,11 @@ export function MessageNodePanel({
</p> </p>
)} )}
</SectionCard> </SectionCard>
</PanelAnchor>
<SectionCard <PanelAnchor id="completion">
<SectionCard
icon={<ArrowRight size={15} />} icon={<ArrowRight size={15} />}
title="继续方式" title="继续方式"
description="控制 Message 节点何时进入下一节点" description="控制 Message 节点何时进入下一节点"
@@ -169,7 +173,8 @@ export function MessageNodePanel({
</p> </p>
</div> </div>
)} )}
</SectionCard> </SectionCard>
</PanelAnchor>
</> </>
); );
} }

View File

@@ -13,6 +13,11 @@ import { ActionNodePanel } from "./ActionNodePanel";
import { AgentNodePanel } from "./AgentNodePanel"; import { AgentNodePanel } from "./AgentNodePanel";
import { MessageNodePanel } from "./MessageNodePanel"; import { MessageNodePanel } from "./MessageNodePanel";
import { NodeSelect, ToolOptionPicker } from "./controls"; import { NodeSelect, ToolOptionPicker } from "./controls";
import {
PanelAnchor,
PanelAnchorNavigation,
type PanelAnchorSection,
} from "./PanelAnchorNavigation";
import type { RuntimeNodeSpec, WorkflowNodeData } from "../specs"; import type { RuntimeNodeSpec, WorkflowNodeData } from "../specs";
import type { ModelOption, WorkflowSettings } from "../types"; import type { ModelOption, WorkflowSettings } from "../types";
@@ -78,20 +83,39 @@ export function NodeSettingsPanel({
if (panel) { if (panel) {
if (spec.type !== "agent") { if (spec.type !== "agent") {
const sections: PanelAnchorSection[] =
spec.type === "message"
? [
{ id: "speech", label: "固定播报" },
{ id: "completion", label: "继续方式" },
]
: spec.type === "action"
? [{ id: "execution", label: "工具执行" }]
: spec.type === "handoff"
? [{ id: "handoff", label: "转交配置" }]
: spec.type === "end"
? [{ id: "end", label: "结束配置" }]
: [];
return ( return (
<WorkflowNodePanelForm <PanelAnchorNavigation
spec={spec} ariaLabel={`${spec.displayName}节点配置分区`}
draft={draft} sections={sections}
set={set} >
setPatch={setPatch} <WorkflowNodePanelForm
toolOptions={toolOptions} spec={spec}
argumentsJson={argumentsJson} draft={draft}
assignmentsJson={assignmentsJson} set={set}
jsonError={jsonError} setPatch={setPatch}
setArgumentsJson={setArgumentsJson} toolOptions={toolOptions}
setAssignmentsJson={setAssignmentsJson} argumentsJson={argumentsJson}
commitActionJson={commitActionJson} assignmentsJson={assignmentsJson}
/> jsonError={jsonError}
setArgumentsJson={setArgumentsJson}
setAssignmentsJson={setAssignmentsJson}
commitActionJson={commitActionJson}
/>
</PanelAnchorNavigation>
); );
} }
return ( return (
@@ -298,17 +322,19 @@ function WorkflowNodePanelForm({
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{spec.type === "action" && ( {spec.type === "action" && (
<ActionNodePanel <PanelAnchor id="execution">
draft={draft} <ActionNodePanel
set={set} draft={draft}
toolOptions={toolOptions} set={set}
argumentsJson={argumentsJson} toolOptions={toolOptions}
assignmentsJson={assignmentsJson} argumentsJson={argumentsJson}
jsonError={jsonError} assignmentsJson={assignmentsJson}
setArgumentsJson={setArgumentsJson} jsonError={jsonError}
setAssignmentsJson={setAssignmentsJson} setArgumentsJson={setArgumentsJson}
commitActionJson={commitActionJson} setAssignmentsJson={setAssignmentsJson}
/> commitActionJson={commitActionJson}
/>
</PanelAnchor>
)} )}
{spec.type === "message" && ( {spec.type === "message" && (
@@ -316,11 +342,12 @@ function WorkflowNodePanelForm({
)} )}
{spec.type === "handoff" && ( {spec.type === "handoff" && (
<SectionCard <PanelAnchor id="handoff">
icon={<PhoneForwarded size={15} />} <SectionCard
title="转交配置" icon={<PhoneForwarded size={15} />}
description="发送转交事件并继续执行工作流" title="转交配置"
> description="发送转交事件并继续执行工作流"
>
<NodeSelect <NodeSelect
label="转交类型" label="转交类型"
value={(draft.targetType as string) || "human"} value={(draft.targetType as string) || "human"}
@@ -356,15 +383,17 @@ function WorkflowNodePanelForm({
className="field-sizing-fixed min-h-24 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft" className="field-sizing-fixed min-h-24 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
/> />
</label> </label>
</SectionCard> </SectionCard>
</PanelAnchor>
)} )}
{spec.type === "end" && ( {spec.type === "end" && (
<SectionCard <PanelAnchor id="end">
icon={<Flag size={15} />} <SectionCard
title="结束配置" icon={<Flag size={15} />}
description="结束工作流,并可在结束前播放一段固定回复" title="结束配置"
> description="结束工作流,并可在结束前播放一段固定回复"
>
<NodeSelect <NodeSelect
label="结束范围" label="结束范围"
value={(draft.scope as string) || "session"} value={(draft.scope as string) || "session"}
@@ -387,7 +416,8 @@ function WorkflowNodePanelForm({
className="field-sizing-fixed min-h-24 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft" className="field-sizing-fixed min-h-24 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
/> />
</label> </label>
</SectionCard> </SectionCard>
</PanelAnchor>
)} )}
</div> </div>
); );

View File

@@ -0,0 +1,182 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
export type PanelAnchorSection = {
id: string;
label: string;
};
export function PanelAnchorNavigation({
ariaLabel,
sections,
children,
}: {
ariaLabel: string;
sections: readonly PanelAnchorSection[];
children: ReactNode;
}) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const selectedAnchorRef = useRef<string | null>(null);
const [activeSection, setActiveSection] = useState(sections[0]?.id ?? "");
const sectionKey = sections.map((section) => section.id).join("|");
const resolvedActiveSection = sections.some(
(section) => section.id === activeSection,
)
? activeSection
: (sections[0]?.id ?? "");
useEffect(() => {
const container = scrollContainerRef.current;
if (!container || sections.length === 0) return;
const scrollContainer: HTMLDivElement = container;
let animationFrame = 0;
selectedAnchorRef.current = null;
function findSection(sectionId: string) {
return scrollContainer.querySelector<HTMLElement>(
`[data-panel-anchor="${sectionId}"]`,
);
}
function updateActiveSection() {
if (selectedAnchorRef.current) {
setActiveSection(selectedAnchorRef.current);
return;
}
const containerTop = scrollContainer.getBoundingClientRect().top;
const activationLine = containerTop + 24;
let nextSection = sections[0].id;
for (const section of sections) {
const element = findSection(section.id);
if (element && element.getBoundingClientRect().top <= activationLine) {
nextSection = section.id;
}
}
const reachedBottom =
scrollContainer.scrollHeight > scrollContainer.clientHeight + 8 &&
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight <
8;
if (reachedBottom) {
nextSection = sections[sections.length - 1].id;
}
setActiveSection((current) =>
current === nextSection ? current : nextSection,
);
}
function scheduleUpdate() {
window.cancelAnimationFrame(animationFrame);
animationFrame = window.requestAnimationFrame(updateActiveSection);
}
function releaseSelectedAnchor() {
selectedAnchorRef.current = null;
scheduleUpdate();
}
scheduleUpdate();
scrollContainer.addEventListener("scroll", scheduleUpdate, {
passive: true,
});
scrollContainer.addEventListener("wheel", releaseSelectedAnchor, {
passive: true,
});
scrollContainer.addEventListener("touchstart", releaseSelectedAnchor, {
passive: true,
});
window.addEventListener("resize", scheduleUpdate);
return () => {
window.cancelAnimationFrame(animationFrame);
scrollContainer.removeEventListener("scroll", scheduleUpdate);
scrollContainer.removeEventListener("wheel", releaseSelectedAnchor);
scrollContainer.removeEventListener("touchstart", releaseSelectedAnchor);
window.removeEventListener("resize", scheduleUpdate);
};
// sectionKey 只在面板实际增减分区时变化,避免编辑字段时重复绑定事件。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sectionKey]);
function scrollToSection(sectionId: string) {
const container = scrollContainerRef.current;
const section = container?.querySelector<HTMLElement>(
`[data-panel-anchor="${sectionId}"]`,
);
if (!container || !section) return;
selectedAnchorRef.current = sectionId;
setActiveSection(sectionId);
container.scrollTo({
top:
container.scrollTop +
section.getBoundingClientRect().top -
container.getBoundingClientRect().top,
behavior: "smooth",
});
}
return (
<div className="flex min-h-0 flex-1 flex-col bg-canvas-soft">
{sections.length > 1 && (
<nav
aria-label={ariaLabel}
className="shrink-0 overflow-x-auto overscroll-none border-b border-hairline bg-card px-4"
>
<div className="flex min-w-max">
{sections.map((section) => {
const active = resolvedActiveSection === section.id;
return (
<button
key={section.id}
type="button"
aria-current={active ? "location" : undefined}
onClick={() => scrollToSection(section.id)}
className={[
"-mb-px min-w-24 whitespace-nowrap border-b-2 px-3 py-2.5 text-center text-xs transition-colors",
active
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:bg-surface-strong/50 hover:text-foreground",
].join(" ")}
>
{section.label}
</button>
);
})}
</div>
</nav>
)}
<div
ref={scrollContainerRef}
className="scrollbar-subtle min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-none px-4 pb-5 pt-4"
>
{children}
</div>
</div>
);
}
export function PanelAnchor({
id,
children,
}: {
id: string;
children: ReactNode;
}) {
return (
<section data-panel-anchor={id} className="scroll-mt-3 space-y-3">
{children}
</section>
);
}