feat(frontend): add top prompt anchor navigation
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
Brain,
|
Brain,
|
||||||
@@ -37,6 +38,15 @@ import type { DynamicVariableDefinition, Tool } from "@/lib/api";
|
|||||||
|
|
||||||
type ResourceOption = { value: string; label: string };
|
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 = {
|
type PromptEditorProps = {
|
||||||
assistantId: string | null;
|
assistantId: string | null;
|
||||||
form: AssistantForm;
|
form: AssistantForm;
|
||||||
@@ -86,6 +96,102 @@ export function PromptEditor({
|
|||||||
handlePromptModelChange,
|
handlePromptModelChange,
|
||||||
}: PromptEditorProps) {
|
}: PromptEditorProps) {
|
||||||
const openingMessage = form.startup.openingMessage;
|
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) {
|
function setOpeningMessage(enabled: boolean) {
|
||||||
updateForm("startup", {
|
updateForm("startup", {
|
||||||
@@ -156,7 +262,45 @@ export function PromptEditor({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 gap-4">
|
<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">
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
|
||||||
|
<nav
|
||||||
|
aria-label="提示词配置分区"
|
||||||
|
className="shrink-0 overflow-x-auto"
|
||||||
|
>
|
||||||
|
<div className="grid min-w-[22rem] grid-cols-4 border-b border-hairline">
|
||||||
|
{promptSections.map((section) => {
|
||||||
|
const active = activeSection === section.id;
|
||||||
|
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
ref={(element) => {
|
||||||
|
sectionRefs.current.conversation = element;
|
||||||
|
}}
|
||||||
|
className="scroll-mt-3 space-y-3"
|
||||||
|
>
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<MessageSquareText size={15} />}
|
icon={<MessageSquareText size={15} />}
|
||||||
title="提示词"
|
title="提示词"
|
||||||
@@ -215,7 +359,9 @@ export function PromptEditor({
|
|||||||
<TextAreaField
|
<TextAreaField
|
||||||
label="重要信息"
|
label="重要信息"
|
||||||
value={openingMessage.message}
|
value={openingMessage.message}
|
||||||
onChange={(message) => updateOpeningMessage({ message })}
|
onChange={(message) =>
|
||||||
|
updateOpeningMessage({ message })
|
||||||
|
}
|
||||||
placeholder="请输入需要用户确认的重要信息"
|
placeholder="请输入需要用户确认的重要信息"
|
||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
@@ -243,7 +389,14 @@ export function PromptEditor({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
ref={(element) => {
|
||||||
|
sectionRefs.current.models = element;
|
||||||
|
}}
|
||||||
|
className="scroll-mt-3 space-y-3"
|
||||||
|
>
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Brain size={15} />}
|
icon={<Brain size={15} />}
|
||||||
title="模型与语音"
|
title="模型与语音"
|
||||||
@@ -259,7 +412,8 @@ export function PromptEditor({
|
|||||||
updateForm("runtimeMode", runtimeMode);
|
updateForm("runtimeMode", runtimeMode);
|
||||||
if (
|
if (
|
||||||
runtimeMode === "realtime" &&
|
runtimeMode === "realtime" &&
|
||||||
(form.startup.actions.length || form.startup.openingMessage)
|
(form.startup.actions.length ||
|
||||||
|
form.startup.openingMessage)
|
||||||
) {
|
) {
|
||||||
updateForm("startup", {
|
updateForm("startup", {
|
||||||
executionMode: "sequential",
|
executionMode: "sequential",
|
||||||
@@ -304,7 +458,14 @@ export function PromptEditor({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
ref={(element) => {
|
||||||
|
sectionRefs.current.capabilities = element;
|
||||||
|
}}
|
||||||
|
className="scroll-mt-3 space-y-3"
|
||||||
|
>
|
||||||
{form.runtimeMode === "pipeline" && (
|
{form.runtimeMode === "pipeline" && (
|
||||||
<VisionConfigSection
|
<VisionConfigSection
|
||||||
description="配置提示词助手是否可以按需理解用户摄像头画面"
|
description="配置提示词助手是否可以按需理解用户摄像头画面"
|
||||||
@@ -358,7 +519,14 @@ export function PromptEditor({
|
|||||||
onChange={(toolIds) => updateForm("toolIds", toolIds)}
|
onChange={(toolIds) => updateForm("toolIds", toolIds)}
|
||||||
/>
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
ref={(element) => {
|
||||||
|
sectionRefs.current.interaction = element;
|
||||||
|
}}
|
||||||
|
className="scroll-mt-3 space-y-3"
|
||||||
|
>
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Sparkles size={15} />}
|
icon={<Sparkles size={15} />}
|
||||||
title="交互策略"
|
title="交互策略"
|
||||||
@@ -368,8 +536,12 @@ export function PromptEditor({
|
|||||||
<TurnConfigEditor
|
<TurnConfigEditor
|
||||||
enabled={form.enableInterrupt}
|
enabled={form.enableInterrupt}
|
||||||
config={form.turnConfig}
|
config={form.turnConfig}
|
||||||
onEnabledChange={(checked) => updateForm("enableInterrupt", checked)}
|
onEnabledChange={(checked) =>
|
||||||
onConfigChange={(config) => updateForm("turnConfig", config)}
|
updateForm("enableInterrupt", checked)
|
||||||
|
}
|
||||||
|
onConfigChange={(config) =>
|
||||||
|
updateForm("turnConfig", config)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -377,6 +549,8 @@ export function PromptEditor({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DebugDrawer
|
<DebugDrawer
|
||||||
|
|||||||
Reference in New Issue
Block a user