Refactor AssistantPage and DebugDrawer components
- Remove debug mode state management from AssistantPage, simplifying the component structure. - Update DebugDrawer to eliminate mode selection, focusing on voice interaction features. - Enhance the VoiceVisualizer component with improved visual effects and responsiveness to audio input. - Adjust styles and layout for better user experience in the debugging interface.
This commit is contained in:
@@ -23,13 +23,12 @@ import {
|
||||
Save,
|
||||
Mic,
|
||||
Send,
|
||||
MessageCircle,
|
||||
HelpCircle,
|
||||
Waypoints,
|
||||
AudioLines,
|
||||
Square,
|
||||
Terminal,
|
||||
Loader2,
|
||||
PhoneOff,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -201,8 +200,6 @@ type AssistantListItem = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type DebugMode = "text" | "voice";
|
||||
|
||||
type TypeFilter = "全部" | AssistantType;
|
||||
|
||||
const typeFilters: TypeFilter[] = ["全部", ...assistantTypes];
|
||||
@@ -271,8 +268,6 @@ export function AssistantPage() {
|
||||
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);
|
||||
@@ -1229,7 +1224,7 @@ export function AssistantPage() {
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<DebugDrawer mode={debugMode} onModeChange={setDebugMode} />
|
||||
<DebugDrawer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1333,7 +1328,7 @@ export function AssistantPage() {
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<DebugDrawer mode={debugMode} onModeChange={setDebugMode} />
|
||||
<DebugDrawer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1451,7 +1446,7 @@ export function AssistantPage() {
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<DebugDrawer mode={debugMode} onModeChange={setDebugMode} />
|
||||
<DebugDrawer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1661,88 +1656,121 @@ export function AssistantPage() {
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<DebugDrawer mode={debugMode} onModeChange={setDebugMode} />
|
||||
<DebugDrawer />
|
||||
</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} /> },
|
||||
];
|
||||
function DebugDrawer() {
|
||||
const [showTranscript, setShowTranscript] = useState(false);
|
||||
|
||||
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 className="flex shrink-0 items-center justify-between gap-3 border-b border-hairline px-5 py-4">
|
||||
<div className="text-sm font-medium text-foreground">调试与预览</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant={showTranscript ? "default" : "outline"}
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full text-xs font-medium"
|
||||
onClick={() => setShowTranscript((value) => !value)}
|
||||
aria-label={showTranscript ? "显示音频可视化" : "显示文字聊天记录"}
|
||||
aria-pressed={showTranscript}
|
||||
>
|
||||
文
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{mode === "text" ? <DebugTextPanel /> : <DebugVoicePanel />}
|
||||
<DebugVoicePanel showTranscript={showTranscript} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function DebugTextPanel() {
|
||||
function DebugVoicePanel({ showTranscript }: { showTranscript: boolean }) {
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [micError, setMicError] = useState(false);
|
||||
|
||||
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>
|
||||
{showTranscript ? (
|
||||
<DebugTranscriptPanel />
|
||||
) : (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col items-center justify-center overflow-y-auto px-5 py-3 text-center">
|
||||
<div
|
||||
className="pointer-events-none absolute left-1/2 top-2 h-72 w-72 -translate-x-1/2 rounded-full opacity-50 blur-3xl"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle, color-mix(in srgb, var(--gradient-sky) 42%, transparent), color-mix(in srgb, var(--gradient-lavender) 18%, transparent) 48%, transparent 74%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="relative gap-1.5 rounded-full border border-hairline bg-canvas-soft px-3 py-1 text-[11px] font-medium text-muted-foreground shadow-none"
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
"h-1.5 w-1.5 rounded-full",
|
||||
recording ? "animate-pulse bg-success" : "bg-muted-soft",
|
||||
].join(" ")}
|
||||
/>
|
||||
{recording ? "会话进行中" : "准备开始"}
|
||||
</Badge>
|
||||
|
||||
<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>
|
||||
<VoiceVisualizer
|
||||
active={recording}
|
||||
size={220}
|
||||
barCount={96}
|
||||
className="relative -my-2 shrink-0"
|
||||
onError={() => {
|
||||
setMicError(true);
|
||||
setRecording(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="shrink-0 border-t border-hairline p-3">
|
||||
<div className="relative -mt-1">
|
||||
<div className="font-display text-xl text-foreground">
|
||||
{recording ? "我在聆听" : "开始一次语音对话"}
|
||||
</div>
|
||||
<p className="mx-auto mt-1 max-w-xs text-xs leading-5 text-muted-foreground">
|
||||
{micError
|
||||
? "无法访问麦克风,请检查浏览器权限后重试。"
|
||||
: recording
|
||||
? "直接说话即可。助手会在您停顿后自然回应。"
|
||||
: "测试语音识别、响应速度与助手的播报效果。"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-3 flex items-center gap-3">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setMicError(false);
|
||||
setRecording((value) => !value);
|
||||
}}
|
||||
size="icon"
|
||||
className={[
|
||||
"h-12 w-12 rounded-full shadow-md transition-transform hover:scale-105",
|
||||
recording
|
||||
? "bg-destructive text-white hover:bg-destructive/90"
|
||||
: "",
|
||||
].join(" ")}
|
||||
aria-label={recording ? "结束语音测试" : "开始语音测试"}
|
||||
>
|
||||
{recording ? <PhoneOff size={20} /> : <Mic size={21} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="shrink-0 border-t border-hairline bg-card 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"
|
||||
placeholder="输入文字以模拟用户消息…"
|
||||
className="max-h-24 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="发送">
|
||||
<Button size="icon" className="shrink-0" aria-label="发送调试消息">
|
||||
<Send size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1751,54 +1779,31 @@ function DebugTextPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
function DebugVoicePanel() {
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [micError, setMicError] = useState(false);
|
||||
|
||||
function DebugTranscriptPanel() {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-6 p-6 text-center">
|
||||
<VoiceVisualizer
|
||||
active={recording}
|
||||
size={200}
|
||||
onError={() => {
|
||||
setMicError(true);
|
||||
setRecording(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{recording ? "正在聆听…" : "点击开始语音测试"}
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-5 py-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex max-w-[88%] flex-col gap-1 self-start">
|
||||
<span className="px-1 text-[11px] text-muted-soft">助手 · 10:24</span>
|
||||
<div className="rounded-2xl rounded-tl-sm bg-surface-strong px-4 py-2.5 text-sm leading-6 text-foreground">
|
||||
您好,我是 AI 视频助手,请问有什么可以帮您?
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1.5 text-sm leading-6 text-muted-foreground">
|
||||
{micError
|
||||
? "无法访问麦克风,请检查浏览器权限后重试。"
|
||||
: "点击下方按钮开始录音,实时识别结果将显示在下方。"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
setMicError(false);
|
||||
setRecording((value) => !value);
|
||||
}}
|
||||
variant={recording ? "outline" : "default"}
|
||||
className={[
|
||||
"gap-2",
|
||||
recording
|
||||
? "border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
: "",
|
||||
].join(" ")}
|
||||
>
|
||||
{recording ? <Square size={16} /> : <Mic size={16} />}
|
||||
{recording ? "结束录音" : "开始语音测试"}
|
||||
</Button>
|
||||
<div className="flex max-w-[88%] flex-col items-end gap-1 self-end">
|
||||
<span className="px-1 text-[11px] text-muted-soft">我 · 10:25</span>
|
||||
<div className="rounded-2xl rounded-tr-sm bg-primary px-4 py-2.5 text-sm leading-6 text-primary-foreground">
|
||||
我想了解一下社保卡的办理流程。
|
||||
</div>
|
||||
</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">
|
||||
{recording ? "聆听中,请开始说话…" : "等待语音输入…"}
|
||||
</p>
|
||||
<div className="flex max-w-[88%] flex-col gap-1 self-start">
|
||||
<span className="px-1 text-[11px] text-muted-soft">助手 · 10:25</span>
|
||||
<div className="rounded-2xl rounded-tl-sm bg-surface-strong px-4 py-2.5 text-sm leading-6 text-foreground">
|
||||
社保卡可通过线上或线下渠道办理。线上可在政务服务 App
|
||||
提交申请,线下可前往社保经办网点。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -150,8 +150,8 @@ export function VoiceVisualizer({
|
||||
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const orbR = size * 0.2;
|
||||
const maxBar = size * 0.18;
|
||||
const orbR = size * 0.18;
|
||||
const maxBar = size * 0.15;
|
||||
const freq = new Uint8Array(128);
|
||||
|
||||
let raf = 0;
|
||||
@@ -201,38 +201,82 @@ export function VoiceVisualizer({
|
||||
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
const breathe = 0.5 + 0.5 * Math.sin(t * 1.4);
|
||||
const pulse = 1 + energy * 0.12 + breathe * 0.02;
|
||||
const pulse = 1 + energy * 0.2 + breathe * 0.035;
|
||||
|
||||
// 外层光晕
|
||||
// 整体环境光,让频谱像悬浮在空间中。
|
||||
const glow = ctx.createRadialGradient(
|
||||
cx,
|
||||
cy,
|
||||
orbR * 0.3,
|
||||
orbR * 0.1,
|
||||
cx,
|
||||
cy,
|
||||
size * 0.5,
|
||||
);
|
||||
glow.addColorStop(0, rgba(sky, 0.18 + energy * 0.35));
|
||||
glow.addColorStop(0.55, rgba(lav, 0.08 + energy * 0.18));
|
||||
glow.addColorStop(0, rgba(sky, 0.24 + energy * 0.42));
|
||||
glow.addColorStop(0.38, rgba(lav, 0.12 + energy * 0.2));
|
||||
glow.addColorStop(0.72, rgba(rose, 0.04 + energy * 0.08));
|
||||
glow.addColorStop(1, rgba(lav, 0));
|
||||
ctx.fillStyle = glow;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
|
||||
// 环形频谱柱
|
||||
// 远景漂浮粒子,旋转速度很慢,提供空间纵深。
|
||||
for (let i = 0; i < 18; i++) {
|
||||
const phase = i * 2.399 + t * (0.035 + (i % 4) * 0.008);
|
||||
const radius = size * (0.31 + (i % 6) * 0.026);
|
||||
const drift = Math.sin(t * 0.45 + i * 1.7) * size * 0.018;
|
||||
const x = cx + Math.cos(phase) * (radius + drift);
|
||||
const y = cy + Math.sin(phase) * (radius + drift);
|
||||
const color = cyclicColor(palette, i / 18);
|
||||
const particleR = size * (0.004 + (i % 3) * 0.002);
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, particleR, 0, Math.PI * 2);
|
||||
ctx.fillStyle = rgba(color, 0.13 + energy * 0.28);
|
||||
ctx.shadowColor = rgba(color, 0.55);
|
||||
ctx.shadowBlur = 5 + energy * 10;
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// 两条不完整的轨道弧光,制造环绕与旋转感。
|
||||
ctx.lineWidth = Math.max(0.8, size * 0.005);
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineWidth = Math.max(2, size * 0.012);
|
||||
const rotation = t * 0.15;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const radius = size * (0.32 + i * 0.07);
|
||||
const start = t * (i === 0 ? 0.12 : -0.08) + i * Math.PI;
|
||||
const arc = Math.PI * (0.55 + i * 0.18);
|
||||
const orbit = ctx.createLinearGradient(
|
||||
cx - radius,
|
||||
cy,
|
||||
cx + radius,
|
||||
cy,
|
||||
);
|
||||
orbit.addColorStop(0, rgba(lav, 0));
|
||||
orbit.addColorStop(0.5, rgba(i === 0 ? sky : rose, 0.22 + energy * 0.3));
|
||||
orbit.addColorStop(1, rgba(lav, 0));
|
||||
ctx.strokeStyle = orbit;
|
||||
ctx.shadowColor = rgba(i === 0 ? sky : rose, 0.4);
|
||||
ctx.shadowBlur = 7;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, start, start + arc);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// 外层频谱环:更细、更轻,负责表现扩散。
|
||||
const rotation = t * 0.12;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineWidth = Math.max(1.2, size * 0.007);
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const angle = (i / barCount) * Math.PI * 2 + rotation;
|
||||
const v = smooth[i];
|
||||
const r0 = orbR * pulse + size * 0.03;
|
||||
const r1 = r0 + maxBar * v + 1.5;
|
||||
const r0 = size * 0.285 + energy * size * 0.025;
|
||||
const r1 = r0 + maxBar * (0.2 + v * 0.95);
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
const color = cyclicColor(palette, i / barCount);
|
||||
ctx.strokeStyle = rgba(color, 0.55 + v * 0.4);
|
||||
ctx.strokeStyle = rgba(color, 0.3 + v * 0.55);
|
||||
ctx.shadowColor = rgba(color, 0.8);
|
||||
ctx.shadowBlur = 6 + v * 16;
|
||||
ctx.shadowBlur = 5 + v * 18;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + cos * r0, cy + sin * r0);
|
||||
ctx.lineTo(cx + cos * r1, cy + sin * r1);
|
||||
@@ -240,16 +284,53 @@ export function VoiceVisualizer({
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// 中心柔光:随音量发亮,不画实体球
|
||||
// 内层频谱环:反向旋转,形成更有机的声场边缘。
|
||||
ctx.lineWidth = Math.max(1.5, size * 0.009);
|
||||
for (let i = 0; i < barCount; i += 2) {
|
||||
const angle = (i / barCount) * Math.PI * 2 - t * 0.09;
|
||||
const v = smooth[(i + Math.floor(barCount / 3)) % barCount];
|
||||
const r0 = orbR * pulse + size * 0.018;
|
||||
const r1 = r0 + size * (0.018 + v * 0.055);
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
const color = cyclicColor(palette, 1 - i / barCount);
|
||||
ctx.strokeStyle = rgba(color, 0.38 + v * 0.5);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + cos * r0, cy + sin * r0);
|
||||
ctx.lineTo(cx + cos * r1, cy + sin * r1);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 中心声场核心:深色边缘包住高亮中心,音量越高越通透。
|
||||
const coreR = orbR * pulse;
|
||||
const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, coreR);
|
||||
core.addColorStop(0, rgba(sky, 0.22 + energy * 0.4));
|
||||
core.addColorStop(0.6, rgba(lav, 0.06 + energy * 0.15));
|
||||
core.addColorStop(1, rgba(lav, 0));
|
||||
core.addColorStop(0, rgba(sky, 0.42 + energy * 0.45));
|
||||
core.addColorStop(0.28, rgba(lav, 0.2 + energy * 0.28));
|
||||
core.addColorStop(0.68, rgba(rose, 0.08 + energy * 0.16));
|
||||
core.addColorStop(1, rgba(lav, 0.01));
|
||||
ctx.shadowColor = rgba(sky, 0.5 + energy * 0.35);
|
||||
ctx.shadowBlur = 18 + energy * 35;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, coreR, 0, Math.PI * 2);
|
||||
ctx.fillStyle = core;
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// 核心表面的柔和高光,让它不只是一个平面渐变。
|
||||
const highlight = ctx.createRadialGradient(
|
||||
cx - coreR * 0.28,
|
||||
cy - coreR * 0.32,
|
||||
0,
|
||||
cx - coreR * 0.15,
|
||||
cy - coreR * 0.18,
|
||||
coreR * 0.85,
|
||||
);
|
||||
highlight.addColorStop(0, rgba(sky, 0.22 + energy * 0.22));
|
||||
highlight.addColorStop(1, rgba(sky, 0));
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, coreR * 0.96, 0, Math.PI * 2);
|
||||
ctx.fillStyle = highlight;
|
||||
ctx.fill();
|
||||
|
||||
raf = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user