Files
ai-video-fullstack/frontend/src/components/assistant-editor/prompt-editor.tsx
2026-08-04 13:29:21 +08:00

675 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useRef, useState } from "react";
import {
Braces,
Bot,
Brain,
Database,
Loader2,
MessageSquareText,
Save,
Sparkles,
Wrench,
} from "lucide-react";
import { DebugDrawer } from "@/components/assistant-editor/debug-preview";
import {
DynamicVariableEditorHint,
DynamicVariablesSection,
} from "@/components/assistant-editor/dynamic-variables";
import {
AssistantIdentity,
EditableTitle,
EditorBackButton,
KnowledgeRetrievalConfigDialog,
ResourceSelectField,
RuntimeModeSelector,
TextAreaField,
ToolPicker,
} from "@/components/assistant-editor/editor-controls";
import type { AssistantForm } from "@/components/assistant-editor/types";
import { SectionCard } from "@/components/editor/section-card";
import { VisionConfigSection } from "@/components/editor/vision-config-section";
import { TurnConfigEditor } from "@/components/turn-config-editor";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type {
DynamicVariableDefinition,
StartupConfig,
Tool,
} from "@/lib/api";
type ResourceOption = { value: string; label: string };
type PromptOpeningMode = StartupConfig["openingMode"];
type PromptEntryMode = StartupConfig["entryMode"];
const OPENING_MODE_OPTIONS: Array<{
value: PromptOpeningMode;
label: string;
description: string;
}> = [
{
value: "interruptible",
label: "可打断",
description: "用户说话、发文字或图片时立即结束开场白,并保留这次输入。",
},
{
value: "playback",
label: "播完继续",
description: "开场白播放期间关闭输入,实际播放完成后进入 Agent。",
},
{
value: "confirmation",
label: "需要弹窗确认继续",
description: "关闭对话输入并显示弹窗,用户确认后立即进入 Agent。",
},
];
const ENTRY_MODE_OPTIONS: Array<{
value: PromptEntryMode;
label: string;
}> = [
{ value: "wait_user", label: "等待下一轮用户输入" },
{ value: "generate", label: "进入后立即回复" },
];
const promptSections = [
{ id: "conversation", label: "对话内容" },
{ id: "models", label: "模型与语音" },
{ id: "capabilities", label: "知识与工具" },
{ id: "interaction", label: "交互策略" },
{ id: "variables", label: "动态变量" },
] as const;
type PromptSectionId = (typeof promptSections)[number]["id"];
type PromptEditorProps = {
assistantId: string | null;
form: AssistantForm;
dynamicVariableDefinitions: Record<string, DynamicVariableDefinition>;
saving: boolean;
dirty: boolean;
saveError: string | null;
llmOptions: ResourceOption[];
asrOptions: ResourceOption[];
ttsOptions: ResourceOption[];
realtimeOptions: ResourceOption[];
visionModelOptions: ResourceOption[];
knowledgeOptions: ResourceOption[];
tools: Tool[];
onBack: () => void;
onSave: () => void;
updateForm: <K extends keyof AssistantForm>(
key: K,
value: AssistantForm[K],
) => void;
handlePromptVisionEnabledChange: (enabled: boolean) => void;
handlePromptModelChange: (value: string) => void;
};
export function PromptEditor({
assistantId,
form,
dynamicVariableDefinitions,
saving,
dirty,
saveError,
llmOptions,
asrOptions,
ttsOptions,
realtimeOptions,
visionModelOptions,
knowledgeOptions: kbOptions,
tools,
onBack,
onSave,
updateForm,
handlePromptVisionEnabledChange,
handlePromptModelChange,
}: PromptEditorProps) {
const openingMessage = form.startup.openingMessage;
const openingMode = form.startup.openingMode;
const openingModeDescription = OPENING_MODE_OPTIONS.find(
(option) => option.value === openingMode,
)?.description;
const scrollContainerRef = useRef<HTMLDivElement>(null);
const sectionRefs = useRef<Record<PromptSectionId, HTMLElement | null>>({
conversation: null,
models: null,
capabilities: null,
interaction: null,
variables: 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 setOpeningMode(nextMode: PromptOpeningMode) {
updateForm("startup", {
...form.startup,
openingMode: nextMode,
openingMessage: nextMode === "confirmation"
? {
title: openingMessage?.title ?? "重要提示",
message: openingMessage?.message ?? "请确认已阅读以上信息。",
confirmLabel: openingMessage?.confirmLabel ?? "确认",
}
: null,
});
}
function updateOpeningMessage(
patch: Partial<NonNullable<typeof openingMessage>>,
) {
if (!openingMessage) return;
updateForm("startup", {
...form.startup,
openingMessage: { ...openingMessage, ...patch },
});
}
return (
<div className="-mt-6 flex h-full flex-col gap-4 overflow-hidden bg-background">
<div className="flex shrink-0 items-center justify-between gap-6 border-b border-hairline pb-3 pt-1">
<div className="flex min-w-0 items-center gap-2">
<EditorBackButton onClick={onBack} />
<EditableTitle
value={form.name}
onChange={(value) => updateForm("name", value)}
/>
<AssistantIdentity assistantId={assistantId} />
</div>
<div className="flex shrink-0 gap-2">
{saveError && (
<span className="self-center text-xs text-destructive">
{saveError}
</span>
)}
<Button
className="gap-2"
disabled={saving || !dirty || !form.name.trim()}
onClick={() => onSave()}
>
{saving ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Save size={16} />
)}
</Button>
</div>
</div>
<div className="flex min-h-0 flex-1 gap-4">
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
<nav
aria-label="提示词配置分区"
className="shrink-0 overflow-x-auto overscroll-none"
>
<div className="grid min-w-[28rem] grid-cols-5 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 overscroll-none bg-background pr-1"
>
<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={() => scrollToSection("variables")}
/>
</SectionCard>
<SectionCard
icon={<Bot size={15} />}
title="开场白"
description="助手与用户首次对话时的开场语"
>
<TextAreaField
value={form.greeting}
onChange={(value) => updateForm("greeting", value)}
placeholder="请输入助手开场白"
/>
<DynamicVariableEditorHint
count={Object.keys(dynamicVariableDefinitions).length}
onOpen={() => scrollToSection("variables")}
/>
{form.runtimeMode === "pipeline" && (
<div className="mt-4 space-y-3 border-t border-hairline pt-4">
<div>
<div className="mb-1.5 text-sm font-medium text-foreground">
</div>
<Select
value={openingMode}
onValueChange={(value: PromptOpeningMode) =>
setOpeningMode(value)
}
>
<SelectTrigger className="w-full border-hairline-strong bg-background text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPENING_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
{openingModeDescription}
</p>
</div>
{openingMode === "confirmation" && 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">
Agent
</p>
</div>
)}
<div className="border-t border-hairline pt-3">
<div className="mb-1.5 text-sm font-medium text-foreground">
Agent
</div>
<Select
value={form.startup.entryMode}
onValueChange={(entryMode: PromptEntryMode) =>
updateForm("startup", {
...form.startup,
entryMode,
})
}
>
<SelectTrigger className="w-full border-hairline-strong bg-background text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ENTRY_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
Agent
</p>
</div>
</div>
)}
</SectionCard>
</section>
<section
ref={(element) => {
sectionRefs.current.models = element;
}}
className="scroll-mt-3 space-y-3"
>
<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.openingMode !== "interruptible" ||
form.startup.entryMode !== "wait_user" ||
form.startup.openingMessage)
) {
updateForm("startup", {
executionMode: "sequential",
openingMode: "interruptible",
entryMode: "wait_user",
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)
}
/>
)}
{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={<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>
<section
ref={(element) => {
sectionRefs.current.variables = element;
}}
className="scroll-mt-3 space-y-3"
>
<SectionCard
icon={<Braces size={15} />}
title="动态变量"
description="编辑提示词和开场白中引用的自定义变量"
>
<DynamicVariablesSection
definitions={dynamicVariableDefinitions}
onChange={(dynamicVariableDefinitions) =>
updateForm(
"dynamicVariableDefinitions",
dynamicVariableDefinitions,
)
}
/>
</SectionCard>
</section>
</div>
</div>
<DebugDrawer
assistantId={assistantId}
hasUnsavedChanges={dirty}
vision={form.visionEnabled}
dynamicVariablesEnabled
dynamicVariableDefinitions={dynamicVariableDefinitions}
/>
</div>
</div>
);
}