675 lines
24 KiB
TypeScript
675 lines
24 KiB
TypeScript
"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>
|
||
);
|
||
}
|