feat(workflow): add drawer anchor navigation
This commit is contained in:
@@ -759,7 +759,7 @@ export function WorkflowCanvas({
|
||||
) : dynamicVariablesOpen ? (
|
||||
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 ? (
|
||||
<GlobalSettingsPanel
|
||||
settings={settings}
|
||||
|
||||
@@ -20,6 +20,10 @@ import type { KnowledgeRetrievalConfig } from "@/lib/api";
|
||||
import { normalizeTurnConfig } from "@/lib/turn-config";
|
||||
|
||||
import { NodeSelect, ToolOptionPicker } from "./controls";
|
||||
import {
|
||||
PanelAnchor,
|
||||
PanelAnchorNavigation,
|
||||
} from "./PanelAnchorNavigation";
|
||||
import type { WorkflowNodeData } from "../specs";
|
||||
import type { ModelOption, WorkflowSettings } from "../types";
|
||||
|
||||
@@ -99,7 +103,22 @@ export function AgentNodePanel({
|
||||
};
|
||||
|
||||
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
|
||||
icon={<Settings2 size={15} />}
|
||||
title="配置范围"
|
||||
@@ -119,7 +138,9 @@ export function AgentNodePanel({
|
||||
<Switch checked={inheritsGlobal} onCheckedChange={setInheritance} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
</PanelAnchor>
|
||||
|
||||
<PanelAnchor id="prompt">
|
||||
<SectionCard
|
||||
icon={<MessageSquareText size={15} />}
|
||||
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"
|
||||
/>
|
||||
</SectionCard>
|
||||
</PanelAnchor>
|
||||
|
||||
<PanelAnchor id="entry">
|
||||
<SectionCard
|
||||
icon={<Bot size={15} />}
|
||||
title="进入行为"
|
||||
@@ -161,9 +184,11 @@ export function AgentNodePanel({
|
||||
等待模式不会回复触发跳转的当前输入;立即回复会处理该输入。固定播报、客户端弹窗和确认门禁请使用独立的 Message 节点。
|
||||
</p>
|
||||
</SectionCard>
|
||||
</PanelAnchor>
|
||||
|
||||
{!inheritsGlobal && (
|
||||
<>
|
||||
<PanelAnchor id="models">
|
||||
<SectionCard
|
||||
icon={<Brain size={15} />}
|
||||
title="模型与语音"
|
||||
@@ -198,7 +223,9 @@ export function AgentNodePanel({
|
||||
noneLabel="请选择语音合成"
|
||||
/>
|
||||
</SectionCard>
|
||||
</PanelAnchor>
|
||||
|
||||
<PanelAnchor id="capabilities">
|
||||
<VisionConfigSection
|
||||
description="配置当前 Agent 是否可以按需理解用户摄像头画面"
|
||||
hint="开启后,该 Agent 会获得读取当前视频画面的工具。选择「模型自己」时,当前大语言模型必须支持图片输入。"
|
||||
@@ -259,6 +286,9 @@ export function AgentNodePanel({
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
</PanelAnchor>
|
||||
|
||||
<PanelAnchor id="interaction">
|
||||
<SectionCard
|
||||
icon={<Sparkles size={15} />}
|
||||
title="交互策略"
|
||||
@@ -275,8 +305,9 @@ export function AgentNodePanel({
|
||||
onConfigChange={(turnConfig) => set("turnConfig", turnConfig)}
|
||||
/>
|
||||
</SectionCard>
|
||||
</PanelAnchor>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PanelAnchorNavigation>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
import { NodeSelect } from "./controls";
|
||||
import {
|
||||
PanelAnchor,
|
||||
PanelAnchorNavigation,
|
||||
} from "./PanelAnchorNavigation";
|
||||
import type { ExpressionRule, WorkflowEdgeData } from "../specs";
|
||||
|
||||
export function EdgeSettingsPanel({
|
||||
@@ -85,7 +89,14 @@ export function EdgeSettingsPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<PanelAnchorNavigation
|
||||
ariaLabel="连接配置分区"
|
||||
sections={[
|
||||
{ id: "routing", label: "路由方式" },
|
||||
{ id: "condition", label: "触发条件" },
|
||||
]}
|
||||
>
|
||||
<PanelAnchor id="routing">
|
||||
<SectionCard
|
||||
icon={<GitBranch size={15} />}
|
||||
title="路由方式"
|
||||
@@ -125,7 +136,9 @@ export function EdgeSettingsPanel({
|
||||
</span>
|
||||
)}
|
||||
</SectionCard>
|
||||
</PanelAnchor>
|
||||
|
||||
<PanelAnchor id="condition">
|
||||
<SectionCard
|
||||
icon={<Braces size={15} />}
|
||||
title="触发条件"
|
||||
@@ -272,6 +285,7 @@ export function EdgeSettingsPanel({
|
||||
</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
</div>
|
||||
</PanelAnchor>
|
||||
</PanelAnchorNavigation>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ import { TurnConfigEditor } from "@/components/turn-config-editor";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
import { ModelSelect, NodeSelect, ToolOptionPicker } from "./controls";
|
||||
import {
|
||||
PanelAnchor,
|
||||
PanelAnchorNavigation,
|
||||
} from "./PanelAnchorNavigation";
|
||||
import type {
|
||||
ModelOption,
|
||||
WorkflowEditorProps,
|
||||
@@ -35,7 +39,16 @@ export function GlobalSettingsPanel({
|
||||
knowledgeOptions: ModelOption[];
|
||||
}) {
|
||||
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
|
||||
icon={<MessageSquareText size={15} />}
|
||||
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"
|
||||
/>
|
||||
</SectionCard>
|
||||
</PanelAnchor>
|
||||
|
||||
<PanelAnchor id="models">
|
||||
<SectionCard
|
||||
icon={<Brain size={15} />}
|
||||
title="模型与语音"
|
||||
@@ -86,7 +101,9 @@ export function GlobalSettingsPanel({
|
||||
onChange={(tts) => onSettingsChange({ ...settings, tts })}
|
||||
/>
|
||||
</SectionCard>
|
||||
</PanelAnchor>
|
||||
|
||||
<PanelAnchor id="capabilities">
|
||||
<VisionConfigSection
|
||||
description="配置继承全局设置的 Agent 是否可以按需理解用户摄像头画面"
|
||||
hint="开启后,继承全局配置的 Agent 会获得读取当前视频画面的工具。选择「模型自己」时,全局大语言模型必须支持图片输入。"
|
||||
@@ -146,7 +163,9 @@ export function GlobalSettingsPanel({
|
||||
onChange={(toolIds) => onSettingsChange({ ...settings, toolIds })}
|
||||
/>
|
||||
</SectionCard>
|
||||
</PanelAnchor>
|
||||
|
||||
<PanelAnchor id="interaction">
|
||||
<SectionCard
|
||||
icon={<Sparkles size={15} />}
|
||||
title="交互策略"
|
||||
@@ -163,6 +182,7 @@ export function GlobalSettingsPanel({
|
||||
}
|
||||
/>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</PanelAnchor>
|
||||
</PanelAnchorNavigation>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type MessageCompletionPolicy,
|
||||
type WorkflowNodeData,
|
||||
} from "../specs";
|
||||
import { PanelAnchor } from "./PanelAnchorNavigation";
|
||||
|
||||
const COMPLETION_OPTIONS: Array<{
|
||||
value: MessageCompletionPolicy;
|
||||
@@ -65,6 +66,7 @@ export function MessageNodePanel({
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelAnchor id="speech">
|
||||
<SectionCard
|
||||
icon={<MessageSquareText size={15} />}
|
||||
title="固定播报"
|
||||
@@ -92,7 +94,9 @@ export function MessageNodePanel({
|
||||
</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
</PanelAnchor>
|
||||
|
||||
<PanelAnchor id="completion">
|
||||
<SectionCard
|
||||
icon={<ArrowRight size={15} />}
|
||||
title="继续方式"
|
||||
@@ -170,6 +174,7 @@ export function MessageNodePanel({
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
</PanelAnchor>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ import { ActionNodePanel } from "./ActionNodePanel";
|
||||
import { AgentNodePanel } from "./AgentNodePanel";
|
||||
import { MessageNodePanel } from "./MessageNodePanel";
|
||||
import { NodeSelect, ToolOptionPicker } from "./controls";
|
||||
import {
|
||||
PanelAnchor,
|
||||
PanelAnchorNavigation,
|
||||
type PanelAnchorSection,
|
||||
} from "./PanelAnchorNavigation";
|
||||
import type { RuntimeNodeSpec, WorkflowNodeData } from "../specs";
|
||||
import type { ModelOption, WorkflowSettings } from "../types";
|
||||
|
||||
@@ -78,7 +83,25 @@ export function NodeSettingsPanel({
|
||||
|
||||
if (panel) {
|
||||
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 (
|
||||
<PanelAnchorNavigation
|
||||
ariaLabel={`${spec.displayName}节点配置分区`}
|
||||
sections={sections}
|
||||
>
|
||||
<WorkflowNodePanelForm
|
||||
spec={spec}
|
||||
draft={draft}
|
||||
@@ -92,6 +115,7 @@ export function NodeSettingsPanel({
|
||||
setAssignmentsJson={setAssignmentsJson}
|
||||
commitActionJson={commitActionJson}
|
||||
/>
|
||||
</PanelAnchorNavigation>
|
||||
);
|
||||
}
|
||||
return (
|
||||
@@ -298,6 +322,7 @@ function WorkflowNodePanelForm({
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{spec.type === "action" && (
|
||||
<PanelAnchor id="execution">
|
||||
<ActionNodePanel
|
||||
draft={draft}
|
||||
set={set}
|
||||
@@ -309,6 +334,7 @@ function WorkflowNodePanelForm({
|
||||
setAssignmentsJson={setAssignmentsJson}
|
||||
commitActionJson={commitActionJson}
|
||||
/>
|
||||
</PanelAnchor>
|
||||
)}
|
||||
|
||||
{spec.type === "message" && (
|
||||
@@ -316,6 +342,7 @@ function WorkflowNodePanelForm({
|
||||
)}
|
||||
|
||||
{spec.type === "handoff" && (
|
||||
<PanelAnchor id="handoff">
|
||||
<SectionCard
|
||||
icon={<PhoneForwarded size={15} />}
|
||||
title="转交配置"
|
||||
@@ -357,9 +384,11 @@ function WorkflowNodePanelForm({
|
||||
/>
|
||||
</label>
|
||||
</SectionCard>
|
||||
</PanelAnchor>
|
||||
)}
|
||||
|
||||
{spec.type === "end" && (
|
||||
<PanelAnchor id="end">
|
||||
<SectionCard
|
||||
icon={<Flag size={15} />}
|
||||
title="结束配置"
|
||||
@@ -388,6 +417,7 @@ function WorkflowNodePanelForm({
|
||||
/>
|
||||
</label>
|
||||
</SectionCard>
|
||||
</PanelAnchor>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user