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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user