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,227 +262,295 @@ 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">
<SectionCard <nav
icon={<MessageSquareText size={15} />} aria-label="提示词配置分区"
title="提示词" className="shrink-0 overflow-x-auto"
description="描述助手的角色、能力和回答要求"
> >
<TextAreaField <div className="grid min-w-[22rem] grid-cols-4 border-b border-hairline">
value={form.prompt} {promptSections.map((section) => {
onChange={(value) => updateForm("prompt", value)} const active = activeSection === section.id;
placeholder="请输入提示词,描述助手的角色、能力和回答要求"
rows={8}
/>
<DynamicVariableEditorHint
count={Object.keys(dynamicVariableDefinitions).length}
onOpen={() => setDynamicVariablesOpen(true)}
/>
</SectionCard>
<SectionCard return (
icon={<Bot size={15} />} <button
title="开场白" key={section.id}
description="助手与用户首次对话时的开场语" 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 <section
value={form.greeting} ref={(element) => {
onChange={(value) => updateForm("greeting", value)} sectionRefs.current.conversation = element;
placeholder="请输入助手开场白" }}
/> className="scroll-mt-3 space-y-3"
<DynamicVariableEditorHint >
count={Object.keys(dynamicVariableDefinitions).length} <SectionCard
onOpen={() => setDynamicVariablesOpen(true)} icon={<MessageSquareText size={15} />}
/> title="提示词"
{form.runtimeMode === "pipeline" && ( description="描述助手的角色、能力和回答要求"
<div className="mt-4 space-y-3 border-t border-hairline pt-4"> >
<ToggleRow <TextAreaField
title="开场确认弹窗" value={form.prompt}
description="与开场白同时显示,用户确认后即可开始对话,不等待播报完成。" onChange={(value) => updateForm("prompt", value)}
checked={Boolean(openingMessage)} placeholder="请输入提示词,描述助手的角色、能力和回答要求"
onChange={setOpeningMessage} rows={8}
/> />
<DynamicVariableEditorHint
count={Object.keys(dynamicVariableDefinitions).length}
onOpen={() => setDynamicVariablesOpen(true)}
/>
</SectionCard>
{openingMessage && ( <SectionCard
<div className="space-y-3 rounded-xl border border-hairline bg-canvas-soft p-4"> icon={<Bot size={15} />}
<label className="block"> title="开场白"
<span className="mb-1.5 block text-sm font-medium text-foreground"> description="助手与用户首次对话时的开场语"
>
</span> <TextAreaField
<Input value={form.greeting}
value={openingMessage.title} onChange={(value) => updateForm("greeting", value)}
onChange={(event) => placeholder="请输入助手开场白"
updateOpeningMessage({ title: event.target.value }) />
} <DynamicVariableEditorHint
placeholder="重要提示" count={Object.keys(dynamicVariableDefinitions).length}
className="border-hairline-strong bg-background" onOpen={() => setDynamicVariablesOpen(true)}
/> />
</label> {form.runtimeMode === "pipeline" && (
<TextAreaField <div className="mt-4 space-y-3 border-t border-hairline pt-4">
label="重要信息" <ToggleRow
value={openingMessage.message} title="开场确认弹窗"
onChange={(message) => updateOpeningMessage({ message })} description="与开场白同时显示,用户确认后即可开始对话,不等待播报完成。"
placeholder="请输入需要用户确认的重要信息" checked={Boolean(openingMessage)}
rows={4} onChange={setOpeningMessage}
/> />
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-foreground"> {openingMessage && (
<div className="space-y-3 rounded-xl border border-hairline bg-canvas-soft p-4">
</span> <label className="block">
<Input <span className="mb-1.5 block text-sm font-medium text-foreground">
value={openingMessage.confirmLabel}
onChange={(event) => </span>
updateOpeningMessage({ <Input
confirmLabel: event.target.value, value={openingMessage.title}
}) onChange={(event) =>
} updateOpeningMessage({ title: event.target.value })
placeholder="确认" }
className="border-hairline-strong bg-background" placeholder="重要提示"
/> className="border-hairline-strong bg-background"
</label> />
<p className="text-xs leading-5 text-muted-foreground"> </label>
<TextAreaField
label="重要信息"
</p> 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>
)} )}
</div> </SectionCard>
)} </section>
</SectionCard>
<SectionCard <section
icon={<Brain size={15} />} ref={(element) => {
title="模型与语音" sectionRefs.current.models = element;
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,
});
}
}} }}
/> className="scroll-mt-3 space-y-3"
{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="选择助手回答时可检索的业务知识来源"
> >
<div className="flex items-center gap-1.5"> <SectionCard
<span className="text-sm font-medium text-foreground"> icon={<Brain size={15} />}
title="模型与语音"
</span> description={
<KnowledgeRetrievalConfigDialog form.runtimeMode === "pipeline"
disabled={!form.knowledgeBase} ? "选择运行方式,以及大语言模型、语音识别与语音合成资源"
value={form.knowledgeRetrievalConfig} : "选择运行方式Realtime 模型内置语音识别与语音合成"
onChange={(config) => }
updateForm("knowledgeRetrievalConfig", config) >
<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 {form.runtimeMode === "pipeline" && (
icon={<Wrench size={15} />} <SectionCard
title="工具" icon={<Database size={15} />}
description="配置该提示词助手可以调用的工具" title="知识库配置"
> description="选择助手回答时可检索的业务知识来源"
<ToolPicker >
tools={tools.filter((tool) => tool.status === "active")} <div className="flex items-center gap-1.5">
selectedIds={form.toolIds} <span className="text-sm font-medium text-foreground">
onChange={(toolIds) => updateForm("toolIds", toolIds)}
/> </span>
</SectionCard> <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 <SectionCard
icon={<Sparkles size={15} />} icon={<Wrench size={15} />}
title="交互策略" title="工具"
description="设置实时视频对话时的交互体验" description="配置该提示词助手可以调用的工具"
> >
{form.runtimeMode === "pipeline" ? ( <ToolPicker
<TurnConfigEditor tools={tools.filter((tool) => tool.status === "active")}
enabled={form.enableInterrupt} selectedIds={form.toolIds}
config={form.turnConfig} onChange={(toolIds) => updateForm("toolIds", toolIds)}
onEnabledChange={(checked) => updateForm("enableInterrupt", checked)} />
onConfigChange={(config) => updateForm("turnConfig", config)} </SectionCard>
/> </section>
) : (
<p className="text-sm text-muted-foreground"> <section
Pipeline ref={(element) => {
</p> sectionRefs.current.interaction = element;
)} }}
</SectionCard> 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> </div>
<DebugDrawer <DebugDrawer