Files
ai-video-admin-frontend/src/components/pages/AssistantPage.tsx
Xin Wang 21aef75fb5 Add debug mode functionality to AssistantPage with text and voice testing options
Introduced a DebugDrawer component to allow users to switch between text and voice testing modes. Enhanced the AssistantPage layout for better organization and added a debug interface for testing AI responses. This update improves usability by providing clear options for debugging and testing the assistant's capabilities.
2026-06-07 21:05:52 +08:00

1140 lines
38 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 {
Bot,
Boxes,
Brain,
Check,
Database,
MessageSquareText,
MoreHorizontal,
Pencil,
Plus,
Rocket,
Search,
Sparkles,
Trash2,
Workflow,
ChevronLeft,
ChevronRight,
Save,
Mic,
Volume2,
Send,
MessageCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useState } from "react";
type RuntimeMode = "pipeline" | "realtime";
type AssistantForm = {
name: string;
greeting: string;
prompt: string;
runtimeMode: RuntimeMode;
realtimeModel: string;
model: string;
asr: string;
voice: string;
knowledgeBase: string;
enableInterrupt: boolean;
};
type AssistantType = "提示词" | "工作流" | "Dify" | "FastGPT";
const assistantTypes: AssistantType[] = ["提示词", "工作流", "Dify", "FastGPT"];
type AssistantTypeOption = {
type: AssistantType;
label: string;
description: string;
icon: React.ReactNode;
/** 提示词类型已落地,其余三种暂时显示占位页 */
available: boolean;
};
const assistantTypeOptions: AssistantTypeOption[] = [
{
type: "提示词",
label: "使用提示词构建",
description: "通过提示词、模型与语音快速搭建对话助手,适合大多数场景。",
icon: <MessageSquareText size={20} />,
available: true,
},
{
type: "工作流",
label: "使用工作流构建",
description: "用可视化编排串联多个节点,适合多步骤、带分支的复杂流程。",
icon: <Workflow size={20} />,
available: false,
},
{
type: "Dify",
label: "使用 Dify 构建",
description: "对接 Dify 应用,复用其编排能力与知识库配置。",
icon: <Boxes size={20} />,
available: false,
},
{
type: "FastGPT",
label: "使用 FastGPT 构建",
description: "对接 FastGPT 应用,复用其知识库问答与工作流能力。",
icon: <Database size={20} />,
available: false,
},
];
type AssistantListItem = {
id: string;
name: string;
type: AssistantType;
updatedAt: string;
};
const mockAssistants: AssistantListItem[] = [
{
id: "asst_001",
name: "政务视频咨询助手",
type: "提示词",
updatedAt: "2026-06-05 18:20",
},
{
id: "asst_002",
name: "热线工单辅助助手",
type: "工作流",
updatedAt: "2026-06-04 15:12",
},
{
id: "asst_003",
name: "Dify 知识库问答助手",
type: "Dify",
updatedAt: "2026-06-02 09:48",
},
{
id: "asst_004",
name: "FastGPT 售后咨询助手",
type: "FastGPT",
updatedAt: "2026-05-29 11:06",
},
{
id: "asst_005",
name: "医院挂号问询助手",
type: "提示词",
updatedAt: "2026-05-25 14:30",
},
{
id: "asst_006",
name: "政务办事进度助手",
type: "工作流",
updatedAt: "2026-05-22 10:15",
},
{
id: "asst_007",
name: "Dify 智能客服助手",
type: "Dify",
updatedAt: "2026-05-18 09:02",
},
{
id: "asst_008",
name: "FastGPT 智能催收助手",
type: "FastGPT",
updatedAt: "2026-05-15 17:20",
},
{
id: "asst_009",
name: "高校课程咨询助手",
type: "提示词",
updatedAt: "2026-05-10 12:48",
},
{
id: "asst_010",
name: "企业入驻流程助手",
type: "工作流",
updatedAt: "2026-05-06 08:43",
},
{
id: "asst_011",
name: "Dify 政策助手",
type: "Dify",
updatedAt: "2026-05-01 16:45",
},
{
id: "asst_012",
name: "FastGPT 报修助手",
type: "FastGPT",
updatedAt: "2026-04-28 19:34",
},
];
type DebugMode = "text" | "voice";
type TypeFilter = "全部" | AssistantType;
const typeFilters: TypeFilter[] = ["全部", ...assistantTypes];
export function AssistantPage() {
const [form, setForm] = useState<AssistantForm>({
name: "政务视频咨询助手",
greeting: "您好,我是 AI 视频助手,请问有什么可以帮您?",
prompt:
"你是一名专业的政务视频咨询助手,负责为市民提供政策解读、办事指南和常见问题解答。\n\n请遵循以下原则\n1. 回答准确、简洁,使用通俗易懂的语言\n2. 不确定的信息应明确告知,不编造政策内容\n3. 涉及具体办事流程时,引导用户前往官方渠道核实",
runtimeMode: "pipeline",
realtimeModel: "gpt-realtime-2",
model: "DeepSeek-V3",
asr: "SenseVoice",
voice: "晓宁",
knowledgeBase: "无",
enableInterrupt: true,
});
const [view, setView] = useState<"list" | "choose" | "create" | "placeholder">(
"list",
);
const [searchQuery, setSearchQuery] = useState("");
const [typeFilter, setTypeFilter] = useState<TypeFilter>("全部");
const [currentPage, setCurrentPage] = useState(1);
// 右侧调试 drawer 的当前模式
const [debugMode, setDebugMode] = useState<DebugMode>("text");
// choose 步骤的草稿:名称与已选类型,确认后才决定进入哪个构建页
const [draftName, setDraftName] = useState("");
const [draftType, setDraftType] = useState<AssistantType | null>(null);
function startCreate() {
setDraftName("");
setDraftType(null);
setView("choose");
}
function confirmType() {
if (!draftName.trim() || !draftType) {
return;
}
if (draftType === "提示词") {
// 提示词类型:复用现有创建表单,并把已填的名称带过去
updateForm("name", draftName.trim());
setView("create");
} else {
// 工作流 / Dify / FastGPT暂时显示占位页
setView("placeholder");
}
}
const filteredAssistants = mockAssistants.filter((assistant) => {
if (typeFilter !== "全部" && assistant.type !== typeFilter) {
return false;
}
const keyword = searchQuery.trim().toLowerCase();
if (!keyword) {
return true;
}
return [assistant.name, assistant.id, assistant.type, assistant.updatedAt]
.join(" ")
.toLowerCase()
.includes(keyword);
});
const pageSize = 5;
const totalPages = Math.max(1, Math.ceil(filteredAssistants.length / pageSize));
const safeCurrentPage = Math.min(currentPage, totalPages);
const pageStart = (safeCurrentPage - 1) * pageSize;
const pageEnd = pageStart + pageSize;
const paginatedAssistants = filteredAssistants.slice(pageStart, pageEnd);
function handleSearchChange(value: string) {
setSearchQuery(value);
setCurrentPage(1);
}
function handleFilterChange(filter: TypeFilter) {
setTypeFilter(filter);
setCurrentPage(1);
}
function updateForm<K extends keyof AssistantForm>(
key: K,
value: AssistantForm[K],
) {
setForm((prev) => ({
...prev,
[key]: value,
}));
}
if (view === "list") {
return (
<div className="mx-auto flex w-full max-w-[1440px] flex-col gap-8">
<div className="flex flex-col items-start justify-between gap-5 sm:flex-row sm:gap-6">
<div>
<h1 className="font-display display-lg text-ink"></h1>
<p className="mt-3 max-w-2xl text-[15px] leading-7 text-muted-foreground">
Dify FastGPT
</p>
</div>
<Button
size="lg"
className="w-full shrink-0 gap-2 sm:w-auto"
onClick={startCreate}
>
<Plus size={16} />
</Button>
</div>
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap items-center gap-2">
{typeFilters.map((filter) => (
<Button
key={filter}
variant={filter === typeFilter ? "default" : "outline"}
size="sm"
className={
filter === typeFilter
? "rounded-full"
: "rounded-full border-hairline-strong text-muted-foreground hover:text-foreground"
}
onClick={() => handleFilterChange(filter)}
>
{filter}
</Button>
))}
</div>
<div className="relative w-full lg:w-[320px]">
<Search
size={15}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-soft"
/>
<Input
value={searchQuery}
onChange={(event) => handleSearchChange(event.target.value)}
className="h-10 border-hairline-strong bg-background pl-9 text-sm text-foreground placeholder:text-muted-soft"
placeholder="搜索助手名称、类型或 ID..."
/>
</div>
</div>
<div className="overflow-hidden rounded-xl border border-hairline">
<div className="hidden items-center gap-4 bg-surface-strong/60 px-5 py-3 md:flex">
<div className="caption-label flex-1 text-muted-soft">
</div>
<div className="caption-label w-[110px] text-muted-soft">
</div>
<div className="caption-label w-[150px] text-muted-soft">
</div>
<div className="caption-label w-[116px] text-right text-muted-soft">
</div>
</div>
<div className="divide-y divide-hairline">
{paginatedAssistants.map((assistant) => (
<div
key={assistant.id}
className="flex flex-col gap-3 px-5 py-4 text-sm transition-colors hover:bg-surface-strong/40 md:flex-row md:items-center md:gap-4"
>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-foreground">
{assistant.name}
</div>
<div className="mt-1 text-xs text-muted-soft">
{assistant.id}
</div>
</div>
<div className="md:w-[110px]">
<Badge
variant="secondary"
className="h-6 bg-surface-strong px-3 text-muted-foreground"
>
{assistant.type}
</Badge>
</div>
<div className="text-muted-foreground md:w-[150px]">
{assistant.updatedAt}
</div>
<div className="flex justify-end gap-2 md:w-[116px]">
<Button
variant="outline"
size="sm"
className="gap-1.5 border-hairline-strong text-xs text-muted-foreground hover:text-foreground"
onClick={() => setView("create")}
>
<Pencil size={14} />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon-sm"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
aria-label={`${assistant.name} 更多操作`}
>
<MoreHorizontal size={15} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1"
>
<DropdownMenuItem
variant="destructive"
className="rounded-lg"
>
<Trash2 size={14} />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
{filteredAssistants.length === 0 && (
<div className="px-5 py-12 text-center">
<div className="font-medium text-foreground">
</div>
<div className="mt-2 text-sm text-muted-foreground">
</div>
</div>
)}
</div>
</div>
<div className="mt-5 flex flex-col gap-3 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<div>
{filteredAssistants.length === 0
? "没有数据"
: `显示 ${pageStart + 1}-${Math.min(pageEnd, filteredAssistants.length)} / 共 ${filteredAssistants.length} 个助手`}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon-sm"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
disabled={safeCurrentPage <= 1}
onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}
aria-label="上一页"
>
<ChevronLeft size={15} />
</Button>
{Array.from({ length: totalPages }, (_, index) => index + 1).map(
(page) => (
<Button
key={page}
variant={page === safeCurrentPage ? "default" : "outline"}
size="sm"
className={[
"h-8 min-w-8 px-2",
page === safeCurrentPage
? ""
: "border-hairline-strong text-muted-foreground hover:text-foreground",
].join(" ")}
onClick={() => setCurrentPage(page)}
>
{page}
</Button>
),
)}
<Button
variant="outline"
size="icon-sm"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
disabled={safeCurrentPage >= totalPages}
onClick={() =>
setCurrentPage((page) => Math.min(totalPages, page + 1))
}
aria-label="下一页"
>
<ChevronRight size={15} />
</Button>
</div>
</div>
</section>
</div>
);
}
if (view === "choose") {
const selectedOption = assistantTypeOptions.find(
(option) => option.type === draftType,
);
return (
<div className="mx-auto flex w-full max-w-[920px] flex-col gap-8">
<div className="flex items-start justify-between gap-6">
<div>
<h1 className="font-display display-lg text-ink"></h1>
<p className="mt-3 max-w-2xl text-[15px] leading-7 text-muted-foreground">
</p>
</div>
<Button
variant="outline"
size="lg"
className="shrink-0 gap-2 border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={() => setView("list")}
>
<ChevronLeft size={16} />
</Button>
</div>
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
<label className="block">
<div className="mb-2 text-sm font-medium text-foreground">
</div>
<Input
value={draftName}
autoFocus
onChange={(event) => setDraftName(event.target.value)}
placeholder="请输入助手名称"
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</label>
</section>
<section className="flex flex-col gap-4">
<div className="text-sm font-medium text-foreground"></div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{assistantTypeOptions.map((option) => {
const selected = draftType === option.type;
return (
<button
key={option.type}
type="button"
onClick={() => setDraftType(option.type)}
className={`group relative flex flex-col gap-4 rounded-2xl border bg-card p-5 text-left transition-colors ${
selected
? "border-primary ring-1 ring-primary"
: "border-hairline hover:border-hairline-strong"
}`}
>
<div className="flex items-center justify-between">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-surface-strong text-foreground">
{option.icon}
</div>
{selected ? (
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Check size={14} />
</span>
) : (
!option.available && (
<Badge
variant="secondary"
className="h-6 bg-surface-strong px-3 text-xs text-muted-foreground"
>
线
</Badge>
)
)}
</div>
<div>
<div className="text-base font-medium text-foreground">
{option.label}
</div>
<p className="mt-1.5 text-sm leading-6 text-muted-foreground">
{option.description}
</p>
</div>
</button>
);
})}
</div>
</section>
<div className="flex items-center justify-end gap-3">
<Button
variant="outline"
size="lg"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={() => setView("list")}
>
</Button>
<Button
size="lg"
className="gap-2"
disabled={!draftName.trim() || !draftType}
onClick={confirmType}
>
<Rocket size={16} />
{selectedOption && !selectedOption.available
? "下一步"
: "开始构建"}
</Button>
</div>
</div>
);
}
if (view === "placeholder") {
return (
<div className="mx-auto flex w-full max-w-[1180px] flex-col gap-6">
<div className="flex items-start justify-between gap-6">
<div>
<h1 className="font-display display-lg text-ink">
{draftName.trim() || "创建助手"}
</h1>
<p className="mt-3 max-w-2xl text-[15px] leading-7 text-muted-foreground">
{draftType}
</p>
</div>
<Button
variant="outline"
size="lg"
className="shrink-0 gap-2 border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={() => setView("list")}
>
<ChevronLeft size={16} />
</Button>
</div>
<section className="relative overflow-hidden rounded-3xl border border-hairline bg-canvas-soft px-10 py-20 text-center">
<div
aria-hidden
className="pointer-events-none absolute -right-24 top-0 h-72 w-72 rounded-full opacity-50 blur-3xl"
style={{
backgroundImage:
"radial-gradient(circle, color-mix(in srgb, var(--gradient-sky) 50%, transparent), transparent 70%)",
}}
/>
<div
aria-hidden
className="pointer-events-none absolute -left-20 bottom-0 h-64 w-64 rounded-full opacity-45 blur-3xl"
style={{
backgroundImage:
"radial-gradient(circle, color-mix(in srgb, var(--gradient-lavender) 50%, transparent), transparent 70%)",
}}
/>
<div className="relative">
<div className="caption-label inline-flex rounded-full bg-surface-strong px-3 py-1 text-muted-foreground">
</div>
<p className="font-display display-sm mx-auto mt-5 max-w-md text-ink">
{draftType}
</p>
<p className="mx-auto mt-3 max-w-md text-sm leading-7 text-body">
</p>
</div>
</section>
</div>
);
}
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>
<div className="flex shrink-0 gap-2">
<Button
variant="outline"
className="gap-2 border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={() => setView("list")}
>
<ChevronLeft size={16} />
</Button>
<Button className="gap-2">
<Save size={16} />
</Button>
</div>
</div>
<div className="flex min-h-0 flex-1 gap-6">
<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"
onClick={() => updateForm("runtimeMode", "pipeline")}
className={[
"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 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} />
</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">
<Check size={14} />
</span>
)}
</div>
<p className="text-sm leading-6 text-muted-foreground">
ASRLLM TTS 线
</p>
</button>
<button
type="button"
onClick={() => updateForm("runtimeMode", "realtime")}
className={[
"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 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} />
</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">
<Check size={14} />
</span>
)}
</div>
<p className="text-sm leading-6 text-muted-foreground">
使
</p>
</button>
</div>
</SectionCard>
<SectionCard
icon={<Bot 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}
onChange={(value) => updateForm("prompt", value)}
placeholder="请输入提示词,描述助手的角色、能力和回答要求"
rows={8}
/>
</SectionCard>
{form.runtimeMode === "realtime" && (
<SectionCard
icon={<Brain size={18} />}
title="Realtime 配置"
description="当前模式下 ASR 与 TTS 由 Realtime 模型内置完成"
>
<SelectField
label="Realtime 模型"
value={form.realtimeModel}
onChange={(value) => updateForm("realtimeModel", value)}
options={["gpt-realtime-2", "gpt-realtime", "gpt-4o-realtime-preview"]}
/>
</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={<Database size={18} />}
title="知识库配置"
description="选择助手回答时可检索的业务知识来源"
>
<SelectField
label="知识库"
value={form.knowledgeBase}
onChange={(value) => updateForm("knowledgeBase", value)}
options={["无", "政务政策知识库", "售后知识库", "教育课程知识库", "医疗问答知识库"]}
/>
</SectionCard>
<SectionCard
icon={<Sparkles size={18} />}
title="交互策略"
description="设置实时视频对话时的交互体验"
>
<ToggleRow
title="允许用户打断"
description="用户说话时,助手可以停止当前播报并重新理解用户输入"
checked={form.enableInterrupt}
onChange={(checked) => updateForm("enableInterrupt", checked)}
/>
</SectionCard>
</div>
<DebugDrawer mode={debugMode} onModeChange={setDebugMode} />
</div>
</div>
);
}
function DebugDrawer({
mode,
onModeChange,
}: {
mode: DebugMode;
onModeChange: (mode: DebugMode) => void;
}) {
const modeTabs: { key: DebugMode; label: string; icon: React.ReactNode }[] = [
{ key: "text", label: "文字测试", icon: <MessageCircle size={15} /> },
{ key: "voice", label: "语音测试", icon: <Mic size={15} /> },
];
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="flex gap-1 rounded-full bg-surface-strong p-1">
{modeTabs.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => onModeChange(tab.key)}
className={[
"flex flex-1 items-center justify-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-colors",
mode === tab.key
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
].join(" ")}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
</div>
{mode === "text" ? <DebugTextPanel /> : <DebugVoicePanel />}
</aside>
);
}
function DebugTextPanel() {
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">
<div className="flex max-w-[85%] flex-col gap-1">
<div className="rounded-2xl rounded-tl-sm bg-surface-strong px-4 py-2.5 text-sm leading-6 text-foreground">
AI
</div>
<span className="px-1 text-xs text-muted-soft"></span>
</div>
<div className="flex max-w-[85%] flex-col items-end gap-1 self-end">
<div className="rounded-2xl rounded-tr-sm bg-primary px-4 py-2.5 text-sm leading-6 text-primary-foreground">
</div>
<span className="px-1 text-xs text-muted-soft"></span>
</div>
<div className="flex max-w-[85%] flex-col gap-1">
<div className="rounded-2xl rounded-tl-sm bg-surface-strong px-4 py-2.5 text-sm leading-6 text-foreground">
线线线 App
线
</div>
<span className="px-1 text-xs text-muted-soft"></span>
</div>
</div>
<div className="shrink-0 border-t border-hairline p-3">
<div className="flex items-end gap-2">
<Textarea
rows={1}
placeholder="输入测试消息…"
className="max-h-28 min-h-10 flex-1 resize-none border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
/>
<Button size="icon" className="shrink-0" aria-label="发送">
<Send size={16} />
</Button>
</div>
</div>
</div>
);
}
function DebugVoicePanel() {
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-6 p-6 text-center">
<button
type="button"
className="flex h-24 w-24 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm transition-transform hover:scale-105"
aria-label="开始语音测试"
>
<Mic size={32} />
</button>
<div>
<div className="text-sm font-medium text-foreground"></div>
<p className="mt-1.5 text-sm leading-6 text-muted-foreground">
</p>
</div>
<div className="w-full rounded-xl border border-hairline bg-canvas-soft p-4 text-left">
<div className="caption-label mb-2 text-muted-soft"></div>
<p className="text-sm leading-6 text-muted-soft"></p>
</div>
</div>
);
}
function SectionCard({
icon,
title,
description,
children,
}: {
icon?: React.ReactNode;
title?: string;
description?: string;
children: React.ReactNode;
}) {
const hasHeader = Boolean(title);
return (
<Card className="rounded-2xl border-hairline bg-card text-card-foreground shadow-sm">
{hasHeader && (
<CardHeader>
<div className="flex items-start gap-3">
{icon && (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-surface-strong text-foreground">
{icon}
</div>
)}
<div>
<CardTitle className="text-base font-medium">{title}</CardTitle>
{description && (
<CardDescription className="mt-1 text-muted-foreground">
{description}
</CardDescription>
)}
</div>
</div>
</CardHeader>
)}
<CardContent className={hasHeader ? "space-y-4" : undefined}>
{children}
</CardContent>
</Card>
);
}
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,
placeholder,
rows = 4,
onChange,
}: {
label: string;
value: string;
placeholder?: string;
rows?: number;
onChange: (value: string) => void;
}) {
return (
<label className="block">
<div className="mb-2 text-sm font-medium text-foreground">{label}</div>
<Textarea
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
rows={rows}
className="resize-none border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</label>
);
}
function SelectField({
label,
value,
options,
onChange,
}: {
label: string;
value: string;
options: string[];
onChange: (value: string) => void;
}) {
return (
<div className="block">
<div className="mb-2 text-sm font-medium text-foreground">{label}</div>
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="w-full border-hairline-strong bg-background text-foreground">
<SelectValue placeholder={`请选择${label}`} />
</SelectTrigger>
<SelectContent className="border-hairline bg-popover text-popover-foreground">
{options.map((item) => (
<SelectItem key={item} value={item}>
{item}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
function ToggleRow({
title,
description,
checked,
onChange,
}: {
title: string;
description: string;
checked: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<div className="flex items-center justify-between rounded-xl border border-hairline bg-canvas-soft p-4">
<div>
<div className="font-medium text-foreground">{title}</div>
<div className="mt-1 text-sm text-muted-foreground">{description}</div>
</div>
<Switch checked={checked} onCheckedChange={onChange} />
</div>
);
}