Enhance AssistantPage layout with new popover component and improved button accessibility

Updated the AssistantPage to include a new Popover component for better user interaction. Enhanced button accessibility by adding keyboard navigation support and improved visual elements for runtime mode selection. Refined the layout for clearer presentation of assistant configuration options, including updated icons and descriptions.
This commit is contained in:
Xin Wang
2026-06-07 22:05:18 +08:00
parent 38880f89bf
commit 4988b1c5fb
2 changed files with 229 additions and 121 deletions

View File

@@ -19,9 +19,11 @@ import {
ChevronRight,
Save,
Mic,
Volume2,
Send,
MessageCircle,
HelpCircle,
Waypoints,
AudioLines,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -42,14 +44,18 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
type RuntimeMode = "pipeline" | "realtime";
@@ -675,11 +681,11 @@ export function AssistantPage() {
return (
<div className="-mt-6 flex h-full flex-col gap-4">
<div className="flex shrink-0 items-center justify-between gap-6 border-b border-hairline pb-3 pt-1">
<div className="flex items-baseline gap-3">
<h1 className="font-display display-sm text-ink"></h1>
<p className="hidden text-sm text-muted-foreground lg:block">
AI
</p>
<div className="flex min-w-0 items-center gap-2">
<EditableTitle
value={form.name}
onChange={(value) => updateForm("name", value)}
/>
</div>
<div className="flex shrink-0 gap-2">
@@ -703,22 +709,32 @@ export function AssistantPage() {
<div className="min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
<SectionCard>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<button
type="button"
<div
role="button"
tabIndex={0}
onClick={() => updateForm("runtimeMode", "pipeline")}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
updateForm("runtimeMode", "pipeline");
}
}}
className={[
"rounded-2xl border p-5 text-left transition-colors",
"cursor-pointer rounded-2xl border p-5 text-left transition-colors",
form.runtimeMode === "pipeline"
? "border-primary bg-primary/5 ring-1 ring-primary"
: "border-hairline bg-canvas-soft hover:border-hairline-strong",
].join(" ")}
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
<Boxes size={18} />
<Waypoints size={18} />
</div>
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">Pipeline </span>
<HelpHint text="通过 ASR、LLM 和 TTS 级联组成语音管线,灵活选配各模块。" />
</div>
<div className="font-medium text-foreground">Pipeline </div>
</div>
{form.runtimeMode === "pipeline" && (
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
@@ -726,27 +742,34 @@ export function AssistantPage() {
</span>
)}
</div>
<p className="text-sm leading-6 text-muted-foreground">
ASRLLM TTS 线
</p>
</button>
</div>
<button
type="button"
<div
role="button"
tabIndex={0}
onClick={() => updateForm("runtimeMode", "realtime")}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
updateForm("runtimeMode", "realtime");
}
}}
className={[
"rounded-2xl border p-5 text-left transition-colors",
"cursor-pointer rounded-2xl border p-5 text-left transition-colors",
form.runtimeMode === "realtime"
? "border-primary bg-primary/5 ring-1 ring-primary"
: "border-hairline bg-canvas-soft hover:border-hairline-strong",
].join(" ")}
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
<MessageSquareText size={18} />
<AudioLines size={18} />
</div>
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">Realtime </span>
<HelpHint text="使用原生实时语音模型,模型直接处理音频输入并生成语音回复。" />
</div>
<div className="font-medium text-foreground">Realtime </div>
</div>
{form.runtimeMode === "realtime" && (
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
@@ -754,32 +777,15 @@ export function AssistantPage() {
</span>
)}
</div>
<p className="text-sm leading-6 text-muted-foreground">
使
</p>
</button>
</div>
</div>
</SectionCard>
<SectionCard
icon={<Bot size={18} />}
title="基础信息"
description="定义助手名称、开场白和提示词"
icon={<MessageSquareText size={18} />}
title="提示词"
description="描述助手的角色、能力和回答要求"
>
<TextField
label="助手名称"
value={form.name}
onChange={(value) => updateForm("name", value)}
placeholder="请输入助手名称"
/>
<TextAreaField
label="开场白"
value={form.greeting}
onChange={(value) => updateForm("greeting", value)}
placeholder="请输入助手开场白"
/>
<TextAreaField
label="提示词"
value={form.prompt}
@@ -789,10 +795,35 @@ export function AssistantPage() {
/>
</SectionCard>
{form.runtimeMode === "realtime" && (
{form.runtimeMode === "pipeline" ? (
<SectionCard
icon={<Brain size={18} />}
title="Realtime 配置"
title="模型配置"
description="配置语音管线的大语言模型、语音识别与播报音色"
>
<SelectField
label="大语言模型"
value={form.model}
onChange={(value) => updateForm("model", value)}
options={["DeepSeek-V3", "Qwen-Max", "Kimi-K2", "Doubao-Pro", "GPT-4o"]}
/>
<SelectField
label="语音识别"
value={form.asr}
onChange={(value) => updateForm("asr", value)}
options={["SenseVoice", "Paraformer", "Whisper", "FunASR"]}
/>
<SelectField
label="播报声音"
value={form.voice}
onChange={(value) => updateForm("voice", value)}
options={["晓宁", "晓美", "晓宇", "晓晨"]}
/>
</SectionCard>
) : (
<SectionCard
icon={<Brain size={18} />}
title="模型配置"
description="当前模式下 ASR 与 TTS 由 Realtime 模型内置完成"
>
<SelectField
@@ -804,48 +835,18 @@ export function AssistantPage() {
</SectionCard>
)}
{form.runtimeMode === "pipeline" && (
<>
<SectionCard
icon={<Brain size={18} />}
title="LLM 配置"
description="选择驱动对话理解与生成的大语言模型"
>
<SelectField
label="大语言模型"
value={form.model}
onChange={(value) => updateForm("model", value)}
options={["DeepSeek-V3", "Qwen-Max", "Kimi-K2", "Doubao-Pro", "GPT-4o"]}
/>
</SectionCard>
<SectionCard
icon={<Mic size={18} />}
title="ASR 配置"
description="选择将用户语音转为文本的识别引擎"
>
<SelectField
label="语音识别"
value={form.asr}
onChange={(value) => updateForm("asr", value)}
options={["SenseVoice", "Paraformer", "Whisper", "FunASR"]}
/>
</SectionCard>
<SectionCard
icon={<Volume2 size={18} />}
title="TTS 配置"
description="选择助手播报回复时使用的合成音色"
>
<SelectField
label="播报声音"
value={form.voice}
onChange={(value) => updateForm("voice", value)}
options={["晓宁", "晓美", "晓宇", "晓晨"]}
/>
</SectionCard>
</>
)}
<SectionCard
icon={<Bot size={18} />}
title="开场白"
description="助手与用户首次对话时的开场语"
>
<TextAreaField
label="开场白"
value={form.greeting}
onChange={(value) => updateForm("greeting", value)}
placeholder="请输入助手开场白"
/>
</SectionCard>
<SectionCard
icon={<Database size={18} />}
@@ -895,7 +896,7 @@ function DebugDrawer({
return (
<aside className="hidden min-w-0 flex-1 flex-col overflow-hidden rounded-2xl border border-hairline bg-card shadow-sm lg:flex">
<div className="shrink-0 border-b border-hairline p-4">
<div className="mb-3 text-sm font-medium text-foreground"></div>
<div className="mb-3 text-sm font-medium text-foreground"></div>
<div className="flex gap-1 rounded-full bg-surface-strong p-1">
{modeTabs.map((tab) => (
<button
@@ -990,6 +991,99 @@ function DebugVoicePanel() {
);
}
function EditableTitle({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editing) {
inputRef.current?.focus();
inputRef.current?.select();
}
}, [editing]);
function startEdit() {
setDraft(value);
setEditing(true);
}
function commit() {
const next = draft.trim();
if (next) {
onChange(next);
}
setEditing(false);
}
if (editing) {
return (
<input
ref={inputRef}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commit();
} else if (event.key === "Escape") {
event.preventDefault();
setEditing(false);
}
}}
className="font-display display-sm w-[min(60vw,420px)] border-b border-primary bg-transparent text-ink outline-none"
/>
);
}
return (
<button
type="button"
onClick={startEdit}
title="点击修改助手名称"
className="group -mx-2 flex min-w-0 items-center gap-2 rounded-lg px-2 py-1 text-left transition-colors hover:bg-surface-strong"
>
<span className="font-display display-sm truncate text-ink">
{value || "未命名助手"}
</span>
<Pencil
size={16}
className="shrink-0 text-muted-soft opacity-0 transition-opacity group-hover:opacity-100"
/>
</button>
);
}
function HelpHint({ text }: { text: string }) {
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label="查看说明"
onClick={(event) => event.stopPropagation()}
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
>
<HelpCircle size={14} />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-72 text-sm leading-6 text-muted-foreground"
>
{text}
</PopoverContent>
</Popover>
);
}
function SectionCard({
icon,
title,
@@ -1007,20 +1101,16 @@ function SectionCard({
<Card className="rounded-2xl border-hairline bg-card text-card-foreground shadow-sm">
{hasHeader && (
<CardHeader>
<div className="flex items-start gap-3">
<div className="flex items-center gap-3">
{icon && (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-surface-strong text-foreground">
{icon}
</div>
)}
<div>
<div className="flex items-center gap-1.5">
<CardTitle className="text-base font-medium">{title}</CardTitle>
{description && (
<CardDescription className="mt-1 text-muted-foreground">
{description}
</CardDescription>
)}
{description && <HelpHint text={description} />}
</div>
</div>
</CardHeader>
@@ -1033,30 +1123,6 @@ function SectionCard({
);
}
function TextField({
label,
value,
placeholder,
onChange,
}: {
label: string;
value: string;
placeholder?: string;
onChange: (value: string) => void;
}) {
return (
<label className="block">
<div className="mb-2 text-sm font-medium text-foreground">{label}</div>
<Input
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</label>
);
}
function TextAreaField({
label,
value,

View File

@@ -0,0 +1,42 @@
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 6,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-xl border border-hairline bg-popover p-4 text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
export { Popover, PopoverTrigger, PopoverContent }