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,7 +103,22 @@ export function AgentNodePanel({
}; };
return ( return (
<div className="space-y-3"> <PanelAnchorNavigation
ariaLabel="Agent 节点配置分区"
sections={[
{ id: "scope", label: "配置范围" },
{ id: "prompt", label: inheritsGlobal ? "任务" : "提示词" },
{ id: "entry", label: "进入行为" },
...(!inheritsGlobal
? [
{ id: "models", label: "模型与语音" },
{ id: "capabilities", label: "知识与工具" },
{ id: "interaction", label: "交互策略" },
]
: []),
]}
>
<PanelAnchor id="scope">
<SectionCard <SectionCard
icon={<Settings2 size={15} />} icon={<Settings2 size={15} />}
title="配置范围" title="配置范围"
@@ -119,7 +138,9 @@ export function AgentNodePanel({
<Switch checked={inheritsGlobal} onCheckedChange={setInheritance} /> <Switch checked={inheritsGlobal} onCheckedChange={setInheritance} />
</div> </div>
</SectionCard> </SectionCard>
</PanelAnchor>
<PanelAnchor id="prompt">
<SectionCard <SectionCard
icon={<MessageSquareText size={15} />} icon={<MessageSquareText size={15} />}
title={inheritsGlobal ? "任务" : "提示词"} title={inheritsGlobal ? "任务" : "提示词"}
@@ -141,7 +162,9 @@ export function AgentNodePanel({
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft" className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
/> />
</SectionCard> </SectionCard>
</PanelAnchor>
<PanelAnchor id="entry">
<SectionCard <SectionCard
icon={<Bot size={15} />} icon={<Bot size={15} />}
title="进入行为" title="进入行为"
@@ -161,9 +184,11 @@ export function AgentNodePanel({
使 Message 使 Message
</p> </p>
</SectionCard> </SectionCard>
</PanelAnchor>
{!inheritsGlobal && ( {!inheritsGlobal && (
<> <>
<PanelAnchor id="models">
<SectionCard <SectionCard
icon={<Brain size={15} />} icon={<Brain size={15} />}
title="模型与语音" title="模型与语音"
@@ -198,7 +223,9 @@ export function AgentNodePanel({
noneLabel="请选择语音合成" noneLabel="请选择语音合成"
/> />
</SectionCard> </SectionCard>
</PanelAnchor>
<PanelAnchor id="capabilities">
<VisionConfigSection <VisionConfigSection
description="配置当前 Agent 是否可以按需理解用户摄像头画面" description="配置当前 Agent 是否可以按需理解用户摄像头画面"
hint="开启后,该 Agent 会获得读取当前视频画面的工具。选择「模型自己」时,当前大语言模型必须支持图片输入。" hint="开启后,该 Agent 会获得读取当前视频画面的工具。选择「模型自己」时,当前大语言模型必须支持图片输入。"
@@ -259,6 +286,9 @@ export function AgentNodePanel({
/> />
</SectionCard> </SectionCard>
</PanelAnchor>
<PanelAnchor id="interaction">
<SectionCard <SectionCard
icon={<Sparkles size={15} />} icon={<Sparkles size={15} />}
title="交互策略" title="交互策略"
@@ -275,8 +305,9 @@ export function AgentNodePanel({
onConfigChange={(turnConfig) => set("turnConfig", turnConfig)} onConfigChange={(turnConfig) => set("turnConfig", turnConfig)}
/> />
</SectionCard> </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,7 +89,14 @@ export function EdgeSettingsPanel({
}; };
return ( return (
<div className="space-y-3"> <PanelAnchorNavigation
ariaLabel="连接配置分区"
sections={[
{ id: "routing", label: "路由方式" },
{ id: "condition", label: "触发条件" },
]}
>
<PanelAnchor id="routing">
<SectionCard <SectionCard
icon={<GitBranch size={15} />} icon={<GitBranch size={15} />}
title="路由方式" title="路由方式"
@@ -125,7 +136,9 @@ export function EdgeSettingsPanel({
</span> </span>
)} )}
</SectionCard> </SectionCard>
</PanelAnchor>
<PanelAnchor id="condition">
<SectionCard <SectionCard
icon={<Braces size={15} />} icon={<Braces size={15} />}
title="触发条件" title="触发条件"
@@ -272,6 +285,7 @@ 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,7 +39,16 @@ export function GlobalSettingsPanel({
knowledgeOptions: ModelOption[]; knowledgeOptions: ModelOption[];
}) { }) {
return ( return (
<div className="space-y-3"> <PanelAnchorNavigation
ariaLabel="工作流设置分区"
sections={[
{ id: "prompt", label: "提示词" },
{ id: "models", label: "模型与语音" },
{ id: "capabilities", label: "知识与工具" },
{ id: "interaction", label: "交互策略" },
]}
>
<PanelAnchor id="prompt">
<SectionCard <SectionCard
icon={<MessageSquareText size={15} />} icon={<MessageSquareText size={15} />}
title="全局提示词" title="全局提示词"
@@ -53,7 +66,9 @@ export function GlobalSettingsPanel({
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft" className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
/> />
</SectionCard> </SectionCard>
</PanelAnchor>
<PanelAnchor id="models">
<SectionCard <SectionCard
icon={<Brain size={15} />} icon={<Brain size={15} />}
title="模型与语音" title="模型与语音"
@@ -86,7 +101,9 @@ export function GlobalSettingsPanel({
onChange={(tts) => onSettingsChange({ ...settings, tts })} onChange={(tts) => onSettingsChange({ ...settings, tts })}
/> />
</SectionCard> </SectionCard>
</PanelAnchor>
<PanelAnchor id="capabilities">
<VisionConfigSection <VisionConfigSection
description="配置继承全局设置的 Agent 是否可以按需理解用户摄像头画面" description="配置继承全局设置的 Agent 是否可以按需理解用户摄像头画面"
hint="开启后,继承全局配置的 Agent 会获得读取当前视频画面的工具。选择「模型自己」时,全局大语言模型必须支持图片输入。" hint="开启后,继承全局配置的 Agent 会获得读取当前视频画面的工具。选择「模型自己」时,全局大语言模型必须支持图片输入。"
@@ -146,7 +163,9 @@ export function GlobalSettingsPanel({
onChange={(toolIds) => onSettingsChange({ ...settings, toolIds })} onChange={(toolIds) => onSettingsChange({ ...settings, toolIds })}
/> />
</SectionCard> </SectionCard>
</PanelAnchor>
<PanelAnchor id="interaction">
<SectionCard <SectionCard
icon={<Sparkles size={15} />} icon={<Sparkles size={15} />}
title="交互策略" title="交互策略"
@@ -163,6 +182,7 @@ export function GlobalSettingsPanel({
} }
/> />
</SectionCard> </SectionCard>
</div> </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,6 +66,7 @@ export function MessageNodePanel({
return ( return (
<> <>
<PanelAnchor id="speech">
<SectionCard <SectionCard
icon={<MessageSquareText size={15} />} icon={<MessageSquareText size={15} />}
title="固定播报" title="固定播报"
@@ -92,7 +94,9 @@ export function MessageNodePanel({
</p> </p>
)} )}
</SectionCard> </SectionCard>
</PanelAnchor>
<PanelAnchor id="completion">
<SectionCard <SectionCard
icon={<ArrowRight size={15} />} icon={<ArrowRight size={15} />}
title="继续方式" title="继续方式"
@@ -170,6 +174,7 @@ export function MessageNodePanel({
</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,7 +83,25 @@ 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 (
<PanelAnchorNavigation
ariaLabel={`${spec.displayName}节点配置分区`}
sections={sections}
>
<WorkflowNodePanelForm <WorkflowNodePanelForm
spec={spec} spec={spec}
draft={draft} draft={draft}
@@ -92,6 +115,7 @@ export function NodeSettingsPanel({
setAssignmentsJson={setAssignmentsJson} setAssignmentsJson={setAssignmentsJson}
commitActionJson={commitActionJson} commitActionJson={commitActionJson}
/> />
</PanelAnchorNavigation>
); );
} }
return ( return (
@@ -298,6 +322,7 @@ function WorkflowNodePanelForm({
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{spec.type === "action" && ( {spec.type === "action" && (
<PanelAnchor id="execution">
<ActionNodePanel <ActionNodePanel
draft={draft} draft={draft}
set={set} set={set}
@@ -309,6 +334,7 @@ function WorkflowNodePanelForm({
setAssignmentsJson={setAssignmentsJson} setAssignmentsJson={setAssignmentsJson}
commitActionJson={commitActionJson} commitActionJson={commitActionJson}
/> />
</PanelAnchor>
)} )}
{spec.type === "message" && ( {spec.type === "message" && (
@@ -316,6 +342,7 @@ function WorkflowNodePanelForm({
)} )}
{spec.type === "handoff" && ( {spec.type === "handoff" && (
<PanelAnchor id="handoff">
<SectionCard <SectionCard
icon={<PhoneForwarded size={15} />} icon={<PhoneForwarded size={15} />}
title="转交配置" title="转交配置"
@@ -357,9 +384,11 @@ function WorkflowNodePanelForm({
/> />
</label> </label>
</SectionCard> </SectionCard>
</PanelAnchor>
)} )}
{spec.type === "end" && ( {spec.type === "end" && (
<PanelAnchor id="end">
<SectionCard <SectionCard
icon={<Flag size={15} />} icon={<Flag size={15} />}
title="结束配置" title="结束配置"
@@ -388,6 +417,7 @@ function WorkflowNodePanelForm({
/> />
</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>
);
}