feat(workflow): add drawer anchor navigation
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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