feat(frontend): add top prompt anchor navigation

This commit is contained in:
Xin Wang
2026-08-03 09:56:03 +08:00
parent 02c88b36e6
commit 807afb3099

View File

@@ -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