Enhance voice interaction features and introduce voice preview functionality

- Update README to reflect the integration of the DebugVoicePanel with WebSocket support for voice interactions.
- Refactor voice_webrtc.py to improve error handling during WebRTC signaling and include assistant_id in the offer payload.
- Add useVoicePreview hook to manage microphone access and WebRTC connections for real-time voice previews.
- Modify AssistantPage to incorporate new visualizer options and pass assistantId to DebugVoicePanel, enhancing user experience during audio interactions.
- Update API model to include new fields for voice, speed, and language, supporting TTS and ASR configurations.
This commit is contained in:
Xin Wang
2026-06-10 10:17:46 +08:00
parent c839779d87
commit ac3f4dd806
5 changed files with 419 additions and 71 deletions

View File

@@ -57,6 +57,7 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { AuraVisualizer } from "@/components/ui/aura-visualizer";
import { NebulaVisualizer } from "@/components/ui/nebula-visualizer";
import { SpectrumVisualizer } from "@/components/ui/spectrum-visualizer";
import { WaveVisualizer } from "@/components/ui/wave-visualizer";
import {
@@ -76,6 +77,7 @@ import {
type Credential,
type KnowledgeBase,
} from "@/lib/api";
import { useVoicePreview } from "@/hooks/use-voice-preview";
type RuntimeMode = "pipeline" | "realtime";
@@ -425,7 +427,6 @@ export function AssistantPage() {
appId: "",
apiUrl: "",
apiKey: "",
model: "",
asr: "",
voice: "",
enableInterrupt: true,
@@ -455,6 +456,7 @@ export function AssistantPage() {
prompt: "",
apiUrl: "",
apiKey: "",
model: "",
asr: "",
voice: "",
enableInterrupt: true,
@@ -549,7 +551,6 @@ export function AssistantPage() {
apiUrl: a.apiUrl,
// 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key
apiKey: "",
model: a.llmCredentialId ?? "",
asr: a.asrCredentialId ?? "",
voice: a.ttsCredentialId ?? "",
enableInterrupt: a.enableInterrupt,
@@ -607,6 +608,7 @@ export function AssistantPage() {
apiUrl: a.apiUrl,
// 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key
apiKey: "",
model: a.llmCredentialId ?? "",
asr: a.asrCredentialId ?? "",
voice: a.ttsCredentialId ?? "",
enableInterrupt: a.enableInterrupt,
@@ -1229,7 +1231,7 @@ export function AssistantPage() {
</SectionCard>
</div>
<DebugDrawer />
<DebugDrawer assistantId={editingId} />
</div>
</div>
);
@@ -1334,7 +1336,7 @@ export function AssistantPage() {
</SectionCard>
</div>
<DebugDrawer />
<DebugDrawer assistantId={editingId} />
</div>
</div>
);
@@ -1453,7 +1455,7 @@ export function AssistantPage() {
</SectionCard>
</div>
<DebugDrawer />
<DebugDrawer assistantId={editingId} />
</div>
</div>
);
@@ -1664,71 +1666,117 @@ export function AssistantPage() {
</SectionCard>
</div>
<DebugDrawer />
<DebugDrawer assistantId={editingId} />
</div>
</div>
);
}
type VizStyle = "aura" | "bars" | "wave";
type VizStyle = "aura" | "nebula" | "bars" | "wave";
const VIZ_ORDER: VizStyle[] = ["aura", "bars", "wave"];
const VIZ_LABEL: Record<VizStyle, string> = {
aura: "光环",
bars: "频谱",
wave: "波形",
};
const VIZ_OPTIONS: { style: VizStyle; label: string; icon: React.ReactNode }[] =
[
{ style: "aura", label: "光环", icon: <Orbit size={14} /> },
{ style: "nebula", label: "星云", icon: <Sparkles size={14} /> },
{ style: "bars", label: "频谱", icon: <AudioLines size={14} /> },
{ style: "wave", label: "波形", icon: <Waves size={14} /> },
];
function DebugDrawer() {
function SegmentedIconGroup({
children,
label,
}: {
children: React.ReactNode;
label: string;
}) {
return (
<div
role="group"
aria-label={label}
className="flex items-center gap-0.5 rounded-full border border-hairline bg-canvas-soft p-0.5"
>
{children}
</div>
);
}
function SegmentedIconButton({
selected,
label,
onClick,
children,
}: {
selected: boolean;
label: string;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
aria-pressed={selected}
title={label}
className={[
"flex h-7 w-7 items-center justify-center rounded-full transition-colors",
selected
? "bg-surface-strong text-foreground shadow-sm"
: "text-muted-soft hover:text-foreground",
].join(" ")}
>
{children}
</button>
);
}
function DebugDrawer({ assistantId }: { assistantId: string | null }) {
const [showTranscript, setShowTranscript] = useState(false);
const [vizStyle, setVizStyle] = useState<VizStyle>("wave");
const [vizStyle, setVizStyle] = useState<VizStyle>("aura");
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="flex shrink-0 items-center justify-between gap-3 border-b border-hairline px-5 py-4">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-hairline px-5 py-3">
<div className="text-sm font-medium text-foreground"></div>
<div className="flex items-center gap-2">
{!showTranscript && (
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() =>
setVizStyle(
(value) =>
VIZ_ORDER[
(VIZ_ORDER.indexOf(value) + 1) % VIZ_ORDER.length
],
)
}
aria-label={`切换可视化样式(当前:${VIZ_LABEL[vizStyle]}`}
title={`可视化:${VIZ_LABEL[vizStyle]}`}
>
{vizStyle === "aura" ? (
<Orbit size={16} />
) : vizStyle === "bars" ? (
<AudioLines size={16} />
) : (
<Waves size={16} />
)}
</Button>
<SegmentedIconGroup label="可视化样式">
{VIZ_OPTIONS.map((option) => (
<SegmentedIconButton
key={option.style}
selected={vizStyle === option.style}
label={`可视化样式:${option.label}`}
onClick={() => setVizStyle(option.style)}
>
{option.icon}
</SegmentedIconButton>
))}
</SegmentedIconGroup>
)}
<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>
<SegmentedIconGroup label="预览视图">
<SegmentedIconButton
selected={!showTranscript}
label="语音可视化视图"
onClick={() => setShowTranscript(false)}
>
<Mic size={14} />
</SegmentedIconButton>
<SegmentedIconButton
selected={showTranscript}
label="文字聊天记录视图"
onClick={() => setShowTranscript(true)}
>
<MessageSquareText size={14} />
</SegmentedIconButton>
</SegmentedIconGroup>
</div>
</div>
<DebugVoicePanel showTranscript={showTranscript} vizStyle={vizStyle} />
<DebugVoicePanel
showTranscript={showTranscript}
vizStyle={vizStyle}
assistantId={assistantId}
/>
</aside>
);
}
@@ -1736,15 +1784,22 @@ function DebugDrawer() {
function DebugVoicePanel({
showTranscript,
vizStyle,
assistantId,
}: {
showTranscript: boolean;
vizStyle: VizStyle;
assistantId: string | null;
}) {
const [recording, setRecording] = useState(false);
const [micError, setMicError] = useState(false);
const { status, error, localStream, connect, disconnect, audioRef } =
useVoicePreview(assistantId, { onMicError: () => setMicError(true) });
// 连接中或已连通都视作"会话进行中"
const recording = status === "connecting" || status === "connected";
return (
<div className="flex min-h-0 flex-1 flex-col">
{/* 后端 TTS 音频经 WebRTC 媒体流过来,挂这里播放 */}
<audio ref={audioRef} autoPlay playsInline className="hidden" />
{showTranscript ? (
<DebugTranscriptPanel />
) : (
@@ -1774,40 +1829,55 @@ function DebugVoicePanel({
{(() => {
const onVizError = () => {
setMicError(true);
setRecording(false);
disconnect();
};
const shared = {
active: recording,
active: Boolean(localStream),
stream: localStream,
className: "relative shrink-0",
onError: onVizError,
} as const;
if (vizStyle === "aura")
return <AuraVisualizer {...shared} size={200} />;
if (vizStyle === "nebula")
return <NebulaVisualizer {...shared} size={200} />;
if (vizStyle === "bars")
return (
<SpectrumVisualizer {...shared} size={200} barCount={64} />
);
return <SpectrumVisualizer {...shared} size={200} />;
return <WaveVisualizer {...shared} size={200} />;
})()}
</div>
<div className="relative max-w-xs space-y-1.5">
<div className="font-display display-sm text-foreground">
{recording ? "我在聆听" : "开始一次语音对话"}
{status === "connecting"
? "连接中…"
: status === "connected"
? "我在聆听"
: "开始一次语音对话"}
</div>
<p className="mx-auto text-xs leading-5 text-muted-foreground">
{micError
? "无法访问麦克风,请检查浏览器权限后重试。"
: recording
? "直接说话即可。助手会在您停顿后自然回应。"
: "测试语音识别、响应速度与助手的播报效果。"}
: status === "failed"
? error ||
"连接失败,请确认后端已启动且助手已保存后重试。"
: !assistantId
? "请先保存助手,再开始语音预览。"
: recording
? "直接说话即可。助手会在您停顿后自然回应。"
: "测试语音识别、响应速度与助手的播报效果。"}
</p>
</div>
<Button
disabled={!assistantId || status === "connecting"}
onClick={() => {
setMicError(false);
setRecording((value) => !value);
if (recording) {
disconnect();
} else {
void connect();
}
}}
className={[
"relative h-11 gap-2 rounded-full px-6 text-sm font-medium shadow-sm transition-transform hover:scale-[1.03]",
@@ -1817,7 +1887,13 @@ function DebugVoicePanel({
].join(" ")}
aria-label={recording ? "结束语音测试" : "开始语音测试"}
>
{recording ? <PhoneOff size={18} /> : <Mic size={18} />}
{status === "connecting" ? (
<Loader2 size={18} className="animate-spin" />
) : recording ? (
<PhoneOff size={18} />
) : (
<Mic size={18} />
)}
{recording ? "结束对话" : "开始对话"}
</Button>
</div>