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:
@@ -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">
|
||||
通过 ASR、LLM 和 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,
|
||||
|
||||
42
src/components/ui/popover.tsx
Normal file
42
src/components/ui/popover.tsx
Normal 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 }
|
||||
Reference in New Issue
Block a user