feat: enhance call end coordination with state management

- Added support for tracking call end states (pending, speaking, cancelled) in CallEndCoordinator.
- Introduced a callback mechanism for state changes to allow external handling of call end states.
- Updated pipeline and tests to accommodate new state management features, ensuring proper handling of call end scenarios.
This commit is contained in:
Xin Wang
2026-08-12 14:25:24 +08:00
parent d0bb5b3868
commit 79a239489a
5 changed files with 234 additions and 12 deletions

View File

@@ -58,6 +58,7 @@ import {
createUserInputId,
useVoicePreview,
type ChatMessage,
type CallEndState,
type ClientToolDefinition,
type UserInputPart,
type VoicePreview,
@@ -273,6 +274,7 @@ export function DebugDrawer({
<DebugConnectionStatus
status={preview.status}
micWarning={preview.micWarning}
callEndState={preview.callEndState}
/>
</>
)}
@@ -724,6 +726,7 @@ function DeviceSelectField({
value,
devices,
onSelect,
disabled = false,
}: {
icon: React.ReactNode;
ariaLabel: string;
@@ -732,9 +735,11 @@ function DeviceSelectField({
value: string;
devices: MediaDeviceInfo[];
onSelect: (deviceId: string) => void;
disabled?: boolean;
}) {
return (
<Select
disabled={disabled}
value={value || "default"}
onValueChange={(nextValue) =>
onSelect(nextValue === "default" ? "" : nextValue)
@@ -769,7 +774,13 @@ function DeviceSelectField({
);
}
function MicrophoneDeviceField({ preview }: { preview: VoicePreview }) {
function MicrophoneDeviceField({
preview,
disabled = false,
}: {
preview: VoicePreview;
disabled?: boolean;
}) {
return (
<DeviceSelectField
icon={<Mic size={15} className="shrink-0 text-muted-soft" />}
@@ -779,6 +790,7 @@ function MicrophoneDeviceField({ preview }: { preview: VoicePreview }) {
value={preview.selectedDeviceId}
devices={preview.audioInputs}
onSelect={(deviceId) => preview.selectDevice(deviceId)}
disabled={disabled}
/>
);
}
@@ -808,11 +820,13 @@ function DebugInputModeButton({
label,
onClick,
children,
disabled = false,
}: {
selected: boolean;
label: string;
onClick: () => void;
children: React.ReactNode;
disabled?: boolean;
}) {
return (
<button
@@ -820,12 +834,14 @@ function DebugInputModeButton({
aria-label={label}
aria-pressed={selected}
title={label}
disabled={disabled}
onClick={onClick}
className={[
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors",
selected
? "bg-surface-strong text-foreground"
: "text-muted-soft hover:bg-surface-strong hover:text-foreground",
disabled ? "cursor-not-allowed" : "",
].join(" ")}
>
{children}
@@ -875,6 +891,12 @@ function DebugVoicePanel({
audioRef,
} = preview;
const recording = status === "connecting" || status === "connected";
const callEndActive =
preview.callEndState.phase === "pending" ||
preview.callEndState.phase === "speaking";
const canInterruptEnd =
callEndActive && preview.callEndState.interruptible;
const endInputLocked = callEndActive && !preview.callEndState.interruptible;
const [textDraft, setTextDraft] = useState("");
const [inputMode, setInputMode] = useState<DebugInputMode>("mic");
const [pendingImage, setPendingImage] =
@@ -1181,6 +1203,30 @@ function DebugVoicePanel({
</div>
<div className="shrink-0 border-t border-hairline bg-card p-3">
{callEndActive && (
<div
role="status"
aria-live="polite"
className="mb-2 flex min-w-0 items-center gap-2 px-2 text-[11px] leading-4 text-muted-foreground"
>
<span
aria-hidden
className={[
"h-1.5 w-1.5 shrink-0 rounded-full",
canInterruptEnd
? "animate-pulse bg-gradient-peach"
: "bg-muted-soft",
].join(" ")}
/>
<span className="truncate">
{canInterruptEnd
? preview.callEndState.voiceInterruptible
? "直接说话或发送文字,可以继续对话"
: "发送文字可以继续对话;当前未开启语音打断"
: "输入已暂停,结束语播放完成后将结束对话"}
</span>
</div>
)}
<div className="flex items-end gap-2">
<div className="min-w-0 flex-1">
<div
@@ -1191,7 +1237,10 @@ function DebugVoicePanel({
"relative flex min-h-10 min-w-0 items-end gap-1 overflow-hidden rounded-[1.4rem] border bg-background px-2 transition-colors",
draggingImage
? "border-foreground"
: "border-hairline-strong",
: canInterruptEnd
? "border-gradient-peach shadow-[0_0_0_2px_color-mix(in_srgb,var(--gradient-peach)_16%,transparent)]"
: "border-hairline-strong",
endInputLocked ? "pointer-events-none opacity-55" : "",
].join(" ")}
>
<div className="mb-1 flex shrink-0 items-center gap-0.5 rounded-full bg-canvas-soft p-0.5">
@@ -1199,6 +1248,7 @@ function DebugVoicePanel({
selected={inputMode === "mic"}
label="选择麦克风设备"
onClick={() => setInputMode("mic")}
disabled={endInputLocked}
>
<Mic size={15} />
</DebugInputModeButton>
@@ -1206,13 +1256,17 @@ function DebugVoicePanel({
selected={inputMode === "text"}
label="文字输入"
onClick={() => setInputMode("text")}
disabled={endInputLocked}
>
<MessageSquareText size={15} />
</DebugInputModeButton>
</div>
{inputMode === "mic" ? (
<div className="flex min-w-0 flex-1">
<MicrophoneDeviceField preview={preview} />
<MicrophoneDeviceField
preview={preview}
disabled={endInputLocked}
/>
</div>
) : (
<div className="min-w-0 flex-1 py-1">
@@ -1254,7 +1308,9 @@ function DebugVoicePanel({
<Textarea
rows={1}
value={textDraft}
disabled={status !== "connected" || sendingInput}
disabled={
status !== "connected" || sendingInput || endInputLocked
}
onChange={(event) => {
setTextDraft(event.target.value);
if (inputError) setInputError("");
@@ -1272,7 +1328,9 @@ function DebugVoicePanel({
}}
placeholder={
status === "connected"
? vision
? canInterruptEnd
? "输入内容以继续对话…"
: vision
? "输入文字,或粘贴 / 拖入图片…"
: "输入文字发送给助手,将打断当前播报…"
: "开始对话后可输入文字…"
@@ -1302,6 +1360,7 @@ function DebugVoicePanel({
disabled={
status !== "connected" ||
sendingInput ||
endInputLocked ||
(!textDraft.trim() && !pendingImage)
}
onClick={() => void handleSendInput()}
@@ -1661,13 +1720,27 @@ function DebugVideoPanel({
function DebugConnectionStatus({
status,
micWarning,
callEndState,
}: {
status: VoicePreviewStatus;
micWarning: string | null;
callEndState: CallEndState;
}) {
const recording = status === "connecting" || status === "connected";
const ending =
callEndState.phase === "pending" || callEndState.phase === "speaking";
const label =
status === "connecting"
status === "connected" && callEndState.phase === "cancelled"
? "已继续对话"
: status === "connected" && ending
? callEndState.phase === "pending"
? callEndState.interruptible
? "准备结束 · 可打断"
: "准备结束"
: callEndState.interruptible
? "结束语播放中 · 可打断"
: "结束语播放中 · 即将结束"
: status === "connecting"
? "连接中…"
: status === "connected"
? micWarning
@@ -1678,11 +1751,19 @@ function DebugConnectionStatus({
: "准备开始";
return (
<span className="flex min-w-0 items-center gap-1.5 truncate text-xs text-muted-foreground">
<span
role="status"
aria-live="polite"
className="flex min-w-0 items-center gap-1.5 truncate text-xs text-muted-foreground"
>
<span
className={[
"h-1.5 w-1.5 shrink-0 rounded-full",
recording
status === "connected" && ending && callEndState.interruptible
? "animate-pulse bg-gradient-peach"
: status === "connected" && callEndState.phase === "cancelled"
? "bg-success"
: recording
? "animate-pulse bg-success"
: status === "failed"
? "bg-destructive"

View File

@@ -17,9 +17,16 @@ import { API_BASE, webrtcApi } from "@/lib/api";
export type VoicePreviewStatus = "idle" | "connecting" | "connected" | "failed";
export type NetworkQuality = "unknown" | "good" | "fair" | "poor";
export type CallEndPhase = "idle" | "pending" | "speaking" | "cancelled";
export type CallEndState = {
phase: CallEndPhase;
interruptible: boolean;
voiceInterruptible: boolean;
};
const NETWORK_SAMPLE_INTERVAL_MS = 2_000;
const SESSION_UPDATE_TIMEOUT_MS = 10_000;
const CALL_END_CANCELLED_FEEDBACK_MS = 1_500;
type NetworkStatsSample = {
packetsSent: number;
@@ -293,6 +300,11 @@ export function useVoicePreview(
Record<string, DynamicVariableValue>
>({});
const [callEnded, setCallEnded] = useState(false);
const [callEndState, setCallEndState] = useState<CallEndState>({
phase: "idle",
interruptible: false,
voiceInterruptible: false,
});
const [networkQuality, setNetworkQuality] =
useState<NetworkQuality>("unknown");
const [audioInputs, setAudioInputs] = useState<MediaDeviceInfo[]>([]);
@@ -318,6 +330,7 @@ export function useVoicePreview(
new Map<string, RegisteredClientTool>(),
);
const endedByServerRef = useRef(false);
const callEndFeedbackTimeoutRef = useRef<number | null>(null);
const selectedDeviceIdRef = useRef("");
const selectedOutputDeviceIdRef = useRef("");
const onNodeActiveRef = useRef(onNodeActive);
@@ -387,6 +400,10 @@ export function useVoicePreview(
});
pendingSessionUpdatesRef.current.clear();
networkStatsRef.current = null;
if (callEndFeedbackTimeoutRef.current !== null) {
window.clearTimeout(callEndFeedbackTimeoutRef.current);
callEndFeedbackTimeoutRef.current = null;
}
onNodeActiveRef.current?.(null);
}, []);
@@ -401,6 +418,11 @@ export function useVoicePreview(
setError(null);
setMicWarning(null);
setNetworkQuality("unknown");
setCallEndState({
phase: "idle",
interruptible: false,
voiceInterruptible: false,
});
setStatus("idle");
}, [releaseResources]);
@@ -411,6 +433,11 @@ export function useVoicePreview(
setRemoteStream(null);
setError(message);
setNetworkQuality("unknown");
setCallEndState({
phase: "idle",
interruptible: false,
voiceInterruptible: false,
});
setStatus("failed");
}, [releaseResources]);
@@ -620,6 +647,32 @@ export function useVoicePreview(
setSessionVariables(publicVariableSnapshot(msg.variables));
} else if (msg.type === "session-variables") {
setSessionVariables(publicVariableSnapshot(msg.variables));
} else if (
msg.type === "call-end-state" &&
(msg.state === "pending" ||
msg.state === "speaking" ||
msg.state === "cancelled")
) {
if (callEndFeedbackTimeoutRef.current !== null) {
window.clearTimeout(callEndFeedbackTimeoutRef.current);
callEndFeedbackTimeoutRef.current = null;
}
const phase = msg.state as Exclude<CallEndPhase, "idle">;
setCallEndState({
phase,
interruptible: msg.interruptible === true,
voiceInterruptible: msg.voice_interruptible === true,
});
if (phase === "cancelled") {
callEndFeedbackTimeoutRef.current = window.setTimeout(() => {
setCallEndState({
phase: "idle",
interruptible: false,
voiceInterruptible: false,
});
callEndFeedbackTimeoutRef.current = null;
}, CALL_END_CANCELLED_FEEDBACK_MS);
}
} else if (msg.type === "call-ended") {
endedByServerRef.current = true;
setCallEnded(true);
@@ -645,6 +698,11 @@ export function useVoicePreview(
setSessionVariables(publicVariableSnapshot(options.dynamicVariables ?? {}));
pendingAssistantTurnsRef.current.clear();
setCallEnded(false);
setCallEndState({
phase: "idle",
interruptible: false,
voiceInterruptible: false,
});
endedByServerRef.current = false;
await cleanupPromiseRef.current;
@@ -1000,6 +1058,7 @@ export function useVoicePreview(
sessionVariables,
clientTools,
callEnded,
callEndState,
networkQuality,
audioInputs,
audioOutputs,