feat(frontend): add top prompt anchor navigation
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Bot,
|
||||
Brain,
|
||||
@@ -37,6 +38,15 @@ import type { DynamicVariableDefinition, Tool } from "@/lib/api";
|
||||
|
||||
type ResourceOption = { value: string; label: string };
|
||||
|
||||
const promptSections = [
|
||||
{ id: "conversation", label: "对话内容" },
|
||||
{ id: "models", label: "模型与语音" },
|
||||
{ id: "capabilities", label: "知识与工具" },
|
||||
{ id: "interaction", label: "交互策略" },
|
||||
] as const;
|
||||
|
||||
type PromptSectionId = (typeof promptSections)[number]["id"];
|
||||
|
||||
type PromptEditorProps = {
|
||||
assistantId: string | null;
|
||||
form: AssistantForm;
|
||||
@@ -86,6 +96,102 @@ export function PromptEditor({
|
||||
handlePromptModelChange,
|
||||
}: PromptEditorProps) {
|
||||
const openingMessage = form.startup.openingMessage;
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const sectionRefs = useRef<Record<PromptSectionId, HTMLElement | null>>({
|
||||
conversation: null,
|
||||
models: null,
|
||||
capabilities: null,
|
||||
interaction: null,
|
||||
});
|
||||
const selectedAnchorRef = useRef<PromptSectionId | null>(null);
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<PromptSectionId>("conversation");
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
const scrollContainer: HTMLDivElement = container;
|
||||
|
||||
let animationFrame = 0;
|
||||
|
||||
function updateActiveSection() {
|
||||
if (selectedAnchorRef.current) {
|
||||
setActiveSection(selectedAnchorRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
const containerTop = scrollContainer.getBoundingClientRect().top;
|
||||
const activationLine = containerTop + 24;
|
||||
let nextSection: PromptSectionId = promptSections[0].id;
|
||||
|
||||
for (const section of promptSections) {
|
||||
const element = sectionRefs.current[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 = promptSections[promptSections.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);
|
||||
};
|
||||
}, [form.runtimeMode]);
|
||||
|
||||
function scrollToSection(sectionId: PromptSectionId) {
|
||||
const container = scrollContainerRef.current;
|
||||
const section = sectionRefs.current[sectionId];
|
||||
if (!container || !section) return;
|
||||
|
||||
const containerTop = container.getBoundingClientRect().top;
|
||||
const sectionTop = section.getBoundingClientRect().top;
|
||||
|
||||
selectedAnchorRef.current = sectionId;
|
||||
setActiveSection(sectionId);
|
||||
container.scrollTo({
|
||||
top: container.scrollTop + sectionTop - containerTop,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
function setOpeningMessage(enabled: boolean) {
|
||||
updateForm("startup", {
|
||||
@@ -156,227 +262,295 @@ export function PromptEditor({
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-1 gap-4">
|
||||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||
<SectionCard
|
||||
icon={<MessageSquareText size={15} />}
|
||||
title="提示词"
|
||||
description="描述助手的角色、能力和回答要求"
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
|
||||
<nav
|
||||
aria-label="提示词配置分区"
|
||||
className="shrink-0 overflow-x-auto"
|
||||
>
|
||||
<TextAreaField
|
||||
value={form.prompt}
|
||||
onChange={(value) => updateForm("prompt", value)}
|
||||
placeholder="请输入提示词,描述助手的角色、能力和回答要求"
|
||||
rows={8}
|
||||
/>
|
||||
<DynamicVariableEditorHint
|
||||
count={Object.keys(dynamicVariableDefinitions).length}
|
||||
onOpen={() => setDynamicVariablesOpen(true)}
|
||||
/>
|
||||
</SectionCard>
|
||||
<div className="grid min-w-[22rem] grid-cols-4 border-b border-hairline">
|
||||
{promptSections.map((section) => {
|
||||
const active = activeSection === section.id;
|
||||
|
||||
<SectionCard
|
||||
icon={<Bot size={15} />}
|
||||
title="开场白"
|
||||
description="助手与用户首次对话时的开场语"
|
||||
return (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
aria-current={active ? "location" : undefined}
|
||||
onClick={() => scrollToSection(section.id)}
|
||||
className={[
|
||||
"-mb-px whitespace-nowrap border-b-2 px-2 py-2.5 text-center text-xs transition-colors sm:px-3 sm:text-sm",
|
||||
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 min-w-0 flex-1 space-y-3 overflow-y-auto pr-1"
|
||||
>
|
||||
<TextAreaField
|
||||
value={form.greeting}
|
||||
onChange={(value) => updateForm("greeting", value)}
|
||||
placeholder="请输入助手开场白"
|
||||
/>
|
||||
<DynamicVariableEditorHint
|
||||
count={Object.keys(dynamicVariableDefinitions).length}
|
||||
onOpen={() => setDynamicVariablesOpen(true)}
|
||||
/>
|
||||
{form.runtimeMode === "pipeline" && (
|
||||
<div className="mt-4 space-y-3 border-t border-hairline pt-4">
|
||||
<ToggleRow
|
||||
title="开场确认弹窗"
|
||||
description="与开场白同时显示,用户确认后即可开始对话,不等待播报完成。"
|
||||
checked={Boolean(openingMessage)}
|
||||
onChange={setOpeningMessage}
|
||||
<section
|
||||
ref={(element) => {
|
||||
sectionRefs.current.conversation = element;
|
||||
}}
|
||||
className="scroll-mt-3 space-y-3"
|
||||
>
|
||||
<SectionCard
|
||||
icon={<MessageSquareText size={15} />}
|
||||
title="提示词"
|
||||
description="描述助手的角色、能力和回答要求"
|
||||
>
|
||||
<TextAreaField
|
||||
value={form.prompt}
|
||||
onChange={(value) => updateForm("prompt", value)}
|
||||
placeholder="请输入提示词,描述助手的角色、能力和回答要求"
|
||||
rows={8}
|
||||
/>
|
||||
<DynamicVariableEditorHint
|
||||
count={Object.keys(dynamicVariableDefinitions).length}
|
||||
onOpen={() => setDynamicVariablesOpen(true)}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
{openingMessage && (
|
||||
<div className="space-y-3 rounded-xl border border-hairline bg-canvas-soft p-4">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
弹窗标题
|
||||
</span>
|
||||
<Input
|
||||
value={openingMessage.title}
|
||||
onChange={(event) =>
|
||||
updateOpeningMessage({ title: event.target.value })
|
||||
}
|
||||
placeholder="重要提示"
|
||||
className="border-hairline-strong bg-background"
|
||||
/>
|
||||
</label>
|
||||
<TextAreaField
|
||||
label="重要信息"
|
||||
value={openingMessage.message}
|
||||
onChange={(message) => updateOpeningMessage({ message })}
|
||||
placeholder="请输入需要用户确认的重要信息"
|
||||
rows={4}
|
||||
<SectionCard
|
||||
icon={<Bot size={15} />}
|
||||
title="开场白"
|
||||
description="助手与用户首次对话时的开场语"
|
||||
>
|
||||
<TextAreaField
|
||||
value={form.greeting}
|
||||
onChange={(value) => updateForm("greeting", value)}
|
||||
placeholder="请输入助手开场白"
|
||||
/>
|
||||
<DynamicVariableEditorHint
|
||||
count={Object.keys(dynamicVariableDefinitions).length}
|
||||
onOpen={() => setDynamicVariablesOpen(true)}
|
||||
/>
|
||||
{form.runtimeMode === "pipeline" && (
|
||||
<div className="mt-4 space-y-3 border-t border-hairline pt-4">
|
||||
<ToggleRow
|
||||
title="开场确认弹窗"
|
||||
description="与开场白同时显示,用户确认后即可开始对话,不等待播报完成。"
|
||||
checked={Boolean(openingMessage)}
|
||||
onChange={setOpeningMessage}
|
||||
/>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
确认按钮
|
||||
</span>
|
||||
<Input
|
||||
value={openingMessage.confirmLabel}
|
||||
onChange={(event) =>
|
||||
updateOpeningMessage({
|
||||
confirmLabel: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="确认"
|
||||
className="border-hairline-strong bg-background"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
弹窗不可跳过;失败时将结束本次通话。这是平台内置开场阶段,
|
||||
不会作为工具暴露给模型。
|
||||
</p>
|
||||
|
||||
{openingMessage && (
|
||||
<div className="space-y-3 rounded-xl border border-hairline bg-canvas-soft p-4">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
弹窗标题
|
||||
</span>
|
||||
<Input
|
||||
value={openingMessage.title}
|
||||
onChange={(event) =>
|
||||
updateOpeningMessage({ title: event.target.value })
|
||||
}
|
||||
placeholder="重要提示"
|
||||
className="border-hairline-strong bg-background"
|
||||
/>
|
||||
</label>
|
||||
<TextAreaField
|
||||
label="重要信息"
|
||||
value={openingMessage.message}
|
||||
onChange={(message) =>
|
||||
updateOpeningMessage({ message })
|
||||
}
|
||||
placeholder="请输入需要用户确认的重要信息"
|
||||
rows={4}
|
||||
/>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
确认按钮
|
||||
</span>
|
||||
<Input
|
||||
value={openingMessage.confirmLabel}
|
||||
onChange={(event) =>
|
||||
updateOpeningMessage({
|
||||
confirmLabel: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="确认"
|
||||
className="border-hairline-strong bg-background"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
弹窗不可跳过;失败时将结束本次通话。这是平台内置开场阶段,
|
||||
不会作为工具暴露给模型。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
</SectionCard>
|
||||
</section>
|
||||
|
||||
<SectionCard
|
||||
icon={<Brain size={15} />}
|
||||
title="模型与语音"
|
||||
description={
|
||||
form.runtimeMode === "pipeline"
|
||||
? "选择运行方式,以及大语言模型、语音识别与语音合成资源"
|
||||
: "选择运行方式;Realtime 模型内置语音识别与语音合成"
|
||||
}
|
||||
>
|
||||
<RuntimeModeSelector
|
||||
value={form.runtimeMode}
|
||||
onChange={(runtimeMode) => {
|
||||
updateForm("runtimeMode", runtimeMode);
|
||||
if (
|
||||
runtimeMode === "realtime" &&
|
||||
(form.startup.actions.length || form.startup.openingMessage)
|
||||
) {
|
||||
updateForm("startup", {
|
||||
executionMode: "sequential",
|
||||
actions: [],
|
||||
openingMessage: null,
|
||||
});
|
||||
}
|
||||
<section
|
||||
ref={(element) => {
|
||||
sectionRefs.current.models = element;
|
||||
}}
|
||||
/>
|
||||
|
||||
{form.runtimeMode === "pipeline" ? (
|
||||
<>
|
||||
<ResourceSelectField
|
||||
label="大语言模型"
|
||||
value={form.model}
|
||||
onChange={handlePromptModelChange}
|
||||
options={llmOptions}
|
||||
noneLabel="无"
|
||||
/>
|
||||
<ResourceSelectField
|
||||
label="语音识别"
|
||||
value={form.asr}
|
||||
onChange={(value) => updateForm("asr", value)}
|
||||
options={asrOptions}
|
||||
noneLabel="无"
|
||||
/>
|
||||
<ResourceSelectField
|
||||
label="语音合成"
|
||||
value={form.voice}
|
||||
onChange={(value) => updateForm("voice", value)}
|
||||
options={ttsOptions}
|
||||
noneLabel="无"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<ResourceSelectField
|
||||
label="Realtime 模型"
|
||||
value={form.realtimeModel}
|
||||
onChange={(value) => updateForm("realtimeModel", value)}
|
||||
options={realtimeOptions}
|
||||
noneLabel="无"
|
||||
/>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{form.runtimeMode === "pipeline" && (
|
||||
<VisionConfigSection
|
||||
description="配置提示词助手是否可以按需理解用户摄像头画面"
|
||||
hint="开启后,助手会获得读取当前视频画面的工具。选择「模型自己」时,大语言模型必须支持图片输入。"
|
||||
enabled={form.visionEnabled}
|
||||
modelResourceId={form.visionModelResourceId}
|
||||
mainModelResourceId={form.model}
|
||||
modelOptions={visionModelOptions}
|
||||
onEnabledChange={handlePromptVisionEnabledChange}
|
||||
onModelResourceIdChange={(value) =>
|
||||
updateForm("visionModelResourceId", value)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{form.runtimeMode === "pipeline" && (
|
||||
<SectionCard
|
||||
icon={<Database size={15} />}
|
||||
title="知识库配置"
|
||||
description="选择助手回答时可检索的业务知识来源"
|
||||
className="scroll-mt-3 space-y-3"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
知识库选择
|
||||
</span>
|
||||
<KnowledgeRetrievalConfigDialog
|
||||
disabled={!form.knowledgeBase}
|
||||
value={form.knowledgeRetrievalConfig}
|
||||
onChange={(config) =>
|
||||
updateForm("knowledgeRetrievalConfig", config)
|
||||
<SectionCard
|
||||
icon={<Brain size={15} />}
|
||||
title="模型与语音"
|
||||
description={
|
||||
form.runtimeMode === "pipeline"
|
||||
? "选择运行方式,以及大语言模型、语音识别与语音合成资源"
|
||||
: "选择运行方式;Realtime 模型内置语音识别与语音合成"
|
||||
}
|
||||
>
|
||||
<RuntimeModeSelector
|
||||
value={form.runtimeMode}
|
||||
onChange={(runtimeMode) => {
|
||||
updateForm("runtimeMode", runtimeMode);
|
||||
if (
|
||||
runtimeMode === "realtime" &&
|
||||
(form.startup.actions.length ||
|
||||
form.startup.openingMessage)
|
||||
) {
|
||||
updateForm("startup", {
|
||||
executionMode: "sequential",
|
||||
actions: [],
|
||||
openingMessage: null,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{form.runtimeMode === "pipeline" ? (
|
||||
<>
|
||||
<ResourceSelectField
|
||||
label="大语言模型"
|
||||
value={form.model}
|
||||
onChange={handlePromptModelChange}
|
||||
options={llmOptions}
|
||||
noneLabel="无"
|
||||
/>
|
||||
<ResourceSelectField
|
||||
label="语音识别"
|
||||
value={form.asr}
|
||||
onChange={(value) => updateForm("asr", value)}
|
||||
options={asrOptions}
|
||||
noneLabel="无"
|
||||
/>
|
||||
<ResourceSelectField
|
||||
label="语音合成"
|
||||
value={form.voice}
|
||||
onChange={(value) => updateForm("voice", value)}
|
||||
options={ttsOptions}
|
||||
noneLabel="无"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<ResourceSelectField
|
||||
label="Realtime 模型"
|
||||
value={form.realtimeModel}
|
||||
onChange={(value) => updateForm("realtimeModel", value)}
|
||||
options={realtimeOptions}
|
||||
noneLabel="无"
|
||||
/>
|
||||
)}
|
||||
</SectionCard>
|
||||
</section>
|
||||
|
||||
<section
|
||||
ref={(element) => {
|
||||
sectionRefs.current.capabilities = element;
|
||||
}}
|
||||
className="scroll-mt-3 space-y-3"
|
||||
>
|
||||
{form.runtimeMode === "pipeline" && (
|
||||
<VisionConfigSection
|
||||
description="配置提示词助手是否可以按需理解用户摄像头画面"
|
||||
hint="开启后,助手会获得读取当前视频画面的工具。选择「模型自己」时,大语言模型必须支持图片输入。"
|
||||
enabled={form.visionEnabled}
|
||||
modelResourceId={form.visionModelResourceId}
|
||||
mainModelResourceId={form.model}
|
||||
modelOptions={visionModelOptions}
|
||||
onEnabledChange={handlePromptVisionEnabledChange}
|
||||
onModelResourceIdChange={(value) =>
|
||||
updateForm("visionModelResourceId", value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ResourceSelectField
|
||||
value={form.knowledgeBase}
|
||||
onChange={(value) => updateForm("knowledgeBase", value)}
|
||||
options={kbOptions}
|
||||
noneLabel="无"
|
||||
/>
|
||||
</SectionCard>
|
||||
)}
|
||||
)}
|
||||
|
||||
<SectionCard
|
||||
icon={<Wrench size={15} />}
|
||||
title="工具"
|
||||
description="配置该提示词助手可以调用的工具"
|
||||
>
|
||||
<ToolPicker
|
||||
tools={tools.filter((tool) => tool.status === "active")}
|
||||
selectedIds={form.toolIds}
|
||||
onChange={(toolIds) => updateForm("toolIds", toolIds)}
|
||||
/>
|
||||
</SectionCard>
|
||||
{form.runtimeMode === "pipeline" && (
|
||||
<SectionCard
|
||||
icon={<Database size={15} />}
|
||||
title="知识库配置"
|
||||
description="选择助手回答时可检索的业务知识来源"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
知识库选择
|
||||
</span>
|
||||
<KnowledgeRetrievalConfigDialog
|
||||
disabled={!form.knowledgeBase}
|
||||
value={form.knowledgeRetrievalConfig}
|
||||
onChange={(config) =>
|
||||
updateForm("knowledgeRetrievalConfig", config)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ResourceSelectField
|
||||
value={form.knowledgeBase}
|
||||
onChange={(value) => updateForm("knowledgeBase", value)}
|
||||
options={kbOptions}
|
||||
noneLabel="无"
|
||||
/>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<SectionCard
|
||||
icon={<Sparkles size={15} />}
|
||||
title="交互策略"
|
||||
description="设置实时视频对话时的交互体验"
|
||||
>
|
||||
{form.runtimeMode === "pipeline" ? (
|
||||
<TurnConfigEditor
|
||||
enabled={form.enableInterrupt}
|
||||
config={form.turnConfig}
|
||||
onEnabledChange={(checked) => updateForm("enableInterrupt", checked)}
|
||||
onConfigChange={(config) => updateForm("turnConfig", config)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
高级抢话与轮次检测当前仅支持 Pipeline 模式。
|
||||
</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
<SectionCard
|
||||
icon={<Wrench size={15} />}
|
||||
title="工具"
|
||||
description="配置该提示词助手可以调用的工具"
|
||||
>
|
||||
<ToolPicker
|
||||
tools={tools.filter((tool) => tool.status === "active")}
|
||||
selectedIds={form.toolIds}
|
||||
onChange={(toolIds) => updateForm("toolIds", toolIds)}
|
||||
/>
|
||||
</SectionCard>
|
||||
</section>
|
||||
|
||||
<section
|
||||
ref={(element) => {
|
||||
sectionRefs.current.interaction = element;
|
||||
}}
|
||||
className="scroll-mt-3 space-y-3"
|
||||
>
|
||||
<SectionCard
|
||||
icon={<Sparkles size={15} />}
|
||||
title="交互策略"
|
||||
description="设置实时视频对话时的交互体验"
|
||||
>
|
||||
{form.runtimeMode === "pipeline" ? (
|
||||
<TurnConfigEditor
|
||||
enabled={form.enableInterrupt}
|
||||
config={form.turnConfig}
|
||||
onEnabledChange={(checked) =>
|
||||
updateForm("enableInterrupt", checked)
|
||||
}
|
||||
onConfigChange={(config) =>
|
||||
updateForm("turnConfig", config)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
高级抢话与轮次检测当前仅支持 Pipeline 模式。
|
||||
</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DebugDrawer
|
||||
|
||||
Reference in New Issue
Block a user