From 807afb3099035cadd8872e5d60f560ec4764f8cf Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Mon, 3 Aug 2026 09:56:03 +0800 Subject: [PATCH] feat(frontend): add top prompt anchor navigation --- .../assistant-editor/prompt-editor.tsx | 586 ++++++++++++------ 1 file changed, 380 insertions(+), 206 deletions(-) diff --git a/frontend/src/components/assistant-editor/prompt-editor.tsx b/frontend/src/components/assistant-editor/prompt-editor.tsx index bd1fa8a..f93f9b3 100644 --- a/frontend/src/components/assistant-editor/prompt-editor.tsx +++ b/frontend/src/components/assistant-editor/prompt-editor.tsx @@ -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(null); + const sectionRefs = useRef>({ + conversation: null, + models: null, + capabilities: null, + interaction: null, + }); + const selectedAnchorRef = useRef(null); + const [activeSection, setActiveSection] = + useState("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({ />
-
- } - title="提示词" - description="描述助手的角色、能力和回答要求" +
+ + +
- updateForm("greeting", value)} - placeholder="请输入助手开场白" - /> - setDynamicVariablesOpen(true)} - /> - {form.runtimeMode === "pipeline" && ( -
- { + sectionRefs.current.conversation = element; + }} + className="scroll-mt-3 space-y-3" + > + } + title="提示词" + description="描述助手的角色、能力和回答要求" + > + updateForm("prompt", value)} + placeholder="请输入提示词,描述助手的角色、能力和回答要求" + rows={8} /> + setDynamicVariablesOpen(true)} + /> + - {openingMessage && ( -
- - updateOpeningMessage({ message })} - placeholder="请输入需要用户确认的重要信息" - rows={4} + } + title="开场白" + description="助手与用户首次对话时的开场语" + > + updateForm("greeting", value)} + placeholder="请输入助手开场白" + /> + setDynamicVariablesOpen(true)} + /> + {form.runtimeMode === "pipeline" && ( +
+ - -

- 弹窗不可跳过;失败时将结束本次通话。这是平台内置开场阶段, - 不会作为工具暴露给模型。 -

+ + {openingMessage && ( +
+ + + updateOpeningMessage({ message }) + } + placeholder="请输入需要用户确认的重要信息" + rows={4} + /> + +

+ 弹窗不可跳过;失败时将结束本次通话。这是平台内置开场阶段, + 不会作为工具暴露给模型。 +

+
+ )}
)} -
- )} - + + - } - title="模型与语音" - description={ - form.runtimeMode === "pipeline" - ? "选择运行方式,以及大语言模型、语音识别与语音合成资源" - : "选择运行方式;Realtime 模型内置语音识别与语音合成" - } - > - { - updateForm("runtimeMode", runtimeMode); - if ( - runtimeMode === "realtime" && - (form.startup.actions.length || form.startup.openingMessage) - ) { - updateForm("startup", { - executionMode: "sequential", - actions: [], - openingMessage: null, - }); - } +
{ + sectionRefs.current.models = element; }} - /> - - {form.runtimeMode === "pipeline" ? ( - <> - - updateForm("asr", value)} - options={asrOptions} - noneLabel="无" - /> - updateForm("voice", value)} - options={ttsOptions} - noneLabel="无" - /> - - ) : ( - updateForm("realtimeModel", value)} - options={realtimeOptions} - noneLabel="无" - /> - )} - - - {form.runtimeMode === "pipeline" && ( - - updateForm("visionModelResourceId", value) - } - /> - )} - - {form.runtimeMode === "pipeline" && ( - } - title="知识库配置" - description="选择助手回答时可检索的业务知识来源" + className="scroll-mt-3 space-y-3" > -
- - 知识库选择 - - - updateForm("knowledgeRetrievalConfig", config) + } + title="模型与语音" + description={ + form.runtimeMode === "pipeline" + ? "选择运行方式,以及大语言模型、语音识别与语音合成资源" + : "选择运行方式;Realtime 模型内置语音识别与语音合成" + } + > + { + updateForm("runtimeMode", runtimeMode); + if ( + runtimeMode === "realtime" && + (form.startup.actions.length || + form.startup.openingMessage) + ) { + updateForm("startup", { + executionMode: "sequential", + actions: [], + openingMessage: null, + }); + } + }} + /> + + {form.runtimeMode === "pipeline" ? ( + <> + + updateForm("asr", value)} + options={asrOptions} + noneLabel="无" + /> + updateForm("voice", value)} + options={ttsOptions} + noneLabel="无" + /> + + ) : ( + updateForm("realtimeModel", value)} + options={realtimeOptions} + noneLabel="无" + /> + )} + +
+ +
{ + sectionRefs.current.capabilities = element; + }} + className="scroll-mt-3 space-y-3" + > + {form.runtimeMode === "pipeline" && ( + + updateForm("visionModelResourceId", value) } /> -
- updateForm("knowledgeBase", value)} - options={kbOptions} - noneLabel="无" - /> - - )} + )} - } - title="工具" - description="配置该提示词助手可以调用的工具" - > - tool.status === "active")} - selectedIds={form.toolIds} - onChange={(toolIds) => updateForm("toolIds", toolIds)} - /> - + {form.runtimeMode === "pipeline" && ( + } + title="知识库配置" + description="选择助手回答时可检索的业务知识来源" + > +
+ + 知识库选择 + + + updateForm("knowledgeRetrievalConfig", config) + } + /> +
+ updateForm("knowledgeBase", value)} + options={kbOptions} + noneLabel="无" + /> +
+ )} - } - title="交互策略" - description="设置实时视频对话时的交互体验" - > - {form.runtimeMode === "pipeline" ? ( - updateForm("enableInterrupt", checked)} - onConfigChange={(config) => updateForm("turnConfig", config)} - /> - ) : ( -

- 高级抢话与轮次检测当前仅支持 Pipeline 模式。 -

- )} -
+ } + title="工具" + description="配置该提示词助手可以调用的工具" + > + tool.status === "active")} + selectedIds={form.toolIds} + onChange={(toolIds) => updateForm("toolIds", toolIds)} + /> + + + +
{ + sectionRefs.current.interaction = element; + }} + className="scroll-mt-3 space-y-3" + > + } + title="交互策略" + description="设置实时视频对话时的交互体验" + > + {form.runtimeMode === "pipeline" ? ( + + updateForm("enableInterrupt", checked) + } + onConfigChange={(config) => + updateForm("turnConfig", config) + } + /> + ) : ( +

+ 高级抢话与轮次检测当前仅支持 Pipeline 模式。 +

+ )} +
+
+