feat(frontend): compact theme selector, adaptive prompt editor layout

- Theme dropdown: 亮色/暗色/随系统 as one row of three icons
- Prompt editor: sections centered (max-w-7xl) when debug drawer is
  closed, full-width aligned with tab edges when open
- Debug drawer opens by default
- Prompt textarea: 8 → 16 rows

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Xin Wang
2026-08-05 08:54:24 +08:00
co-authored by Claude
parent b88e37789a
commit bee1e97cdb
2 changed files with 382 additions and 367 deletions
@@ -43,11 +43,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import type { import type { DynamicVariableDefinition, StartupConfig, Tool } from "@/lib/api";
DynamicVariableDefinition,
StartupConfig,
Tool,
} from "@/lib/api";
type ResourceOption = { value: string; label: string }; type ResourceOption = { value: string; label: string };
type PromptOpeningMode = StartupConfig["openingMode"]; type PromptOpeningMode = StartupConfig["openingMode"];
@@ -153,7 +149,7 @@ export function PromptEditor({
const selectedAnchorRef = useRef<PromptSectionId | null>(null); const selectedAnchorRef = useRef<PromptSectionId | null>(null);
const [activeSection, setActiveSection] = const [activeSection, setActiveSection] =
useState<PromptSectionId>("conversation"); useState<PromptSectionId>("conversation");
const [debugOpen, setDebugOpen] = useState(false); const [debugOpen, setDebugOpen] = useState(true);
useEffect(() => { useEffect(() => {
const container = scrollContainerRef.current; const container = scrollContainerRef.current;
@@ -245,13 +241,14 @@ export function PromptEditor({
updateForm("startup", { updateForm("startup", {
...form.startup, ...form.startup,
openingMode: nextMode, openingMode: nextMode,
openingMessage: nextMode === "confirmation" openingMessage:
? { nextMode === "confirmation"
title: openingMessage?.title ?? "重要提示", ? {
message: openingMessage?.message ?? "请确认已阅读以上信息。", title: openingMessage?.title ?? "重要提示",
confirmLabel: openingMessage?.confirmLabel ?? "确认", message: openingMessage?.message ?? "请确认已阅读以上信息。",
} confirmLabel: openingMessage?.confirmLabel ?? "确认",
: null, }
: null,
}); });
} }
@@ -322,377 +319,395 @@ export function PromptEditor({
aria-label="提示词配置分区" aria-label="提示词配置分区"
className="shrink-0 overflow-x-auto overscroll-none bg-background px-4 pt-3 sm:px-6 sm:pt-4 lg:px-8" className="shrink-0 overflow-x-auto overscroll-none bg-background px-4 pt-3 sm:px-6 sm:pt-4 lg:px-8"
> >
<div className="grid min-w-[28rem] grid-cols-5 border-b border-hairline"> <div className={debugOpen ? "w-full" : "mx-auto w-full max-w-7xl"}>
{promptSections.map((section) => { <div className="grid min-w-[28rem] grid-cols-5 border-b border-hairline">
const active = activeSection === section.id; {promptSections.map((section) => {
const active = activeSection === section.id;
return ( return (
<button <button
key={section.id} key={section.id}
type="button" type="button"
aria-current={active ? "location" : undefined} aria-current={active ? "location" : undefined}
onClick={() => scrollToSection(section.id)} onClick={() => scrollToSection(section.id)}
className={[ 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", "-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 active
? "border-primary text-foreground" ? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:bg-surface-strong/50 hover:text-foreground", : "border-transparent text-muted-foreground hover:bg-surface-strong/50 hover:text-foreground",
].join(" ")} ].join(" ")}
> >
{section.label} {section.label}
</button> </button>
); );
})} })}
</div>
</div> </div>
</nav> </nav>
<div <div
ref={scrollContainerRef} ref={scrollContainerRef}
className="scrollbar-subtle min-h-0 min-w-0 flex-1 space-y-3 overflow-y-auto overscroll-none bg-background px-4 pb-6 pt-3 sm:px-6 sm:pb-8 lg:px-8 lg:pb-10" className="scrollbar-subtle min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-none bg-background px-4 pb-6 pt-3 sm:px-6 sm:pb-8 lg:px-8 lg:pb-10"
> >
<section <div
ref={(element) => { className={
sectionRefs.current.conversation = element; debugOpen ? "space-y-3" : "mx-auto max-w-7xl space-y-3"
}} }
className="scroll-mt-3 space-y-3"
> >
<SectionCard <section
icon={<MessageSquareText size={15} />} ref={(element) => {
title="提示词" sectionRefs.current.conversation = element;
description="描述助手的角色、能力和回答要求" }}
className="scroll-mt-3 space-y-3"
> >
<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,
});
}
if (runtimeMode === "realtime") {
updateForm(
"toolIds",
form.toolIds.filter(
(id) =>
tools.find((tool) => tool.id === id)?.type !==
"system",
),
);
}
}}
/>
{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 <SectionCard
icon={<Database size={15} />} icon={<MessageSquareText size={15} />}
title="知识库配置" title="提示词"
description="选择助手回答时可检索的业务知识来源" description="描述助手的角色、能力和回答要求"
> >
<div className="flex items-center gap-1.5"> <TextAreaField
<span className="text-sm font-medium text-foreground"> value={form.prompt}
知识库选择 onChange={(value) => updateForm("prompt", value)}
</span> placeholder="请输入提示词,描述助手的角色、能力和回答要求"
<KnowledgeRetrievalConfigDialog rows={16}
disabled={!form.knowledgeBase} />
value={form.knowledgeRetrievalConfig} <DynamicVariableEditorHint
onChange={(config) => count={Object.keys(dynamicVariableDefinitions).length}
updateForm("knowledgeRetrievalConfig", config) onOpen={() => scrollToSection("variables")}
}
/>
</div>
<ResourceSelectField
value={form.knowledgeBase}
onChange={(value) => updateForm("knowledgeBase", value)}
options={kbOptions}
noneLabel="无"
/> />
</SectionCard> </SectionCard>
)}
<SectionCard <SectionCard
icon={<Wrench size={15} />} icon={<Bot size={15} />}
title="工具" title="开场白"
description="配置该提示词助手可以调用的工具" description="助手与用户首次对话时的开场语"
> >
<ToolPicker <TextAreaField
tools={tools.filter( value={form.greeting}
(tool) => onChange={(value) => updateForm("greeting", value)}
tool.status === "active" && placeholder="请输入助手开场白"
(form.runtimeMode === "pipeline" || tool.type !== "system"), />
<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>
)} )}
selectedIds={form.toolIds} </SectionCard>
onChange={(toolIds) => updateForm("toolIds", toolIds)} </section>
/>
</SectionCard>
</section>
<section <section
ref={(element) => { ref={(element) => {
sectionRefs.current.interaction = element; sectionRefs.current.models = element;
}} }}
className="scroll-mt-3 space-y-3" className="scroll-mt-3 space-y-3"
>
<SectionCard
icon={<Sparkles size={15} />}
title="交互策略"
description="设置实时视频对话时的交互体验"
> >
{form.runtimeMode === "pipeline" ? ( <SectionCard
<TurnConfigEditor icon={<Brain size={15} />}
enabled={form.enableInterrupt} title="模型与语音"
config={form.turnConfig} description={
onEnabledChange={(checked) => form.runtimeMode === "pipeline"
updateForm("enableInterrupt", checked) ? "选择运行方式,以及大语言模型、语音识别与语音合成资源"
} : "选择运行方式;Realtime 模型内置语音识别与语音合成"
onConfigChange={(config) => }
updateForm("turnConfig", config) >
<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,
});
}
if (runtimeMode === "realtime") {
updateForm(
"toolIds",
form.toolIds.filter(
(id) =>
tools.find((tool) => tool.id === id)?.type !==
"system",
),
);
}
}}
/>
{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)
} }
/> />
) : (
<p className="text-sm text-muted-foreground">
高级抢话与轮次检测当前仅支持 Pipeline 模式。
</p>
)} )}
</SectionCard>
</section>
<section {form.runtimeMode === "pipeline" && (
ref={(element) => { <SectionCard
sectionRefs.current.variables = element; icon={<Database size={15} />}
}} title="知识库配置"
className="scroll-mt-3 space-y-3" description="选择助手回答时可检索的业务知识来源"
> >
<SectionCard <div className="flex items-center gap-1.5">
icon={<Braces size={15} />} <span className="text-sm font-medium text-foreground">
title="动态变量" 知识库选择
description="编辑提示词和开场白中引用的自定义变量" </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" &&
(form.runtimeMode === "pipeline" ||
tool.type !== "system"),
)}
selectedIds={form.toolIds}
onChange={(toolIds) => updateForm("toolIds", toolIds)}
/>
</SectionCard>
</section>
<section
ref={(element) => {
sectionRefs.current.interaction = element;
}}
className="scroll-mt-3 space-y-3"
> >
<DynamicVariablesSection <SectionCard
definitions={dynamicVariableDefinitions} icon={<Sparkles size={15} />}
onChange={(dynamicVariableDefinitions) => title="交互策略"
updateForm( description="设置实时视频对话时的交互体验"
"dynamicVariableDefinitions", >
dynamicVariableDefinitions, {form.runtimeMode === "pipeline" ? (
) <TurnConfigEditor
} enabled={form.enableInterrupt}
/> config={form.turnConfig}
</SectionCard> onEnabledChange={(checked) =>
</section> 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> </div>
</div> </div>
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Check, Laptop, Moon, Sun } from "lucide-react"; import { Laptop, Moon, Sun } from "lucide-react";
export type ThemeMode = "light" | "dark" | "system"; export type ThemeMode = "light" | "dark" | "system";
@@ -60,7 +60,7 @@ export function ThemeModeSelector() {
<div <div
role="radiogroup" role="radiogroup"
aria-label="界面主题" aria-label="界面主题"
className="space-y-1" className="grid grid-cols-3 gap-1"
> >
{themeOptions.map((option) => { {themeOptions.map((option) => {
const Icon = option.icon; const Icon = option.icon;
@@ -72,17 +72,17 @@ export function ThemeModeSelector() {
type="button" type="button"
role="radio" role="radio"
aria-checked={selected} aria-checked={selected}
aria-label={option.label}
title={option.label}
onClick={() => selectMode(option.value)} onClick={() => selectMode(option.value)}
className={[ className={[
"flex h-10 w-full items-center gap-2.5 rounded-xl px-3 text-sm font-normal transition-colors", "flex h-9 items-center justify-center rounded-xl transition-colors",
selected selected
? "bg-surface-strong text-foreground shadow-sm" ? "bg-surface-strong text-foreground shadow-sm"
: "text-muted-foreground hover:bg-surface-strong/60 hover:text-foreground", : "text-muted-foreground hover:bg-surface-strong/60 hover:text-foreground",
].join(" ")} ].join(" ")}
> >
<Icon size={16} /> <Icon size={16} />
<span className="min-w-0 flex-1 text-left">{option.label}</span>
{selected && <Check size={15} className="shrink-0" />}
</button> </button>
); );
})} })}