feat: add voice capability status reporting to client
This commit is contained in:
@@ -4,7 +4,6 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Platform,
|
Platform,
|
||||||
Alert,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -32,6 +31,7 @@ import { encodeImages } from "@/utils/encode-images";
|
|||||||
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
||||||
import { focusWithRetries } from "@/utils/web-focus";
|
import { focusWithRetries } from "@/utils/web-focus";
|
||||||
import { useVoiceOptional } from "@/contexts/voice-context";
|
import { useVoiceOptional } from "@/contexts/voice-context";
|
||||||
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
|
|
||||||
@@ -80,6 +80,7 @@ export function AgentInputArea({
|
|||||||
const client = useSessionStore(
|
const client = useSessionStore(
|
||||||
(state) => state.sessions[serverId]?.client ?? null
|
(state) => state.sessions[serverId]?.client ?? null
|
||||||
);
|
);
|
||||||
|
const toast = useToast();
|
||||||
const voice = useVoiceOptional();
|
const voice = useVoiceOptional();
|
||||||
const isConnected = client?.isConnected ?? false;
|
const isConnected = client?.isConnected ?? false;
|
||||||
|
|
||||||
@@ -470,10 +471,17 @@ export function AgentInputArea({
|
|||||||
}
|
}
|
||||||
void voice.startVoice(serverId, agentId).catch((error) => {
|
void voice.startVoice(serverId, agentId).catch((error) => {
|
||||||
console.error("[AgentInputArea] Failed to start voice mode", error);
|
console.error("[AgentInputArea] Failed to start voice mode", error);
|
||||||
const message = error instanceof Error ? error.message : "Voice features are not available right now.";
|
const message =
|
||||||
Alert.alert("Voice unavailable", message);
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: typeof error === "string"
|
||||||
|
? error
|
||||||
|
: null;
|
||||||
|
if (message && message.trim().length > 0) {
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}, [agentId, isConnected, serverId, voice]);
|
}, [agentId, isConnected, serverId, toast, voice]);
|
||||||
|
|
||||||
function handleEditQueuedMessage(id: string) {
|
function handleEditQueuedMessage(id: string) {
|
||||||
const item = queuedMessages.find((q) => q.id === id);
|
const item = queuedMessages.find((q) => q.id === id);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
Image,
|
Image,
|
||||||
Platform,
|
Platform,
|
||||||
BackHandler,
|
BackHandler,
|
||||||
Alert,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import {
|
import {
|
||||||
useState,
|
useState,
|
||||||
@@ -34,6 +33,7 @@ import type { DaemonClient } from "@server/client/daemon-client";
|
|||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useVoiceOptional } from "@/contexts/voice-context";
|
import { useVoiceOptional } from "@/contexts/voice-context";
|
||||||
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities";
|
import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
@@ -136,6 +136,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const toast = useToast();
|
||||||
const voice = useVoiceOptional();
|
const voice = useVoiceOptional();
|
||||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
||||||
@@ -288,11 +289,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
|
|
||||||
const startDictationIfAvailable = useCallback(async () => {
|
const startDictationIfAvailable = useCallback(async () => {
|
||||||
if (dictationUnavailableMessage) {
|
if (dictationUnavailableMessage) {
|
||||||
Alert.alert("Dictation unavailable", dictationUnavailableMessage);
|
toast.error(dictationUnavailableMessage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await startDictation();
|
await startDictation();
|
||||||
}, [dictationUnavailableMessage, startDictation]);
|
}, [dictationUnavailableMessage, startDictation, toast]);
|
||||||
|
|
||||||
// Cmd+D to start/submit dictation, Cmd+Shift+D toggles realtime voice, Escape cancels dictation
|
// Cmd+D to start/submit dictation, Cmd+Shift+D toggles realtime voice, Escape cancels dictation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -324,8 +325,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
}
|
}
|
||||||
void voice.startVoice(voiceServerId, voiceAgentId).catch((error) => {
|
void voice.startVoice(voiceServerId, voiceAgentId).catch((error) => {
|
||||||
console.error("[MessageInput] Failed to start realtime voice", error);
|
console.error("[MessageInput] Failed to start realtime voice", error);
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message =
|
||||||
Alert.alert("Voice unavailable", message);
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: typeof error === "string"
|
||||||
|
? error
|
||||||
|
: null;
|
||||||
|
if (message && message.trim().length > 0) {
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -396,6 +404,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
isRealtimeVoiceForCurrentAgent,
|
isRealtimeVoiceForCurrentAgent,
|
||||||
isScreenFocused,
|
isScreenFocused,
|
||||||
startDictationIfAvailable,
|
startDictationIfAvailable,
|
||||||
|
toast,
|
||||||
voiceAgentId,
|
voiceAgentId,
|
||||||
voiceServerId,
|
voiceServerId,
|
||||||
voice,
|
voice,
|
||||||
@@ -490,13 +499,21 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
}
|
}
|
||||||
void voice.startVoice(voiceServerId, voiceAgentId).catch((error) => {
|
void voice.startVoice(voiceServerId, voiceAgentId).catch((error) => {
|
||||||
console.error("[MessageInput] Failed to start realtime voice", error);
|
console.error("[MessageInput] Failed to start realtime voice", error);
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message =
|
||||||
Alert.alert("Voice unavailable", message);
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: typeof error === "string"
|
||||||
|
? error
|
||||||
|
: null;
|
||||||
|
if (message && message.trim().length > 0) {
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
disabled,
|
disabled,
|
||||||
handleStopRealtimeVoice,
|
handleStopRealtimeVoice,
|
||||||
isConnected,
|
isConnected,
|
||||||
|
toast,
|
||||||
voice,
|
voice,
|
||||||
voiceAgentId,
|
voiceAgentId,
|
||||||
voiceServerId,
|
voiceServerId,
|
||||||
|
|||||||
@@ -190,6 +190,57 @@ describe("daemon client E2E", () => {
|
|||||||
await client.close();
|
await client.close();
|
||||||
}, 15000);
|
}, 15000);
|
||||||
|
|
||||||
|
test("emits disabled voice capability reasons on fresh daemon startup", async () => {
|
||||||
|
const isolatedCtx = await createDaemonTestContext({
|
||||||
|
speech: {
|
||||||
|
providers: {
|
||||||
|
dictationStt: { provider: "local", explicit: true, enabled: false },
|
||||||
|
voiceStt: { provider: "local", explicit: true, enabled: false },
|
||||||
|
voiceTts: { provider: "local", explicit: true, enabled: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: `ws://127.0.0.1:${isolatedCtx.daemon.port}/ws`,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const infoPromise = waitForSignal<{
|
||||||
|
dictationEnabled: boolean;
|
||||||
|
dictationReason: string;
|
||||||
|
voiceEnabled: boolean;
|
||||||
|
voiceReason: string;
|
||||||
|
}>(5000, (resolve) => {
|
||||||
|
const unsubscribe = client.on("status", (message) => {
|
||||||
|
if (message.type !== "status") return;
|
||||||
|
const payload = parseServerInfoStatusPayload(message.payload);
|
||||||
|
if (!payload) return;
|
||||||
|
const voice = payload.capabilities?.voice;
|
||||||
|
if (!voice) return;
|
||||||
|
resolve({
|
||||||
|
dictationEnabled: voice.dictation.enabled,
|
||||||
|
dictationReason: voice.dictation.reason,
|
||||||
|
voiceEnabled: voice.voice.enabled,
|
||||||
|
voiceReason: voice.voice.reason,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.connect();
|
||||||
|
const info = await infoPromise;
|
||||||
|
|
||||||
|
expect(info.dictationEnabled).toBe(false);
|
||||||
|
expect(info.dictationReason).toBe("Dictation is disabled in daemon config.");
|
||||||
|
expect(info.voiceEnabled).toBe(false);
|
||||||
|
expect(info.voiceReason).toBe("Realtime voice is disabled in daemon config.");
|
||||||
|
} finally {
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
await isolatedCtx.cleanup();
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
test("handles concurrent filtered agent fetch requests", async () => {
|
test("handles concurrent filtered agent fetch requests", async () => {
|
||||||
const firstRequestId = `fetch-${Date.now()}-a`;
|
const firstRequestId = `fetch-${Date.now()}-a`;
|
||||||
const secondRequestId = `fetch-${Date.now()}-b`;
|
const secondRequestId = `fetch-${Date.now()}-b`;
|
||||||
|
|||||||
@@ -197,6 +197,47 @@ function createReadySpeechReadinessSnapshot(): SpeechReadinessSnapshot {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDownloadInProgressSpeechReadinessSnapshot(): SpeechReadinessSnapshot {
|
||||||
|
return {
|
||||||
|
generatedAt: "2026-02-14T00:00:00.000Z",
|
||||||
|
requiredLocalModelIds: [
|
||||||
|
"sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20",
|
||||||
|
],
|
||||||
|
missingLocalModelIds: [
|
||||||
|
"sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20",
|
||||||
|
],
|
||||||
|
download: {
|
||||||
|
inProgress: true,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
dictation: {
|
||||||
|
enabled: true,
|
||||||
|
available: false,
|
||||||
|
reasonCode: "stt_unavailable",
|
||||||
|
message: "Dictation is unavailable: speech-to-text service is not ready.",
|
||||||
|
retryable: false,
|
||||||
|
missingModelIds: [],
|
||||||
|
},
|
||||||
|
realtimeVoice: {
|
||||||
|
enabled: true,
|
||||||
|
available: false,
|
||||||
|
reasonCode: "stt_unavailable",
|
||||||
|
message: "Realtime voice is unavailable: speech-to-text service is not ready.",
|
||||||
|
retryable: false,
|
||||||
|
missingModelIds: [],
|
||||||
|
},
|
||||||
|
voiceFeature: {
|
||||||
|
enabled: true,
|
||||||
|
available: false,
|
||||||
|
reasonCode: "model_download_in_progress",
|
||||||
|
message:
|
||||||
|
"Voice features are unavailable while models download in the background (sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20).",
|
||||||
|
retryable: true,
|
||||||
|
missingModelIds: ["sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("relay external socket reconnect behavior", () => {
|
describe("relay external socket reconnect behavior", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sessionMock.instances.length = 0;
|
sessionMock.instances.length = 0;
|
||||||
@@ -318,6 +359,35 @@ describe("relay external socket reconnect behavior", () => {
|
|||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("includes temporary retry guidance while models are downloading", async () => {
|
||||||
|
const server = createServer();
|
||||||
|
const metadata: ExternalSocketMetadata = {
|
||||||
|
transport: "relay",
|
||||||
|
externalSessionKey: "relay:client-server-info-download-guidance",
|
||||||
|
};
|
||||||
|
const socket = new MockSocket();
|
||||||
|
await server.attachExternalSocket(socket, metadata);
|
||||||
|
expect(socket.sent).toHaveLength(1);
|
||||||
|
|
||||||
|
server.publishSpeechReadiness(createDownloadInProgressSpeechReadinessSnapshot());
|
||||||
|
expect(socket.sent).toHaveLength(2);
|
||||||
|
|
||||||
|
const envelope = JSON.parse(socket.sent[1] as string) as {
|
||||||
|
message?: { payload?: unknown };
|
||||||
|
};
|
||||||
|
const payload = parseServerInfoStatusPayload(envelope.message?.payload);
|
||||||
|
expect(payload?.capabilities?.voice?.dictation.enabled).toBe(true);
|
||||||
|
expect(payload?.capabilities?.voice?.voice.enabled).toBe(true);
|
||||||
|
expect(payload?.capabilities?.voice?.dictation.reason).toContain(
|
||||||
|
"Try again in a few minutes."
|
||||||
|
);
|
||||||
|
expect(payload?.capabilities?.voice?.voice.reason).toContain(
|
||||||
|
"Try again in a few minutes."
|
||||||
|
);
|
||||||
|
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
test("routes inbound binary mux frames to session.handleBinaryFrame", async () => {
|
test("routes inbound binary mux frames to session.handleBinaryFrame", async () => {
|
||||||
const server = createServer();
|
const server = createServer();
|
||||||
const metadata: ExternalSocketMetadata = {
|
const metadata: ExternalSocketMetadata = {
|
||||||
|
|||||||
@@ -55,14 +55,38 @@ type WebSocketServerConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function toServerCapabilityState(
|
function toServerCapabilityState(
|
||||||
state: SpeechReadinessSnapshot["dictation"]
|
params: {
|
||||||
|
state: SpeechReadinessSnapshot["dictation"];
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
): ServerCapabilityState {
|
): ServerCapabilityState {
|
||||||
|
const { state, reason } = params;
|
||||||
return {
|
return {
|
||||||
enabled: state.enabled,
|
enabled: state.enabled,
|
||||||
reason: state.available ? "" : state.message,
|
reason,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveCapabilityReason(params: {
|
||||||
|
state: SpeechReadinessSnapshot["dictation"];
|
||||||
|
readiness: SpeechReadinessSnapshot;
|
||||||
|
}): string {
|
||||||
|
const { state, readiness } = params;
|
||||||
|
if (state.available) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (readiness.voiceFeature.reasonCode === "model_download_in_progress") {
|
||||||
|
const baseMessage = readiness.voiceFeature.message.trim();
|
||||||
|
if (baseMessage.includes("Try again in a few minutes")) {
|
||||||
|
return baseMessage;
|
||||||
|
}
|
||||||
|
return `${baseMessage} Try again in a few minutes.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.message;
|
||||||
|
}
|
||||||
|
|
||||||
function buildServerCapabilities(params: {
|
function buildServerCapabilities(params: {
|
||||||
readiness: SpeechReadinessSnapshot | null;
|
readiness: SpeechReadinessSnapshot | null;
|
||||||
}): ServerCapabilities | undefined {
|
}): ServerCapabilities | undefined {
|
||||||
@@ -72,8 +96,20 @@ function buildServerCapabilities(params: {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
voice: {
|
voice: {
|
||||||
dictation: toServerCapabilityState(readiness.dictation),
|
dictation: toServerCapabilityState({
|
||||||
voice: toServerCapabilityState(readiness.realtimeVoice),
|
state: readiness.dictation,
|
||||||
|
reason: resolveCapabilityReason({
|
||||||
|
state: readiness.dictation,
|
||||||
|
readiness,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
voice: toServerCapabilityState({
|
||||||
|
state: readiness.realtimeVoice,
|
||||||
|
reason: resolveCapabilityReason({
|
||||||
|
state: readiness.realtimeVoice,
|
||||||
|
readiness,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user