diff --git a/apps/mobile/.oxlintrc.json b/apps/mobile/.oxlintrc.json
index b5da29bb3a..047c173490 100644
--- a/apps/mobile/.oxlintrc.json
+++ b/apps/mobile/.oxlintrc.json
@@ -64,6 +64,10 @@
{
"name": "lucide-react-native",
"message": "Import icons and Lucide types from @/components/ui/icons."
+ },
+ {
+ "name": "expo-file-system/legacy",
+ "message": "Use the modern expo-file-system API (File, Directory, Paths, UploadType). The legacy module is deprecated."
}
]
}
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 47275edf69..56261efa8e 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -60,6 +60,7 @@
"expo": "~57.0.21",
"expo-apple-authentication": "~57.0.1",
"expo-application": "~57.0.2",
+ "expo-audio": "~57.0.4",
"expo-battery": "~57.0.2",
"expo-blur": "~57.0.2",
"expo-build-properties": "~57.0.13",
diff --git a/apps/mobile/src/app/(app)/_layout.tsx b/apps/mobile/src/app/(app)/_layout.tsx
index b906648f86..41481af941 100644
--- a/apps/mobile/src/app/(app)/_layout.tsx
+++ b/apps/mobile/src/app/(app)/_layout.tsx
@@ -203,6 +203,15 @@ export default function AppLayout() {
headerShown: false,
}}
/>
+
{
});
});
+ it.each(['PUSH', 'NAVIGATE', 'JUMP_TO'])(
+ 'replays a forward %s removal unconfirmed — the durable draft survives it',
+ actionType => {
+ const { renderer } = mountGuard(true, noOpDiscard);
+
+ // Tapping another screen (e.g. Preferences from the tab bar) can remove
+ // this screen as a side effect; that is forward navigation, not an
+ // abandon, so the discard confirm must not hijack it (spot check
+ // e12-tap-prefs: the dialog blocked the Preferences screen from opening).
+ const action = { type: actionType };
+ usePreventRemoveHolder.callback?.({ data: { action } });
+
+ expect(alertMock).not.toHaveBeenCalled();
+ expect(dispatchMock).toHaveBeenCalledTimes(1);
+ expect(dispatchMock).toHaveBeenCalledWith(action);
+
+ act(() => {
+ renderer.unmount();
+ });
+ }
+ );
+
it('Discard runs onDiscard before dispatching the captured action', async () => {
const order: string[] = [];
dispatchMock.mockImplementation(() => {
diff --git a/apps/mobile/src/app/(app)/transcription-model-picker.tsx b/apps/mobile/src/app/(app)/transcription-model-picker.tsx
new file mode 100644
index 0000000000..75d651a57b
--- /dev/null
+++ b/apps/mobile/src/app/(app)/transcription-model-picker.tsx
@@ -0,0 +1,7 @@
+import { TranscriptionModelPickerSheet } from '@/components/transcription-model-picker-sheet';
+
+/** Route shell for the transcription model picker: the sheet owns the flow and
+ * writes the SecureStore-backed store directly, so no picker bridge is needed. */
+export default function TranscriptionModelPickerScreen() {
+ return ;
+}
diff --git a/apps/mobile/src/components/agents/chat-composer.test.ts b/apps/mobile/src/components/agents/chat-composer.test.ts
index 25841e658e..06b49b05d3 100644
--- a/apps/mobile/src/components/agents/chat-composer.test.ts
+++ b/apps/mobile/src/components/agents/chat-composer.test.ts
@@ -301,18 +301,27 @@ vi.mock('@/lib/share-prefill', () => ({
useSharePrefill: vi.fn(),
}));
-vi.mock('@/lib/voice-input/use-voice-input', () => ({
- useVoiceInput: () => ({
- available: false,
- isActive: false,
- settleBeforeSubmit: vi.fn(async () => true),
- status: 'idle',
- toggle: vi.fn(),
- }),
+// The voice hook options (getDraft/onDraftChange) are captured so a test can
+// drive the transcript-into-draft path through the composer's wiring; the
+// draft module stays unmocked (pure logic) so the real splice runs.
+const voiceHookOptions = vi.hoisted(() => ({
+ current: null as {
+ getDraft: () => string;
+ onDraftChange: (draft: string) => void;
+ } | null,
}));
-vi.mock('@/lib/voice-input/voice-input-draft', () => ({
- applyVoiceDraftToInput: vi.fn(),
+vi.mock('@/lib/voice-input/use-voice-input', () => ({
+ useVoiceInput: (options: { getDraft: () => string; onDraftChange: (draft: string) => void }) => {
+ voiceHookOptions.current = options;
+ return {
+ available: false,
+ isActive: false,
+ settleBeforeSubmit: vi.fn(async () => true),
+ status: 'idle',
+ toggle: vi.fn(),
+ };
+ },
}));
vi.mock('@/lib/hooks/use-return-sends-message-preference', () => ({
@@ -560,7 +569,49 @@ describe('ChatComposer draft restore', () => {
onOptimisticSend: expect.any(Function),
});
});
+
+ // The gateway transcript lands after the Stop tap (the upload resolves
+ // post-stop). The remounted input row must show it: a stop-time snapshot
+ // restored the empty pre-transcript text while the live ref and the durable
+ // draft kept the transcript, so the next dictation appended to the hidden
+ // text and the draft showed the same transcript twice (spot check e12-back).
+ it('restores a transcript that lands after the Stop tap onto the remounted row', async () => {
+ const setNativeProps = vi.fn();
+ const props = makeProps({ draftKey: 'agent-composer:sess-1' });
+ const render = await mount(props);
+ // The first useRef slot is textRef; the second is the TextInput ref.
+ const inputRefSlot = refSlots.slots[1];
+ if (inputRefSlot === undefined) {
+ throw new Error('TextInput ref slot was not mounted');
+ }
+ inputRefSlot.current = { setNativeProps };
+ const voice = voiceHookOptions.current;
+ if (voice === null) {
+ throw new Error('useVoiceInput options were not captured');
+ }
+ voice.getDraft();
+ const onStop = findInputRowProps(render)?.onStop as (() => void) | undefined;
+ if (onStop === undefined) {
+ throw new Error('ChatComposerInputRow element did not carry an onStop handler');
+ }
+
+ onStop();
+ await settle();
+ voice.onDraftChange('Gateway transcription online');
+ expect(setNativeProps).toHaveBeenCalledTimes(1);
+
+ // The first re-render lets the stop-remount machine bump `inputEpoch`
+ // and lets the restore effect write the live text into the (new) row;
+ // the second re-render proves the restore is a one-shot.
+ await rerender(props);
+ await rerender(props);
+
+ expect(setNativeProps).toHaveBeenCalledTimes(2);
+ const restoreCall = setNativeProps.mock.calls[1]?.[0] as { text?: string };
+ expect(restoreCall.text).toBe('Gateway transcription online');
+ });
});
+
describe('ChatComposer return-sends wiring', () => {
it('wires the return-sends preference and an insert-newline handler to the input row', async () => {
returnSendsPref.returnSendsMessage = true;
diff --git a/apps/mobile/src/components/agents/chat-composer.tsx b/apps/mobile/src/components/agents/chat-composer.tsx
index 7c2c5ce586..ea722d6f49 100644
--- a/apps/mobile/src/components/agents/chat-composer.tsx
+++ b/apps/mobile/src/components/agents/chat-composer.tsx
@@ -299,7 +299,8 @@ export function ChatComposer({
// mounts with editable=true from the start. Draft text is restored after
// remount.
const [inputEpoch, setInputEpoch] = useState(0);
- const pendingDraftRestoreRef = useRef(null);
+ // Armed by handleStop; the remount effect restores the live text once.
+ const pendingDraftRestoreRef = useRef(false);
const stopRemountPhaseRef = useRef('idle');
const [stopCompleted, setStopCompleted] = useState(false);
const stopGenerationRef = useRef(0);
@@ -460,11 +461,18 @@ export function ChatComposer({
}, []);
useEffect(() => {
- const draft = pendingDraftRestoreRef.current;
- if (draft === null) {
+ if (!pendingDraftRestoreRef.current) {
return;
}
- pendingDraftRestoreRef.current = null;
+ pendingDraftRestoreRef.current = false;
+ // Read the live text at remount time, never the stop-tap snapshot: the
+ // gateway transcript lands after Stop (the upload resolves post-stop),
+ // so a stop-time snapshot is the pre-transcript text. Restoring it — or
+ // skipping the restore when it is empty — left the remounted row blank
+ // while the live ref and the durable draft kept the transcript, and the
+ // next dictation then appended to the hidden text: the draft showed the
+ // same transcript twice (spot check e12-back).
+ const draft = textRef.current;
if (!draft) {
return;
}
@@ -1053,7 +1061,10 @@ export function ChatComposer({
function handleStop() {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
- pendingDraftRestoreRef.current = textRef.current;
+ // Arm the remount restore; the remount effect reads the live text, so
+ // a transcript that lands between this tap and the remount is kept (the
+ // gateway upload resolves after Stop).
+ pendingDraftRestoreRef.current = true;
Keyboard.dismiss();
setIsFocused(false);
// Arm the state machine and clear the completion flag. The effect
@@ -1194,7 +1205,14 @@ export function ChatComposer({
) : null}
-
+
diff --git a/apps/mobile/src/components/agents/use-new-session-discard-guard.ts b/apps/mobile/src/components/agents/use-new-session-discard-guard.ts
index c22534fdd1..e345fb8444 100644
--- a/apps/mobile/src/components/agents/use-new-session-discard-guard.ts
+++ b/apps/mobile/src/components/agents/use-new-session-discard-guard.ts
@@ -6,11 +6,27 @@ import { toast } from 'sonner-native';
import { i18n } from '@/i18n';
import { usePreventRemove } from '@/lib/navigation/prevent-remove';
+/**
+ * Navigation action types that mean "the user is leaving this screen": the
+ * header back button and Android hardware back (`GO_BACK`), the iOS swipe-back
+ * gesture and programmatic `router.back()` (`POP`). Every other action that
+ * can remove this screen — a `NAVIGATE`/`PUSH` to another route (e.g. tapping
+ * Preferences from the tab bar behind this screen), a `RESET` — is forward
+ * navigation: the user is going somewhere else, not abandoning the prompt.
+ * The draft is durable (saved on every change), so a forward leave loses
+ * nothing and must not be blocked by a discard confirm (spot check
+ * e12-tap-prefs: the confirm hijacked a Preferences push and the screen never
+ * opened).
+ */
+const LEAVE_ACTION_TYPES: ReadonlySet = new Set(['GO_BACK', 'POP', 'POP_TO', 'POP_TO_TOP']);
+
/**
* New-session discard confirm. Registers a predictive-Back-safe guard via
* `usePreventRemove`, which fires for every way the screen can be removed —
* header back, Android hardware back, and the iOS swipe-back gesture — so all
* three paths get the same confirmation instead of only the header button.
+ * Forward navigation (any other action type) is replayed unconfirmed: the
+ * durable draft survives the leave.
*
* Mirrors the `usePreventRemove` + `Alert.alert` pattern of
* `useSettingsBackGuard` without any Security-specific helpers: when the
@@ -54,6 +70,12 @@ export function useNewSessionDiscardGuard({
return;
}
const action = data.action;
+ if (!LEAVE_ACTION_TYPES.has(action.type)) {
+ // Forward navigation (push/navigate/reset): replay it now. The durable
+ // draft keeps the prompt, so there is nothing to confirm away.
+ navigation.dispatch(data.action);
+ return;
+ }
Alert.alert(
i18n.t('agentChat.newSession.discardDraftTitle'),
i18n.t(
diff --git a/apps/mobile/src/components/agents/use-remote-spawn-dispatch.test.ts b/apps/mobile/src/components/agents/use-remote-spawn-dispatch.test.ts
index 43976f81b4..529631ba28 100644
--- a/apps/mobile/src/components/agents/use-remote-spawn-dispatch.test.ts
+++ b/apps/mobile/src/components/agents/use-remote-spawn-dispatch.test.ts
@@ -58,12 +58,6 @@ vi.mock('sonner-native', () => ({
vi.mock('expo-crypto', () => ({ randomUUID: () => 'share-id-fixed' }));
-vi.mock('expo-file-system/legacy', () => ({
- cacheDirectory: null,
- copyAsync: vi.fn().mockResolvedValue('/tmp/copy'),
- deleteAsync: vi.fn().mockResolvedValue(undefined),
-}));
-
const expoFileSystemMock = vi.hoisted(() => {
const files = new Map();
const File = vi.fn(function FileMock(_base: unknown, ...rest: unknown[]) {
diff --git a/apps/mobile/src/components/app-root-providers.tsx b/apps/mobile/src/components/app-root-providers.tsx
index 6dca9833ea..757079b9fe 100644
--- a/apps/mobile/src/components/app-root-providers.tsx
+++ b/apps/mobile/src/components/app-root-providers.tsx
@@ -53,8 +53,13 @@ export function AppRootProviders({
toasts render BEHIND Expo formSheets despite FullWindowOverlay; this reordering
addresses Portal overlays only — sheets/modals still need inline errors (P2);
re-verification scheduled in the final device pass.
+ bottom-center: sonner-native's default top-center placement renders a toast
+ over the screen header, hiding the back control for the toast's whole
+ lifetime (spot check e4-end). Bottom is the transient-message convention:
+ a toast may cover the composer briefly, never the navigation.
*/}
,
error: ,
diff --git a/apps/mobile/src/components/app-unlock-screen.test-helpers.tsx b/apps/mobile/src/components/app-unlock-screen.test-helpers.tsx
index 90ffffc64f..5842b4d9c2 100644
--- a/apps/mobile/src/components/app-unlock-screen.test-helpers.tsx
+++ b/apps/mobile/src/components/app-unlock-screen.test-helpers.tsx
@@ -94,6 +94,7 @@ vi.mock('react-native-reanimated', () => ({
useSharedValue: (value: number) => ({ value }),
useAnimatedStyle: (build: () => unknown) => build(),
}));
+vi.mock('@sentry/react-native', () => ({ captureException: vi.fn() }));
vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' }));
vi.mock('@/components/centered-state-surface', () => ({
NativeStateSurface: ({ children }: { children: ReactElement }) => children,
@@ -105,11 +106,13 @@ vi.mock('@/components/ui/icons', () => ({
Brain: 'Icon',
CheckCircle2: 'Icon',
CornerDownLeft: 'Icon',
+ Cpu: 'Icon',
Gauge: 'Icon',
Globe: 'Icon',
Info: 'Icon',
Loader: 'Icon',
MessageSquare: 'Icon',
+ Mic: 'Icon',
Shield: 'Icon',
Smartphone: 'Icon',
TriangleAlert: 'Icon',
diff --git a/apps/mobile/src/components/kilo-pass/kilo-pass-subscription-card.tsx b/apps/mobile/src/components/kilo-pass/kilo-pass-subscription-card.tsx
index 98bce39bdc..9fe809a22e 100644
--- a/apps/mobile/src/components/kilo-pass/kilo-pass-subscription-card.tsx
+++ b/apps/mobile/src/components/kilo-pass/kilo-pass-subscription-card.tsx
@@ -16,7 +16,18 @@ import {
getKiloPassSubscriptionCardContentState,
} from '@/lib/kilo-pass/subscription-card-state';
-export function KiloPassSubscriptionCard() {
+export function KiloPassSubscriptionCard({
+ hideLoadingSkeleton = false,
+}: Readonly<{
+ /**
+ * Render no loading shimmer while the card's queries are still in flight:
+ * the credits section already shows its one loading indicator (the balance
+ * skeleton), and stacking a second skeleton card reads as two loaders at
+ * once. The slot keeps the card's final height so the swap in and out of
+ * this state never moves the sections below.
+ */
+ hideLoadingSkeleton?: boolean;
+}>) {
const colors = useThemeColors();
const router = useRouter();
const trpc = useTRPC();
@@ -138,7 +149,14 @@ export function KiloPassSubscriptionCard() {
return (
- {contentState.kind === 'loading' ? (
+ {contentState.kind === 'loading' && hideLoadingSkeleton ? (
+ // p-3 (24) + the h-10 icon row (40) + the 1px borders (2): the
+ // exact height every card state below renders at, so the swap to
+ // content or to the shimmering skeleton never moves layout.
+
+ ) : null}
+
+ {contentState.kind === 'loading' && !hideLoadingSkeleton ? (
({
Bell: 'Bell',
Brain: 'Brain',
CornerDownLeft: 'CornerDownLeft',
- Gauge: 'Gauge',
+ Cpu: 'Cpu',
Globe: 'Globe',
MessageSquare: 'MessageSquare',
+ Mic: 'Mic',
Shield: 'Shield',
Smartphone: 'Smartphone',
}));
@@ -112,6 +113,17 @@ vi.mock('@/lib/hooks/use-theme-preference', () => ({
setThemePreference: vi.fn(),
useThemePreference: () => ({ preference: 'system' }),
}));
+// The gateway preference store loads Sentry at module scope; the real RN CJS
+// it transitively requires cannot resolve under vitest (see
+// preferences-screen.mounted.test.tsx for the same mock).
+vi.mock('@/lib/voice-input/gateway/gateway-transcription-preference', () => ({
+ useGatewayTranscriptionPreference: () => ({
+ gatewayTranscriptionEnabled: false,
+ hasLoaded: true,
+ setGatewayTranscriptionEnabled: vi.fn(),
+ }),
+ useGatewayTranscriptionModel: () => null,
+}));
vi.mock('@/lib/hooks/use-return-sends-message-preference', () => ({
useReturnSendsMessagePreference: () => ({
returnSendsMessage: false,
diff --git a/apps/mobile/src/components/preferences-screen.mounted.test.tsx b/apps/mobile/src/components/preferences-screen.mounted.test.tsx
index a0ae5fa427..41cbfe46ca 100644
--- a/apps/mobile/src/components/preferences-screen.mounted.test.tsx
+++ b/apps/mobile/src/components/preferences-screen.mounted.test.tsx
@@ -1,5 +1,6 @@
/* eslint-disable max-lines -- Biometric cases share one mounted harness with the feature-flag mock. */
/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as image-viewer-modal.mounted.test.tsx) */
+/* eslint-disable max-lines -- the preference suites share one mock harness in this file */
import { type ElementType } from 'react';
import { act, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -14,6 +15,12 @@ vi.hoisted(() => {
});
const push = vi.hoisted(() => vi.fn());
const setLanguagePickerBridge = vi.hoisted(() => vi.fn());
+const gatewayTranscription = vi.hoisted(() => ({
+ enabled: false,
+ hasLoaded: true,
+ model: null as { id: string; name: string } | null,
+ setEnabled: vi.fn(),
+}));
// The screen mounts the feature-flag debug surface, which reads PostHog flag
// statuses; seed an empty registry so the section stays out of these tests'
// snapshots. The debug surface itself is covered in
@@ -66,9 +73,10 @@ vi.mock('@/components/ui/icons', () => ({
Bell: 'Bell',
Brain: 'Brain',
CornerDownLeft: 'CornerDownLeft',
- Gauge: 'Gauge',
+ Cpu: 'Cpu',
Globe: 'Globe',
MessageSquare: 'MessageSquare',
+ Mic: 'Mic',
Shield: 'Shield',
Smartphone: 'Smartphone',
}));
@@ -125,6 +133,14 @@ vi.mock('@/lib/hooks/use-return-sends-message-preference', () => ({
setReturnSendsMessage: vi.fn(),
}),
}));
+vi.mock('@/lib/voice-input/gateway/gateway-transcription-preference', () => ({
+ useGatewayTranscriptionPreference: () => ({
+ gatewayTranscriptionEnabled: gatewayTranscription.enabled,
+ hasLoaded: gatewayTranscription.hasLoaded,
+ setGatewayTranscriptionEnabled: gatewayTranscription.setEnabled,
+ }),
+ useGatewayTranscriptionModel: () => gatewayTranscription.model,
+}));
vi.mock('@/lib/hooks/use-theme-colors', () => ({
useThemeColors: () => ({ secondaryForeground: '#000000', mutedForeground: '#000000' }),
}));
@@ -154,6 +170,9 @@ beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.stubGlobal('__DEV__', false);
vi.resetAllMocks();
+ gatewayTranscription.enabled = false;
+ gatewayTranscription.hasLoaded = true;
+ gatewayTranscription.model = null;
posthog.statuses = [];
storage.setItemAsync.mockImplementation(async (_key: string, value: string) => {
await Promise.resolve();
@@ -236,6 +255,127 @@ describe('PreferencesScreen Return-sends switch', () => {
});
});
+function findConfigureRow(renderer: ReactTestRenderer, title: string) {
+ const rows = renderer.root.findAll(
+ node => typeof node.type === 'string' && (node.type as string) === 'ConfigureRow'
+ );
+ const row = rows.find(item => item.props.title === title);
+ if (!row) {
+ throw new Error(`ConfigureRow for ${title} not found`);
+ }
+ return row;
+}
+
+function findGatewaySwitch(renderer: ReactTestRenderer) {
+ const found = renderer.root.findAll(
+ node => typeof node.type === 'string' && (node.type as string) === 'Switch'
+ );
+ const foundSwitch = found.find(sw => sw.props.accessibilityLabel === 'Gateway transcription');
+ if (!foundSwitch) {
+ throw new Error('Gateway transcription switch not found');
+ }
+ return foundSwitch;
+}
+
+describe('PreferencesScreen Gateway transcription', () => {
+ it('renders the gateway transcription switch and the model row showing no choice yet', async () => {
+ const renderer = await mountPreferences();
+
+ const gatewaySwitch = findGatewaySwitch(renderer);
+ expect(gatewaySwitch.props).toMatchObject({ value: false, disabled: false });
+
+ const texts = renderer.root.findAll(
+ node =>
+ typeof node.type === 'string' &&
+ (node.type as string) === 'Text' &&
+ typeof node.props.children === 'string'
+ );
+ expect(texts.some(node => node.props.children === 'Gateway transcription')).toBe(true);
+ expect(
+ texts.some(
+ node =>
+ node.props.children ===
+ "Transcribe voice input with a Kilo gateway model instead of the device's speech recognition. Your recording is sent to the Kilo gateway."
+ )
+ ).toBe(true);
+
+ const modelRow = findConfigureRow(renderer, 'Transcription model');
+ expect(modelRow.props).toMatchObject({ icon: 'Cpu', subtitle: 'None chosen', disabled: true });
+
+ renderer.unmount();
+ });
+
+ it('shows the stored model name in the model row subtitle', async () => {
+ gatewayTranscription.enabled = true;
+ gatewayTranscription.model = { id: 'kilo/whisper-large-v3', name: 'Whisper Large v3' };
+ const renderer = await mountPreferences();
+
+ const modelRow = findConfigureRow(renderer, 'Transcription model');
+ expect(modelRow.props).toMatchObject({ subtitle: 'Whisper Large v3', disabled: false });
+
+ renderer.unmount();
+ });
+
+ it('shows the empty caption while the switch is off even when a model is stored', async () => {
+ // A stale stored choice must not promise a model that the off switch
+ // never applies: with the switch off the OS recogniser runs, so the row
+ // reads the empty caption and stays disabled (reduced opacity, no
+ // chevron — ConfigureRow's disabled rendering).
+ gatewayTranscription.enabled = false;
+ gatewayTranscription.model = { id: 'fake-transcribe', name: 'Fake Transcribe' };
+ const renderer = await mountPreferences();
+
+ const modelRow = findConfigureRow(renderer, 'Transcription model');
+ expect(modelRow.props).toMatchObject({
+ subtitle: 'None chosen',
+ disabled: true,
+ });
+
+ renderer.unmount();
+ });
+
+ it('opens the transcription model picker from the model row', async () => {
+ gatewayTranscription.enabled = true;
+ const renderer = await mountPreferences();
+
+ act(() => {
+ (findConfigureRow(renderer, 'Transcription model').props.onPress as () => void)();
+ });
+
+ expect(push).toHaveBeenCalledWith('/(app)/transcription-model-picker');
+
+ renderer.unmount();
+ });
+
+ it('keeps the switch disabled until the preference has loaded', async () => {
+ gatewayTranscription.hasLoaded = false;
+ const renderer = await mountPreferences();
+
+ expect(findGatewaySwitch(renderer).props.disabled).toBe(true);
+
+ renderer.unmount();
+ });
+
+ it('toggling the switch writes the store', async () => {
+ const renderer = await mountPreferences();
+
+ await flush(() => {
+ (findGatewaySwitch(renderer).props.onValueChange as (next: boolean) => void)(true);
+ });
+
+ expect(gatewayTranscription.setEnabled).toHaveBeenCalledTimes(1);
+ expect(gatewayTranscription.setEnabled).toHaveBeenCalledWith(true);
+
+ await flush(() => {
+ (findGatewaySwitch(renderer).props.onValueChange as (next: boolean) => void)(false);
+ });
+
+ expect(gatewayTranscription.setEnabled).toHaveBeenCalledWith(false);
+
+ renderer.unmount();
+ });
+});
+
function biometric(renderer: ReactTestRenderer) {
return renderer.root.findByProps({ accessibilityLabel: 'Unlock with biometrics' });
}
diff --git a/apps/mobile/src/components/preferences-screen.tsx b/apps/mobile/src/components/preferences-screen.tsx
index 4cfda55fef..2734fe758d 100644
--- a/apps/mobile/src/components/preferences-screen.tsx
+++ b/apps/mobile/src/components/preferences-screen.tsx
@@ -3,14 +3,14 @@ import {
Bell,
Brain,
CornerDownLeft,
+ Cpu,
Globe,
- type LucideIcon,
MessageSquare,
+ Mic,
Shield,
Smartphone,
} from '@/components/ui/icons';
-import { Switch, View } from 'react-native';
-import { ActivityIndicator } from '@/components/ui/activity-indicator';
+import { View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { AppUnlockFeedback } from '@/components/app-unlock-screen';
@@ -18,6 +18,7 @@ import { FeatureFlagsSection } from '@/components/feature-flags-section';
import { ScreenHeader } from '@/components/screen-header';
import { TabScreenScrollView } from '@/components/tab-screen';
import { ConfigureRow } from '@/components/ui/configure-row';
+import { PreferenceRow } from '@/components/ui/preference-row';
import { SegmentedControl } from '@/components/ui/segmented-control';
import { Text } from '@/components/ui/text';
import { useAppUnlock } from '@/lib/app-unlock-context';
@@ -29,65 +30,18 @@ import { usePrReviewFooterPreference } from '@/lib/hooks/use-pr-review-footer-pr
import { useReasoningPreference } from '@/lib/hooks/use-reasoning-preference';
import { useReturnSendsMessagePreference } from '@/lib/hooks/use-return-sends-message-preference';
import { useTrustedHosts } from '@/lib/hooks/use-trusted-hosts';
-import { cn } from '@/lib/utils';
import { LANGUAGE_ENDONYMS } from '@/i18n/languages';
-import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { setLanguagePickerBridge } from '@/lib/picker-bridge';
+import {
+ useGatewayTranscriptionModel,
+ useGatewayTranscriptionPreference,
+} from '@/lib/voice-input/gateway/gateway-transcription-preference';
import {
setThemePreference,
type ThemePreference,
useThemePreference,
} from '@/lib/hooks/use-theme-preference';
-type PreferenceRowProps = Readonly<{
- icon: LucideIcon;
- title: string;
- subtitle: string;
- value: boolean;
- disabled: boolean;
- busy?: boolean;
- onValueChange: (next: boolean) => void;
-}>;
-
-/** Switch row shaped like the Notifications category row. */
-function PreferenceRow({
- icon: Icon,
- title,
- subtitle,
- value,
- disabled,
- busy = false,
- onValueChange,
-}: PreferenceRowProps) {
- const colors = useThemeColors();
- return (
-
- {busy ? (
-
- ) : (
-
- )}
-
- {/* Disabled cue is the muted title, not row opacity — see the same
- pattern in notifications-screen's CategoryRow. */}
-
- {title}
-
-
- {subtitle}
-
-
-
-
- );
-}
-
export function PreferencesScreen() {
const router = useRouter();
const unlock = useAppUnlock();
@@ -110,6 +64,12 @@ export function PreferencesScreen() {
} = usePrReviewFooterPreference();
const { returnSendsMessage, hasLoaded, setReturnSendsMessage } =
useReturnSendsMessagePreference();
+ const {
+ gatewayTranscriptionEnabled,
+ hasLoaded: gatewayTranscriptionLoaded,
+ setGatewayTranscriptionEnabled,
+ } = useGatewayTranscriptionPreference();
+ const storedTranscriptionModel = useGatewayTranscriptionModel();
const { t } = useTranslation();
const { userId } = useCurrentUserId();
const { preference: languagePreference } = useLanguagePreference();
@@ -172,6 +132,33 @@ export function PreferencesScreen() {
disabled={!hasLoaded}
onValueChange={setReturnSendsMessage}
/>
+
+ {/* Model choice only matters while gateway transcription is on, so the
+ row stays disabled — and its chevron hidden — when the switch is
+ off. The caption follows the same rule: with the switch off no
+ gateway model applies, so the row shows the empty caption even
+ when a model is still stored for when the switch turns on. With no
+ stored choice the gateway's first catalogue model is the default. */}
+ {
+ router.push('/(app)/transcription-model-picker' as Href);
+ }}
+ />
{/* Appearance */}
diff --git a/apps/mobile/src/components/profile-credits-card.mounted.test.tsx b/apps/mobile/src/components/profile-credits-card.mounted.test.tsx
index 2f6c2e1d81..a6102f0291 100644
--- a/apps/mobile/src/components/profile-credits-card.mounted.test.tsx
+++ b/apps/mobile/src/components/profile-credits-card.mounted.test.tsx
@@ -132,10 +132,21 @@ vi.mock('@/components/add-credits-row', () => ({
AddCreditsRow: () => 'ADD_CREDITS_ROW',
}));
+const kiloPassCardProps = vi.hoisted(() => ({
+ latest: undefined as { hideLoadingSkeleton?: boolean } | undefined,
+}));
vi.mock('@/components/kilo-pass/kilo-pass-subscription-card', () => ({
- KiloPassSubscriptionCard: () => null,
+ KiloPassSubscriptionCard: (props: { hideLoadingSkeleton?: boolean }) => {
+ kiloPassCardProps.latest = props;
+ return null;
+ },
}));
+/** Read the last render's props without the caller's narrowing of the store. */
+function lastKiloPassProps(): { hideLoadingSkeleton?: boolean } | undefined {
+ return kiloPassCardProps.latest;
+}
+
vi.mock('@/lib/config', () => ({
WEB_BASE_URL: 'https://example.com',
}));
@@ -316,6 +327,25 @@ describe('CreditsCard balance state', () => {
unmount();
});
+ it('hides the KiloPass loading skeleton while the balance skeleton is the section loader', async () => {
+ // One loading indicator per section: while the balance slot shimmers, the
+ // card reserves its slot quietly; once the balance resolves, the card may
+ // show its own loader again.
+ kiloPassCardProps.latest = undefined;
+ const { unmount } = await mountCard();
+ expect(lastKiloPassProps()?.hideLoadingSkeleton).toBe(true);
+ unmount();
+
+ kiloPassCardProps.latest = undefined;
+ const queryClient = createTestQueryClient();
+ currentUser.userId = 'user-1';
+ queryClient.setQueryData([...BALANCE_KEY], { balance: 10 });
+ const settled = await mountCard(queryClient);
+ await waitFor(() => settled.texts().includes('$10.00'));
+ expect(lastKiloPassProps()?.hideLoadingSkeleton).toBe(false);
+ settled.unmount();
+ });
+
it('shows a cached balance without reusing it after an account change', async () => {
const queryClient = createTestQueryClient();
diff --git a/apps/mobile/src/components/profile-credits-card.tsx b/apps/mobile/src/components/profile-credits-card.tsx
index 9da1f923ae..e93090c02f 100644
--- a/apps/mobile/src/components/profile-credits-card.tsx
+++ b/apps/mobile/src/components/profile-credits-card.tsx
@@ -268,7 +268,13 @@ export function CreditsCard({ enabled, orgs }: Readonly) {
)}
- {enabled && !selectedOrgId ? : null}
+ {/* One loading indicator per section: while the balance slot shows its
+ skeleton, the KiloPass card reserves its slot quietly instead of
+ stacking a second skeleton card. The card's queries still run from
+ mount, so the swap adds no fetch latency. */}
+ {enabled && !selectedOrgId ? (
+
+ ) : null}
);
}
diff --git a/apps/mobile/src/components/share/share-gate-sheet.mounted.test.tsx b/apps/mobile/src/components/share/share-gate-sheet.mounted.test.tsx
index 4ebfdbcfa4..f81da77146 100644
--- a/apps/mobile/src/components/share/share-gate-sheet.mounted.test.tsx
+++ b/apps/mobile/src/components/share/share-gate-sheet.mounted.test.tsx
@@ -133,15 +133,10 @@ vi.mock('expo-crypto', () => {
},
};
});
-vi.mock('expo-file-system/legacy', () => ({
- cacheDirectory: null,
- copyAsync: vi.fn().mockResolvedValue('/tmp/copy'),
- deleteAsync: vi.fn().mockResolvedValue(undefined),
-}));
// The real `share-payload.ts` pulls `registerTempFile` from
-// `@/lib/temp-file-registry`, which imports the new `expo-file-system` API.
-// Mock the main entry (not only `expo-file-system/legacy`) so the
-// `importOriginal()` chain in the `@/lib/share-payload` mock stays harmless.
+// `@/lib/temp-file-registry`, which imports the modern `expo-file-system` API.
+// Mock the main entry so the `importOriginal()` chain in the
+// `@/lib/share-payload` mock stays harmless.
vi.mock('expo-file-system', () => {
const File = vi.fn(function FileMock(_base: unknown, ..._rest: unknown[]) {
return {
diff --git a/apps/mobile/src/components/transcription-model-picker-sheet.mounted.test.tsx b/apps/mobile/src/components/transcription-model-picker-sheet.mounted.test.tsx
new file mode 100644
index 0000000000..1f9253f53e
--- /dev/null
+++ b/apps/mobile/src/components/transcription-model-picker-sheet.mounted.test.tsx
@@ -0,0 +1,406 @@
+/* eslint-disable max-lines -- the state suites share one mock harness in this file */
+/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as language-picker-sheet.mounted.test.tsx) */
+import { createElement, Fragment, type ReactNode } from 'react';
+import TestRenderer, { act } from 'react-test-renderer';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import '@/i18n';
+import { TranscriptionModelPickerSheet } from '@/components/transcription-model-picker-sheet';
+import {
+ readGatewayTranscriptionModel,
+ writeGatewayTranscriptionModel,
+} from '@/lib/voice-input/gateway/gateway-transcription-preference';
+
+// ── Hoisted mocks ──────────────────────────────────────────────────────────
+
+const routerBack = vi.hoisted(() => vi.fn());
+const secureStore = vi.hoisted(() => {
+ const map = new Map();
+ // While held, every getItemAsync call returns a pending promise, so a
+ // freshly-created preference store sits in its pre-load state (the real
+ // SecureStore read resolves after mount). `release` settles them.
+ let held = 0;
+ const pending: { key: string; resolve: (raw: string | null) => void }[] = [];
+ return {
+ map,
+ hold: () => {
+ held += 1;
+ },
+ release: () => {
+ held = Math.max(0, held - 1);
+ if (held === 0) {
+ for (const entry of pending.splice(0)) {
+ entry.resolve(map.get(entry.key) ?? null);
+ }
+ }
+ },
+ // The union return is deliberate: a held read stays pending until
+ // `release` settles it, an unheld read answers from the map synchronously.
+ getItemAsync: (key: string): Promise | string | null =>
+ held > 0
+ ? new Promise(resolve => {
+ pending.push({ key, resolve });
+ })
+ : (map.get(key) ?? null),
+ setItemAsync: (key: string, value: string) => {
+ map.set(key, value);
+ },
+ deleteItemAsync: (key: string) => {
+ map.delete(key);
+ },
+ };
+});
+
+type HookState = {
+ models: { id: string; name: string }[];
+ isLoading: boolean;
+ isError: boolean;
+ error: Error | null;
+ refetch: () => void;
+};
+
+const hookState = vi.hoisted(() => {
+ const current: HookState = {
+ models: [],
+ isLoading: true,
+ isError: false,
+ error: null,
+ refetch: vi.fn<() => void>(),
+ };
+ return { current };
+});
+const hookArgs = vi.hoisted(() => ({ organizationId: undefined as string | undefined }));
+vi.mock('@/lib/hooks/use-transcription-models', () => ({
+ useTranscriptionModels: (organizationId?: string) => {
+ hookArgs.organizationId = organizationId;
+ return hookState.current;
+ },
+}));
+
+const orgState = vi.hoisted(() => ({ organizationId: 'org-1' as string | null }));
+vi.mock('@/lib/organization-context', () => ({
+ useOrganization: () => ({ organizationId: orgState.organizationId, isLoaded: true }),
+}));
+
+const MODELS = [
+ { id: 'kilo/whisper-large-v3', name: 'Whisper Large v3' },
+ { id: 'openai/gpt-4o-mini-transcribe', name: 'GPT-4o Mini Transcribe' },
+];
+
+function setHookState(patch: Partial): void {
+ hookState.current = { ...hookState.current, ...patch };
+}
+
+// FlatList renders through a callback, so a host-string mock would drop every
+// row. This mock calls the render props so the row assertions still see rows
+// (same pattern as language-picker-sheet.mounted.test.tsx).
+const flatListMock = vi.hoisted(
+ () =>
+ ({
+ data,
+ renderItem,
+ keyExtractor,
+ ListFooterComponent,
+ }: {
+ data: readonly unknown[];
+ renderItem: (info: { item: unknown; index: number }) => ReactNode;
+ keyExtractor: (item: unknown, index: number) => string;
+ ListFooterComponent?: ReactNode;
+ }) => {
+ const rows = data.map((item, index) =>
+ createElement(Fragment, { key: keyExtractor(item, index) }, renderItem({ item, index }))
+ );
+ return createElement('FlatList', null, ...rows, ListFooterComponent);
+ }
+);
+vi.mock('react-native', () => ({
+ FlatList: flatListMock,
+ View: 'View',
+}));
+vi.mock('expo-router', () => ({
+ useRouter: () => ({ back: routerBack, push: vi.fn() }),
+}));
+vi.mock('react-native-safe-area-context', () => ({
+ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
+}));
+vi.mock('expo-secure-store', () => secureStore);
+vi.mock('@sentry/react-native', () => ({ captureException: vi.fn() }));
+vi.mock('sonner-native', () => ({ toast: { error: vi.fn() } }));
+vi.mock('@/components/picker-sheet', () => ({
+ PickerSheet: (props: { children?: ReactNode }) =>
+ createElement('PickerSheet', props, props.children),
+}));
+vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' }));
+vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' }));
+vi.mock('@/components/ui/choice-row', () => ({ ChoiceRow: 'ChoiceRow' }));
+vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' }));
+vi.mock('@/components/ui/icons', () => ({ Mic: 'Mic' }));
+
+// ── Helpers ────────────────────────────────────────────────────────────────
+
+function findByType(root: TestRenderer.ReactTestInstance, type: string) {
+ return root.findAll(node => typeof node.type === 'string' && node.type === type);
+}
+
+async function mountSheet(): Promise {
+ const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined };
+ await act(async () => {
+ ref.current = TestRenderer.create(createElement(TranscriptionModelPickerSheet));
+ await Promise.resolve();
+ });
+ const renderer = ref.current;
+ if (!renderer) {
+ throw new Error('renderer was not created');
+ }
+ return renderer;
+}
+
+function mountPickerSheetProps(renderer: TestRenderer.ReactTestRenderer) {
+ const sheet = findByType(renderer.root, 'PickerSheet')[0];
+ if (!sheet) {
+ throw new Error('PickerSheet not found');
+ }
+ return sheet.props as { title: string; onDone: () => void; onCancel: () => void };
+}
+
+// ── Tests ──────────────────────────────────────────────────────────────────
+
+describe('TranscriptionModelPickerSheet', () => {
+ beforeEach(() => {
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+ routerBack.mockClear();
+ secureStore.map.clear();
+ writeGatewayTranscriptionModel(null);
+ orgState.organizationId = 'org-1';
+ hookArgs.organizationId = undefined;
+ setHookState({
+ models: [],
+ isLoading: true,
+ isError: false,
+ error: null,
+ refetch: vi.fn<() => void>(),
+ });
+ });
+
+ afterEach(() => {
+ writeGatewayTranscriptionModel(null);
+ });
+
+ it('renders the sheet with the transcription model title', async () => {
+ const renderer = await mountSheet();
+ expect(mountPickerSheetProps(renderer).title).toBe('Transcription model');
+ renderer.unmount();
+ });
+
+ it('scopes the model catalogue read to the selected organization', async () => {
+ orgState.organizationId = 'org-42';
+ setHookState({ models: MODELS, isLoading: false, isError: false, error: null });
+ const renderer = await mountSheet();
+
+ expect(hookArgs.organizationId).toBe('org-42');
+ renderer.unmount();
+ });
+
+ it('reads the catalogue unscoped for a personal account', async () => {
+ orgState.organizationId = null;
+ setHookState({ models: MODELS, isLoading: false, isError: false, error: null });
+ const renderer = await mountSheet();
+
+ expect(hookArgs.organizationId).toBeUndefined();
+ renderer.unmount();
+ });
+
+ it('shows six skeleton rows sized like real rows while loading', async () => {
+ const renderer = await mountSheet();
+
+ // 6 rows × (name line + id caption): no trailing control, because the
+ // loaded row's check is transparent unless selected.
+ const skeletons = findByType(renderer.root, 'Skeleton');
+ expect(skeletons).toHaveLength(12);
+ // Each skeleton sits in a row reserved at the real row's height
+ // (min-h-11 + py-3), so loading → rows never jumps layout.
+ const skeletonRows = findByType(renderer.root, 'View').filter(
+ node =>
+ typeof node.props.className === 'string' &&
+ node.props.className.includes('min-h-11') &&
+ node.props.className.includes('py-3')
+ );
+ expect(skeletonRows).toHaveLength(6);
+ expect(skeletonRows[0]?.props.className).toContain('items-center justify-between');
+ expect(findByType(renderer.root, 'ChoiceRow')).toHaveLength(0);
+ expect(findByType(renderer.root, 'EmptyState')).toHaveLength(0);
+
+ renderer.unmount();
+ });
+
+ it('shows the error state with a working retry when the load fails', async () => {
+ const refetch = vi.fn<() => void>();
+ setHookState({ isLoading: false, isError: true, error: new Error('boom'), refetch });
+ const renderer = await mountSheet();
+
+ const errorState = findByType(renderer.root, 'QueryError')[0];
+ if (!errorState) {
+ throw new Error('QueryError not found');
+ }
+ expect(errorState.props.title).toBe("Couldn't load transcription models.");
+ expect(findByType(renderer.root, 'ChoiceRow')).toHaveLength(0);
+
+ act(() => {
+ (errorState.props.onRetry as () => void)();
+ });
+ expect(refetch).toHaveBeenCalledTimes(1);
+
+ renderer.unmount();
+ });
+
+ it('shows the empty state when the gateway offers no models', async () => {
+ setHookState({ isLoading: false, isError: false, error: null });
+ const renderer = await mountSheet();
+
+ const emptyState = findByType(renderer.root, 'EmptyState')[0];
+ expect(emptyState?.props).toMatchObject({
+ icon: 'Mic',
+ title: 'No transcription models',
+ description: 'The gateway offers no transcription models right now.',
+ });
+ expect(findByType(renderer.root, 'FlatList')).toHaveLength(0);
+
+ renderer.unmount();
+ });
+
+ it('renders rows with model name and id caption, marking the stored model', async () => {
+ writeGatewayTranscriptionModel({ id: 'kilo/whisper-large-v3', name: 'Whisper Large v3' });
+ setHookState({ models: MODELS, isLoading: false, isError: false, error: null });
+ const renderer = await mountSheet();
+
+ const rows = findByType(renderer.root, 'ChoiceRow');
+ expect(rows).toHaveLength(2);
+ expect(rows[0]?.props).toMatchObject({
+ label: 'Whisper Large v3',
+ description: 'kilo/whisper-large-v3',
+ selected: true,
+ });
+ expect(rows[1]?.props).toMatchObject({
+ label: 'GPT-4o Mini Transcribe',
+ description: 'openai/gpt-4o-mini-transcribe',
+ selected: false,
+ });
+ expect(findByType(renderer.root, 'Skeleton')).toHaveLength(0);
+
+ renderer.unmount();
+ });
+
+ it('holds the skeletons until the stored-model read settles, then marks the current model', async () => {
+ // A cold start races the models query against the SecureStore read: with
+ // the read still pending the store reports "no choice", which would draw
+ // every loaded row unchecked (e2-picker spot defect). The sheet holds the
+ // skeleton state until the read settles, so the rows render once, with
+ // the correct check.
+ secureStore.map.set(
+ 'gateway-transcription-model',
+ JSON.stringify({ id: 'kilo/whisper-large-v3', name: 'Whisper Large v3' })
+ );
+ secureStore.hold();
+ try {
+ // A fresh preference store: its initial SecureStore read is the held one.
+ vi.resetModules();
+ const { TranscriptionModelPickerSheet: ColdStartSheet } =
+ await import('@/components/transcription-model-picker-sheet');
+ setHookState({ models: MODELS, isLoading: false, isError: false, error: null });
+
+ const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined };
+ await act(async () => {
+ ref.current = TestRenderer.create(createElement(ColdStartSheet));
+ await Promise.resolve();
+ });
+ const coldRenderer = ref.current;
+ if (!coldRenderer) {
+ throw new Error('renderer was not created');
+ }
+
+ // Models are loaded but the store read is pending: skeletons hold and
+ // no row renders unchecked.
+ expect(findByType(coldRenderer.root, 'Skeleton')).toHaveLength(12);
+ expect(findByType(coldRenderer.root, 'ChoiceRow')).toHaveLength(0);
+
+ secureStore.release();
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ const rows = findByType(coldRenderer.root, 'ChoiceRow');
+ expect(rows).toHaveLength(2);
+ expect(rows[0]?.props).toMatchObject({ label: 'Whisper Large v3', selected: true });
+ expect(findByType(coldRenderer.root, 'Skeleton')).toHaveLength(0);
+
+ coldRenderer.unmount();
+ } finally {
+ secureStore.release();
+ vi.resetModules();
+ }
+ });
+
+ it('persists the tapped model to the store and dismisses the sheet', async () => {
+ setHookState({ models: MODELS, isLoading: false, isError: false, error: null });
+ const renderer = await mountSheet();
+
+ const row = findByType(renderer.root, 'ChoiceRow').find(
+ item => item.props.label === 'GPT-4o Mini Transcribe'
+ );
+ if (!row) {
+ throw new Error('row not found');
+ }
+ act(() => {
+ (row.props.onPress as () => void)();
+ });
+
+ expect(readGatewayTranscriptionModel()).toEqual({
+ id: 'openai/gpt-4o-mini-transcribe',
+ name: 'GPT-4o Mini Transcribe',
+ });
+ expect(secureStore.map.get('gateway-transcription-model')).toBe(
+ JSON.stringify({ id: 'openai/gpt-4o-mini-transcribe', name: 'GPT-4o Mini Transcribe' })
+ );
+ expect(routerBack).toHaveBeenCalledTimes(1);
+
+ renderer.unmount();
+ });
+
+ it('keeps the rows on screen when a refetch fails', async () => {
+ setHookState({ models: MODELS, isLoading: false, isError: false, error: null });
+ const renderer = await mountSheet();
+ expect(findByType(renderer.root, 'ChoiceRow')).toHaveLength(2);
+
+ // A refresh that fails leaves the loaded data in place: the error state
+ // never blanks rows the user can still pick from.
+ setHookState({ isError: true, error: new Error('boom') });
+ await act(async () => {
+ renderer.update(createElement(TranscriptionModelPickerSheet));
+ await Promise.resolve();
+ });
+
+ expect(findByType(renderer.root, 'ChoiceRow')).toHaveLength(2);
+ expect(findByType(renderer.root, 'QueryError')).toHaveLength(0);
+
+ renderer.unmount();
+ });
+
+ it('dismisses from the header Done and Cancel controls without writing', async () => {
+ setHookState({ models: MODELS, isLoading: false, isError: false, error: null });
+ const renderer = await mountSheet();
+ const props = mountPickerSheetProps(renderer);
+
+ act(() => {
+ props.onDone();
+ });
+ expect(routerBack).toHaveBeenCalledTimes(1);
+ expect(readGatewayTranscriptionModel()).toBeNull();
+
+ act(() => {
+ props.onCancel();
+ });
+ expect(routerBack).toHaveBeenCalledTimes(2);
+
+ renderer.unmount();
+ });
+});
diff --git a/apps/mobile/src/components/transcription-model-picker-sheet.tsx b/apps/mobile/src/components/transcription-model-picker-sheet.tsx
new file mode 100644
index 0000000000..97589a193d
--- /dev/null
+++ b/apps/mobile/src/components/transcription-model-picker-sheet.tsx
@@ -0,0 +1,124 @@
+import { type ReactNode } from 'react';
+import { useRouter } from 'expo-router';
+import { useTranslation } from 'react-i18next';
+import { FlatList, View } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import { EmptyState } from '@/components/empty-state';
+import { PickerSheet } from '@/components/picker-sheet';
+import { QueryError } from '@/components/query-error';
+import { ChoiceRow } from '@/components/ui/choice-row';
+import { Mic } from '@/components/ui/icons';
+import { Skeleton } from '@/components/ui/skeleton';
+import { useTranscriptionModels } from '@/lib/hooks/use-transcription-models';
+import { useOrganization } from '@/lib/organization-context';
+import {
+ useGatewayTranscriptionModel,
+ useGatewayTranscriptionModelLoaded,
+ writeGatewayTranscriptionModel,
+} from '@/lib/voice-input/gateway/gateway-transcription-preference';
+
+// Static skeleton rows: count and shape match the real ChoiceRow rows
+// (name line + id caption; the final row's trailing check is transparent
+// unless selected, so the skeleton carries no trailing control) so the swap
+// never moves layout and never shows a shape the loaded row will not have.
+const SKELETON_ROW_COUNT = 6;
+
+function SkeletonRows() {
+ return (
+
+ {Array.from({ length: SKELETON_ROW_COUNT }, (_, index) => (
+ // eslint-disable-next-line react/no-array-index-key -- static skeleton rows, no reordering
+
+
+
+
+
+
+ ))}
+
+ );
+}
+
+/**
+ * Picks the gateway transcription model for voice input. Writes the
+ * SecureStore-backed store directly — no picker bridge — and dismisses on
+ * selection, mirroring the language picker's route shell.
+ */
+export function TranscriptionModelPickerSheet() {
+ const { t } = useTranslation();
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+ const { organizationId } = useOrganization();
+ // Scope the catalogue to the selected organization so the picker cannot
+ // offer, or check-mark, a model the scoped upload then rejects.
+ const { models, isLoading, isError, refetch } = useTranscriptionModels(
+ organizationId ?? undefined
+ );
+ const storedModel = useGatewayTranscriptionModel();
+ // With no explicit choice the gateway's first catalogue model is the
+ // default, so the check mark lands on the row the engine will run.
+ const selectedModelId = storedModel?.id ?? models[0]?.id;
+ // The SecureStore read resolves after mount; until it does the stored-model
+ // comparison would report "no choice" and draw every row unchecked. Hold
+ // the skeleton state until both sources settle so the rows render once,
+ // with the correct check on the current model.
+ const modelStoreLoaded = useGatewayTranscriptionModelLoaded();
+
+ let content: ReactNode = null;
+ if (isLoading || !modelStoreLoaded) {
+ content = ;
+ } else if (isError && models.length === 0) {
+ // A failed refresh keeps the loaded rows on screen: the error state
+ // only takes over when there is nothing to keep.
+ content = (
+ void refetch()} />
+ );
+ } else if (models.length === 0) {
+ content = (
+
+ );
+ } else {
+ content = (
+ item.id}
+ contentContainerClassName="px-4 pb-4"
+ ListFooterComponent={}
+ renderItem={({ item, index }) => (
+ {
+ writeGatewayTranscriptionModel({ id: item.id, name: item.name });
+ router.back();
+ }}
+ />
+ )}
+ />
+ );
+ }
+
+ return (
+ {
+ router.back();
+ }}
+ onCancel={() => {
+ router.back();
+ }}
+ scrollable={false}
+ >
+ {content}
+
+ );
+}
diff --git a/apps/mobile/src/components/ui/preference-row.tsx b/apps/mobile/src/components/ui/preference-row.tsx
new file mode 100644
index 0000000000..6a08646d34
--- /dev/null
+++ b/apps/mobile/src/components/ui/preference-row.tsx
@@ -0,0 +1,56 @@
+import { type LucideIcon } from '@/components/ui/icons';
+import { Switch, View } from 'react-native';
+
+import { ActivityIndicator } from '@/components/ui/activity-indicator';
+import { Text } from '@/components/ui/text';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { cn } from '@/lib/utils';
+
+type PreferenceRowProps = Readonly<{
+ icon: LucideIcon;
+ title: string;
+ subtitle: string;
+ value: boolean;
+ disabled: boolean;
+ busy?: boolean;
+ onValueChange: (next: boolean) => void;
+}>;
+
+/** Switch row shaped like the Notifications category row. */
+export function PreferenceRow({
+ icon: Icon,
+ title,
+ subtitle,
+ value,
+ disabled,
+ busy = false,
+ onValueChange,
+}: PreferenceRowProps) {
+ const colors = useThemeColors();
+ return (
+
+ {busy ? (
+
+ ) : (
+
+ )}
+
+ {/* Disabled cue is the muted title, not row opacity — see the same
+ pattern in notifications-screen's CategoryRow. */}
+
+ {title}
+
+
+ {subtitle}
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/voice-input-control.tsx b/apps/mobile/src/components/voice-input-control.tsx
index 216a8395e9..885b323c4d 100644
--- a/apps/mobile/src/components/voice-input-control.tsx
+++ b/apps/mobile/src/components/voice-input-control.tsx
@@ -67,7 +67,8 @@ export function VoiceInputButton({
}: Readonly): React.ReactElement {
const colors = useThemeColors();
const control = resolveVoiceInputControlState(status, disabled);
- const isListeningOrStopping = status === 'listening' || status === 'stopping';
+ const isListeningOrStopping =
+ status === 'listening' || status === 'stopping' || status === 'transcribing';
const showSpinner = control.busy;
const iconColor = isListeningOrStopping ? colors.destructiveForeground : colors.foreground;
const restingBg = isListeningOrStopping ? LISTENING_BG : RESTING_BG;
@@ -115,8 +116,15 @@ export function VoiceInputStatus({
status,
}: Readonly): React.ReactElement | null {
const { t } = useTranslation();
- if (status !== 'listening') {
- return null;
+ if (status === 'listening') {
+ return (
+
+ );
}
- return ;
+ if (status === 'transcribing') {
+ return (
+
+ );
+ }
+ return null;
}
diff --git a/apps/mobile/src/i18n/locales/af.json b/apps/mobile/src/i18n/locales/af.json
index f8ffe7cbad..e184d310f5 100644
--- a/apps/mobile/src/i18n/locales/af.json
+++ b/apps/mobile/src/i18n/locales/af.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Gateway-transkripsie",
+ "gatewayTranscriptionSubtitle": "Transkribeer steminvoer met 'n Kilo Gateway-model in plaas van die toestel se spraakherkenning. Jou opname word na die Kilo Gateway gestuur.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Trekversoek onbeskikbaar",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "Die vanlyn-spraaklêers vir {{language}} is nie op hierdie foon geïnstalleer nie. Laai hulle af en probeer steminvoer weer.",
"downloadOfflineModel": "Laai af",
"offlineModelDownloadScheduled": "Die vanlyn-spraaklêers sal op die agtergrond afgelaai word. Probeer steminvoer later weer.",
- "listeningStopped": "Die toestel luister nie meer nie."
+ "listeningStopped": "Die toestel luister nie meer nie.",
+ "transcribing": "Transkribeer...",
+ "gatewayUnreachable": "Die Kilo Gateway kon nie bereik word nie. Gaan jou verbinding na en probeer weer.",
+ "gatewayTimeout": "Die transkripsie het te lank geneem. Probeer weer.",
+ "gatewayModelUnavailable": "Hierdie transkripsiemodel is nie beskikbaar nie. Kies 'n ander een in Voorkeure.",
+ "gatewaySignInRequired": "Teken in om gateway-transkripsie te gebruik.",
+ "gatewayNoModel": "Kies eers 'n transkripsiemodel in Voorkeure."
},
"share": {
"title": "Deel met Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "Wag op invoer",
"channelName": "Aktiewe agente",
"activityKitDisabledBody": "Skakel regstreekse aktiwiteite in die instellings aan om aktiewe agente op die sluitskerm te sien."
+ },
+ "transcriptionModel": {
+ "title": "Transkripsiemodel",
+ "noneChosen": "Geen gekies nie",
+ "emptyTitle": "Geen transkripsiemodelle nie",
+ "emptyDescription": "Die Kilo Gateway bied tans geen transkripsiemodelle nie.",
+ "loadFailed": "Die transkripsiemodelle kon nie gelaai word nie."
}
}
diff --git a/apps/mobile/src/i18n/locales/am.json b/apps/mobile/src/i18n/locales/am.json
index fdad640ba8..89134cfae2 100644
--- a/apps/mobile/src/i18n/locales/am.json
+++ b/apps/mobile/src/i18n/locales/am.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "በKilo Gateway ድምጽን ወደ ጽሑፍ መቀየር",
+ "gatewayTranscriptionSubtitle": "የድምፅ ግብዓትን በመሣሪያው የንግግር ማውቂያ ምትክ በKilo Gateway ሞዴል ወደ ጽሑፍ ቀይሩ። ቀረጻዎ ወደ Kilo Gateway ይላካል።",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "የማዋሃድ ጥያቄው አይገኝም",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "የ{{language}} ከመስመር ውጭ ያሉ የንግግር ፋይሎች በዚህ ስልክ ላይ አልተጫኑም። ያውርዱዋቸው፣ ከዚያ የድምፅ ግብዓትን እንደገና ይሞክሩ።",
"downloadOfflineModel": "አውርድ",
"offlineModelDownloadScheduled": "ከመስመር ውጭ ያሉ የንግግር ፋይሎች በጀርባ ይወርዳሉ። የድምፅ ግብዓትን በኋላ እንደገና ይሞክሩ።",
- "listeningStopped": "ማዳመጥ ቆሟል።"
+ "listeningStopped": "ማዳመጥ ቆሟል።",
+ "transcribing": "ወደ ጽሑፍ በመቀየር ላይ...",
+ "gatewayUnreachable": "Kilo Gateway ማግኘት አልተቻለም። ግንኙነትዎን ያረጋግጡ እና እንደገና ይሞክሩ።",
+ "gatewayTimeout": "ድምጽን ወደ ጽሑፍ መቀየር ረጅም ጊዜ አስፈልጎታል። እንደገና ይሞክሩ።",
+ "gatewayModelUnavailable": "ይህ የድምጽ ወደ ጽሑፍ ሞዴል አይገኝም። በምርጫዎች ውስጥ ሌላ ይምረጡ።",
+ "gatewaySignInRequired": "በKilo Gateway ለመቀየር ይግቡ።",
+ "gatewayNoModel": "በምርጫዎች ውስጥ መጀመሪያ የድምጽ ወደ ጽሑፍ ሞዴል ይምረጡ።"
},
"share": {
"title": "ወደ Kilo ማጋራት",
@@ -2950,5 +2959,12 @@
"needsInput": "ምላሽ ይፈልጋል",
"channelName": "ንቁ ወኪሎች",
"activityKitDisabledBody": "ንቁ ወኪሎችን በተቆለፈው ማያ ገጽ ላይ ለማየት በቅንብሮች ውስጥ የቀጥታ እንቅስቃሴዎችን አንቃ።"
+ },
+ "transcriptionModel": {
+ "title": "የድምጽ ወደ ጽሑፍ ሞዴል",
+ "noneChosen": "ምንም አልተመረጠም",
+ "emptyTitle": "ምንም የድምጽ ወደ ጽሑፍ ሞዴሎች የሉም",
+ "emptyDescription": "Kilo Gateway በአሁኑ ሰዓት ምንም የድምጽ ወደ ጽሑፍ ሞዴሎችን አያቀርብም።",
+ "loadFailed": "የድምጽ ወደ ጽሑፍ ሞዴሎችን መጫን አልተቻለም።"
}
}
diff --git a/apps/mobile/src/i18n/locales/ar.json b/apps/mobile/src/i18n/locales/ar.json
index 928d5001c3..3b64f5b8be 100644
--- a/apps/mobile/src/i18n/locales/ar.json
+++ b/apps/mobile/src/i18n/locales/ar.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "التفريغ الصوتي عبر Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "حوّل الإدخال الصوتي إلى نص باستخدام أحد نماذج Kilo Gateway بدلاً من التعرف على الكلام في جهازك. يُرسل تسجيلك إلى Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"notifications": {
"liveActivities": "الأنشطة المباشرة",
@@ -2190,7 +2193,13 @@
"languageNotInstalledMessage": "ملفات الكلام دون اتصال للغة {{language}} غير مثبّتة على هذا الهاتف. نزّلها، ثم حاول استخدام الإدخال الصوتي مرة أخرى.",
"downloadOfflineModel": "تنزيل",
"offlineModelDownloadScheduled": "ستُنزَّل ملفات الكلام دون اتصال في الخلفية. حاول استخدام الإدخال الصوتي مرة أخرى لاحقًا.",
- "listeningStopped": "توقف الاستماع."
+ "listeningStopped": "توقف الاستماع.",
+ "transcribing": "جارٍ التفريغ...",
+ "gatewayUnreachable": "تعذّر الوصول إلى Kilo Gateway. تحقق من اتصالك وحاول مرة أخرى.",
+ "gatewayTimeout": "استغرق التفريغ وقتًا طويلًا. حاول مرة أخرى.",
+ "gatewayModelUnavailable": "نموذج التفريغ الصوتي هذا غير متاح. اختر نموذجًا آخر من التفضيلات.",
+ "gatewaySignInRequired": "سجّل الدخول لاستخدام التفريغ الصوتي عبر Kilo Gateway.",
+ "gatewayNoModel": "اختر نموذج التفريغ الصوتي في التفضيلات أولًا."
},
"share": {
"title": "المشاركة مع Kilo",
@@ -3038,5 +3047,12 @@
"needsInput": "بانتظار تدخلك",
"channelName": "الوكلاء النشطون",
"activityKitDisabledBody": "فعّل الأنشطة المباشرة في الإعدادات لعرض الوكلاء النشطين على شاشة القفل."
+ },
+ "transcriptionModel": {
+ "title": "نموذج التفريغ الصوتي",
+ "noneChosen": "لا يوجد اختيار",
+ "emptyTitle": "لا توجد نماذج تفريغ صوتي",
+ "emptyDescription": "لا يقدّم Kilo Gateway أي نماذج تفريغ صوتي في الوقت الحالي.",
+ "loadFailed": "تعذّر تحميل نماذج التفريغ الصوتي."
}
}
diff --git a/apps/mobile/src/i18n/locales/az.json b/apps/mobile/src/i18n/locales/az.json
index 1e9c0c6f91..20c443368a 100644
--- a/apps/mobile/src/i18n/locales/az.json
+++ b/apps/mobile/src/i18n/locales/az.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Səsin Kilo Gateway ilə mətnə çevrilməsi",
+ "gatewayTranscriptionSubtitle": "Səslə daxiletməni cihazın nitq tanımasının əvəzinə Kilo Gateway modeli ilə mətnə çevirin. Səs yazınız Kilo Gateway-e göndərilir.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Birləşdirmə sorğusu əlçatan deyil",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "{{language}} dilinin oflayn nitq faylları bu telefonda quraşdırılmayıb. Onları endir, sonra səslə daxiletməni yenidən sına.",
"downloadOfflineModel": "Endir",
"offlineModelDownloadScheduled": "Oflayn nitq faylları fonda endiriləcək. Səslə daxiletməni daha sonra yenidən sına.",
- "listeningStopped": "Dinləmə dayandı."
+ "listeningStopped": "Dinləmə dayandı.",
+ "transcribing": "Mətnə çevrilir...",
+ "gatewayUnreachable": "Kilo Gateway-e qoşulmaq mümkün olmadı. Bağlantınızı yoxlayın və yenidən cəhd edin.",
+ "gatewayTimeout": "Səsin mətnə çevrilməsi çox vaxt apardı. Yenidən cəhd edin.",
+ "gatewayModelUnavailable": "Bu səsin mətnə çevrilməsi modeli əlçatan deyil. Seçimlərdə başqasını seçin.",
+ "gatewaySignInRequired": "Kilo Gateway ilə mətnə çevirməkdən istifadə etmək üçün hesabınıza daxil olun.",
+ "gatewayNoModel": "Əvvəlcə Seçimlərdə bir səsin mətnə çevrilməsi modeli seçin."
},
"share": {
"title": "Kilo-ya göndər",
@@ -2950,5 +2959,12 @@
"needsInput": "Cavab gözləyir",
"channelName": "Aktiv agentlər",
"activityKitDisabledBody": "Kilid ekranında aktiv agentləri görmək üçün parametrlərdə canlı fəaliyyətləri aktivləşdir."
+ },
+ "transcriptionModel": {
+ "title": "Səsin mətnə çevrilməsi modeli",
+ "noneChosen": "Seçilməyib",
+ "emptyTitle": "Səsin mətnə çevrilməsi modeli yoxdur",
+ "emptyDescription": "Kilo Gateway hazırda səsin mətnə çevrilməsi modeli təklif etmir.",
+ "loadFailed": "Səsin mətnə çevrilməsi modellərini yükləmək mümkün olmadı."
}
}
diff --git a/apps/mobile/src/i18n/locales/be.json b/apps/mobile/src/i18n/locales/be.json
index 7d106c7853..47f77624d0 100644
--- a/apps/mobile/src/i18n/locales/be.json
+++ b/apps/mobile/src/i18n/locales/be.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Распазнаванне маўлення праз Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Пераўтварайце галасавы ўвод у тэкст з дапамогай мадэлі Kilo Gateway замест распазнавання маўлення на самой прыладзе. Ваш запіс адпраўляецца ў Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Запыт на зліццё недаступны",
@@ -2610,7 +2613,13 @@
"languageNotInstalledMessage": "Афлайн-файлы маўлення для {{language}} не ўсталяваны на гэтым тэлефоне. Спампуйце іх, а затым паспрабуйце галасавы ўвод яшчэ раз.",
"downloadOfflineModel": "Спампаваць",
"offlineModelDownloadScheduled": "Афлайн-файлы маўлення будуць спампаваны ў фонавым рэжыме. Паспрабуйце галасавы ўвод пазней.",
- "listeningStopped": "Праслухоўванне спынена."
+ "listeningStopped": "Праслухоўванне спынена.",
+ "transcribing": "Распазнаванне маўлення…",
+ "gatewayUnreachable": "Не атрымалася падключыцца да Kilo Gateway. Праверце падключэнне і паспрабуйце яшчэ раз.",
+ "gatewayTimeout": "Распазнаванне маўлення занадта доўга доўжылася. Паспрабуйце яшчэ раз.",
+ "gatewayModelUnavailable": "Гэта мадэль распазнавання маўлення недаступная. Абярыце іншую ў наладах.",
+ "gatewaySignInRequired": "Увайдзіце, каб карыстацца распазнаваннем маўлення праз Kilo Gateway.",
+ "gatewayNoModel": "Спачатку абярыце мадэль распазнавання маўлення ў наладах."
},
"share": {
"title": "Адпраўка ў Kilo",
@@ -2994,5 +3003,12 @@
"needsInput": "Чакае адказу",
"channelName": "Актыўныя агенты",
"activityKitDisabledBody": "Уключы дзеянні ў рэальным часе ў наладах, каб бачыць актыўных агентаў на экране блакіроўкі."
+ },
+ "transcriptionModel": {
+ "title": "Мадэль распазнавання маўлення",
+ "noneChosen": "Не выбрана",
+ "emptyTitle": "Няма мадэляў распазнавання маўлення",
+ "emptyDescription": "Зараз Kilo Gateway не прапануе мадэляў распазнавання маўлення.",
+ "loadFailed": "Не атрымалася загрузіць мадэлі распазнавання маўлення."
}
}
diff --git a/apps/mobile/src/i18n/locales/bg.json b/apps/mobile/src/i18n/locales/bg.json
index 4780816823..90f9608ea3 100644
--- a/apps/mobile/src/i18n/locales/bg.json
+++ b/apps/mobile/src/i18n/locales/bg.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Транскрипция през Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Преобразувай гласовото въвеждане с модел на Kilo Gateway вместо разпознаването на реч на устройството. Записът ти се изпраща до Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Недостъпна заявка за обединяване",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "Файловете с офлайн реч за {{language}} не са инсталирани на този телефон. Изтегли ги, след което опитай гласовото въвеждане отново.",
"downloadOfflineModel": "Изтегляне",
"offlineModelDownloadScheduled": "Файловете с офлайн реч ще се изтеглят на заден план. Опитай гласовото въвеждане отново по-късно.",
- "listeningStopped": "Слушането е спряно."
+ "listeningStopped": "Слушането е спряно.",
+ "transcribing": "Транскрибирам…",
+ "gatewayUnreachable": "Kilo Gateway не може да бъде достигнат. Провери връзката си и опитай отново.",
+ "gatewayTimeout": "Транскрипцията отне твърде много време. Опитай отново.",
+ "gatewayModelUnavailable": "Този модел за транскрипция не е наличен. Избери друг в Предпочитания.",
+ "gatewaySignInRequired": "Влез, за да използваш транскрипция през Kilo Gateway.",
+ "gatewayNoModel": "Първо избери модел за транскрипция в Предпочитания."
},
"share": {
"title": "Споделяне в Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "Нужен е отговор",
"channelName": "Активни агенти",
"activityKitDisabledBody": "Включи дейностите на живо в настройките, за да виждаш активните агенти на заключения екран."
+ },
+ "transcriptionModel": {
+ "title": "Модел за транскрипция",
+ "noneChosen": "Не е избран",
+ "emptyTitle": "Няма модели за транскрипция",
+ "emptyDescription": "Kilo Gateway в момента не предлага модели за транскрипция.",
+ "loadFailed": "Моделите за транскрипция не се заредиха."
}
}
diff --git a/apps/mobile/src/i18n/locales/bn.json b/apps/mobile/src/i18n/locales/bn.json
index 0fbd619f26..abe0d81308 100644
--- a/apps/mobile/src/i18n/locales/bn.json
+++ b/apps/mobile/src/i18n/locales/bn.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Kilo Gateway-এ কথা থেকে লেখা",
+ "gatewayTranscriptionSubtitle": "ডিভাইসের স্পিচ রিকগনিশনের বদলে Kilo Gateway মডেল ব্যবহার করে ভয়েস ইনপুট থেকে লেখা তৈরি করুন। আপনার রেকর্ডিং Kilo Gateway-এ পাঠানো হবে।",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "পুল রিকোয়েস্ট পাওয়া যাচ্ছে না",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "{{language}}-এর অফলাইন স্পিচ ফাইলগুলি এই ফোনে ইনস্টল করা নেই। সেগুলি ডাউনলোড করুন, তারপর আবার ভয়েস ইনপুট ব্যবহার করুন।",
"downloadOfflineModel": "ডাউনলোড",
"offlineModelDownloadScheduled": "অফলাইন স্পিচ ফাইলগুলি ব্যাকগ্রাউন্ডে ডাউনলোড হবে। পরে আবার ভয়েস ইনপুট চেষ্টা করুন।",
- "listeningStopped": "শোনা বন্ধ হয়েছে।"
+ "listeningStopped": "শোনা বন্ধ হয়েছে।",
+ "transcribing": "কথা থেকে লেখা হচ্ছে...",
+ "gatewayUnreachable": "Kilo Gateway-এর সঙ্গে যোগাযোগ করা যায়নি। আপনার সংযোগ পরীক্ষা করে আবার চেষ্টা করুন।",
+ "gatewayTimeout": "কথা থেকে লেখতে অনেক সময় লেগেছে। আবার চেষ্টা করুন।",
+ "gatewayModelUnavailable": "কথা থেকে লেখার এই মডেলটি পাওয়া যাচ্ছে না। পছন্দের সেটিংস থেকে আরেকটি বেছে নিন।",
+ "gatewaySignInRequired": "Kilo Gateway দিয়ে কথা থেকে লেখা ব্যবহার করতে সাইন ইন করুন।",
+ "gatewayNoModel": "প্রথমে পছন্দের সেটিংস থেকে কথা থেকে লেখার একটি মডেল বেছে নিন।"
},
"share": {
"title": "Kilo-তে শেয়ার",
@@ -2950,5 +2959,12 @@
"needsInput": "ইনপুট প্রয়োজন",
"channelName": "সক্রিয় এজেন্ট",
"activityKitDisabledBody": "লক স্ক্রিনে সক্রিয় এজেন্ট দেখতে সেটিংসে লাইভ অ্যাক্টিভিটি চালু করুন।"
+ },
+ "transcriptionModel": {
+ "title": "কথা থেকে লেখার মডেল",
+ "noneChosen": "কোনোটিই নির্বাচন করা হয়নি",
+ "emptyTitle": "কথা থেকে লেখার কোনো মডেল নেই",
+ "emptyDescription": "Kilo Gateway এই মুহূর্তে কথা থেকে লেখার কোনো মডেল দিচ্ছে না।",
+ "loadFailed": "কথা থেকে লেখার মডেল লোড করা যায়নি।"
}
}
diff --git a/apps/mobile/src/i18n/locales/bs.json b/apps/mobile/src/i18n/locales/bs.json
index 127f36e224..5070fe049e 100644
--- a/apps/mobile/src/i18n/locales/bs.json
+++ b/apps/mobile/src/i18n/locales/bs.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Transkripcija putem Kilo Gatewaya",
+ "gatewayTranscriptionSubtitle": "Prepiši glasovni unos modelom Kilo Gatewaya umjesto prepoznavanja govora na uređaju. Snimka se šalje na Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Zahtjev za spajanje nije dostupan",
@@ -2589,7 +2592,13 @@
"languageNotInstalledMessage": "Vanmrežne datoteke govora za {{language}} nisu instalirane na ovom telefonu. Preuzmi ih, a zatim pokušaj glasovni unos ponovo.",
"downloadOfflineModel": "Preuzmi",
"offlineModelDownloadScheduled": "Vanmrežne datoteke govora bit će preuzete u pozadini. Pokušaj glasovni unos ponovo kasnije.",
- "listeningStopped": "Slušanje je zaustavljeno."
+ "listeningStopped": "Slušanje je zaustavljeno.",
+ "transcribing": "Transkribiram...",
+ "gatewayUnreachable": "Nije bilo moguće povezati se s Kilo Gatewayem. Provjeri vezu i pokušaj ponovo.",
+ "gatewayTimeout": "Transkripcija je predugo trajala. Pokušaj ponovo.",
+ "gatewayModelUnavailable": "Ovaj model za transkripciju nije dostupan. Odaberi drugi u Postavkama.",
+ "gatewaySignInRequired": "Prijavi se da koristiš transkripciju putem Kilo Gatewaya.",
+ "gatewayNoModel": "Prvo odaberi model za transkripciju u Postavkama."
},
"share": {
"title": "Podijeli s aplikacijom Kilo",
@@ -2972,5 +2981,12 @@
"needsInput": "Čeka unos",
"channelName": "Aktivni agenti",
"activityKitDisabledBody": "Uključi aktivnosti uživo u postavkama da vidiš aktivne agente na zaključanom ekranu."
+ },
+ "transcriptionModel": {
+ "title": "Model za transkripciju",
+ "noneChosen": "Nije odabran",
+ "emptyTitle": "Nema modela za transkripciju",
+ "emptyDescription": "Kilo Gateway trenutno ne nudi modele za transkripciju.",
+ "loadFailed": "Nije moguće učitati modele za transkripciju."
}
}
diff --git a/apps/mobile/src/i18n/locales/ca.json b/apps/mobile/src/i18n/locales/ca.json
index 8cf7e91a04..0a2ec76227 100644
--- a/apps/mobile/src/i18n/locales/ca.json
+++ b/apps/mobile/src/i18n/locales/ca.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Transcripció amb Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Transcriu el dictat amb un model de Kilo Gateway en lloc del reconeixement de veu del dispositiu. Enviarem la teva gravació a Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Sol·licitud d'integració no disponible",
@@ -2589,7 +2592,13 @@
"languageNotInstalledMessage": "Els fitxers de veu fora de línia de {{language}} no estan instal·lats en aquest telèfon. Baixa'ls i torna-ho a provar amb el dictat.",
"downloadOfflineModel": "Baixa",
"offlineModelDownloadScheduled": "Els fitxers de veu fora de línia es baixaran en segon pla. Torna a provar el dictat més tard.",
- "listeningStopped": "S'ha deixat d'escoltar."
+ "listeningStopped": "S'ha deixat d'escoltar.",
+ "transcribing": "S'està transcrivint…",
+ "gatewayUnreachable": "No s'ha pogut contactar amb Kilo Gateway. Comprova la connexió i torna-ho a provar.",
+ "gatewayTimeout": "La transcripció ha trigat massa. Torna-ho a provar.",
+ "gatewayModelUnavailable": "Aquest model de transcripció no està disponible. Tria'n un altre a les Preferències.",
+ "gatewaySignInRequired": "Inicia la sessió per utilitzar la transcripció amb Kilo Gateway.",
+ "gatewayNoModel": "Primer tria un model de transcripció a les Preferències."
},
"share": {
"title": "Compartir amb Kilo",
@@ -2972,5 +2981,12 @@
"needsInput": "Pendent de resposta",
"channelName": "Agents actius",
"activityKitDisabledBody": "Activa les activitats en directe a la configuració del dispositiu per veure els agents actius a la pantalla de bloqueig."
+ },
+ "transcriptionModel": {
+ "title": "Model de transcripció",
+ "noneChosen": "Cap seleccionat",
+ "emptyTitle": "Cap model de transcripció",
+ "emptyDescription": "Kilo Gateway no ofereix cap model de transcripció ara mateix.",
+ "loadFailed": "No s'han pogut carregar els models de transcripció."
}
}
diff --git a/apps/mobile/src/i18n/locales/ckb.json b/apps/mobile/src/i18n/locales/ckb.json
index 305fb9c751..76797592f2 100644
--- a/apps/mobile/src/i18n/locales/ckb.json
+++ b/apps/mobile/src/i18n/locales/ckb.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "گۆڕینی دەنگ بۆ دەق بە Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "دەنگەکانت بە مۆدێلێکی Kilo Gateway بگۆڕە بۆ دەق، نەک بە ناسینەوەی قسەکردنی ئامێرەکەت. تۆمارەکەت بۆ Kilo Gateway دەنێردرێت.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "داواکاری ڕاکێشان بەردەست نییە",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "فایلە دەنگییە ئۆفلاینەکانی {{language}} لەسەر ئەم مۆبایلە دانەمەزراون. داگریان بکە، پاشان دووبارە نووسین بە دەنگ تاقی بکەوە.",
"downloadOfflineModel": "داگرتن",
"offlineModelDownloadScheduled": "فایلە دەنگییە ئۆفلاینەکان لە پاشبنەمادا داگیرێن. پاشان دووبارە نووسین بە دەنگ تاقی بکەوە.",
- "listeningStopped": "گوێگرتن وەستا."
+ "listeningStopped": "گوێگرتن وەستا.",
+ "transcribing": "گۆڕانی دەنگ بۆ دەق...",
+ "gatewayUnreachable": "نەتوانرا پەیوەندی بە Kilo Gateway بکرێت. پەیوەندییەکەت بپشکنە و دووبارە هەوڵ بدە.",
+ "gatewayTimeout": "گۆڕینی دەنگ بۆ دەق زۆر درێژەی خایاند. دووبارە هەوڵ بدە.",
+ "gatewayModelUnavailable": "ئەم مۆدێلەی گۆڕینی دەنگ بۆ دەق بەردەست نییە. لە ڕێکخستنە کەسییەکان یەکی تر هەڵبژێرە.",
+ "gatewaySignInRequired": "بچۆ ژوورەوە بۆ بەکارهێنانی گۆڕینی دەنگ بە Kilo Gateway.",
+ "gatewayNoModel": "سەرەتا لە ڕێکخستنە کەسییەکان مۆدێلێکی گۆڕینی دەنگ بۆ دەق هەڵبژێرە."
},
"share": {
"title": "هاوبەشکردن بۆ Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "چاوەڕێی وەڵام",
"channelName": "ئەجێنتە چالاکەکان",
"activityKitDisabledBody": "بۆ بینینی ئەجێنتە چالاکەکان لە شاشەی قوفڵدا، چالاکییە ڕاستەوخۆکان لە ڕێکخستنەکاندا چالاک بکە."
+ },
+ "transcriptionModel": {
+ "title": "مۆدێلی گۆڕینی دەنگ بۆ دەق",
+ "noneChosen": "هیچ هەڵنەبژێردراوە",
+ "emptyTitle": "هیچ مۆدێلێکی گۆڕینی دەنگ بۆ دەق نییە",
+ "emptyDescription": "Kilo Gateway ئێستا هیچ مۆدێلێکی گۆڕینی دەنگ بۆ دەقی نییە.",
+ "loadFailed": "نەتوانرا مۆدێلەکانی گۆڕینی دەنگ بۆ دەق بار بکرێن."
}
}
diff --git a/apps/mobile/src/i18n/locales/cs.json b/apps/mobile/src/i18n/locales/cs.json
index 024b89f67f..fa1f43da57 100644
--- a/apps/mobile/src/i18n/locales/cs.json
+++ b/apps/mobile/src/i18n/locales/cs.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Přepis přes Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Přepisujte diktování pomocí modelu Kilo Gateway místo rozpoznávání řeči ve vašem zařízení. Nahraný zvuk se odešle do Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "PR není dostupný",
@@ -2610,7 +2613,13 @@
"languageNotInstalledMessage": "Offline soubory řeči pro {{language}} nejsou v tomto telefonu nainstalovány. Stáhněte je a poté zkuste diktování znovu.",
"downloadOfflineModel": "Stáhnout",
"offlineModelDownloadScheduled": "Offline soubory řeči se stáhnou na pozadí. Zkuste diktování znovu později.",
- "listeningStopped": "Poslech zastaven."
+ "listeningStopped": "Poslech zastaven.",
+ "transcribing": "Přepisuji…",
+ "gatewayUnreachable": "Kilo Gateway se nepodařilo kontaktovat. Zkontrolujte připojení a zkuste to znovu.",
+ "gatewayTimeout": "Přepis trval příliš dlouho. Zkuste to znovu.",
+ "gatewayModelUnavailable": "Tento model přepisu není dostupný. Vyberte jiný v Předvolbách.",
+ "gatewaySignInRequired": "Pro použití přepisu přes Kilo Gateway se přihlaste.",
+ "gatewayNoModel": "Nejprve vyberte model přepisu v Předvolbách."
},
"share": {
"title": "Sdílení do Kilo",
@@ -2994,5 +3003,12 @@
"needsInput": "Čeká na reakci",
"channelName": "Aktivní agenti",
"activityKitDisabledBody": "V nastavení zapni živé aktivity, aby se aktivní agenti zobrazovali na zamknuté obrazovce."
+ },
+ "transcriptionModel": {
+ "title": "Model přepisu",
+ "noneChosen": "Není vybrán žádný",
+ "emptyTitle": "Žádné modely přepisu",
+ "emptyDescription": "Kilo Gateway nyní nenabízí žádné modely přepisu.",
+ "loadFailed": "Modely přepisu se nepodařilo načíst."
}
}
diff --git a/apps/mobile/src/i18n/locales/cy.json b/apps/mobile/src/i18n/locales/cy.json
index d54e27b0cc..f117cc71e9 100644
--- a/apps/mobile/src/i18n/locales/cy.json
+++ b/apps/mobile/src/i18n/locales/cy.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Trawsgrifio drwy Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Trawsgrifiwch fewnbwn llais â model Kilo Gateway yn lle adnabod lleferydd y ddyfais. Anfonir eich recordiad i Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Cais tynnu ddim ar gael",
@@ -2652,7 +2655,13 @@
"languageNotInstalledMessage": "Nid yw ffeiliau lleferydd all-lein {{language}} wedi'u gosod ar y ffôn hon. Llwythwch nhw i lawr, yna ceisiwch fewnbwn llais eto.",
"downloadOfflineModel": "Llwytho i lawr",
"offlineModelDownloadScheduled": "Bydd y ffeiliau lleferydd all-lein yn cael eu llwytho i lawr yn y cefndir. Ceisiwch fewnbwn llais eto yn nes ymlaen.",
- "listeningStopped": "Daeth y gwrando i ben."
+ "listeningStopped": "Daeth y gwrando i ben.",
+ "transcribing": "Yn trawsgrifio...",
+ "gatewayUnreachable": "Doedd dim modd cyrraedd Kilo Gateway. Gwiriwch eich cysylltiad a cheisiwch eto.",
+ "gatewayTimeout": "Cymrodd y trawsgrifio rhy lawer o amser. Ceisiwch eto.",
+ "gatewayModelUnavailable": "Nid yw'r model trawsgrifio hwn ar gael. Dewiswch un arall yn y Dewisiadau.",
+ "gatewaySignInRequired": "Mewngofnodwch i ddefnyddio trawsgrifio drwy Kilo Gateway.",
+ "gatewayNoModel": "Dewiswch fodel trawsgrifio yn y Dewisiadau yn gyntaf."
},
"share": {
"title": "Rhannu â Kilo",
@@ -3038,5 +3047,12 @@
"needsInput": "Angen mewnbwn",
"channelName": "Asiantau gweithredol",
"activityKitDisabledBody": "Trowch weithgareddau byw ymlaen yn y gosodiadau i weld asiantau gweithredol ar y sgrin glo."
+ },
+ "transcriptionModel": {
+ "title": "Model trawsgrifio",
+ "noneChosen": "Dim wedi'i ddewis",
+ "emptyTitle": "Dim modelau trawsgrifio",
+ "emptyDescription": "Does dim modelau trawsgrifio ar gael gan Kilo Gateway ar hyn o bryd.",
+ "loadFailed": "Doedd dim modd llwytho'r modelau trawsgrifio."
}
}
diff --git a/apps/mobile/src/i18n/locales/da.json b/apps/mobile/src/i18n/locales/da.json
index 0f636bdd00..ab0132454f 100644
--- a/apps/mobile/src/i18n/locales/da.json
+++ b/apps/mobile/src/i18n/locales/da.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Transskription via Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Transskribér stemmeinput med en Kilo Gateway-model i stedet for enhedens stemmegenkendelse. Din optagelse sendes til Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Pull request ikke tilgængelig",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "De offline talefiler til {{language}} er ikke installeret på denne telefon. Download dem, og prøv diktering igen.",
"downloadOfflineModel": "Download",
"offlineModelDownloadScheduled": "De offline talefiler downloades i baggrunden. Prøv diktering igen senere.",
- "listeningStopped": "Dikteringen er stoppet."
+ "listeningStopped": "Dikteringen er stoppet.",
+ "transcribing": "Transskriberer...",
+ "gatewayUnreachable": "Kunne ikke nå Kilo Gateway. Tjek din forbindelse og prøv igen.",
+ "gatewayTimeout": "Transskriptionen tog for lang tid. Prøv igen.",
+ "gatewayModelUnavailable": "Denne transskriptionsmodel er ikke tilgængelig. Vælg en anden under Indstillinger.",
+ "gatewaySignInRequired": "Log ind for at bruge transskription via Kilo Gateway.",
+ "gatewayNoModel": "Vælg først en transskriptionsmodel under Indstillinger."
},
"share": {
"title": "Del med Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "Afventer svar",
"channelName": "Aktive agenter",
"activityKitDisabledBody": "Slå liveaktiviteter til i Indstillinger for at se aktive agenter på låseskærmen."
+ },
+ "transcriptionModel": {
+ "title": "Transskriptionsmodel",
+ "noneChosen": "Ingen valgt",
+ "emptyTitle": "Ingen transskriptionsmodeller",
+ "emptyDescription": "Kilo Gateway tilbyder ikke nogen transskriptionsmodeller lige nu.",
+ "loadFailed": "Transskriptionsmodellerne kunne ikke indlæses."
}
}
diff --git a/apps/mobile/src/i18n/locales/de.json b/apps/mobile/src/i18n/locales/de.json
index e9190f5111..48a5d24faf 100644
--- a/apps/mobile/src/i18n/locales/de.json
+++ b/apps/mobile/src/i18n/locales/de.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Gateway-Transkription",
+ "gatewayTranscriptionSubtitle": "Transkribiere die Spracheingabe mit einem Kilo-Gateway-Modell statt mit der Spracherkennung des Geräts. Deine Aufnahme wird an das Kilo Gateway gesendet.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"notifications": {
"liveActivities": "Live-Aktivitäten",
@@ -2134,7 +2137,13 @@
"languageNotInstalledMessage": "Die Offline-Sprachdateien für {{language}} sind auf diesem Telefon nicht installiert. Lade sie herunter und versuche die Spracheingabe erneut.",
"downloadOfflineModel": "Herunterladen",
"offlineModelDownloadScheduled": "Die Offline-Sprachdateien werden im Hintergrund heruntergeladen. Versuche die Spracheingabe später erneut.",
- "listeningStopped": "Die Aufnahme wurde beendet."
+ "listeningStopped": "Die Aufnahme wurde beendet.",
+ "transcribing": "Transkription läuft …",
+ "gatewayUnreachable": "Das Kilo Gateway konnte nicht erreicht werden. Prüfe deine Verbindung und versuche es erneut.",
+ "gatewayTimeout": "Die Transkription hat zu lange gedauert. Versuche es erneut.",
+ "gatewayModelUnavailable": "Dieses Transkriptionsmodell ist nicht verfügbar. Wähle ein anderes in den Einstellungen.",
+ "gatewaySignInRequired": "Melde dich an, um die Gateway-Transkription zu nutzen.",
+ "gatewayNoModel": "Wähle zuerst in den Einstellungen ein Transkriptionsmodell."
},
"share": {
"title": "Mit Kilo teilen",
@@ -2950,5 +2959,12 @@
"needsInput": "Eingabe erforderlich",
"channelName": "Aktive Agenten",
"activityKitDisabledBody": "Aktiviere Live-Aktivitäten in den Einstellungen, um aktive Agenten auf dem Sperrbildschirm zu sehen."
+ },
+ "transcriptionModel": {
+ "title": "Transkriptionsmodell",
+ "noneChosen": "Keines ausgewählt",
+ "emptyTitle": "Keine Transkriptionsmodelle",
+ "emptyDescription": "Kilo Gateway bietet derzeit keine Transkriptionsmodelle.",
+ "loadFailed": "Die Transkriptionsmodelle konnten nicht geladen werden."
}
}
diff --git a/apps/mobile/src/i18n/locales/el.json b/apps/mobile/src/i18n/locales/el.json
index 5442ce65ea..73929c25c3 100644
--- a/apps/mobile/src/i18n/locales/el.json
+++ b/apps/mobile/src/i18n/locales/el.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Απομαγνητοφώνηση μέσω Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Η φωνητική πληκτρολόγηση μετατρέπεται σε κείμενο με ένα μοντέλο του Kilo Gateway αντί για την αναγνώριση ομιλίας της συσκευής. Η ηχογράφηση αποστέλλεται στο Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Μη διαθέσιμο αίτημα ενσωμάτωσης",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "Τα αρχεία ομιλίας εκτός σύνδεσης για {{language}} δεν είναι εγκατεστημένα σε αυτό το τηλέφωνο. Κάνε λήψη τους και μετά δοκίμασε ξανά τη φωνητική πληκτρολόγηση.",
"downloadOfflineModel": "Λήψη",
"offlineModelDownloadScheduled": "Η λήψη των αρχείων ομιλίας εκτός σύνδεσης θα γίνει στο παρασκήνιο. Δοκίμασε ξανά τη φωνητική πληκτρολόγηση αργότερα.",
- "listeningStopped": "Η ακρόαση σταμάτησε."
+ "listeningStopped": "Η ακρόαση σταμάτησε.",
+ "transcribing": "Απομαγνητοφώνηση...",
+ "gatewayUnreachable": "Δεν ήταν δυνατή η επικοινωνία με το Kilo Gateway. Έλεγξε τη σύνδεσή σου και δοκίμασε ξανά.",
+ "gatewayTimeout": "Η απομαγνητοφώνηση πήρε υπερβολικό χρόνο. Δοκίμασε ξανά.",
+ "gatewayModelUnavailable": "Αυτό το μοντέλο απομαγνητοφώνησης δεν είναι διαθέσιμο. Διάλεξε άλλο στις Προτιμήσεις.",
+ "gatewaySignInRequired": "Συνδέσου για να χρησιμοποιήσεις την απομαγνητοφώνηση μέσω Kilo Gateway.",
+ "gatewayNoModel": "Διάλεξε πρώτα ένα μοντέλο απομαγνητοφώνησης στις Προτιμήσεις."
},
"share": {
"title": "Κοινοποίηση στο Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "Αναμονή απάντησης",
"channelName": "Ενεργοί πράκτορες",
"activityKitDisabledBody": "Ενεργοποίησε τις ζωντανές δραστηριότητες στις ρυθμίσεις για να βλέπεις τους ενεργούς πράκτορες στην οθόνη κλειδώματος."
+ },
+ "transcriptionModel": {
+ "title": "Μοντέλο απομαγνητοφώνησης",
+ "noneChosen": "Δεν έχει επιλεγεί",
+ "emptyTitle": "Δεν υπάρχουν μοντέλα απομαγνητοφώνησης",
+ "emptyDescription": "Το Kilo Gateway δεν προσφέρει αυτή τη στιγμή μοντέλα απομαγνητοφώνησης.",
+ "loadFailed": "Δεν ήταν δυνατή η φόρτωση των μοντέλων απομαγνητοφώνησης."
}
}
diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json
index b4a557db87..6e42359f6b 100644
--- a/apps/mobile/src/i18n/locales/en.json
+++ b/apps/mobile/src/i18n/locales/en.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Gateway transcription",
+ "gatewayTranscriptionSubtitle": "Transcribe voice input with a Kilo gateway model instead of the device's speech recognition. Your recording is sent to the Kilo gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Pull request unavailable",
@@ -2584,7 +2587,13 @@
"languageNotInstalledTitle": "Voice input for {{language}} isn't ready",
"languageNotInstalledMessage": "The {{language}} offline speech files aren't installed on this phone. Download them, then try voice input again.",
"downloadOfflineModel": "Download",
- "offlineModelDownloadScheduled": "The offline speech files will download in the background. Try voice input again later."
+ "offlineModelDownloadScheduled": "The offline speech files will download in the background. Try voice input again later.",
+ "transcribing": "Transcribing...",
+ "gatewayUnreachable": "Couldn't reach the Kilo gateway. Check your connection and try again.",
+ "gatewayTimeout": "Transcription took too long. Try again.",
+ "gatewayModelUnavailable": "This transcription model isn't available. Pick another one in Preferences.",
+ "gatewaySignInRequired": "Sign in to use gateway transcription.",
+ "gatewayNoModel": "Choose a transcription model in Preferences first."
},
"share": {
"title": "Share to Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "Needs input",
"channelName": "Active agents",
"activityKitDisabledBody": "Turn on Live Activities in Settings to see Active Agents on the Lock Screen."
+ },
+ "transcriptionModel": {
+ "title": "Transcription model",
+ "noneChosen": "None chosen",
+ "emptyTitle": "No transcription models",
+ "emptyDescription": "The gateway offers no transcription models right now.",
+ "loadFailed": "Couldn't load transcription models."
}
}
diff --git a/apps/mobile/src/i18n/locales/es.json b/apps/mobile/src/i18n/locales/es.json
index 77efea6f49..e90280d428 100644
--- a/apps/mobile/src/i18n/locales/es.json
+++ b/apps/mobile/src/i18n/locales/es.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Transcripción con Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Transcribe el dictado con un modelo de Kilo Gateway en lugar del reconocimiento de voz del dispositivo. Tu grabación se envía a Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"notifications": {
"channel": {
@@ -2148,7 +2151,13 @@
"languageNotInstalledMessage": "Los archivos de voz sin conexión de {{language}} no están instalados en este teléfono. Descárgalos y vuelve a intentar el dictado.",
"downloadOfflineModel": "Descargar",
"offlineModelDownloadScheduled": "Los archivos de voz sin conexión se descargarán en segundo plano. Vuelve a probar el dictado más tarde.",
- "listeningStopped": "Se ha detenido la escucha."
+ "listeningStopped": "Se ha detenido la escucha.",
+ "transcribing": "Transcribiendo...",
+ "gatewayUnreachable": "No se pudo conectar con Kilo Gateway. Comprueba tu conexión e inténtalo de nuevo.",
+ "gatewayTimeout": "La transcripción tardó demasiado. Inténtalo de nuevo.",
+ "gatewayModelUnavailable": "Este modelo de transcripción no está disponible. Elige otro en Preferencias.",
+ "gatewaySignInRequired": "Inicia sesión para usar la transcripción con Kilo Gateway.",
+ "gatewayNoModel": "Elige primero un modelo de transcripción en Preferencias."
},
"share": {
"title": "Compartir en Kilo",
@@ -2972,5 +2981,12 @@
"needsInput": "En espera de respuesta",
"channelName": "Agentes activos",
"activityKitDisabledBody": "Activa las actividades en directo en Ajustes para ver los agentes activos en la pantalla de bloqueo."
+ },
+ "transcriptionModel": {
+ "title": "Modelo de transcripción",
+ "noneChosen": "Ninguno seleccionado",
+ "emptyTitle": "No hay modelos de transcripción",
+ "emptyDescription": "Kilo Gateway no ofrece modelos de transcripción en este momento.",
+ "loadFailed": "No se pudieron cargar los modelos de transcripción."
}
}
diff --git a/apps/mobile/src/i18n/locales/et.json b/apps/mobile/src/i18n/locales/et.json
index 5e8378516f..9a0093cd2e 100644
--- a/apps/mobile/src/i18n/locales/et.json
+++ b/apps/mobile/src/i18n/locales/et.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Kõnetuvastus Kilo Gateway kaudu",
+ "gatewayTranscriptionSubtitle": "Kasuta häälsisestuse tekstiks teisendamiseks seadme kõnetuvastuse asemel Kilo Gateway mudelit. Sinu salvestus saadetakse Kilo Gateway'sse.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Tõmbetaotlus pole saadaval",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "Keele {{language}} võrguühenduseta kõnefailid pole selles telefonis installitud. Laadi need alla ja proovi häälsisestust uuesti.",
"downloadOfflineModel": "Laadi alla",
"offlineModelDownloadScheduled": "Võrguühenduseta kõnefailid laaditakse taustal alla. Proovi häälsisestust hiljem uuesti.",
- "listeningStopped": "Kuulamine peatus."
+ "listeningStopped": "Kuulamine peatus.",
+ "transcribing": "Kõnetuvastus...",
+ "gatewayUnreachable": "Ühendust Kilo Gatewayga ei õnnestunud saada. Kontrolli ühendust ja proovi uuesti.",
+ "gatewayTimeout": "Kõnetuvastus võttis liiga palju aega. Proovi uuesti.",
+ "gatewayModelUnavailable": "Seda kõnetuvastuse mudelit pole saadaval. Vali Eelistustes mõni teine.",
+ "gatewaySignInRequired": "Logi sisse, et kasutada Kilo Gateway kõnetuvastust.",
+ "gatewayNoModel": "Vali kõigepealt Eelistustes kõnetuvastuse mudel."
},
"share": {
"title": "Kilosse jagamine",
@@ -2950,5 +2959,12 @@
"needsInput": "Vajab sisendit",
"channelName": "Aktiivsed agendid",
"activityKitDisabledBody": "Lülita seadetes reaalajas tegevused sisse, et näha lukustuskuval aktiivseid agente."
+ },
+ "transcriptionModel": {
+ "title": "Kõnetuvastuse mudel",
+ "noneChosen": "Pole valitud",
+ "emptyTitle": "Kõnetuvastuse mudelid puuduvad",
+ "emptyDescription": "Kilo Gateway ei paku praegu ühtegi kõnetuvastuse mudelit.",
+ "loadFailed": "Kõnetuvastuse mudeleid ei õnnestunud laadida."
}
}
diff --git a/apps/mobile/src/i18n/locales/eu.json b/apps/mobile/src/i18n/locales/eu.json
index ae14f41b50..fbfa15a581 100644
--- a/apps/mobile/src/i18n/locales/eu.json
+++ b/apps/mobile/src/i18n/locales/eu.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Kilo Gateway bidezko transkripzioa",
+ "gatewayTranscriptionSubtitle": "Transkribatu ahots-sarrera gailuaren hitz-sorgailuaren ordez Kilo Gateway eredu batekin. Zure grabaketa Kilo Gatewayra bidaltzen da.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Bateratze-eskaera ez dago eskuragarri",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "{{language}} hizkuntzaren lineaz kanpo ahots-fitxategiak ez daude instalatuta telefono honetan. Deskargatu itzazu eta saiatu berriro ahots-sarrerarekin.",
"downloadOfflineModel": "Deskargatu",
"offlineModelDownloadScheduled": "Lineaz kanpoko ahots-fitxategiak atzeko planoan deskargatuko dira. Saiatu ahots-sarrerarekin berriro geroago.",
- "listeningStopped": "Entzutea gelditu da."
+ "listeningStopped": "Entzutea gelditu da.",
+ "transcribing": "Transkribatzen...",
+ "gatewayUnreachable": "Ezin izan da Kilo Gatewayra iritsi. Egiaztatu konexioa eta saiatu berriro.",
+ "gatewayTimeout": "Transkripzioak gehiegi iraun du. Saiatu berriro.",
+ "gatewayModelUnavailable": "Transkripzio-eredu hau ez dago erabilgarri. Hautatu besteren bat Hobespenetan.",
+ "gatewaySignInRequired": "Hasi saioa Kilo Gateway bidezko transkripzioa erabiltzeko.",
+ "gatewayNoModel": "Hautatu lehenik transkripzio-eredu bat Hobespenetan."
},
"share": {
"title": "Partekatu Kilo-n",
@@ -2950,5 +2959,12 @@
"needsInput": "Zure erantzunaren zain",
"channelName": "Agente aktiboak",
"activityKitDisabledBody": "Aktibatu zuzeneko jarduerak ezarpenetan, agente aktiboak blokeo-pantailan ikusteko."
+ },
+ "transcriptionModel": {
+ "title": "Transkripzio-eredua",
+ "noneChosen": "Ezer hautatu gabe",
+ "emptyTitle": "Ez dago transkripzio-eredurik",
+ "emptyDescription": "Kilo Gateway-k ez du transkripzio-eredurik eskaintzen oraintxe.",
+ "loadFailed": "Ezin izan dira transkripzio-ereduak kargatu."
}
}
diff --git a/apps/mobile/src/i18n/locales/fa.json b/apps/mobile/src/i18n/locales/fa.json
index fecfad6e3e..abca2b03e5 100644
--- a/apps/mobile/src/i18n/locales/fa.json
+++ b/apps/mobile/src/i18n/locales/fa.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "تبدیل گفتار به متن با Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "ورودی صوتی را بهجای تشخیص گفتار دستگاه، با یک مدل Kilo Gateway به متن تبدیل کنید. صدای ضبطشده شما به Kilo Gateway فرستاده میشود.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "درخواست ادغام در دسترس نیست",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "فایلهای گفتار آفلاین برای {{language}} روی این گوشی نصب نشدهاند. آنها را دانلود کنید، سپس دوباره ورودی صوتی را امتحان کنید.",
"downloadOfflineModel": "دانلود",
"offlineModelDownloadScheduled": "فایلهای گفتار آفلاین در پسزمینه دانلود میشوند. بعداً دوباره ورودی صوتی را امتحان کنید.",
- "listeningStopped": "گوش دادن متوقف شد."
+ "listeningStopped": "گوش دادن متوقف شد.",
+ "transcribing": "در حال تبدیل به متن...",
+ "gatewayUnreachable": "اتصال به Kilo Gateway ممکن نشد. اتصال خود را بررسی کنید و دوباره تلاش کنید.",
+ "gatewayTimeout": "تبدیل گفتار به متن بیش از حد طول کشید. دوباره تلاش کنید.",
+ "gatewayModelUnavailable": "این مدل تبدیل گفتار به متن در دسترس نیست. در تنظیمات دلخواه مدل دیگری انتخاب کنید.",
+ "gatewaySignInRequired": "برای استفاده از تبدیل گفتار به متن با Kilo Gateway وارد شوید.",
+ "gatewayNoModel": "ابتدا در تنظیمات دلخواه یک مدل تبدیل گفتار به متن انتخاب کنید."
},
"share": {
"title": "اشتراکگذاری با Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "در انتظار ورودی",
"channelName": "عاملهای فعال",
"activityKitDisabledBody": "برای دیدن عاملهای فعال در صفحهٔ قفل، فعالیتهای زنده را در تنظیمات فعال کنید."
+ },
+ "transcriptionModel": {
+ "title": "مدل تبدیل گفتار به متن",
+ "noneChosen": "هیچکدام انتخاب نشده",
+ "emptyTitle": "مدل تبدیل گفتار به متن وجود ندارد",
+ "emptyDescription": "Kilo Gateway در حال حاضر مدلی برای تبدیل گفتار به متن ارائه نمیدهد.",
+ "loadFailed": "بارگذاری مدلهای تبدیل گفتار به متن ناموفق بود."
}
}
diff --git a/apps/mobile/src/i18n/locales/fi.json b/apps/mobile/src/i18n/locales/fi.json
index 47d454f795..d4ae097f4b 100644
--- a/apps/mobile/src/i18n/locales/fi.json
+++ b/apps/mobile/src/i18n/locales/fi.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Puheentunnistus Kilo Gatewayn kautta",
+ "gatewayTranscriptionSubtitle": "Muodosta äänisyötteestä teksti Kilo Gateway -mallilla laitteen puheentunnistuksen sijaan. Äänitteesi lähetetään Kilo Gatewayhin.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Muutospyyntö ei ole käytettävissä",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "Kielen {{language}} offline-puhetiedostoja ei ole asennettu tähän puhelimeen. Lataa ne ja kokeile puheentunnistusta uudelleen.",
"downloadOfflineModel": "Lataa",
"offlineModelDownloadScheduled": "Offline-puhetiedostot ladataan taustalla. Kokeile puheentunnistusta myöhemmin uudelleen.",
- "listeningStopped": "Kuuntelu lopetettiin."
+ "listeningStopped": "Kuuntelu lopetettiin.",
+ "transcribing": "Muunnetaan tekstiksi…",
+ "gatewayUnreachable": "Kilo Gatewayhin ei saatu yhteyttä. Tarkista yhteys ja yritä uudelleen.",
+ "gatewayTimeout": "Puheentunnistus kesti liian kauan. Yritä uudelleen.",
+ "gatewayModelUnavailable": "Tätä puheentunnistusmallia ei ole käytettävissä. Valitse toinen Asetuksissa.",
+ "gatewaySignInRequired": "Kirjaudu sisään käyttääksesi Kilo Gatewayn puheentunnistusta.",
+ "gatewayNoModel": "Valitse ensin puheentunnistusmalli Asetuksissa."
},
"share": {
"title": "Jaa Kiloon",
@@ -2950,5 +2959,12 @@
"needsInput": "Odottaa vastausta",
"channelName": "Aktiiviset agentit",
"activityKitDisabledBody": "Ota live-aktiviteetit käyttöön asetuksissa, niin näet aktiiviset agentit lukitusnäytöllä."
+ },
+ "transcriptionModel": {
+ "title": "Puheentunnistusmalli",
+ "noneChosen": "Ei valintaa",
+ "emptyTitle": "Ei puheentunnistusmalleja",
+ "emptyDescription": "Kilo Gateway ei juuri nyt tarjoa puheentunnistusmalleja.",
+ "loadFailed": "Puheentunnistusmallien lataaminen epäonnistui."
}
}
diff --git a/apps/mobile/src/i18n/locales/fil.json b/apps/mobile/src/i18n/locales/fil.json
index e9268d2689..c40dcb7248 100644
--- a/apps/mobile/src/i18n/locales/fil.json
+++ b/apps/mobile/src/i18n/locales/fil.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Pagsasalin ng boses sa teksto gamit ang Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Gamitin ang modelong Kilo Gateway sa pagsasalin ng boses sa teksto imbes na ang pagkilala sa tinig ng device. Ipinapadala sa Kilo Gateway ang iyong recording.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Hindi mabuksan ang pull request",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "Hindi naka-install ang mga offline na speech file para sa {{language}} sa teleponong ito. I-download muna ang mga ito, pagkatapos ay subukang muli ang pagdikta.",
"downloadOfflineModel": "I-download",
"offlineModelDownloadScheduled": "Ida-download sa background ang mga offline na speech file. Subukang muli ang pagdikta mamaya.",
- "listeningStopped": "Huminto ang pakikinig."
+ "listeningStopped": "Huminto ang pakikinig.",
+ "transcribing": "Nagsasalin ng boses sa teksto...",
+ "gatewayUnreachable": "Hindi maabot ang Kilo Gateway. Suriin ang iyong koneksyon at subukang muli.",
+ "gatewayTimeout": "Nagtagal ang pagsasalin ng boses sa teksto. Subukang muli.",
+ "gatewayModelUnavailable": "Hindi magagamit ang modelong ito para sa pagsasalin ng boses sa teksto. Pumili ng iba sa Mga kagustuhan.",
+ "gatewaySignInRequired": "Mag-sign in para gamitin ang pagsasalin ng boses sa teksto gamit ang Kilo Gateway.",
+ "gatewayNoModel": "Pumili muna ng modelo para sa pagsasalin ng boses sa teksto sa Mga kagustuhan."
},
"share": {
"title": "Pagbabahagi sa Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "Naghihintay ng sagot",
"channelName": "Mga aktibong agent",
"activityKitDisabledBody": "Paganahin ang mga live na aktibidad sa mga setting para makita ang mga aktibong agent sa naka-lock na screen."
+ },
+ "transcriptionModel": {
+ "title": "Modelo para sa pagsasalin ng boses sa teksto",
+ "noneChosen": "Walang napili",
+ "emptyTitle": "Walang modelo para sa pagsasalin ng boses sa teksto",
+ "emptyDescription": "Walang iniaalok na modelo para sa pagsasalin ng boses sa teksto ang Kilo Gateway ngayon.",
+ "loadFailed": "Hindi ma-load ang mga modelo para sa pagsasalin ng boses sa teksto."
}
}
diff --git a/apps/mobile/src/i18n/locales/fr.json b/apps/mobile/src/i18n/locales/fr.json
index 00a5e96e52..a7ae733823 100644
--- a/apps/mobile/src/i18n/locales/fr.json
+++ b/apps/mobile/src/i18n/locales/fr.json
@@ -1986,7 +1986,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Transcription via Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Transcrivez la saisie vocale avec un modèle Kilo Gateway au lieu de la reconnaissance vocale de l'appareil. Votre enregistrement est envoyé à Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"addCredits": {
"cta": "Ajouter des crédits",
@@ -2079,7 +2082,13 @@
"languageNotInstalledMessage": "Les fichiers de reconnaissance vocale hors ligne pour {{language}} ne sont pas installés sur ce téléphone. Téléchargez-les, puis réessayez la saisie vocale.",
"downloadOfflineModel": "Télécharger",
"offlineModelDownloadScheduled": "Les fichiers de reconnaissance vocale hors ligne seront téléchargés en arrière-plan. Réessayez la saisie vocale plus tard.",
- "listeningStopped": "Écoute arrêtée."
+ "listeningStopped": "Écoute arrêtée.",
+ "transcribing": "Transcription en cours…",
+ "gatewayUnreachable": "Impossible de joindre Kilo Gateway. Vérifiez votre connexion et réessayez.",
+ "gatewayTimeout": "La transcription a pris trop de temps. Réessayez.",
+ "gatewayModelUnavailable": "Ce modèle de transcription n'est pas disponible. Choisissez-en un autre dans les Préférences.",
+ "gatewaySignInRequired": "Connectez-vous pour utiliser la transcription via Kilo Gateway.",
+ "gatewayNoModel": "Choisissez d'abord un modèle de transcription dans les Préférences."
},
"share": {
"title": "Partager avec Kilo",
@@ -2972,5 +2981,12 @@
"needsInput": "Intervention requise",
"channelName": "Agents actifs",
"activityKitDisabledBody": "Activez les activités en direct dans les paramètres pour voir les agents actifs sur l'écran verrouillé."
+ },
+ "transcriptionModel": {
+ "title": "Modèle de transcription",
+ "noneChosen": "Aucun choisi",
+ "emptyTitle": "Aucun modèle de transcription",
+ "emptyDescription": "Kilo Gateway ne propose actuellement aucun modèle de transcription.",
+ "loadFailed": "Impossible de charger les modèles de transcription."
}
}
diff --git a/apps/mobile/src/i18n/locales/ga.json b/apps/mobile/src/i18n/locales/ga.json
index 4e7be647e7..19275fd82d 100644
--- a/apps/mobile/src/i18n/locales/ga.json
+++ b/apps/mobile/src/i18n/locales/ga.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Tras-scríobh tríd an Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Déan an t-ionchur gutha a thras-scríobh le samhail Kilo Gateway in ionad aithint cainte an ghléis. Seoltar do thaifeadadh chuig an Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Iarratas tarraingthe gan fáil",
@@ -2631,7 +2634,13 @@
"languageNotInstalledMessage": "Níl comhaid cainte as líne do {{language}} suiteáilte ar an bhfón seo. Íoslódáil iad, agus bain triail as ionchur gutha arís ina dhiaidh sin.",
"downloadOfflineModel": "Íoslódáil",
"offlineModelDownloadScheduled": "Íoslódófar na comhaid cainte as líne sa chúlra. Bain triail as ionchur gutha arís níos déanaí.",
- "listeningStopped": "Stopadh an éisteacht."
+ "listeningStopped": "Stopadh an éisteacht.",
+ "transcribing": "Ag tras-scríobh...",
+ "gatewayUnreachable": "Níorbh fhéidir an Kilo Gateway a bhaint amach. Seiceáil do nasc agus bain triail as arís.",
+ "gatewayTimeout": "Thóg an tras-scríobh rófhada. Bain triail as arís.",
+ "gatewayModelUnavailable": "Níl an samhail tras-scríobh seo ar fáil. Roghnaigh ceann eile sna Roghanna.",
+ "gatewaySignInRequired": "Sínigh isteach chun tras-scríobh tríd an Kilo Gateway a úsáid.",
+ "gatewayNoModel": "Roghnaigh samhail tras-scríobh sna Roghanna ar dtús."
},
"share": {
"title": "Comhroinnt le Kilo",
@@ -3016,5 +3025,12 @@
"needsInput": "Ionchur de dhíth",
"channelName": "Gníomhairí gníomhacha",
"activityKitDisabledBody": "Cumasaigh gníomhaíochtaí beo sna socruithe chun gníomhairí gníomhacha a fheiceáil ar an scáileán glasála."
+ },
+ "transcriptionModel": {
+ "title": "Samhail tras-scríobh",
+ "noneChosen": "Níor roghnaíadh aon cheann",
+ "emptyTitle": "Níl samhail tras-scríobh ar bith ann",
+ "emptyDescription": "Níl aon shamhail tras-scríobh ar fáil ón Kilo Gateway faoi láthair.",
+ "loadFailed": "Níorbh fhéidir na samhlacha tras-scríobh a lódáil."
}
}
diff --git a/apps/mobile/src/i18n/locales/gl.json b/apps/mobile/src/i18n/locales/gl.json
index 4b883d6e27..135f1aa815 100644
--- a/apps/mobile/src/i18n/locales/gl.json
+++ b/apps/mobile/src/i18n/locales/gl.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Transcrición con Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Transcribe o ditado cun modelo de Kilo Gateway no canto do recoñecemento de voz do dispositivo. A túa gravación envíaselle a Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Solicitude de integración non dispoñible",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "Os ficheiros de voz sen conexión de {{language}} non están instalados neste teléfono. Descarga-os e tenta o ditado de novo.",
"downloadOfflineModel": "Descargar",
"offlineModelDownloadScheduled": "Os ficheiros de voz sen conexión descargaránse en segundo plano. Tenta o ditado de novo máis tarde.",
- "listeningStopped": "Detívose o ditado."
+ "listeningStopped": "Detívose o ditado.",
+ "transcribing": "Transcribindo...",
+ "gatewayUnreachable": "Non se puido conectar con Kilo Gateway. Comproba a conexión e téntao de novo.",
+ "gatewayTimeout": "A transcrición tardou demasiado. Téntao de novo.",
+ "gatewayModelUnavailable": "Este modelo de transcrición non está dispoñible. Escolle outro en Preferencias.",
+ "gatewaySignInRequired": "Inicia sesión para usar a transcrición con Kilo Gateway.",
+ "gatewayNoModel": "Escolle primeiro un modelo de transcrición en Preferencias."
},
"share": {
"title": "Compartir en Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "Agardando resposta",
"channelName": "Axentes activos",
"activityKitDisabledBody": "Activa as actividades en directo nos axustes para ver os axentes activos na pantalla de bloqueo."
+ },
+ "transcriptionModel": {
+ "title": "Modelo de transcrición",
+ "noneChosen": "Ningún seleccionado",
+ "emptyTitle": "Non hai modelos de transcrición",
+ "emptyDescription": "Kilo Gateway non ofrece modelos de transcrición neste momento.",
+ "loadFailed": "Non se puideron cargar os modelos de transcrición."
}
}
diff --git a/apps/mobile/src/i18n/locales/gu.json b/apps/mobile/src/i18n/locales/gu.json
index f1a29bcc26..eb630cd5ea 100644
--- a/apps/mobile/src/i18n/locales/gu.json
+++ b/apps/mobile/src/i18n/locales/gu.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Kilo Gateway દ્વારા અવાજને લખાણમાં ફેરવવું",
+ "gatewayTranscriptionSubtitle": "ઉપકરણની સ્પીચ રિકગ્નિશનને બદલે Kilo Gateway મોડેલ વડે વૉઇસ ઇનપુટને લખાણમાં ફેરવો. તમારું રેકોર્ડિંગ Kilo Gateway પર મોકલાય છે.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "પુલ રિક્વેસ્ટ ઉપલબ્ધ નથી",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "{{language}} માટેની ઓફલાઇન સ્પીચ ફાઇલો આ ફોન પર ઇન્સ્ટોલ કરેલી નથી. તેમને ડાઉનલોડ કરો, પછી વૉઇસ ઇનપુટ ફરી અજમાવો.",
"downloadOfflineModel": "ડાઉનલોડ",
"offlineModelDownloadScheduled": "ઓફલાઇન સ્પીચ ફાઇલો બેકગ્રાઉન્ડમાં ડાઉનલોડ થશે. પછી વૉઇસ ઇનપુટ ફરી અજમાવો.",
- "listeningStopped": "સાંભળવાનું બંધ થયું."
+ "listeningStopped": "સાંભળવાનું બંધ થયું.",
+ "transcribing": "લખાણમાં ફેરવી રહ્યાં છીએ...",
+ "gatewayUnreachable": "Kilo Gateway સાથે સંપર્ક થઈ શક્યો નથી. તમારું કનેક્શન તપાસો અને ફરી પ્રયાસ કરો.",
+ "gatewayTimeout": "અવાજને લખાણમાં ફેરવવામાં બહુ સમય લાગ્યો. ફરી પ્રયાસ કરો.",
+ "gatewayModelUnavailable": "અવાજને લખાણમાં ફેરવતું આ મોડેલ ઉપલબ્ધ નથી. પસંદગીઓમાં બીજું પસંદ કરો.",
+ "gatewaySignInRequired": "Kilo Gateway દ્વારા લખાણ વાપરવા સાઇન ઇન કરો.",
+ "gatewayNoModel": "પહેલા પસંદગીઓમાં અવાજને લખાણમાં ફેરવતું મોડેલ પસંદ કરો."
},
"share": {
"title": "Kilo પર શેર",
@@ -2950,5 +2959,12 @@
"needsInput": "જવાબ જરૂરી",
"channelName": "સક્રિય એજન્ટો",
"activityKitDisabledBody": "લૉક સ્ક્રીન પર સક્રિય એજન્ટો જોવા માટે સેટિંગ્સમાં લાઇવ પ્રવૃત્તિઓ ચાલુ કરો."
+ },
+ "transcriptionModel": {
+ "title": "અવાજને લખાણમાં ફેરવવાનું મોડેલ",
+ "noneChosen": "કંઈ પસંદ થયું નથી",
+ "emptyTitle": "અવાજને લખાણમાં ફેરવતાં કોઈ મોડેલ નથી",
+ "emptyDescription": "Kilo Gateway હાલમાં અવાજને લખાણમાં ફેરવતાં કોઈ મોડેલ આપતું નથી.",
+ "loadFailed": "અવાજને લખાણમાં ફેરવતાં મોડેલ લોડ કરી શકાયાં નહીં."
}
}
diff --git a/apps/mobile/src/i18n/locales/ha.json b/apps/mobile/src/i18n/locales/ha.json
index e037ea7942..8fb60abed6 100644
--- a/apps/mobile/src/i18n/locales/ha.json
+++ b/apps/mobile/src/i18n/locales/ha.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Mayar da magana zuwa rubutu ta Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Mayar da shigar da murya zuwa rubutu da samfurin Kilo Gateway maimakon gane magana na na'urar. Ana aika rikodinka zuwa Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Buƙatar haɗawa ba ta samuwa",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "Fayilolin magana na offline na {{language}} ba a shigar da su a wayar ba. Sauke su, sannan sake gwada shigar da rubutu ta murya.",
"downloadOfflineModel": "Sauke",
"offlineModelDownloadScheduled": "Za a sauke fayilolin magana na offline a bango. Gwada shigar da rubutu ta murya daga baya.",
- "listeningStopped": "An dakatar da sauraro."
+ "listeningStopped": "An dakatar da sauraro.",
+ "transcribing": "Ana mayar da magana rubutu...",
+ "gatewayUnreachable": "An kasa samun Kilo Gateway. Duba haɗinka ka sake gwadawa.",
+ "gatewayTimeout": "Mayar da magana zuwa rubutu ta ɗauki lokaci mai tsawo. Sake gwadawa.",
+ "gatewayModelUnavailable": "Wannan samfurin mayar da magana zuwa rubutu ba ya nan. Zaɓi wani a Zaɓuɓɓuka.",
+ "gatewaySignInRequired": "Shiga don amfani da mayar da magana ta Kilo Gateway.",
+ "gatewayNoModel": "Da farko zaɓi samfurin mayar da magana zuwa rubutu a Zaɓuɓɓuka."
},
"share": {
"title": "Raba zuwa Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "Ana buƙatar bayani",
"channelName": "Wakilai da ke aiki",
"activityKitDisabledBody": "Kunna ayyukan kai tsaye a saituna don ganin wakilan da ke aiki a allon kulle."
+ },
+ "transcriptionModel": {
+ "title": "Samfurin mayar da magana zuwa rubutu",
+ "noneChosen": "Ba a zaɓi ba",
+ "emptyTitle": "Babu samfuran mayar da magana zuwa rubutu",
+ "emptyDescription": "Kilo Gateway bai ba da samfuran mayar da magana zuwa rubutu a halin yanzu.",
+ "loadFailed": "An kasa loda samfuran mayar da magana zuwa rubutu."
}
}
diff --git a/apps/mobile/src/i18n/locales/he.json b/apps/mobile/src/i18n/locales/he.json
index 11d6fc86af..baa6f7cd7c 100644
--- a/apps/mobile/src/i18n/locales/he.json
+++ b/apps/mobile/src/i18n/locales/he.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "תמלול דרך Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "תמלל את ההכתבה בעזרת מודל של Kilo Gateway במקום זיהוי הדיבור של המכשיר. ההקלטה נשלחת ל־Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"notifications": {
"liveActivities": "פעילויות בזמן אמת",
@@ -2148,7 +2151,13 @@
"languageNotInstalledMessage": "קובצי הדיבור הלא מקוונים עבור {{language}} לא מותקנים בטלפון הזה. הורד אותם ואז נסה שוב להשתמש בהכתבה.",
"downloadOfflineModel": "הורדה",
"offlineModelDownloadScheduled": "קובצי הדיבור הלא מקוונים יורדו ברקע. נסה שוב להשתמש בהכתבה מאוחר יותר.",
- "listeningStopped": "ההאזנה הופסקה."
+ "listeningStopped": "ההאזנה הופסקה.",
+ "transcribing": "בתמלול...",
+ "gatewayUnreachable": "לא ניתן להתחבר ל־Kilo Gateway. בדוק את החיבור ונסה שוב.",
+ "gatewayTimeout": "התמלול ארך יותר מדי. נסה שוב.",
+ "gatewayModelUnavailable": "מודל התמלול הזה אינו זמין. בחר מודל אחר בהעדפות.",
+ "gatewaySignInRequired": "היכנס כדי להשתמש בתמלול דרך Kilo Gateway.",
+ "gatewayNoModel": "בחר קודם מודל תמלול בהעדפות."
},
"share": {
"title": "שיתוף עם Kilo",
@@ -2972,5 +2981,12 @@
"needsInput": "נדרש קלט",
"channelName": "סוכנים פעילים",
"activityKitDisabledBody": "הפעל פעילויות בזמן אמת בהגדרות כדי לראות סוכנים פעילים במסך הנעילה."
+ },
+ "transcriptionModel": {
+ "title": "מודל תמלול",
+ "noneChosen": "לא נבחר",
+ "emptyTitle": "אין מודלי תמלול",
+ "emptyDescription": "Kilo Gateway לא מציע כרגע מודלי תמלול.",
+ "loadFailed": "לא הצלחנו לטעון את מודלי התמלול."
}
}
diff --git a/apps/mobile/src/i18n/locales/hi.json b/apps/mobile/src/i18n/locales/hi.json
index 8af5afe33a..b5b66ff3d2 100644
--- a/apps/mobile/src/i18n/locales/hi.json
+++ b/apps/mobile/src/i18n/locales/hi.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Kilo Gateway से आवाज़ को टेक्स्ट में बदलना",
+ "gatewayTranscriptionSubtitle": "डिवाइस की स्पीच रिकग्निशन के बजाय Kilo Gateway मॉडल से वॉइस इनपुट को टेक्स्ट में बदलें। आपकी रिकॉर्डिंग Kilo Gateway भेजी जाती है।",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"notifications": {
"liveActivities": "लाइव गतिविधियाँ",
@@ -2134,7 +2137,13 @@
"languageNotInstalledMessage": "{{language}} की ऑफ़लाइन स्पीच फ़ाइलें इस फ़ोन पर इंस्टॉल नहीं हैं। उन्हें डाउनलोड करें, फिर वॉइस इनपुट फिर से आज़माएँ।",
"downloadOfflineModel": "डाउनलोड",
"offlineModelDownloadScheduled": "ऑफ़लाइन स्पीच फ़ाइलें बैकग्राउंड में डाउनलोड होंगी। बाद में वॉइस इनपुट फिर से आज़माएँ।",
- "listeningStopped": "सुनना बंद हो गया।"
+ "listeningStopped": "सुनना बंद हो गया।",
+ "transcribing": "टेक्स्ट में बदला जा रहा है…",
+ "gatewayUnreachable": "Kilo Gateway से संपर्क नहीं हो सका। अपना कनेक्शन जाँचें और फिर से कोशिश करें।",
+ "gatewayTimeout": "आवाज़ को टेक्स्ट में बदलने में बहुत समय लगा। फिर से कोशिश करें।",
+ "gatewayModelUnavailable": "आवाज़ को टेक्स्ट में बदलने का यह मॉडल उपलब्ध नहीं है। पसंद में दूसरा चुनें।",
+ "gatewaySignInRequired": "Kilo Gateway से आवाज़ को टेक्स्ट में बदलने के लिए साइन इन करें।",
+ "gatewayNoModel": "पहले पसंद में आवाज़ को टेक्स्ट में बदलने का मॉडल चुनें।"
},
"share": {
"title": "Kilo पर साझा करें",
@@ -2950,5 +2959,12 @@
"needsInput": "इनपुट चाहिए",
"channelName": "सक्रिय एजेंट",
"activityKitDisabledBody": "लॉक स्क्रीन पर सक्रिय एजेंट देखने के लिए सेटिंग में लाइव ऐक्टिविटी चालू करें।"
+ },
+ "transcriptionModel": {
+ "title": "आवाज़ को टेक्स्ट में बदलने का मॉडल",
+ "noneChosen": "कोई नहीं चुना गया",
+ "emptyTitle": "आवाज़ को टेक्स्ट में बदलने का कोई मॉडल नहीं",
+ "emptyDescription": "Kilo Gateway फिलहाल आवाज़ को टेक्स्ट में बदलने के लिए कोई मॉडल नहीं दे रहा।",
+ "loadFailed": "आवाज़ को टेक्स्ट में बदलने वाले मॉडल लोड नहीं हो सके।"
}
}
diff --git a/apps/mobile/src/i18n/locales/hr.json b/apps/mobile/src/i18n/locales/hr.json
index 12a34cc0c4..49407ccfac 100644
--- a/apps/mobile/src/i18n/locales/hr.json
+++ b/apps/mobile/src/i18n/locales/hr.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Transkripcija putem Kilo Gatewaya",
+ "gatewayTranscriptionSubtitle": "Prepiši glasovni unos modelom Kilo Gatewaya umjesto prepoznavanja govora na uređaju. Snimka se šalje na Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Zahtjev za spajanje nije dostupan",
@@ -2589,7 +2592,13 @@
"languageNotInstalledMessage": "Datoteke govora za izvanmrežni rad za {{language}} nisu instalirane na ovom telefonu. Preuzmi ih, a zatim pokušaj glasovni unos ponovno.",
"downloadOfflineModel": "Preuzmi",
"offlineModelDownloadScheduled": "Datoteke govora za izvanmrežni rad preuzet će se u pozadini. Pokušaj glasovni unos ponovno kasnije.",
- "listeningStopped": "Slušanje je zaustavljeno."
+ "listeningStopped": "Slušanje je zaustavljeno.",
+ "transcribing": "Transkripcija…",
+ "gatewayUnreachable": "Nije bilo moguće doseći Kilo Gateway. Provjeri vezu i pokušaj ponovno.",
+ "gatewayTimeout": "Transkripcija je predugo trajala. Pokušaj ponovno.",
+ "gatewayModelUnavailable": "Ovaj model za transkripciju nije dostupan. Odaberi drugi u Postavkama.",
+ "gatewaySignInRequired": "Prijavi se za korištenje transkripcije putem Kilo Gatewaya.",
+ "gatewayNoModel": "Najprije odaberi model za transkripciju u Postavkama."
},
"share": {
"title": "Dijeljenje u aplikaciju Kilo",
@@ -2972,5 +2981,12 @@
"needsInput": "Čeka unos",
"channelName": "Aktivni agenti",
"activityKitDisabledBody": "Uključi aktivnosti uživo u postavkama za prikaz aktivnih agenata na zaključanom zaslonu."
+ },
+ "transcriptionModel": {
+ "title": "Model za transkripciju",
+ "noneChosen": "Nije odabran",
+ "emptyTitle": "Nema modela za transkripciju",
+ "emptyDescription": "Kilo Gateway trenutačno ne nudi modele za transkripciju.",
+ "loadFailed": "Nije moguće učitati modele za transkripciju."
}
}
diff --git a/apps/mobile/src/i18n/locales/ht.json b/apps/mobile/src/i18n/locales/ht.json
index d53853f56a..a6418b9565 100644
--- a/apps/mobile/src/i18n/locales/ht.json
+++ b/apps/mobile/src/i18n/locales/ht.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Transkripsyon ak Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Transkripte dikte a ak yon modèl Kilo Gateway olye rekonesans vokal aparèy la. Yo voye anrejistrman ou a bay Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Demann fizyon an pa disponib",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "Fichye lapawòl offline pou {{language}} pa enstale sou telefòn sa a. Telechaje yo, epi eseye dikte ankò.",
"downloadOfflineModel": "Telechaje",
"offlineModelDownloadScheduled": "Fichye lapawòl offline yo ap telechaje nan background la. Eseye dikte ankò pita.",
- "listeningStopped": "Sistèm nan sispann koute."
+ "listeningStopped": "Sistèm nan sispann koute.",
+ "transcribing": "Ap transkripte...",
+ "gatewayUnreachable": "Nou pa t ka rive nan Kilo Gateway. Tcheke koneksyon ou a epi eseye ankò.",
+ "gatewayTimeout": "Transkripsyon an te pran twòp tan. Eseye ankò.",
+ "gatewayModelUnavailable": "Modèl transkripsyon sa a pa disponib. Chwazi yon lòt nan Preferans yo.",
+ "gatewaySignInRequired": "Konekte pou sèvi ak transkripsyon Kilo Gateway.",
+ "gatewayNoModel": "Chwazi yon modèl transkripsyon nan Preferans yo an premye."
},
"share": {
"title": "Pataj nan Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "Bezwen repons",
"channelName": "Ajan aktif",
"activityKitDisabledBody": "Nan paramèt yo, aktive aktivite an dirèk yo pou wè ajan aktif yo sou ekran fèmen an."
+ },
+ "transcriptionModel": {
+ "title": "Modèl transkripsyon",
+ "noneChosen": "Okenn pa chwazi",
+ "emptyTitle": "Pa gen modèl transkripsyon",
+ "emptyDescription": "Kilo Gateway pa ofri okenn modèl transkripsyon kounye a.",
+ "loadFailed": "Nou pa t kapab chaje modèl transkripsyon yo."
}
}
diff --git a/apps/mobile/src/i18n/locales/hu.json b/apps/mobile/src/i18n/locales/hu.json
index db8e18ad7f..fcdd65727a 100644
--- a/apps/mobile/src/i18n/locales/hu.json
+++ b/apps/mobile/src/i18n/locales/hu.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Átírás Kilo Gateway-n keresztül",
+ "gatewayTranscriptionSubtitle": "Írd át a hangbevitelt egy Kilo Gateway-modell segítségével az eszköz beszédfelismerése helyett. A felvételt elküldjük a Kilo Gateway-nek.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "A módosítási kérelem nem érhető el",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "A(z) {{language}} nyelv offline beszédfájljai nincsenek telepítve ezen a telefonon. Töltsd le őket, majd próbáld újra a hangbevitelt.",
"downloadOfflineModel": "Letöltés",
"offlineModelDownloadScheduled": "Az offline beszédfájlok a háttérben töltődnek le. Próbáld újra a hangbevitelt később.",
- "listeningStopped": "A beszéd figyelése leállt."
+ "listeningStopped": "A beszéd figyelése leállt.",
+ "transcribing": "Átírás folyamatban…",
+ "gatewayUnreachable": "Nem sikerült elérni a Kilo Gateway-t. Ellenőrizd a kapcsolatot, és próbáld újra.",
+ "gatewayTimeout": "Túl sokáig tartott az átírás. Próbáld újra.",
+ "gatewayModelUnavailable": "Ez az átírási modell nem érhető el. Válassz egy másikat a Beállításokban.",
+ "gatewaySignInRequired": "Jelentkezz be a Kilo Gateway-es átírás használatához.",
+ "gatewayNoModel": "Először válassz egy átírási modellt a Beállításokban."
},
"share": {
"title": "Megosztás a Kilo alkalmazással",
@@ -2950,5 +2959,12 @@
"needsInput": "Válaszra vár",
"channelName": "Aktív ügynökök",
"activityKitDisabledBody": "Kapcsold be az Élő tevékenységek funkciót a Beállításokban, hogy az aktív ügynökök megjelenjenek a zárolási képernyőn."
+ },
+ "transcriptionModel": {
+ "title": "Átírási modell",
+ "noneChosen": "Nincs kiválasztva",
+ "emptyTitle": "Nincsenek átírási modellek",
+ "emptyDescription": "A Kilo Gateway jelenleg nem kínál átírási modelleket.",
+ "loadFailed": "Nem sikerült betölteni az átírási modelleket."
}
}
diff --git a/apps/mobile/src/i18n/locales/hy.json b/apps/mobile/src/i18n/locales/hy.json
index b9bda03796..38c0881371 100644
--- a/apps/mobile/src/i18n/locales/hy.json
+++ b/apps/mobile/src/i18n/locales/hy.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Խոսքի տեքստի վերածում Kilo Gateway-ով",
+ "gatewayTranscriptionSubtitle": "Ձայնային մուտքագրումը տեքստի վերածեք Kilo Gateway-ի մոդելով՝ սարքի խոսքի ճանաչման փոխարեն։ Ձեր ձայնագրությունը ուղարկվում է Kilo Gateway։",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Միավորման հարցումը հասանելի չէ",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "{{language}} լեզվի օֆլայն խոսքի ֆայլերը տեղադրված չեն այս հեռախոսում։ Ներբեռնիր դրանք, ապա կրկին փորձիր ձայնային մուտքագրումը։",
"downloadOfflineModel": "Ներբեռնել",
"offlineModelDownloadScheduled": "Օֆլայն խոսքի ֆայլերը կներբեռնվեն ֆոնային ռեժիմում։ Փորձիր ձայնային մուտքագրումը ավելի ուշ։",
- "listeningStopped": "Լսումը դադարեցվել է։"
+ "listeningStopped": "Լսումը դադարեցվել է։",
+ "transcribing": "Վերածում ենք տեքստի...",
+ "gatewayUnreachable": "Հնարավոր չեղավ կապ հաստատել Kilo Gateway-ի հետ։ Ստուգեք կապը և կրկին փորձեք։",
+ "gatewayTimeout": "Տեքստի վերածումը չափազանց երկար տևեց։ Կրկին փորձեք։",
+ "gatewayModelUnavailable": "Այս տեքստի վերածման մոդելը հասանելի չէ։ Ընտրեք մեկ այլ՝ Նախապատվություններում։",
+ "gatewaySignInRequired": "Մուտք գործեք՝ Kilo Gateway-ի վերածումն օգտագործելու համար։",
+ "gatewayNoModel": "Նախ ընտրեք տեքստի վերածման մոդել՝ Նախապատվություններում։"
},
"share": {
"title": "Ուղարկում Kilo-ին",
@@ -2950,5 +2959,12 @@
"needsInput": "Սպասում է պատասխանի",
"channelName": "Ակտիվ գործակալներ",
"activityKitDisabledBody": "Կարգավորումներում միացրու ընթացիկ գործողությունների ցուցադրումը՝ ակտիվ գործակալներին կողպման էկրանին տեսնելու համար։"
+ },
+ "transcriptionModel": {
+ "title": "Տեքստի վերածման մոդել",
+ "noneChosen": "Ոչինչ չի ընտրվել",
+ "emptyTitle": "Տեքստի վերածման մոդելներ չկան",
+ "emptyDescription": "Kilo Gateway-ը հիմա տեքստի վերածման մոդելներ չի առաջարկում։",
+ "loadFailed": "Չհաջողվեց բեռնել տեքստի վերածման մոդելները։"
}
}
diff --git a/apps/mobile/src/i18n/locales/id.json b/apps/mobile/src/i18n/locales/id.json
index ab830febf6..2e89471334 100644
--- a/apps/mobile/src/i18n/locales/id.json
+++ b/apps/mobile/src/i18n/locales/id.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Transkripsi dengan Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Transkripsikan masukan suara dengan model Kilo Gateway alih-alih pengenalan bicara perangkat. Rekaman Anda dikirim ke Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"notifications": {
"liveActivities": "Aktivitas langsung",
@@ -2134,7 +2137,13 @@
"languageNotInstalledMessage": "File suara offline untuk {{language}} belum terinstal di ponsel ini. Unduh filenya, lalu coba masukan suara lagi.",
"downloadOfflineModel": "Unduh",
"offlineModelDownloadScheduled": "File suara offline akan diunduh di latar belakang. Coba masukan suara lagi nanti.",
- "listeningStopped": "Pendeteksian suara dihentikan."
+ "listeningStopped": "Pendeteksian suara dihentikan.",
+ "transcribing": "Mentranskripsi...",
+ "gatewayUnreachable": "Tidak dapat menghubungi Kilo Gateway. Periksa koneksi Anda dan coba lagi.",
+ "gatewayTimeout": "Transkripsi terlalu lama. Coba lagi.",
+ "gatewayModelUnavailable": "Model transkripsi ini tidak tersedia. Pilih yang lain di Preferensi.",
+ "gatewaySignInRequired": "Masuk untuk menggunakan transkripsi Kilo Gateway.",
+ "gatewayNoModel": "Pilih model transkripsi di Preferensi terlebih dahulu."
},
"share": {
"title": "Bagikan ke Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "Perlu masukan",
"channelName": "Agen aktif",
"activityKitDisabledBody": "Aktifkan Aktivitas Langsung di pengaturan untuk melihat agen aktif di layar terkunci."
+ },
+ "transcriptionModel": {
+ "title": "Model transkripsi",
+ "noneChosen": "Belum ada yang dipilih",
+ "emptyTitle": "Tidak ada model transkripsi",
+ "emptyDescription": "Kilo Gateway tidak menawarkan model transkripsi saat ini.",
+ "loadFailed": "Tidak dapat memuat model transkripsi."
}
}
diff --git a/apps/mobile/src/i18n/locales/ig.json b/apps/mobile/src/i18n/locales/ig.json
index 2104dfbfe7..11b6eeccc1 100644
--- a/apps/mobile/src/i18n/locales/ig.json
+++ b/apps/mobile/src/i18n/locales/ig.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Ịtụgharị olu ka ọ bụrụ ederede site na Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Ejila njirimaka okwu nke ngwaọrụ ahụ; jiri ihe nlereanya Kilo Gateway tụgharịa ntinye olu ka ọ bụrụ ederede. A na-eziga ndekọ gị na Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Arịrịọ njikọta adịghị",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "Faịlụ okwu offline maka {{language}} etinyeghị na ekwentị a. Budata ha, mgbe ahụ nwaa ntinye olu ọzọ.",
"downloadOfflineModel": "Budata",
"offlineModelDownloadScheduled": "A ga-ebudata faịlụ okwu offline na ndabere. Nwaa ntinye olu ọzọ ma e mesịa.",
- "listeningStopped": "Ịge ntị kwụsịrị."
+ "listeningStopped": "Ịge ntị kwụsịrị.",
+ "transcribing": "Na-atụgharị ka ọ bụrụ ederede...",
+ "gatewayUnreachable": "Enweghị ike iru Kilo Gateway. Lelee njikọ gị ma nwaa ọzọ.",
+ "gatewayTimeout": "Ịtụgharị okwu were oge karịrị akarị. Nwaa ọzọ.",
+ "gatewayModelUnavailable": "Ihe nlereanya a maka ịtụgharị okwu adịghị. Họrọ ọzọ na Mmasị.",
+ "gatewaySignInRequired": "Banye ka i jiri ịtụgharị okwu site na Kilo Gateway.",
+ "gatewayNoModel": "Họrọ ihe nlereanya maka ịtụgharị okwu na Mmasị ụzọ."
},
"share": {
"title": "Kekọrịta na Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "Chọrọ nzaghachi",
"channelName": "Ndị nnọchi anya na-arụ ọrụ",
"activityKitDisabledBody": "Gbanye ihe omume na-emelite ozugbo n'ime ntọala ka ị hụ ndị nnọchi anya na-arụ ọrụ n'ihuenyo mkpọchi."
+ },
+ "transcriptionModel": {
+ "title": "Ihe nlereanya maka ịtụgharị okwu ka ọ bụrụ ederede",
+ "noneChosen": "A họpụtaghị nke ọ bụla",
+ "emptyTitle": "Enweghị ihe nlereanya maka ịtụgharị okwu ka ọ bụrụ ederede",
+ "emptyDescription": "Kilo Gateway enyeghị ihe nlereanya ọ bụla maka ịtụgharị okwu ugbu a.",
+ "loadFailed": "Enweghị ike ibudata ihe nlereanya maka ịtụgharị okwu."
}
}
diff --git a/apps/mobile/src/i18n/locales/is.json b/apps/mobile/src/i18n/locales/is.json
index 3f74740d62..c4adc4d7d3 100644
--- a/apps/mobile/src/i18n/locales/is.json
+++ b/apps/mobile/src/i18n/locales/is.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Umritun í gegnum Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Umritið raddinnsláttinn með Kilo Gateway líkani í stað talaþekkingar tækisins. Upptakan er send í Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Sameiningarbeiðni ekki tiltæk",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "Ónettengdu talgagnaskrárnar fyrir {{language}} eru ekki uppsettar á þessum síma. Hladdu þeim niður og reyndu raddinnslátt aftur.",
"downloadOfflineModel": "Hlaða niður",
"offlineModelDownloadScheduled": "Ónettengdu talgagnaskrárnar verða hlaðnar niður í bakgrunni. Reyndu raddinnslátt aftur síðar.",
- "listeningStopped": "Hlustun stöðvuð."
+ "listeningStopped": "Hlustun stöðvuð.",
+ "transcribing": "Umriti…",
+ "gatewayUnreachable": "Ekki tókst að ná sambandi við Kilo Gateway. Athugaðu tenginguna og reyndu aftur.",
+ "gatewayTimeout": "Umritunin tók of langan tíma. Reyndu aftur.",
+ "gatewayModelUnavailable": "Þetta umritunarlíkan er ekki í boði. Veldu annað í Kjörstillingum.",
+ "gatewaySignInRequired": "Skráðu þig inn til að nota umritun í gegnum Kilo Gateway.",
+ "gatewayNoModel": "Veldu fyrst umritunarlíkan í Kjörstillingum."
},
"share": {
"title": "Deila með Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "Bíður eftir svari",
"channelName": "Virkir fulltrúar",
"activityKitDisabledBody": "Kveiktu á rauntímavirkni í stillingum til að sjá virka fulltrúa á lásskjánum."
+ },
+ "transcriptionModel": {
+ "title": "Umritunarlíkan",
+ "noneChosen": "Ekkert valið",
+ "emptyTitle": "Engin umritunarlíkan",
+ "emptyDescription": "Kilo Gateway býður ekki upp á umritunarlíkan um þessar mundir.",
+ "loadFailed": "Ekki tókst að sækja umritunarlíkin."
}
}
diff --git a/apps/mobile/src/i18n/locales/it.json b/apps/mobile/src/i18n/locales/it.json
index 00c137e35d..5e8394ae7a 100644
--- a/apps/mobile/src/i18n/locales/it.json
+++ b/apps/mobile/src/i18n/locales/it.json
@@ -2051,7 +2051,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Trascrizione con Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Trascrivi la dettatura con un modello Kilo Gateway anziché con il riconoscimento vocale del dispositivo. La registrazione viene inviata a Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"addCredits": {
"cta": "Aggiungi crediti",
@@ -2148,7 +2151,13 @@
"languageNotInstalledMessage": "I file vocali offline per {{language}} non sono installati su questo telefono. Scaricali, poi riprova con la dettatura.",
"downloadOfflineModel": "Scarica",
"offlineModelDownloadScheduled": "I file vocali offline verranno scaricati in background. Riprova con la dettatura più tardi.",
- "listeningStopped": "Ascolto interrotto."
+ "listeningStopped": "Ascolto interrotto.",
+ "transcribing": "Trascrizione in corso…",
+ "gatewayUnreachable": "Impossibile contattare Kilo Gateway. Controlla la connessione e riprova.",
+ "gatewayTimeout": "La trascrizione ha richiesto troppo tempo. Riprova.",
+ "gatewayModelUnavailable": "Questo modello di trascrizione non è disponibile. Scegline un altro in Preferenze.",
+ "gatewaySignInRequired": "Accedi per usare la trascrizione con Kilo Gateway.",
+ "gatewayNoModel": "Scegli prima un modello di trascrizione in Preferenze."
},
"share": {
"title": "Condivisione su Kilo",
@@ -2972,5 +2981,12 @@
"needsInput": "In attesa di risposta",
"channelName": "Agenti attivi",
"activityKitDisabledBody": "Attiva le attività in tempo reale nelle impostazioni per vedere gli agenti attivi nella schermata di blocco."
+ },
+ "transcriptionModel": {
+ "title": "Modello di trascrizione",
+ "noneChosen": "Nessuno selezionato",
+ "emptyTitle": "Nessun modello di trascrizione",
+ "emptyDescription": "Kilo Gateway non offre modelli di trascrizione in questo momento.",
+ "loadFailed": "Impossibile caricare i modelli di trascrizione."
}
}
diff --git a/apps/mobile/src/i18n/locales/ja.json b/apps/mobile/src/i18n/locales/ja.json
index e1176c9a3c..a685557c67 100644
--- a/apps/mobile/src/i18n/locales/ja.json
+++ b/apps/mobile/src/i18n/locales/ja.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Kilo Gatewayによる文字起こし",
+ "gatewayTranscriptionSubtitle": "デバイスの音声認識の代わりにKilo Gatewayのモデルで音声入力を文字起こしします。録音はKilo Gatewayに送信されます。",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"notifications": {
"liveActivities": "ライブアクティビティ",
@@ -2134,7 +2137,13 @@
"languageNotInstalledMessage": "{{language}}のオフライン音声ファイルがこのスマートフォンにインストールされていません。ダウンロードしてから、音声入力をもう一度お試しください。",
"downloadOfflineModel": "ダウンロード",
"offlineModelDownloadScheduled": "オフライン音声ファイルはバックグラウンドでダウンロードされます。しばらくしてから、音声入力をもう一度お試しください。",
- "listeningStopped": "聞き取りを停止しました。"
+ "listeningStopped": "聞き取りを停止しました。",
+ "transcribing": "文字起こし中…",
+ "gatewayUnreachable": "Kilo Gatewayに接続できませんでした。接続を確認して、もう一度お試しください。",
+ "gatewayTimeout": "文字起こしに時間がかかりすぎました。もう一度お試しください。",
+ "gatewayModelUnavailable": "この文字起こしモデルは利用できません。設定で別のモデルを選択してください。",
+ "gatewaySignInRequired": "Kilo Gatewayの文字起こしを使うにはサインインしてください。",
+ "gatewayNoModel": "まず設定で文字起こしモデルを選択してください。"
},
"share": {
"title": "Kiloに共有",
@@ -2950,5 +2959,12 @@
"needsInput": "入力が必要",
"channelName": "アクティブなエージェント",
"activityKitDisabledBody": "ロック画面にアクティブなエージェントを表示するには、設定でライブアクティビティをオンにしてください。"
+ },
+ "transcriptionModel": {
+ "title": "文字起こしモデル",
+ "noneChosen": "未選択",
+ "emptyTitle": "文字起こしモデルがありません",
+ "emptyDescription": "現在、Kilo Gatewayは文字起こしモデルを提供していません。",
+ "loadFailed": "文字起こしモデルを読み込めませんでした。"
}
}
diff --git a/apps/mobile/src/i18n/locales/ka.json b/apps/mobile/src/i18n/locales/ka.json
index f8440193f6..9ae832950f 100644
--- a/apps/mobile/src/i18n/locales/ka.json
+++ b/apps/mobile/src/i18n/locales/ka.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "ტრანსკრიფცია Kilo Gateway-ით",
+ "gatewayTranscriptionSubtitle": "ხმოვანი შეყვანა აქციეთ ტექსტად Kilo Gateway-ის მოდელით, მოწყობილობის მეტყველების ამოცნობის ნაცვლად. თქვენი ჩანაწერი იგზავნება Kilo Gateway-ში.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "შერწყმის მოთხოვნა მიუწვდომელია",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "{{language}} ენის ინტერნეტგარე მეტყველების ფაილები ამ ტელეფონზე დაინსტალირებული არ არის. ჩამოტვირთეთ ისინი, შემდეგ კი ხმოვანი შეყვანა ხელახლა სცადეთ.",
"downloadOfflineModel": "ჩამოტვირთვა",
"offlineModelDownloadScheduled": "ინტერნეტგარე მეტყველების ფაილები ფონურად ჩამოიტვირთება. ხმოვანი შეყვანა მოგვიანებით ხელახლა სცადეთ.",
- "listeningStopped": "მოსმენა შეჩერდა."
+ "listeningStopped": "მოსმენა შეჩერდა.",
+ "transcribing": "მიმდინარეობს ტრანსკრიფცია...",
+ "gatewayUnreachable": "Kilo Gateway-ზე წვდომა ვერ მოხერხდა. შეამოწმეთ კავშირი და სცადეთ ხელახლა.",
+ "gatewayTimeout": "ტრანსკრიფციამ ძალიან ბევრი დრო დაისვა. სცადე ხელახლა.",
+ "gatewayModelUnavailable": "ტრანსკრიფციის ეს მოდელი მიუწვდომელია. აირჩიეთ სხვა პარამეტრებში.",
+ "gatewaySignInRequired": "შედით, რომ გამოიყენოთ ტრანსკრიფცია Kilo Gateway-ით.",
+ "gatewayNoModel": "ჯერ აირჩიეთ ტრანსკრიფციის მოდელი პარამეტრებში."
},
"share": {
"title": "Kilo-ში გაზიარება",
@@ -2950,5 +2959,12 @@
"needsInput": "პასუხს ელის",
"channelName": "აქტიური აგენტები",
"activityKitDisabledBody": "დაბლოკილ ეკრანზე აქტიური აგენტების სანახავად პარამეტრებში ჩართე მიმდინარე აქტივობები."
+ },
+ "transcriptionModel": {
+ "title": "ტრანსკრიფციის მოდელი",
+ "noneChosen": "არჩეული არ არის",
+ "emptyTitle": "ტრანსკრიფციის მოდელები არ არის",
+ "emptyDescription": "Kilo Gateway ამჟამად არ სთავაზობს ტრანსკრიფციის მოდელებს.",
+ "loadFailed": "ტრანსკრიფციის მოდელების ჩატვირთვა ვერ მოხერხდა."
}
}
diff --git a/apps/mobile/src/i18n/locales/kk.json b/apps/mobile/src/i18n/locales/kk.json
index 22e64fe4f5..a02a3462e6 100644
--- a/apps/mobile/src/i18n/locales/kk.json
+++ b/apps/mobile/src/i18n/locales/kk.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Kilo Gateway арқылы дауысты мәтінге айналдыру",
+ "gatewayTranscriptionSubtitle": "Дауыстық енгізуді құрылғының сөзді тануының орнына Kilo Gateway моделі арқылы мәтінге айналдырыңыз. Жазбаңыз Kilo Gateway-ге жіберіледі.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Біріктіру сұрауы қолжетімсіз",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "{{language}} тілінің офлайн сөйлеу файлдары бұл телефонда орнатылмаған. Оларды жүктеп алыңыз, содан кейін дауыстық енгізуді қайта көріңіз.",
"downloadOfflineModel": "Жүктеп алу",
"offlineModelDownloadScheduled": "Офлайн сөйлеу файлдары фондық режимде жүктеледі. Дауыстық енгізуді кейінірек қайта көріңіз.",
- "listeningStopped": "Тыңдау тоқтатылды."
+ "listeningStopped": "Тыңдау тоқтатылды.",
+ "transcribing": "Мәтінге айналдыру жүріп жатыр...",
+ "gatewayUnreachable": "Kilo Gateway-ге қосылу мүмкін болмады. Байланысты тексеріп, қайталап көріңіз.",
+ "gatewayTimeout": "Дауысты мәтінге айналдыру тым ұзақ созылды. Қайталап көріңіз.",
+ "gatewayModelUnavailable": "Бұл дауысты мәтінге айналдыру моделі қолжетімсіз. Параметрлерден басқасын таңдаңыз.",
+ "gatewaySignInRequired": "Kilo Gateway арқылы айналдыруды қолдану үшін жүйеге кіріңіз.",
+ "gatewayNoModel": "Алдымен Параметрлерден дауысты мәтінге айналдыру моделін таңдаңыз."
},
"share": {
"title": "Kilo-ға жіберу",
@@ -2950,5 +2959,12 @@
"needsInput": "Жауап қажет",
"channelName": "Белсенді агенттер",
"activityKitDisabledBody": "Құлыптау экранында белсенді агенттерді көру үшін баптауларда тікелей эфирдегі әрекеттерді қосыңыз."
+ },
+ "transcriptionModel": {
+ "title": "Дауысты мәтінге айналдыру моделі",
+ "noneChosen": "Ештеңе таңдалмаған",
+ "emptyTitle": "Дауысты мәтінге айналдыру модельдері жоқ",
+ "emptyDescription": "Kilo Gateway қазір дауысты мәтінге айналдыру модельдерін ұсынбайды.",
+ "loadFailed": "Дауысты мәтінге айналдыру модельдерін жүктеу мүмкін болмады."
}
}
diff --git a/apps/mobile/src/i18n/locales/km.json b/apps/mobile/src/i18n/locales/km.json
index 682a448bd0..b3e4f2bb20 100644
--- a/apps/mobile/src/i18n/locales/km.json
+++ b/apps/mobile/src/i18n/locales/km.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "ការបម្លែងសំឡេងជាអត្ថបទតាម Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "បម្លែងការបញ្ចូលដោយសំឡេងទៅជាអត្ថបទដោយប្រើម៉ូដែល Kilo Gateway ជំនួសឱ្យការចាប់សម្ដីរបស់ឧបករណ៍។ ការថតរបស់អ្នកត្រូវបានផ្ញើទៅ Kilo Gateway។",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "មិនអាចបើកសំណើបញ្ចូលកូដបាន",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "ឯកសារសំឡេងក្រៅបណ្តាញសម្រាប់{{language}}មិនទាន់ដំឡើងនៅលើទូរស័ព្ទនេះទេ។ សូមទាញយកវាមុន បន្ទាប់មកសូមព្យាយាមការបញ្ចូលដោយសំឡេងម្តងទៀត។",
"downloadOfflineModel": "ទាញយក",
"offlineModelDownloadScheduled": "ឯកសារសំឡេងក្រៅបណ្តាញនឹងត្រូវបានទាញយកនៅផ្ទៃខាងក្រោយ។ សូមព្យាយាមការបញ្ចូលដោយសំឡេងម្តងទៀតពេលក្រោយ។",
- "listeningStopped": "បានបញ្ឈប់ការស្តាប់។"
+ "listeningStopped": "បានបញ្ឈប់ការស្តាប់។",
+ "transcribing": "កំពុងបម្លែងជាអត្ថបទ...",
+ "gatewayUnreachable": "មិនអាចទំនាក់ទំនងទៅ Kilo Gateway បានទេ។ សូមពិនិត្យការតភ្ជាប់របស់អ្នក ហើយព្យាយាមម្ដងទៀត។",
+ "gatewayTimeout": "ការបម្លែងសំឡេងជាអត្ថបទចំណាយពេលយូរពេក។ សូមព្យាយាមម្ដងទៀត។",
+ "gatewayModelUnavailable": "ម៉ូដែលបម្លែងសំឡេងជាអត្ថបទនេះមិនមានទេ។ សូមជ្រើសរើសម៉ូដែលផ្សេងទៀតក្នុងចំណូលចិត្ត។",
+ "gatewaySignInRequired": "ចូលគណនីដើម្បីប្រើការបម្លែងសំឡេងជាអត្ថបទតាម Kilo Gateway។",
+ "gatewayNoModel": "សូមជ្រើសរើសម៉ូដែលបម្លែងសំឡេងជាអត្ថបទក្នុងចំណូលចិត្តជាមុនសិន។"
},
"share": {
"title": "ចែករំលែកទៅ Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "ត្រូវការការឆ្លើយតប",
"channelName": "ភ្នាក់ងារសកម្ម",
"activityKitDisabledBody": "បើកសកម្មភាពបន្តផ្ទាល់នៅក្នុងការកំណត់ ដើម្បីមើលភ្នាក់ងារសកម្មនៅលើអេក្រង់ចាក់សោ។"
+ },
+ "transcriptionModel": {
+ "title": "ម៉ូដែលបម្លែងសំឡេងជាអត្ថបទ",
+ "noneChosen": "មិនទាន់ជ្រើសរើសទេ",
+ "emptyTitle": "គ្មានម៉ូដែលបម្លែងសំឡេងជាអត្ថបទ",
+ "emptyDescription": "Kilo Gateway មិនផ្ដល់ម៉ូដែលបម្លែងសំឡេងជាអត្ថបទនៅពេលនេះទេ។",
+ "loadFailed": "មិនអាចផ្ទុកម៉ូដែលបម្លែងសំឡេងជាអត្ថបទបានទេ។"
}
}
diff --git a/apps/mobile/src/i18n/locales/kn.json b/apps/mobile/src/i18n/locales/kn.json
index 06a79e020b..372b020fcb 100644
--- a/apps/mobile/src/i18n/locales/kn.json
+++ b/apps/mobile/src/i18n/locales/kn.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Kilo Gateway ಮೂಲಕ ಮಾತನ್ನು ಪಠ್ಯಕ್ಕೆ ಪರಿವರ್ತಿಸುವಿಕೆ",
+ "gatewayTranscriptionSubtitle": "ಸಾಧನದ ಮಾತು ಗುರುತಿಸುವಿಕೆಗೆ ಬದಲಾಗಿ Kilo Gateway ಮಾದರಿಯನ್ನು ಬಳಸಿ ಧ್ವನಿ ಇನ್ಪುಟ್ ಅನ್ನು ಪಠ್ಯಕ್ಕೆ ಪರಿವರ್ತಿಸಿ. ನಿಮ್ಮ ಧ್ವನಿಮುದ್ರಣ Kilo Gateway ಗೆ ಕಳುಹಿಸಲಾಗುತ್ತದೆ.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "ಪುಲ್ ರಿಕ್ವೆಸ್ಟ್ ಲಭ್ಯವಿಲ್ಲ",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "{{language}} ಭಾಷೆಯ ಆಫ್ಲೈನ್ ಮಾತು ಫೈಲ್ಗಳು ಈ ಫೋನ್ನಲ್ಲಿ ಸ್ಥಾಪಿಸಲಾಗಿಲ್ಲ. ಅವುಗಳನ್ನು ಡೌನ್ಲೋಡ್ ಮಾಡಿ, ನಂತರ ಧ್ವನಿ ಇನ್ಪುಟ್ ಅನ್ನು ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ.",
"downloadOfflineModel": "ಡೌನ್ಲೋಡ್",
"offlineModelDownloadScheduled": "ಆಫ್ಲೈನ್ ಮಾತು ಫೈಲ್ಗಳು ಹಿನ್ನೆಲೆಯಲ್ಲಿ ಡೌನ್ಲೋಡ್ ಆಗುತ್ತವೆ. ನಂತರ ಧ್ವನಿ ಇನ್ಪುಟ್ ಅನ್ನು ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ.",
- "listeningStopped": "ಆಲಿಸುವುದು ನಿಂತಿದೆ."
+ "listeningStopped": "ಆಲಿಸುವುದು ನಿಂತಿದೆ.",
+ "transcribing": "ಪಠ್ಯಕ್ಕೆ ಪರಿವರ್ತಿಸಲಾಗುತ್ತಿದೆ...",
+ "gatewayUnreachable": "Kilo Gateway ಅನ್ನು ತಲುಪಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ. ನಿಮ್ಮ ಸಂಪರ್ಕವನ್ನು ಪರಿಶೀಲಿಸಿ ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ.",
+ "gatewayTimeout": "ಮಾತನ್ನು ಪಠ್ಯಕ್ಕೆ ಪರಿವರ್ತಿಸಲು ತುಂಬಾ ಸಮಯ ತೆಗೆದುಕೊಂಡಿತು. ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ.",
+ "gatewayModelUnavailable": "ಈ ಪರಿವರ್ತನಾ ಮಾದರಿ ಲಭ್ಯವಿಲ್ಲ. ಆದ್ಯತೆಗಳಲ್ಲಿ ಇನ್ನೊಂದನ್ನು ಆಯ್ಕೆಮಾಡಿ.",
+ "gatewaySignInRequired": "Kilo Gateway ಪರಿವರ್ತನೆಯನ್ನು ಬಳಸಲು ಸೈನ್ ಇನ್ ಮಾಡಿ.",
+ "gatewayNoModel": "ಮೊದಲು ಆದ್ಯತೆಗಳಲ್ಲಿ ಪರಿವರ್ತನಾ ಮಾದರಿಯನ್ನು ಆಯ್ಕೆಮಾಡಿ."
},
"share": {
"title": "Kiloಗೆ ಹಂಚಿಕೆ",
@@ -2950,5 +2959,12 @@
"needsInput": "ನಿಮ್ಮ ಪ್ರತಿಕ್ರಿಯೆ ಅಗತ್ಯ",
"channelName": "ಸಕ್ರಿಯ ಏಜೆಂಟ್ಗಳು",
"activityKitDisabledBody": "ಲಾಕ್ ಪರದೆಯಲ್ಲಿ ಸಕ್ರಿಯ ಏಜೆಂಟ್ಗಳನ್ನು ನೋಡಲು ಸೆಟ್ಟಿಂಗ್ಗಳಲ್ಲಿ ಲೈವ್ ಚಟುವಟಿಕೆಗಳನ್ನು ಸಕ್ರಿಯಗೊಳಿಸಿ."
+ },
+ "transcriptionModel": {
+ "title": "ಮಾತನ್ನು ಪಠ್ಯಕ್ಕೆ ಪರಿವರ್ತಿಸುವ ಮಾದರಿ",
+ "noneChosen": "ಯಾವುದನ್ನೂ ಆಯ್ಕೆ ಮಾಡಿಲ್ಲ",
+ "emptyTitle": "ಮಾತನ್ನು ಪಠ್ಯಕ್ಕೆ ಪರಿವರ್ತಿಸುವ ಯಾವುದೇ ಮಾದರಿಗಳಿಲ್ಲ",
+ "emptyDescription": "Kilo Gateway ಪ್ರಸ್ತುತ ಮಾತನ್ನು ಪಠ್ಯಕ್ಕೆ ಪರಿವರ್ತಿಸುವ ಯಾವುದೇ ಮಾದರಿಗಳನ್ನು ಒದಗಿಸುತ್ತಿಲ್ಲ.",
+ "loadFailed": "ಮಾತನ್ನು ಪಠ್ಯಕ್ಕೆ ಪರಿವರ್ತಿಸುವ ಮಾದರಿಗಳನ್ನು ಲೋಡ್ ಮಾಡಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ."
}
}
diff --git a/apps/mobile/src/i18n/locales/ko.json b/apps/mobile/src/i18n/locales/ko.json
index 17c7b44ec9..557f258ce7 100644
--- a/apps/mobile/src/i18n/locales/ko.json
+++ b/apps/mobile/src/i18n/locales/ko.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Kilo Gateway 음성 텍스트 변환",
+ "gatewayTranscriptionSubtitle": "기기의 음성 인식 대신 Kilo Gateway 모델로 음성 입력을 텍스트로 변환합니다. 녹음 내용은 Kilo Gateway로 전송됩니다.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"notifications": {
"liveActivities": "실시간 활동",
@@ -2134,7 +2137,13 @@
"languageNotInstalledMessage": "이 휴대전화에 {{language}} 오프라인 음성 파일이 설치되어 있지 않습니다. 파일을 다운로드한 후 음성 입력을 다시 시도하세요.",
"downloadOfflineModel": "다운로드",
"offlineModelDownloadScheduled": "오프라인 음성 파일이 백그라운드에서 다운로드됩니다. 나중에 음성 입력을 다시 시도하세요.",
- "listeningStopped": "음성 입력이 중지되었습니다."
+ "listeningStopped": "음성 입력이 중지되었습니다.",
+ "transcribing": "변환 중...",
+ "gatewayUnreachable": "Kilo Gateway에 연결하지 못했습니다. 연결 상태를 확인하고 다시 시도하세요.",
+ "gatewayTimeout": "변환에 시간이 너무 오래 걸렸습니다. 다시 시도하세요.",
+ "gatewayModelUnavailable": "이 음성 텍스트 변환 모델은 사용할 수 없습니다. 환경설정에서 다른 모델을 선택하세요.",
+ "gatewaySignInRequired": "Kilo Gateway 변환을 사용하려면 로그인하세요.",
+ "gatewayNoModel": "먼저 환경설정에서 음성 텍스트 변환 모델을 선택하세요."
},
"share": {
"title": "Kilo로 공유",
@@ -2950,5 +2959,12 @@
"needsInput": "입력 필요",
"channelName": "활성 에이전트",
"activityKitDisabledBody": "잠금 화면에서 활성 에이전트를 보려면 설정에서 실시간 현황을 켜세요."
+ },
+ "transcriptionModel": {
+ "title": "음성 텍스트 변환 모델",
+ "noneChosen": "선택 안 됨",
+ "emptyTitle": "음성 텍스트 변환 모델 없음",
+ "emptyDescription": "지금 Kilo Gateway에서 제공하는 음성 텍스트 변환 모델이 없습니다.",
+ "loadFailed": "음성 텍스트 변환 모델을 불러오지 못했습니다."
}
}
diff --git a/apps/mobile/src/i18n/locales/lo.json b/apps/mobile/src/i18n/locales/lo.json
index a792135d40..817c26f878 100644
--- a/apps/mobile/src/i18n/locales/lo.json
+++ b/apps/mobile/src/i18n/locales/lo.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "ການຖອດສຽງເປັນຂໍ້ຄວາມຜ່ານ Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "ຖອດການປ້ອນຂໍ້ຄວາມດ້ວຍສຽງເປັນຂໍ້ຄວາມດ້ວຍໂມເດວ Kilo Gateway ແທນການຈຳລາສຽງພາຍໃນອຸປະກອນ. ໄຟລ໌ບັນທຶກສຽງຂອງທ່ານຈະຖືກສົ່ງໄປ Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "ບໍ່ສາມາດເປີດຄຳຂໍລວມໂຄດ",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "ໄຟລ໌ສຽງເວົ້າອອບໄລນ໌ສຳລັບ{{language}}ຍັງບໍ່ໄດ້ຕິດຕັ້ງໃນໂທລະສັບນີ້. ກະລຸນາດາວໂຫຼດມັນກ່ອນ ແລ້ວລອງໃຊ້ການປ້ອນຂໍ້ຄວາມດ້ວຍສຽງອີກເທື່ອ.",
"downloadOfflineModel": "ດາວໂຫຼດ",
"offlineModelDownloadScheduled": "ໄຟລ໌ສຽງເວົ້າອອບໄລນ໌ຈະຖືກດາວໂຫຼດໃນພື້ນຫຼັງ. ລອງໃຊ້ການປ້ອນຂໍ້ຄວາມດ້ວຍສຽງອີກເທື່ອພາຍຫຼັງ.",
- "listeningStopped": "ຢຸດການຟັງແລ້ວ."
+ "listeningStopped": "ຢຸດການຟັງແລ້ວ.",
+ "transcribing": "ກຳລັງຖອດເປັນຂໍ້ຄວາມ...",
+ "gatewayUnreachable": "ບໍ່ສາມາດເຊື່ອມຕໍ່ Kilo Gateway ໄດ້. ກວດສອບການເຊື່ອມຕໍ່ຂອງທ່ານ ແລ້ວລອງອີກຄັ້ງ.",
+ "gatewayTimeout": "ການຖອດສຽງໃຊ້ເວລາດົນເກີນໄປ. ລອງອີກຄັ້ງ.",
+ "gatewayModelUnavailable": "ໂມເດວຖອດສຽງນີ້ບໍ່ມີໃຫ້ໃຊ້. ເລືອກໂມເດວອື່ນໃນການຕັ້ງຄ່າສ່ວນຕົວ.",
+ "gatewaySignInRequired": "ເຂົ້າສູ່ລະບົບເພື່ອໃຊ້ການຖອດສຽງຜ່ານ Kilo Gateway.",
+ "gatewayNoModel": "ເລືອກໂມເດວຖອດສຽງໃນການຕັ້ງຄ່າສ່ວນຕົວກ່ອນ."
},
"share": {
"title": "ແບ່ງປັນໄປທີ່ Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "ລໍຂໍ້ມູນຈາກທ່ານ",
"channelName": "ເອເຈນທີ່ກຳລັງເຮັດວຽກ",
"activityKitDisabledBody": "ເປີດກິດຈະກຳສົດໃນການຕັ້ງຄ່າ ເພື່ອເບິ່ງເອເຈນທີ່ກຳລັງເຮັດວຽກໃນໜ້າຈໍລັອກ."
+ },
+ "transcriptionModel": {
+ "title": "ໂມເດວຖອດສຽງເປັນຂໍ້ຄວາມ",
+ "noneChosen": "ຍັງບໍ່ໄດ້ເລືອກ",
+ "emptyTitle": "ບໍ່ມີໂມເດວຖອດສຽງເປັນຂໍ້ຄວາມ",
+ "emptyDescription": "ຕອນນີ້ Kilo Gateway ບໍ່ມີໂມເດວຖອດສຽງເປັນຂໍ້ຄວາມ.",
+ "loadFailed": "ບໍ່ສາມາດໂຫຼດໂມເດວຖອດສຽງເປັນຂໍ້ຄວາມໄດ້."
}
}
diff --git a/apps/mobile/src/i18n/locales/lt.json b/apps/mobile/src/i18n/locales/lt.json
index 7b9c369eaa..1ceee2bd69 100644
--- a/apps/mobile/src/i18n/locales/lt.json
+++ b/apps/mobile/src/i18n/locales/lt.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Transkribavimas per Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Transkribuok balso įvestį Kilo Gateway modeliu vietoj įrenginio kalbos atpažinimo. Įrašas siunčiamas į Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Pakeitimų užklausa nepasiekiama",
@@ -2610,7 +2613,13 @@
"languageNotInstalledMessage": "{{language}} kalbos neprisijungus veikiantys kalbos failai šiame telefone nėra įdiegti. Atsisiųsk juos ir vėl pabandyk balso įvestį.",
"downloadOfflineModel": "Atsisiųsti",
"offlineModelDownloadScheduled": "Neprisijungus veikiantys kalbos failai bus atsisiunčiami fone. Pabandyk balso įvestį vėliau.",
- "listeningStopped": "Klausymasis sustabdytas."
+ "listeningStopped": "Klausymasis sustabdytas.",
+ "transcribing": "Transkribuojama...",
+ "gatewayUnreachable": "Nepavyko pasiekti Kilo Gateway. Patikrinkite ryšį ir bandykite dar kartą.",
+ "gatewayTimeout": "Transkribavimas užtruko per ilgai. Bandyk dar kartą.",
+ "gatewayModelUnavailable": "Šis transkribavimo modelis nepasiekiamas. Pasirinkite kitą Nuostatose.",
+ "gatewaySignInRequired": "Prisijunkite, kad naudotumėte transkribavimą per Kilo Gateway.",
+ "gatewayNoModel": "Pirmiausia pasirinkite transkribavimo modelį Nuostatose."
},
"share": {
"title": "Bendrinimas su Kilo",
@@ -2994,5 +3003,12 @@
"needsInput": "Laukia atsakymo",
"channelName": "Aktyvūs agentai",
"activityKitDisabledBody": "Nustatymuose įjunk tiesiogines veiklas, kad užrakinimo ekrane matytum aktyvius agentus."
+ },
+ "transcriptionModel": {
+ "title": "Transkribavimo modelis",
+ "noneChosen": "Neišrinkta",
+ "emptyTitle": "Nėra transkribavimo modelių",
+ "emptyDescription": "Šiuo metu Kilo Gateway neteikia jokių transkribavimo modelių.",
+ "loadFailed": "Nepavyko įkelti transkribavimo modelių."
}
}
diff --git a/apps/mobile/src/i18n/locales/lv.json b/apps/mobile/src/i18n/locales/lv.json
index 972b340555..c3041e1a7c 100644
--- a/apps/mobile/src/i18n/locales/lv.json
+++ b/apps/mobile/src/i18n/locales/lv.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Transkripcija, izmantojot Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Transkribē balss ievadi, izmantojot Kilo Gateway modeli, nevis ierīces runas atpazīšanu. Ieraksts tiek nosūtīts uz Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Izmaiņu pieprasījums nav pieejams",
@@ -2589,7 +2592,13 @@
"languageNotInstalledMessage": "{{language}} valodas bezsauses runas faili šajā tālrunī nav instalēti. Lejupielādē tos un pēc tam mēģini balss ievadi vēlreiz.",
"downloadOfflineModel": "Lejupielādēt",
"offlineModelDownloadScheduled": "Bezsauses runas faili tiks lejupielādēti fonā. Vēlāk mēģini balss ievadi vēlreiz.",
- "listeningStopped": "Klausīšanās apturēta."
+ "listeningStopped": "Klausīšanās apturēta.",
+ "transcribing": "Transkribē…",
+ "gatewayUnreachable": "Neizdevās sasniegt Kilo Gateway. Pārbaudi savienojumu un mēģini vēlreiz.",
+ "gatewayTimeout": "Transkripcija aizņēma par daudz laika. Mēģini vēlreiz.",
+ "gatewayModelUnavailable": "Šis transkripcijas modelis nav pieejams. Izvēlies citu Lietotāja iestatījumos.",
+ "gatewaySignInRequired": "Pieraksties, lai izmantotu transkripciju, izmantojot Kilo Gateway.",
+ "gatewayNoModel": "Vispirms izvēlies transkripcijas modeli Lietotāja iestatījumos."
},
"share": {
"title": "Kopīgošana lietotnē Kilo",
@@ -2972,5 +2981,12 @@
"needsInput": "Gaida ievadi",
"channelName": "Aktīvie aģenti",
"activityKitDisabledBody": "Iestatījumos ieslēdz tiešraides aktivitātes, lai bloķēšanas ekrānā redzētu aktīvos aģentus."
+ },
+ "transcriptionModel": {
+ "title": "Transkripcijas modelis",
+ "noneChosen": "Nav izvēlēts",
+ "emptyTitle": "Nav transkripcijas modeļu",
+ "emptyDescription": "Pašlaik Kilo Gateway nepiedāvā nevienu transkripcijas modeli.",
+ "loadFailed": "Neizdevās ielādēt transkripcijas modeļus."
}
}
diff --git a/apps/mobile/src/i18n/locales/mg.json b/apps/mobile/src/i18n/locales/mg.json
index 38c4936511..125f3eca0c 100644
--- a/apps/mobile/src/i18n/locales/mg.json
+++ b/apps/mobile/src/i18n/locales/mg.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Fanovana feo ho soratra amin'ny Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Aova ho soratra amin'ny modely Kilo Gateway ny fampidirana amin'ny feo fa tsy ny famantarana ny feon'ny fitaovana. Alefa amin'ny Kilo Gateway ny rakitra nosoronanao.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Tsy azo sokafana ny fangatahana fampiraisana",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "Tsy napetraka amin'ity finday ity ny rakitra feo offline ho an'ny {{language}}. Sintomy aloha izy ireo, avy eo andramo indray ny fampidirana amin'ny feo.",
"downloadOfflineModel": "Sintomy",
"offlineModelDownloadScheduled": "Ho sintomy ao amin'ny background ny rakitra feo offline. Andramo indray ny fampidirana amin'ny feo raha vao ela.",
- "listeningStopped": "Nijanona ny fihainoana."
+ "listeningStopped": "Nijanona ny fihainoana.",
+ "transcribing": "Manova feo ho soratra...",
+ "gatewayUnreachable": "Tsy azo ny Kilo Gateway. Hamarino ny fifandraisanao ary andramo indray.",
+ "gatewayTimeout": "Nihoatra ny elanelany ny fanovana feo ho soratra. Andramo indray.",
+ "gatewayModelUnavailable": "Tsy azo ampiasaina ity modely fanovana feo ho soratra ity. Safidio ny hafa ao amin'ny Safidy.",
+ "gatewaySignInRequired": "Midira mba hampiasa ny fanovana feo ho soratra amin'ny Kilo Gateway.",
+ "gatewayNoModel": "Safidio aloha ny modely fanovana feo ho soratra ao amin'ny Safidy."
},
"share": {
"title": "Fizarana amin'ny Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "Mila valiny",
"channelName": "Mpanatanteraka mandeha",
"activityKitDisabledBody": "Alefaso ao amin'ny fikirana ny hetsika mivantana mba hahitana ny mpanatanteraka mandeha eo amin'ny efijery mihidy."
+ },
+ "transcriptionModel": {
+ "title": "Modely fanovana feo ho soratra",
+ "noneChosen": "Tsy misy safidy",
+ "emptyTitle": "Tsy misy modely fanovana feo ho soratra",
+ "emptyDescription": "Tsy manome modely fanovana feo ho soratra amin'izao fotoana izao ny Kilo Gateway.",
+ "loadFailed": "Tsy tafiditra ny modely fanovana feo ho soratra."
}
}
diff --git a/apps/mobile/src/i18n/locales/mi.json b/apps/mobile/src/i18n/locales/mi.json
index 1f758c2dc5..61397ce2db 100644
--- a/apps/mobile/src/i18n/locales/mi.json
+++ b/apps/mobile/src/i18n/locales/mi.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Tuhi ā-reo mā Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Tuhia te tāuru reo mā tētahi tauira Kilo Gateway, kau mā te mōhio kōrero o te pūrere. Ka tukuna tō pūreko ki te Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Kāore i te wātea te tono kume",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "Kāore ngā kōnae kōrero kore-ipurangi mō {{language}} kua tāutitia ki tēnei waea. Tikiake, kātahi ka whakamātau anō i te tāuru reo.",
"downloadOfflineModel": "Tikiake",
"offlineModelDownloadScheduled": "Ka tikiaketia ngā kōnae kōrero kore-ipurangi ki te papamuri. Whakamātau anō i te tāuru reo ā muri atu.",
- "listeningStopped": "Kua mutu te whakarongo."
+ "listeningStopped": "Kua mutu te whakarongo.",
+ "transcribing": "E tuhi ana...",
+ "gatewayUnreachable": "Kāore i taea te tae ki te Kilo Gateway. Tirohia tō hononga, kātahi ka whakamātau anō.",
+ "gatewayTimeout": "He roa rawa te tuhi ā-reo. Whakamātau anō.",
+ "gatewayModelUnavailable": "Kāore e wātea ana tēnei tauira tuhi ā-reo. Kōwhiria tētahi atu i Ngā manakohanga.",
+ "gatewaySignInRequired": "Takiuru kia taea te tuhi ā-reo mā Kilo Gateway.",
+ "gatewayNoModel": "Tuatahi, kōwhiria he tauira tuhi ā-reo i Ngā manakohanga."
},
"share": {
"title": "Tohatoha ki Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "Me whai urupare",
"channelName": "Ngā māngai e mahi ana",
"activityKitDisabledBody": "Whakakāngia ngā mahi wā-tūturu i ngā tautuhinga kia kite ai i ngā māngai e mahi ana i te mata maukati."
+ },
+ "transcriptionModel": {
+ "title": "Tauira tuhi ā-reo",
+ "noneChosen": "Kāore i kōwhiria",
+ "emptyTitle": "Kāore he tauira tuhi ā-reo",
+ "emptyDescription": "I tēnei wā, kāore he tauira tuhi ā-reo e wātea ana i te Kilo Gateway.",
+ "loadFailed": "Kāore i taea te uta i ngā tauira tuhi ā-reo."
}
}
diff --git a/apps/mobile/src/i18n/locales/mk.json b/apps/mobile/src/i18n/locales/mk.json
index 7a2c7beede..f668d54948 100644
--- a/apps/mobile/src/i18n/locales/mk.json
+++ b/apps/mobile/src/i18n/locales/mk.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Транскрипција преку Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Транскрибирај го гласовниот внес со модел на Kilo Gateway наместо со препознавањето на говор на уредот. Снимката се испраќа до Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Барањето за спојување е недостапно",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "Офлајн датотеките со говор за {{language}} не се инсталирани на овој телефон. Преземи ги, а потоа обиди се повторно со гласовниот внес.",
"downloadOfflineModel": "Преземи",
"offlineModelDownloadScheduled": "Офлајн датотеките со говор ќе се преземат во позадина. Обиди се повторно со гласовниот внес подоцна.",
- "listeningStopped": "Слушањето е запрено."
+ "listeningStopped": "Слушањето е запрено.",
+ "transcribing": "Се транскрибира...",
+ "gatewayUnreachable": "Не може да се достигне Kilo Gateway. Провери ја врската и обиди се повторно.",
+ "gatewayTimeout": "Транскрипцијата трае предолго. Обиди се повторно.",
+ "gatewayModelUnavailable": "Овој модел за транскрипција не е достапен. Одбери друг во Лични поставки.",
+ "gatewaySignInRequired": "Најави се за да користиш транскрипција преку Kilo Gateway.",
+ "gatewayNoModel": "Прво одбери модел за транскрипција во Лични поставки."
},
"share": {
"title": "Споделување во Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "Чека внес",
"channelName": "Активни агенти",
"activityKitDisabledBody": "Вклучи ги активностите во живо во поставките за да ги видиш активните агенти на заклучениот екран."
+ },
+ "transcriptionModel": {
+ "title": "Модел за транскрипција",
+ "noneChosen": "Не е избран",
+ "emptyTitle": "Нема модели за транскрипција",
+ "emptyDescription": "Kilo Gateway моментално не нуди модели за транскрипција.",
+ "loadFailed": "Моделите за транскрипција не се вчитаа."
}
}
diff --git a/apps/mobile/src/i18n/locales/ml.json b/apps/mobile/src/i18n/locales/ml.json
index cdd3f0f749..7a3dc50534 100644
--- a/apps/mobile/src/i18n/locales/ml.json
+++ b/apps/mobile/src/i18n/locales/ml.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Kilo Gateway വഴിയുള്ള ശബ്ദം ടെക്സ്റ്റാക്കൽ",
+ "gatewayTranscriptionSubtitle": "ഉപകരണത്തിന്റെ സ്പീച്ച് റെക്കഗ്നിഷന് പകരം Kilo Gateway മോഡൽ ഉപയോഗിച്ച് ശബ്ദ ഇൻപുട്ട് ടെക്സ്റ്റാക്കുക. നിങ്ങളുടെ റെക്കോർഡിംഗ് Kilo Gateway-ലേക്ക് അയയ്ക്കുന്നു.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "പുൾ റിക്വസ്റ്റ് ലഭ്യമല്ല",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "{{language}}-നുള്ള ഓഫ്ലൈൻ സംഭാഷണ ഫയലുകൾ ഈ ഫോണിൽ ഇൻസ്റ്റാൾ ചെയ്തിട്ടില്ല. അവ ഡൗൺലോഡ് ചെയ്യുക, പിന്നീട് ശബ്ദ ഇൻപുട്ട് വീണ്ടും ശ്രമിക്കുക.",
"downloadOfflineModel": "ഡൗൺലോഡ്",
"offlineModelDownloadScheduled": "ഓഫ്ലൈൻ സംഭാഷണ ഫയലുകൾ പശ്ചാത്തലത്തിൽ ഡൗൺലോഡ് ചെയ്യപ്പെടും. പിന്നീട് ശബ്ദ ഇൻപുട്ട് വീണ്ടും ശ്രമിക്കുക.",
- "listeningStopped": "കേൾക്കുന്നത് നിർത്തി."
+ "listeningStopped": "കേൾക്കുന്നത് നിർത്തി.",
+ "transcribing": "ടെക്സ്റ്റാക്കുന്നു...",
+ "gatewayUnreachable": "Kilo Gateway-നെ ബന്ധപ്പെടാനായില്ല. നിങ്ങളുടെ കണക്ഷൻ പരിശോധിച്ച് വീണ്ടും ശ്രമിക്കുക.",
+ "gatewayTimeout": "ശബ്ദം ടെക്സ്റ്റാക്കാൻ വളരെ സമയമെടുത്തു. വീണ്ടും ശ്രമിക്കുക.",
+ "gatewayModelUnavailable": "ഈ ശബ്ദം ടെക്സ്റ്റാക്കുന്ന മോഡൽ ലഭ്യമല്ല. മുൻഗണനകളിൽ മറ്റൊന്ന് തിരഞ്ഞെടുക്കുക.",
+ "gatewaySignInRequired": "Kilo Gateway വഴിയുള്ള ടെക്സ്റ്റാക്കൽ ഉപയോഗിക്കാൻ സൈൻ ഇൻ ചെയ്യുക.",
+ "gatewayNoModel": "ആദ്യം മുൻഗണനകളിൽ ശബ്ദം ടെക്സ്റ്റാക്കുന്ന മോഡൽ തിരഞ്ഞെടുക്കുക."
},
"share": {
"title": "Kilo-യിലേക്ക് പങ്കിടുക",
@@ -2950,5 +2959,12 @@
"needsInput": "നിങ്ങളുടെ പ്രതികരണം വേണം",
"channelName": "സജീവ ഏജന്റുകൾ",
"activityKitDisabledBody": "ലോക്ക് സ്ക്രീനിൽ സജീവ ഏജന്റുകളെ കാണാൻ ക്രമീകരണങ്ങളിൽ തത്സമയ പ്രവർത്തനങ്ങൾ പ്രവർത്തനക്ഷമമാക്കുക."
+ },
+ "transcriptionModel": {
+ "title": "ശബ്ദം ടെക്സ്റ്റാക്കുന്ന മോഡൽ",
+ "noneChosen": "ഒന്നും തിരഞ്ഞെടുത്തിട്ടില്ല",
+ "emptyTitle": "ശബ്ദം ടെക്സ്റ്റാക്കാൻ മോഡലുകളില്ല",
+ "emptyDescription": "Kilo Gateway ഇപ്പോൾ ശബ്ദം ടെക്സ്റ്റാക്കാൻ മോഡലുകൾ നൽകുന്നില്ല.",
+ "loadFailed": "ശബ്ദം ടെക്സ്റ്റാക്കുന്ന മോഡലുകൾ ലോഡ് ചെയ്യാനായില്ല."
}
}
diff --git a/apps/mobile/src/i18n/locales/mn.json b/apps/mobile/src/i18n/locales/mn.json
index 4e33b272a1..8d5f36d310 100644
--- a/apps/mobile/src/i18n/locales/mn.json
+++ b/apps/mobile/src/i18n/locales/mn.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Kilo Gateway-р яриаг бичвэр болгох",
+ "gatewayTranscriptionSubtitle": "Төхөөрөмжийн яриа танихын оронд Kilo Gateway загвар ашиглан дуугаар оруулж буй зүйлээ бичвэр болгоно уу. Таны бичлэг Kilo Gateway руу илгээгдэнэ.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Нэгтгэх хүсэлтийг нээх боломжгүй",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "{{language}} хэлний офлайн ярианы файлууд энэ утсанд суулгагдаагүй байна. Файлуудыг татаж аваад, дуугаар оруулахыг дахин турна уу.",
"downloadOfflineModel": "Татах",
"offlineModelDownloadScheduled": "Офлайн ярианы файлууд дэвсгэрт татагдана. Дуугаар оруулахыг дараа дахин турна уу.",
- "listeningStopped": "Сонсох ажиллагаа зогссон."
+ "listeningStopped": "Сонсох ажиллагаа зогссон.",
+ "transcribing": "Бичвэр болгож байна...",
+ "gatewayUnreachable": "Kilo Gateway-д холбогдож чадсангүй. Холболтоо шалгаад дахин оролдоно уу.",
+ "gatewayTimeout": "Яриаг бичвэр болгох хэт удаан үргэлжиллээ. Дахин оролдоно уу.",
+ "gatewayModelUnavailable": "Энэ яриаг бичвэр болгох загвар боломжгүй. Тохиргооноос өөр загвар сонгоно уу.",
+ "gatewaySignInRequired": "Kilo Gateway-р бичвэр болгохыг ашиглахын тулд нэвтэрнэ үү.",
+ "gatewayNoModel": "Эхлээд Тохиргооноос яриаг бичвэр болгох загвар сонгоно уу."
},
"share": {
"title": "Kilo руу хуваалцах",
@@ -2950,5 +2959,12 @@
"needsInput": "Хариу хүлээж байна",
"channelName": "Идэвхтэй агентууд",
"activityKitDisabledBody": "Түгжээтэй дэлгэц дээр идэвхтэй агентуудыг харахын тулд тохиргооноос шууд үйл ажиллагааг идэвхжүүлнэ үү."
+ },
+ "transcriptionModel": {
+ "title": "Яриаг бичвэр болгох загвар",
+ "noneChosen": "Сонгоогүй",
+ "emptyTitle": "Яриаг бичвэр болгох загвар байхгүй",
+ "emptyDescription": "Kilo Gateway одоогоор яриаг бичвэр болгох загвар санал болгохгүй байна.",
+ "loadFailed": "Яриаг бичвэр болгох загваруудыг ачаалж чадсангүй."
}
}
diff --git a/apps/mobile/src/i18n/locales/mr.json b/apps/mobile/src/i18n/locales/mr.json
index a5f70b4442..1694c173f4 100644
--- a/apps/mobile/src/i18n/locales/mr.json
+++ b/apps/mobile/src/i18n/locales/mr.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Kilo Gateway द्वारे लिप्यंतरण",
+ "gatewayTranscriptionSubtitle": "उपकरणाच्या स्पीच रेकग्निशनऐवजी Kilo Gateway मॉडेल वापरून व्हॉइस इनपुटचे लिप्यंतरण करा. तुमचे रेकॉर्डिंग Kilo Gateway कडे पाठवले जाते.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "पुल रिक्वेस्ट अनुपलब्ध",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "{{language}} साठीच्या ऑफलाइन स्पीच फाइल्स या फोनवर इंस्टॉल केलेल्या नाहीत. त्या डाउनलोड करा, नंतर व्हॉइस इनपुट पुन्हा वापरून पहा.",
"downloadOfflineModel": "डाउनलोड",
"offlineModelDownloadScheduled": "ऑफलाइन स्पीच फाइल्स बॅकग्राउंडमध्ये डाउनलोड होतील. नंतर व्हॉइस इनपुट पुन्हा वापरून पहा.",
- "listeningStopped": "ऐकणे थांबले."
+ "listeningStopped": "ऐकणे थांबले.",
+ "transcribing": "लिप्यंतरण होत आहे...",
+ "gatewayUnreachable": "Kilo Gateway पर्यंत पोहोचता आले नाही. तुमचे कनेक्शन तपासा आणि पुन्हा प्रयत्न करा.",
+ "gatewayTimeout": "लिप्यंतरणास खूप वेळ लागला. पुन्हा प्रयत्न करा.",
+ "gatewayModelUnavailable": "हे लिप्यंतरण मॉडेल उपलब्ध नाही. प्राधान्यांमध्ये दुसरे निवडा.",
+ "gatewaySignInRequired": "Kilo Gateway लिप्यंतरण वापरण्यासाठी साइन इन करा.",
+ "gatewayNoModel": "प्राधान्यांमध्ये आधी लिप्यंतरण मॉडेल निवडा."
},
"share": {
"title": "Kilo वर शेअर करा",
@@ -2950,5 +2959,12 @@
"needsInput": "प्रतिसाद आवश्यक",
"channelName": "सक्रिय एजंट्स",
"activityKitDisabledBody": "लॉक स्क्रीनवर सक्रिय एजंट्स पाहण्यासाठी सेटिंग्जमध्ये लाइव्ह अॅक्टिव्हिटी चालू करा."
+ },
+ "transcriptionModel": {
+ "title": "लिप्यंतरण मॉडेल",
+ "noneChosen": "काहीही निवडले नाही",
+ "emptyTitle": "लिप्यंतरणासाठी मॉडेल्स नाहीत",
+ "emptyDescription": "Kilo Gateway सध्या लिप्यंतरणासाठी कोणतीही मॉडेल्स देत नाही.",
+ "loadFailed": "लिप्यंतरण मॉडेल्स लोड करता आली नाहीत."
}
}
diff --git a/apps/mobile/src/i18n/locales/ms.json b/apps/mobile/src/i18n/locales/ms.json
index 1fe7ee806b..460de1f0f6 100644
--- a/apps/mobile/src/i18n/locales/ms.json
+++ b/apps/mobile/src/i18n/locales/ms.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Transkripsi dengan Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Transkripsikan input suara dengan model Kilo Gateway dan bukan pengecaman pertuturan peranti. Rakaman anda dihantar ke Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Permintaan tarik tidak tersedia",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "Fail pertuturan luar talian untuk {{language}} tidak dipasang pada telefon ini. Muat turun fail itu, kemudian cuba input suara lagi.",
"downloadOfflineModel": "Muat turun",
"offlineModelDownloadScheduled": "Fail pertuturan luar talian akan dimuat turun di latar belakang. Cuba input suara lagi kemudian.",
- "listeningStopped": "Input suara telah dihentikan."
+ "listeningStopped": "Input suara telah dihentikan.",
+ "transcribing": "Mentranskripsi...",
+ "gatewayUnreachable": "Tidak dapat menghubungi Kilo Gateway. Semak sambungan anda dan cuba semula.",
+ "gatewayTimeout": "Transkripsi mengambil masa terlalu lama. Cuba lagi.",
+ "gatewayModelUnavailable": "Model transkripsi ini tidak tersedia. Pilih yang lain dalam Keutamaan.",
+ "gatewaySignInRequired": "Log masuk untuk menggunakan transkripsi Kilo Gateway.",
+ "gatewayNoModel": "Pilih model transkripsi dalam Keutamaan terlebih dahulu."
},
"share": {
"title": "Kongsi ke Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "Perlu input",
"channelName": "Ejen aktif",
"activityKitDisabledBody": "Dayakan Aktiviti Langsung dalam Tetapan untuk melihat ejen aktif pada Skrin Kunci."
+ },
+ "transcriptionModel": {
+ "title": "Model transkripsi",
+ "noneChosen": "Tiada dipilih",
+ "emptyTitle": "Tiada model transkripsi",
+ "emptyDescription": "Kilo Gateway tidak menawarkan model transkripsi pada masa ini.",
+ "loadFailed": "Tidak dapat memuatkan model transkripsi."
}
}
diff --git a/apps/mobile/src/i18n/locales/mt.json b/apps/mobile/src/i18n/locales/mt.json
index 79272c0028..9ea47b73d0 100644
--- a/apps/mobile/src/i18n/locales/mt.json
+++ b/apps/mobile/src/i18n/locales/mt.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Traskrizzjoni permezz ta' Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Ittraskrivi d-dħul bil-vuċi b'mudell ta' Kilo Gateway minflok ir-rikonoxximent tal-vuċi tal-apparat. Ir-reġistrazzjoni tiegħek tintbagħat lil Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "It-talba għall-għaqda mhix disponibbli",
@@ -2631,7 +2634,13 @@
"languageNotInstalledMessage": "Il-fajls tad-diskors offline għal {{language}} mhumiex installati fuq dan it-telefon. Niżżelhom, imbagħad erġa' pprova d-dħul bil-vuċi.",
"downloadOfflineModel": "Niżżel",
"offlineModelDownloadScheduled": "Il-fajls tad-diskors offline se jinżżlu fl-isfond. Erġa' pprova d-dħul bil-vuċi iktar 'il quddiem.",
- "listeningStopped": "Is-smigħ waqaf."
+ "listeningStopped": "Is-smigħ waqaf.",
+ "transcribing": "Qed nitraskrivi…",
+ "gatewayUnreachable": "Ma setgħetx tasal għal Kilo Gateway. Iċċekkja l-konnessjoni tiegħek u erġa' pprova.",
+ "gatewayTimeout": "It-traskrizzjoni ħadet wisq ħin. Erġa' pprova.",
+ "gatewayModelUnavailable": "Dan il-mudell ta' traskrizzjoni mhux disponibbli. Agħżel ieħor fil-Preferenzi.",
+ "gatewaySignInRequired": "Idħol biex tuża t-traskrizzjoni permezz ta' Kilo Gateway.",
+ "gatewayNoModel": "L-ewwel agħżel mudell ta' traskrizzjoni fil-Preferenzi."
},
"share": {
"title": "Aqsam ma' Kilo",
@@ -3016,5 +3025,12 @@
"needsInput": "Jistenna tweġiba",
"channelName": "Aġenti attivi",
"activityKitDisabledBody": "Attiva l-attivitajiet diretti fl-issettjar biex tara l-aġenti attivi fuq l-iskrin imsakkar."
+ },
+ "transcriptionModel": {
+ "title": "Mudell ta' traskrizzjoni",
+ "noneChosen": "Xejn magħżul",
+ "emptyTitle": "L-ebda mudell ta' traskrizzjoni",
+ "emptyDescription": "Kilo Gateway bħalissa ma joffri l-ebda mudell ta' traskrizzjoni.",
+ "loadFailed": "Ma setgħetx titgħabba l-mudelli ta' traskrizzjoni."
}
}
diff --git a/apps/mobile/src/i18n/locales/my.json b/apps/mobile/src/i18n/locales/my.json
index 13541bd320..3628e3a890 100644
--- a/apps/mobile/src/i18n/locales/my.json
+++ b/apps/mobile/src/i18n/locales/my.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Kilo Gateway ဖြင့် အသံမှစာသားပြောင်းခြင်း",
+ "gatewayTranscriptionSubtitle": "စက်၏ အသံခွဲခြားသိရှိမှုအစား Kilo Gateway မော်ဒယ်ဖြင့် အသံထည့်သွင်းမှုကို စာသားပြောင်းပါ။ သင့်အသံသွင်းချက်ကို Kilo Gateway သို့ ပို့ပါမည်။",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "PR မရနိုင်ပါ",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "{{language}} အတွက် အွန်လိုင်းမပါ အသံဖိုင်များကို ဖုန်းတွင် ထည့်သွင်းထားခြင်း မရှိပါ။ ဖိုင်များကို ဒေါင်းလုဒ်လုပ်ပြီး အသံဖြင့် စာထည့်သွင်းမှုကို ထပ်မံ ကြိုးစားပါ။",
"downloadOfflineModel": "ဒေါင်းလုဒ်",
"offlineModelDownloadScheduled": "အွန်လိုင်းမပါ အသံဖိုင်များကို နောက်ခံတွင် ဒေါင်းလုဒ်လုပ်မည်။ နောက်မှ အသံဖြင့် စာထည့်သွင်းမှုကို ထပ်မံ ကြိုးစားပါ။",
- "listeningStopped": "နားထောင်ခြင်း ရပ်သွားသည်။"
+ "listeningStopped": "နားထောင်ခြင်း ရပ်သွားသည်။",
+ "transcribing": "စာသားပြောင်းနေသည်...",
+ "gatewayUnreachable": "Kilo Gateway သို့ မချိတ်ဆက်နိုင်ပါ။ ချိတ်ဆက်မှုကို စစ်ဆေးပြီး ထပ်ကြိုးစားပါ။",
+ "gatewayTimeout": "အသံမှစာသားပြောင်းရန် အချိန်အလွန်ကြာခဲ့သည်။ ထပ်ကြိုးစားပါ။",
+ "gatewayModelUnavailable": "ဤအသံမှစာသားပြောင်းမော်ဒယ် မရရှိနိုင်ပါ။ စိတ်ကြိုက်ဆက်တင်များတွင် အခြားတစ်ခုကို ရွေးချယ်ပါ။",
+ "gatewaySignInRequired": "Kilo Gateway စာသားပြောင်းခြင်းကို သုံးရန် အကောင့်ဝင်ပါ။",
+ "gatewayNoModel": "ပထမ စိတ်ကြိုက်ဆက်တင်များတွင် အသံမှစာသားပြောင်းမော်ဒယ်တစ်ခုကို ရွေးချယ်ပါ။"
},
"share": {
"title": "Kilo သို့ မျှဝေခြင်း",
@@ -2950,5 +2959,12 @@
"needsInput": "တုံ့ပြန်ချက် လိုအပ်",
"channelName": "လုပ်ဆောင်နေသော အေးဂျင့်များ",
"activityKitDisabledBody": "သော့ခတ်မျက်နှာပြင်တွင် လုပ်ဆောင်နေသော အေးဂျင့်များကို ကြည့်ရန် ဆက်တင်များ၌ တိုက်ရိုက်လုပ်ဆောင်မှုများကို ဖွင့်ပါ။"
+ },
+ "transcriptionModel": {
+ "title": "အသံမှစာသားပြောင်းမော်ဒယ်",
+ "noneChosen": "မရွေးချယ်ရသေး",
+ "emptyTitle": "အသံမှစာသားပြောင်းမော်ဒယ်များ မရှိပါ",
+ "emptyDescription": "Kilo Gateway သည် လက်ရှိအချိန်တွင် အသံမှစာသားပြောင်းမော်ဒယ်များ မပေးဆောင်ပါ။",
+ "loadFailed": "အသံမှစာသားပြောင်းမော်ဒယ်များကို မရယူနိုင်ပါ။"
}
}
diff --git a/apps/mobile/src/i18n/locales/nb.json b/apps/mobile/src/i18n/locales/nb.json
index ecbc82b8bb..a80df3ac7b 100644
--- a/apps/mobile/src/i18n/locales/nb.json
+++ b/apps/mobile/src/i18n/locales/nb.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Transkripsjon via Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Transkribér diktering med en Kilo Gateway-modell i stedet for enhetens stemmgjenkjenning. Opptaket ditt sendes til Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "PR-en er utilgjengelig",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "De frakoblede talefilene for {{language}} er ikke installert på denne telefonen. Last dem ned, og prøv diktering igjen.",
"downloadOfflineModel": "Last ned",
"offlineModelDownloadScheduled": "De frakoblede talefilene lastes ned i bakgrunnen. Prøv diktering igjen senere.",
- "listeningStopped": "Dikteringen har stoppet."
+ "listeningStopped": "Dikteringen har stoppet.",
+ "transcribing": "Transkriberer...",
+ "gatewayUnreachable": "Kunne ikke nå Kilo Gateway. Sjekk tilkoblingen og prøv igjen.",
+ "gatewayTimeout": "Transkripsjonen tok for lang tid. Prøv igjen.",
+ "gatewayModelUnavailable": "Denne transkripsjonsmodellen er ikke tilgjengelig. Velg en annen under Innstillinger.",
+ "gatewaySignInRequired": "Logg inn for å bruke transkripsjon via Kilo Gateway.",
+ "gatewayNoModel": "Velg en transkripsjonsmodell under Innstillinger først."
},
"share": {
"title": "Del med Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "Venter på svar",
"channelName": "Aktive agenter",
"activityKitDisabledBody": "Slå på oppdateringer i sanntid i innstillingene for å se aktive agenter på låseskjermen."
+ },
+ "transcriptionModel": {
+ "title": "Transkripsjonsmodell",
+ "noneChosen": "Ingen valgt",
+ "emptyTitle": "Ingen transkripsjonsmodeller",
+ "emptyDescription": "Kilo Gateway tilbyr ingen transkripsjonsmodeller akkurat nå.",
+ "loadFailed": "Kunne ikke laste inn transkripsjonsmodellene."
}
}
diff --git a/apps/mobile/src/i18n/locales/ne.json b/apps/mobile/src/i18n/locales/ne.json
index d25ed2056a..68d7d13480 100644
--- a/apps/mobile/src/i18n/locales/ne.json
+++ b/apps/mobile/src/i18n/locales/ne.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Kilo Gateway मार्फत बोलीलाई पाठमा रूपान्तरण",
+ "gatewayTranscriptionSubtitle": "यन्त्रको वाणी पहिचानको सट्टा Kilo Gateway मोडेल प्रयोग गरेर भ्वाइस इनपुटलाई पाठमा रूपान्तरण गर्नुहोस्। तपाईंको रेकर्डिङ Kilo Gateway मा पठाइन्छ।",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "पुल रिक्वेस्ट उपलब्ध छैन",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "{{language}} का अफलाइन स्पिच फाइलहरू यो फोनमा इन्स्टल छैनन्। तिनीहरूलाई डाउनलोड गर्नुहोस्, अनि भ्वाइस इनपुट फेरि प्रयास गर्नुहोस्।",
"downloadOfflineModel": "डाउनलोड",
"offlineModelDownloadScheduled": "अफलाइन स्पिच फाइलहरू पृष्ठभूमिमा डाउनलोड हुनेछन्। पछि भ्वाइस इनपुट फेरि प्रयास गर्नुहोस्।",
- "listeningStopped": "सुन्ने प्रक्रिया रोकियो।"
+ "listeningStopped": "सुन्ने प्रक्रिया रोकियो।",
+ "transcribing": "पाठमा रूपान्तरण हुँदैछ…",
+ "gatewayUnreachable": "Kilo Gateway सम्म पुग्न सकिएन। आफ्नो जडान जाँचेर फेरि प्रयास गर्नुहोस्।",
+ "gatewayTimeout": "बोलीलाई पाठमा रूपान्तरण गर्न धेरै समय लाग्यो। फेरि प्रयास गर्नुहोस्।",
+ "gatewayModelUnavailable": "यो रूपान्तरण मोडेल उपलब्ध छैन। रुचिहरूमा अर्को छान्नुहोस्।",
+ "gatewaySignInRequired": "Kilo Gateway को रूपान्तरण प्रयोग गर्न साइन इन गर्नुहोस्।",
+ "gatewayNoModel": "पहिले रुचिहरूमा बोलीलाई पाठमा रूपान्तरण गर्ने मोडेल छान्नुहोस्।"
},
"share": {
"title": "Kilo मा सेयर",
@@ -2950,5 +2959,12 @@
"needsInput": "जवाफ चाहिन्छ",
"channelName": "सक्रिय एजेन्टहरू",
"activityKitDisabledBody": "लक स्क्रिनमा सक्रिय एजेन्टहरू हेर्न सेटिङ्समा प्रत्यक्ष गतिविधिहरू चालू गर्नुहोस्।"
+ },
+ "transcriptionModel": {
+ "title": "बोलीलाई पाठमा रूपान्तरण गर्ने मोडेल",
+ "noneChosen": "कुनै पनि चयन गरिएको छैन",
+ "emptyTitle": "बोलीलाई पाठमा रूपान्तरण गर्ने कुनै मोडेल छैन",
+ "emptyDescription": "Kilo Gateway ले अहिले बोलीलाई पाठमा रूपान्तरण गर्ने कुनै मोडेल प्रदान गरिरहेको छैन।",
+ "loadFailed": "बोलीलाई पाठमा रूपान्तरण गर्ने मोडेलहरू लोड गर्न सकिएन।"
}
}
diff --git a/apps/mobile/src/i18n/locales/nl.json b/apps/mobile/src/i18n/locales/nl.json
index 7a7e9e3dd5..931e5e6489 100644
--- a/apps/mobile/src/i18n/locales/nl.json
+++ b/apps/mobile/src/i18n/locales/nl.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Transcriptie via Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Zet spraakinvoer om met een Kilo Gateway-model in plaats van de spraakherkenning van het apparaat. Je opname wordt naar Kilo Gateway verzonden.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"notifications": {
"channel": {
@@ -2134,7 +2137,13 @@
"languageNotInstalledMessage": "De offline spraakbestanden voor {{language}} zijn niet op deze telefoon geïnstalleerd. Download ze en probeer spraakinvoer opnieuw.",
"downloadOfflineModel": "Downloaden",
"offlineModelDownloadScheduled": "De offline spraakbestanden worden op de achtergrond gedownload. Probeer spraakinvoer later opnieuw.",
- "listeningStopped": "Het luisteren is gestopt."
+ "listeningStopped": "Het luisteren is gestopt.",
+ "transcribing": "Transcriberen...",
+ "gatewayUnreachable": "Kilo Gateway kon niet worden bereikt. Controleer je verbinding en probeer het opnieuw.",
+ "gatewayTimeout": "De transcriptie duurde te lang. Probeer het opnieuw.",
+ "gatewayModelUnavailable": "Dit transcriptiemodel is niet beschikbaar. Kies een ander model bij Voorkeuren.",
+ "gatewaySignInRequired": "Meld je aan om transcriptie via Kilo Gateway te gebruiken.",
+ "gatewayNoModel": "Kies eerst een transcriptiemodel bij Voorkeuren."
},
"share": {
"title": "Delen met Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "Invoer nodig",
"channelName": "Actieve agents",
"activityKitDisabledBody": "Schakel liveactiviteiten in via de instellingen om actieve agents op het toegangsscherm te zien."
+ },
+ "transcriptionModel": {
+ "title": "Transcriptiemodel",
+ "noneChosen": "Geen gekozen",
+ "emptyTitle": "Geen transcriptiemodellen",
+ "emptyDescription": "Kilo Gateway biedt op dit moment geen transcriptiemodellen.",
+ "loadFailed": "De transcriptiemodellen konden niet worden geladen."
}
}
diff --git a/apps/mobile/src/i18n/locales/om.json b/apps/mobile/src/i18n/locales/om.json
index b4c75be814..faf9aad4fc 100644
--- a/apps/mobile/src/i18n/locales/om.json
+++ b/apps/mobile/src/i18n/locales/om.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Sagalee interneetiin barreeffamatti jijjiiruu Kilo Gateway'n",
+ "gatewayTranscriptionSubtitle": "Sagalee galchuu mallattoo sagalee meeshaa irraa haa ta'u moodeela Kilo Gateway'ttiin barreeffamatti jijjiiri. Sagaleen kee Kilo Gateway'tti ergama.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Gaaffiin walitti makuu hin argamu",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "Faayiloota sagalee offlaayinii afaan {{language}} tiif bilbilaa kana irratti hin dhaabbatamin. Isaan buufadhu, ergasii sagaleen galchuu irra deebi'ii yaali.",
"downloadOfflineModel": "Buufadhu",
"offlineModelDownloadScheduled": "Faayiloota sagalee offlaayinii duubatti buufamu. Sagaleen galchuu booda irra deebi'ii yaali.",
- "listeningStopped": "Dhaggeeffachuun dhaabbateera."
+ "listeningStopped": "Dhaggeeffachuun dhaabbateera.",
+ "transcribing": "Barreeffamatti jijjiiraa jira...",
+ "gatewayUnreachable": "Kilo Gateway'tti quhamuun hin danda'amne. Walqabannaa kee ilaaliitii deebi'ii yaali.",
+ "gatewayTimeout": "Sagalee barreeffamatti jijjiiruun yeroo dheeraa fudhate. Irra deebi'ii yaali.",
+ "gatewayModelUnavailable": "Moodeeli sagalee jijjiiru kun hin jiru. Filannoowwan keessaa kan biroo filadhu.",
+ "gatewaySignInRequired": "Jijjiirraa sagalee Kilo Gateway'tti fayyadamuuf seeni.",
+ "gatewayNoModel": "Dursee Filannoowwan keessatti moodeela sagalee jijjiiru filadhu."
},
"share": {
"title": "Gara Kilo qoodi",
@@ -2950,5 +2959,12 @@
"needsInput": "Deebii barbaada",
"channelName": "Eejentoota hojii irra jiran",
"activityKitDisabledBody": "Eejentoota hojii irra jiran iskiriinii qulfii irratti ilaaluuf, qindaa'ina keessatti sochiiwwan yeroo ammaa hojii irra oolchi."
+ },
+ "transcriptionModel": {
+ "title": "Moodeela sagalee gara barreeffamaatti jijjiiru",
+ "noneChosen": "Homni hin filatamne",
+ "emptyTitle": "Moodeeli sagalee jijjiiru hin jiru",
+ "emptyDescription": "Kilo Gateway amma moodeela sagalee gara barreeffamaatti jijjiiru hin dhiyeessu.",
+ "loadFailed": "Moodeelota sagalee jijjiiru fe'uun hin danda'amne."
}
}
diff --git a/apps/mobile/src/i18n/locales/or.json b/apps/mobile/src/i18n/locales/or.json
index 2381e9af27..b9303f8d71 100644
--- a/apps/mobile/src/i18n/locales/or.json
+++ b/apps/mobile/src/i18n/locales/or.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Kilo Gateway ମାଧ୍ୟମରେ କଥାକୁ ଲେଖାରେ ବଦଳାଇବା",
+ "gatewayTranscriptionSubtitle": "ଡିଭାଇସ୍ର ସ୍ପିଚ୍ ରେକଗ୍ନିସନ୍ ପରିବର୍ତ୍ତେ Kilo Gateway ମଡେଲ୍ ବ୍ୟବହାର କରି କହି ଲେଖିବାକୁ ଲେଖାରେ ବଦଳାନ୍ତୁ। ଆପଣଙ୍କ ରେକର୍ଡିଂ Kilo Gateway ପଠାଯାଏ।",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "ପୁଲ୍ ରିକ୍ୱେଷ୍ଟ ଉପଲବ୍ଧ ନାହିଁ",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "{{language}} ପାଇଁ ଅଫଲାଇନ୍ ସ୍ପିଚ୍ ଫାଇଲ୍ ଏହି ଫୋନ୍ରେ ଇନଷ୍ଟଲ୍ ହୋଇନାହିଁ। ସେଗୁଡ଼ିକ ଡାଉନଲୋଡ୍ କରନ୍ତୁ, ତାପରେ କହି ଲେଖିବା ପୁଣି ଚେଷ୍ଟା କରନ୍ତୁ।",
"downloadOfflineModel": "ଡାଉନଲୋଡ୍",
"offlineModelDownloadScheduled": "ଅଫଲାଇନ୍ ସ୍ପିଚ୍ ଫାଇଲ୍ ଗୁଡ଼ିକ ପୃଷ୍ଠପଟରେ ଡାଉନଲୋଡ୍ ହେବ। ପରେ କହି ଲେଖିବା ପୁଣି ଚେଷ୍ଟା କରନ୍ତୁ।",
- "listeningStopped": "ଶୁଣିବା ବନ୍ଦ ହେଲା।"
+ "listeningStopped": "ଶୁଣିବା ବନ୍ଦ ହେଲା।",
+ "transcribing": "ଲେଖାରେ ବଦଳାଯାଉଛି...",
+ "gatewayUnreachable": "Kilo Gateway ପର୍ଯ୍ୟନ୍ତ ପହଞ୍ଚି ହୋଇନାହିଁ। ଆପଣଙ୍କ ସଂଯୋଗ ଯାଞ୍ଚ କରି ପୁଣି ଚେଷ୍ଟା କରନ୍ତୁ।",
+ "gatewayTimeout": "କଥାକୁ ଲେଖାରେ ବଦଳାଇବାରେ ଅଧିକ ସମୟ ଲାଗିଲା। ପୁଣି ଚେଷ୍ଟା କରନ୍ତୁ।",
+ "gatewayModelUnavailable": "ଏହି ଲେଖା ବଦଳ ମଡେଲ୍ ଉପଲବ୍ଧ ନୁହେଁ। ପସନ୍ଦରେ ଅନ୍ୟ ଏକ ମଡେଲ୍ ବାଛନ୍ତୁ।",
+ "gatewaySignInRequired": "Kilo Gateway ଲେଖା ବଦଳ ବ୍ୟବହାର ପାଇଁ ସାଇନ୍ ଇନ୍ କରନ୍ତୁ।",
+ "gatewayNoModel": "ପ୍ରଥମେ ପସନ୍ଦରେ ଏକ ଲେଖା ବଦଳ ମଡେଲ୍ ବାଛନ୍ତୁ।"
},
"share": {
"title": "Kilo କୁ ସେୟାର",
@@ -2950,5 +2959,12 @@
"needsInput": "ଆପଣଙ୍କ ଉତ୍ତର ଆବଶ୍ୟକ",
"channelName": "ସକ୍ରିୟ ଏଜେଣ୍ଟଗୁଡ଼ିକ",
"activityKitDisabledBody": "ଲକ୍ ସ୍କ୍ରିନ୍ରେ ସକ୍ରିୟ ଏଜେଣ୍ଟଗୁଡ଼ିକ ଦେଖିବାକୁ ସେଟିଂସ୍ରେ ଲାଇଭ୍ କାର୍ଯ୍ୟକଳାପ ଚାଲୁ କରନ୍ତୁ।"
+ },
+ "transcriptionModel": {
+ "title": "କଥାକୁ ଲେଖାରେ ବଦଳାଇବାର ମଡେଲ୍",
+ "noneChosen": "କିଛି ବଛାଯାଇନାହିଁ",
+ "emptyTitle": "କଥାକୁ ଲେଖାରେ ବଦଳାଇବାର କୌଣସି ମଡେଲ୍ ନାହିଁ",
+ "emptyDescription": "Kilo Gateway ବର୍ତ୍ତମାନ କଥାକୁ ଲେଖାରେ ବଦଳାଇବାର କୌଣସି ମଡେଲ୍ ଦେଉନାହିଁ।",
+ "loadFailed": "କଥାକୁ ଲେଖାରେ ବଦଳାଇବାର ମଡେଲ୍ ଗୁଡ଼ିକୁ ଲୋଡ୍ କରାଯାଇପାରିଲା ନାହିଁ।"
}
}
diff --git a/apps/mobile/src/i18n/locales/pa.json b/apps/mobile/src/i18n/locales/pa.json
index 35840c2be5..b3d9ff0241 100644
--- a/apps/mobile/src/i18n/locales/pa.json
+++ b/apps/mobile/src/i18n/locales/pa.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Kilo Gateway ਰਾਹੀਂ ਬੋਲੀ ਨੂੰ ਲਿਖਤ ਵਿੱਚ ਬਦਲਣਾ",
+ "gatewayTranscriptionSubtitle": "ਡਿਵਾਈਸ ਦੀ ਸਪੀਚ ਰੈਕਗਨੀਸ਼ਨ ਦੀ ਥਾਂ Kilo Gateway ਮਾਡਲ ਨਾਲ ਵੌਇਸ ਇਨਪੁਟ ਨੂੰ ਲਿਖਤ ਵਿੱਚ ਬਦਲੋ। ਤੁਹਾਡੀ ਰਿਕਾਰਡਿੰਗ Kilo Gateway ਨੂੰ ਭੇਜੀ ਜਾਂਦੀ ਹੈ।",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "ਪੁੱਲ ਰਿਕਵੈਸਟ ਉਪਲਬਧ ਨਹੀਂ",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "{{language}} ਦੀਆਂ ਆਫ਼ਲਾਈਨ ਸਪੀਚ ਫ਼ਾਈਲਾਂ ਇਸ ਫ਼ੋਨ 'ਤੇ ਇੰਸਟਾਲ ਨਹੀਂ ਹਨ। ਉਨ੍ਹਾਂ ਨੂੰ ਡਾਊਨਲੋਡ ਕਰੋ, ਫਿਰ ਵੌਇਸ ਇਨਪੁਟ ਦੁਬਾਰਾ ਵਰਤੋ।",
"downloadOfflineModel": "ਡਾਊਨਲੋਡ",
"offlineModelDownloadScheduled": "ਆਫ਼ਲਾਈਨ ਸਪੀਚ ਫ਼ਾਈਲਾਂ ਬੈਕਗ੍ਰਾਊਂਡ ਵਿੱਚ ਡਾਊਨਲੋਡ ਹੋਣਗੀਆਂ। ਬਾਅਦ ਵਿੱਚ ਵੌਇਸ ਇਨਪੁਟ ਦੁਬਾਰਾ ਵਰਤੋ।",
- "listeningStopped": "ਸੁਣਨਾ ਰੁਕ ਗਿਆ।"
+ "listeningStopped": "ਸੁਣਨਾ ਰੁਕ ਗਿਆ।",
+ "transcribing": "ਲਿਖਤ ਵਿੱਚ ਬਦਲਿਆ ਜਾ ਰਿਹਾ ਹੈ...",
+ "gatewayUnreachable": "Kilo Gateway ਨਾਲ ਸੰਪਰਕ ਨਹੀਂ ਹੋ ਸਕਿਆ। ਆਪਣਾ ਕਨੈਕਸ਼ਨ ਚੈੱਕ ਕਰੋ ਅਤੇ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।",
+ "gatewayTimeout": "ਬੋਲੀ ਨੂੰ ਲਿਖਤ ਵਿੱਚ ਬਦਲਣ ਵਿੱਚ ਬਹੁਤ ਸਮਾਂ ਲੱਗਾ। ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।",
+ "gatewayModelUnavailable": "ਇਹ ਬਦਲਣ ਵਾਲਾ ਮਾਡਲ ਉਪਲਬਧ ਨਹੀਂ ਹੈ। ਤਰਜੀਹਾਂ ਵਿੱਚ ਕੋਈ ਹੋਰ ਚੁਣੋ।",
+ "gatewaySignInRequired": "Kilo Gateway ਬਦਲੀ ਵਰਤਣ ਲਈ ਸਾਈਨ ਇਨ ਕਰੋ।",
+ "gatewayNoModel": "ਪਹਿਲਾਂ ਤਰਜੀਹਾਂ ਵਿੱਚ ਬੋਲੀ ਨੂੰ ਲਿਖਤ ਵਿੱਚ ਬਦਲਣ ਵਾਲਾ ਮਾਡਲ ਚੁਣੋ।"
},
"share": {
"title": "Kilo ਵਿੱਚ ਸਾਂਝਾ ਕਰਨਾ",
@@ -2950,5 +2959,12 @@
"needsInput": "ਜਵਾਬ ਦੀ ਉਡੀਕ",
"channelName": "ਸਰਗਰਮ ਏਜੰਟ",
"activityKitDisabledBody": "ਲਾਕ ਸਕ੍ਰੀਨ 'ਤੇ ਸਰਗਰਮ ਏਜੰਟ ਵੇਖਣ ਲਈ ਸੈਟਿੰਗਾਂ ਵਿੱਚ ਲਾਈਵ ਗਤੀਵਿਧੀਆਂ ਚਾਲੂ ਕਰੋ।"
+ },
+ "transcriptionModel": {
+ "title": "ਬੋਲੀ ਨੂੰ ਲਿਖਤ ਵਿੱਚ ਬਦਲਣ ਦਾ ਮਾਡਲ",
+ "noneChosen": "ਕੋਈ ਨਹੀਂ ਚੁਣਿਆ",
+ "emptyTitle": "ਬੋਲੀ ਨੂੰ ਲਿਖਤ ਵਿੱਚ ਬਦਲਣ ਲਈ ਕੋਈ ਮਾਡਲ ਨਹੀਂ",
+ "emptyDescription": "Kilo Gateway ਹੁਣ ਬੋਲੀ ਨੂੰ ਲਿਖਤ ਵਿੱਚ ਬਦਲਣ ਲਈ ਕੋਈ ਮਾਡਲ ਨਹੀਂ ਦੇ ਰਿਹਾ।",
+ "loadFailed": "ਬੋਲੀ ਨੂੰ ਲਿਖਤ ਵਿੱਚ ਬਦਲਣ ਵਾਲੇ ਮਾਡਲ ਲੋਡ ਨਹੀਂ ਹੋ ਸਕੇ।"
}
}
diff --git a/apps/mobile/src/i18n/locales/pl.json b/apps/mobile/src/i18n/locales/pl.json
index 1c011a1765..c37b0f03d7 100644
--- a/apps/mobile/src/i18n/locales/pl.json
+++ b/apps/mobile/src/i18n/locales/pl.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Transkrypcja przez Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Transkrybuj dyktowanie modelem Kilo Gateway zamiast rozpoznawania mowy na urządzeniu. Twoje nagranie zostanie wysłane do Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"notifications": {
"liveActivities": "Aktywności na żywo",
@@ -2162,7 +2165,13 @@
"languageNotInstalledMessage": "Pliki mowy offline dla języka {{language}} nie są zainstalowane na tym telefonie. Pobierz je, a następnie ponownie spróbuj dyktowania.",
"downloadOfflineModel": "Pobierz",
"offlineModelDownloadScheduled": "Pliki mowy offline zostaną pobrane w tle. Spróbuj dyktowania ponownie później.",
- "listeningStopped": "Nasłuchiwanie zostało zatrzymane."
+ "listeningStopped": "Nasłuchiwanie zostało zatrzymane.",
+ "transcribing": "Transkrypcja...",
+ "gatewayUnreachable": "Nie udało się połączyć z Kilo Gateway. Sprawdź połączenie i spróbuj ponownie.",
+ "gatewayTimeout": "Transkrypcja trwała zbyt długo. Spróbuj ponownie.",
+ "gatewayModelUnavailable": "Ten model transkrypcji jest niedostępny. Wybierz inny w Preferencjach.",
+ "gatewaySignInRequired": "Zaloguj się, aby korzystać z transkrypcji przez Kilo Gateway.",
+ "gatewayNoModel": "Najpierw wybierz model transkrypcji w Preferencjach."
},
"share": {
"title": "Udostępnianie w Kilo",
@@ -2994,5 +3003,12 @@
"needsInput": "Czeka na odpowiedź",
"channelName": "Aktywni agenci",
"activityKitDisabledBody": "Włącz aktywności na żywo w ustawieniach, aby widzieć aktywnych agentów na ekranie blokady."
+ },
+ "transcriptionModel": {
+ "title": "Model transkrypcji",
+ "noneChosen": "Nie wybrano",
+ "emptyTitle": "Brak modeli transkrypcji",
+ "emptyDescription": "Kilo Gateway nie oferuje teraz żadnych modeli transkrypcji.",
+ "loadFailed": "Nie udało się wczytać modeli transkrypcji."
}
}
diff --git a/apps/mobile/src/i18n/locales/ps.json b/apps/mobile/src/i18n/locales/ps.json
index b13b1f9506..11daf45f96 100644
--- a/apps/mobile/src/i18n/locales/ps.json
+++ b/apps/mobile/src/i18n/locales/ps.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "د Kilo Gateway له لارې د غږ متن ته بدلول",
+ "gatewayTranscriptionSubtitle": "غږیز لیکل د وسیلې د ویې پېژندنې پر ځای د Kilo Gateway ماډل سره متن ته واړوئ. غږیز ریکارډ مو د Kilo Gateway ته لیږل کېږي.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "پل ریکویسټ نشته",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "د {{language}} آفلاین وینا فایلونه پر دې تلیفون نصب شوي نه دي. هغوی ښکته کړئ، بیا د غږیز لیکلو هڅه وکړئ.",
"downloadOfflineModel": "ښکته کول",
"offlineModelDownloadScheduled": "آفلاین وینا فایلونه به شالید کې ښکته شي. وروسته بیا د غږیز لیکلو هڅه وکړئ.",
- "listeningStopped": "اورېدل ودرېدل."
+ "listeningStopped": "اورېدل ودرېدل.",
+ "transcribing": "متن ته اړول کېږي...",
+ "gatewayUnreachable": "له Kilo Gateway سره اړیکه نشو ټینګېدلی. خپله اړیکه وګورئ او بیا هڅه وکړئ.",
+ "gatewayTimeout": "د غږ بدلول ډېر وخت ونیو. بیا هڅه وکړئ.",
+ "gatewayModelUnavailable": "دا ماډل شتون نلري. په غوره توبونو کې بل وټاکئ.",
+ "gatewaySignInRequired": "د Kilo Gateway بدلولو کارولو لپاره ننوځئ.",
+ "gatewayNoModel": "لومړی په غوره توبونو کې یو ماډل وټاکئ."
},
"share": {
"title": "له Kilo سره شریکول",
@@ -2950,5 +2959,12 @@
"needsInput": "ځواب ته اړتیا لري",
"channelName": "فعال اجنټان",
"activityKitDisabledBody": "په قلف شوې پرده کې د فعالو اجنټانو د لیدلو لپاره په ترتیباتو کې روان فعالیتونه فعال کړئ."
+ },
+ "transcriptionModel": {
+ "title": "د غږ د متن ته بدلولو ماډل",
+ "noneChosen": "هیڅ نه دی ټاکل شوی",
+ "emptyTitle": "د غږ د متن ته بدلولو هیڅ ماډل نشته",
+ "emptyDescription": "Kilo Gateway اوس مهال د غږ د متن ته بدلولو هیڅ ماډل نه وړاندې کوي.",
+ "loadFailed": "د غږ د متن ته بدلولو ماډلونه بار نه شول."
}
}
diff --git a/apps/mobile/src/i18n/locales/pt-BR.json b/apps/mobile/src/i18n/locales/pt-BR.json
index d8fb168da1..d79dae5344 100644
--- a/apps/mobile/src/i18n/locales/pt-BR.json
+++ b/apps/mobile/src/i18n/locales/pt-BR.json
@@ -2051,7 +2051,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Transcrição com Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Transcreva a digitação por voz com um modelo do Kilo Gateway em vez do reconhecimento de fala do dispositivo. Sua gravação é enviada para o Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"addCredits": {
"cta": "Adicionar créditos",
@@ -2148,7 +2151,13 @@
"languageNotInstalledMessage": "Os arquivos de fala off-line de {{language}} não estão instalados neste celular. Baixe-os e tente a digitação por voz novamente.",
"downloadOfflineModel": "Baixar",
"offlineModelDownloadScheduled": "Os arquivos de fala off-line serão baixados em segundo plano. Tente a digitação por voz novamente mais tarde.",
- "listeningStopped": "A captação de voz foi interrompida."
+ "listeningStopped": "A captação de voz foi interrompida.",
+ "transcribing": "Transcrevendo...",
+ "gatewayUnreachable": "Não foi possível conectar ao Kilo Gateway. Verifique sua conexão e tente novamente.",
+ "gatewayTimeout": "A transcrição demorou demais. Tente novamente.",
+ "gatewayModelUnavailable": "Este modelo de transcrição não está disponível. Escolha outro em Preferências.",
+ "gatewaySignInRequired": "Entre para usar a transcrição com Kilo Gateway.",
+ "gatewayNoModel": "Escolha primeiro um modelo de transcrição em Preferências."
},
"share": {
"title": "Compartilhar com o Kilo",
@@ -2972,5 +2981,12 @@
"needsInput": "Aguardando resposta",
"channelName": "Agentes ativos",
"activityKitDisabledBody": "Ative as Atividades ao Vivo nos Ajustes para ver os agentes ativos na tela bloqueada."
+ },
+ "transcriptionModel": {
+ "title": "Modelo de transcrição",
+ "noneChosen": "Nenhum selecionado",
+ "emptyTitle": "Nenhum modelo de transcrição",
+ "emptyDescription": "O Kilo Gateway não oferece modelos de transcrição no momento.",
+ "loadFailed": "Não foi possível carregar os modelos de transcrição."
}
}
diff --git a/apps/mobile/src/i18n/locales/pt.json b/apps/mobile/src/i18n/locales/pt.json
index 38c5a7703b..ecc8735359 100644
--- a/apps/mobile/src/i18n/locales/pt.json
+++ b/apps/mobile/src/i18n/locales/pt.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Transcrição com Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Transcreva o ditado com um modelo do Kilo Gateway em vez do reconhecimento de fala do dispositivo. A sua gravação é enviada para o Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "PR indisponível",
@@ -2589,7 +2592,13 @@
"languageNotInstalledMessage": "Os ficheiros de voz offline de {{language}} não estão instalados neste telemóvel. Descarrega-os e tenta o ditado novamente.",
"downloadOfflineModel": "Descarregar",
"offlineModelDownloadScheduled": "Os ficheiros de voz offline serão descarregados em segundo plano. Tenta o ditado novamente mais tarde.",
- "listeningStopped": "O ditado foi interrompido."
+ "listeningStopped": "O ditado foi interrompido.",
+ "transcribing": "A transcrever...",
+ "gatewayUnreachable": "Não foi possível contactar o Kilo Gateway. Verifique a sua ligação e tente novamente.",
+ "gatewayTimeout": "A transcrição demorou demasiado tempo. Tente novamente.",
+ "gatewayModelUnavailable": "Este modelo de transcrição não está disponível. Escolha outro em Preferências.",
+ "gatewaySignInRequired": "Inicie sessão para usar a transcrição com Kilo Gateway.",
+ "gatewayNoModel": "Escolha primeiro um modelo de transcrição em Preferências."
},
"share": {
"title": "Partilhar com o Kilo",
@@ -2972,5 +2981,12 @@
"needsInput": "À espera de resposta",
"channelName": "Agentes ativos",
"activityKitDisabledBody": "Ativa as atividades em direto nas definições para ver os agentes ativos no ecrã bloqueado."
+ },
+ "transcriptionModel": {
+ "title": "Modelo de transcrição",
+ "noneChosen": "Nenhum selecionado",
+ "emptyTitle": "Sem modelos de transcrição",
+ "emptyDescription": "O Kilo Gateway não oferece modelos de transcrição de momento.",
+ "loadFailed": "Não foi possível carregar os modelos de transcrição."
}
}
diff --git a/apps/mobile/src/i18n/locales/ro.json b/apps/mobile/src/i18n/locales/ro.json
index 48a749237f..eaba1657c8 100644
--- a/apps/mobile/src/i18n/locales/ro.json
+++ b/apps/mobile/src/i18n/locales/ro.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Transcriere cu Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Transcrie dictarea cu un model Kilo Gateway în loc de recunoașterea vocală a dispozitivului. Înregistrarea este trimisă către Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "PR indisponibil",
@@ -2589,7 +2592,13 @@
"languageNotInstalledMessage": "Fișierele vocale offline pentru {{language}} nu sunt instalate pe acest telefon. Descarcă-le, apoi încearcă dictarea din nou.",
"downloadOfflineModel": "Descarcă",
"offlineModelDownloadScheduled": "Fișierele vocale offline se vor descărca în fundal. Încearcă dictarea din nou mai târziu.",
- "listeningStopped": "Ascultarea s-a oprit."
+ "listeningStopped": "Ascultarea s-a oprit.",
+ "transcribing": "Se transcrie…",
+ "gatewayUnreachable": "Nu s-a putut contacta Kilo Gateway. Verifică conexiunea și încearcă din nou.",
+ "gatewayTimeout": "Transcrierea a durat prea mult. Încearcă din nou.",
+ "gatewayModelUnavailable": "Acest model de transcriere nu este disponibil. Alege altul în Preferințe.",
+ "gatewaySignInRequired": "Autentifică-te pentru a folosi transcrierea cu Kilo Gateway.",
+ "gatewayNoModel": "Alege mai întâi un model de transcriere în Preferințe."
},
"share": {
"title": "Distribuire către Kilo",
@@ -2972,5 +2981,12 @@
"needsInput": "Așteaptă un răspuns",
"channelName": "Agenți activi",
"activityKitDisabledBody": "Activează activitățile live din setări pentru a vedea agenții activi pe ecranul de blocare."
+ },
+ "transcriptionModel": {
+ "title": "Model de transcriere",
+ "noneChosen": "Niciunul selectat",
+ "emptyTitle": "Nu există modele de transcriere",
+ "emptyDescription": "Kilo Gateway nu oferă modele de transcriere în acest moment.",
+ "loadFailed": "Nu s-au putut încărca modelele de transcriere."
}
}
diff --git a/apps/mobile/src/i18n/locales/ru.json b/apps/mobile/src/i18n/locales/ru.json
index 757bfe6b34..aba34f4789 100644
--- a/apps/mobile/src/i18n/locales/ru.json
+++ b/apps/mobile/src/i18n/locales/ru.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Распознавание речи через Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Распознавайте голосовой ввод с помощью модели Kilo Gateway вместо распознавания речи на устройстве. Запись отправляется в Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"notifications": {
"liveActivities": "Эфир активности",
@@ -2162,7 +2165,13 @@
"languageNotInstalledMessage": "Офлайн-файлы распознавания речи для {{language}} не установлены на этом телефоне. Скачайте их, а затем снова попробуйте голосовой ввод.",
"downloadOfflineModel": "Скачать",
"offlineModelDownloadScheduled": "Офлайн-файлы распознавания речи будут скачаны в фоновом режиме. Попробуйте голосовой ввод позже.",
- "listeningStopped": "Запись речи остановлена."
+ "listeningStopped": "Запись речи остановлена.",
+ "transcribing": "Распознавание…",
+ "gatewayUnreachable": "Не удалось связаться с Kilo Gateway. Проверьте подключение и попробуйте снова.",
+ "gatewayTimeout": "Распознавание заняло слишком много времени. Попробуйте снова.",
+ "gatewayModelUnavailable": "Эта модель распознавания речи недоступна. Выберите другую в настройках.",
+ "gatewaySignInRequired": "Войдите, чтобы использовать распознавание речи через Kilo Gateway.",
+ "gatewayNoModel": "Сначала выберите модель распознавания речи в настройках."
},
"share": {
"title": "Отправка в Kilo",
@@ -2994,5 +3003,12 @@
"needsInput": "Ожидание ввода",
"channelName": "Активные агенты",
"activityKitDisabledBody": "Включите «Эфир активности» в настройках, чтобы видеть активных агентов на экране блокировки."
+ },
+ "transcriptionModel": {
+ "title": "Модель распознавания речи",
+ "noneChosen": "Не выбрано",
+ "emptyTitle": "Нет моделей распознавания речи",
+ "emptyDescription": "Сейчас Kilo Gateway не предлагает моделей распознавания речи.",
+ "loadFailed": "Не удалось загрузить модели распознавания речи."
}
}
diff --git a/apps/mobile/src/i18n/locales/si.json b/apps/mobile/src/i18n/locales/si.json
index f72ff553eb..12f42757a9 100644
--- a/apps/mobile/src/i18n/locales/si.json
+++ b/apps/mobile/src/i18n/locales/si.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Kilo Gateway හරහා හඬ පෙළට හැරවීම",
+ "gatewayTranscriptionSubtitle": "උපාංගයේ කථන හඳුනාගැනීම වෙනුවට Kilo Gateway ආකෘතියක් භාවිතයෙන් හඬ ආදානය පෙළ බවට හරින්න. ඔබේ පටිගත කිරීම Kilo Gateway වෙත යවනු ලැබේ.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "පුල් ඉල්ලීම ලබා ගත නොහැක",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "{{language}} සඳහා ඔෆ්ලයින් කථන ගොනු මෙම දුරකථනයේ ස්ථාපනය කර නැත. ඒවා බාගන්න, පසුව හඬ ආදානය නැවත උත්සාහ කරන්න.",
"downloadOfflineModel": "බාගන්න",
"offlineModelDownloadScheduled": "ඔෆ්ලයින් කථන ගොනු පසුබිමින් බාගත වනු ඇත. පසුව හඬ ආදානය නැවත උත්සාහ කරන්න.",
- "listeningStopped": "සවන් දීම නතර විය."
+ "listeningStopped": "සවන් දීම නතර විය.",
+ "transcribing": "පෙළට හරිමින්...",
+ "gatewayUnreachable": "Kilo Gateway වෙත ළඟා විය නොහැකි විය. ඔබේ සම්බන්ධතාවය පරීක්ෂා කර නැවත උත්සාහ කරන්න.",
+ "gatewayTimeout": "හඬ පෙළට හැරවීමට වැඩි වේලාවක් ගත විය. නැවත උත්සාහ කරන්න.",
+ "gatewayModelUnavailable": "මෙම හඬ පෙළට හැරවීමේ ආකෘතිය ලබා ගත නොහැක. මනාප වලින් තවත් එකක් තෝරන්න.",
+ "gatewaySignInRequired": "Kilo Gateway හඬ පෙළට හැරවීම භාවිත කිරීමට පුරනය වන්න.",
+ "gatewayNoModel": "පළමුව මනාප වලින් හඬ පෙළට හැරවීමේ ආකෘතියක් තෝරන්න."
},
"share": {
"title": "Kilo වෙත බෙදාගැනීම",
@@ -2950,5 +2959,12 @@
"needsInput": "ඔබේ ප්රතිචාරය අවශ්යයි",
"channelName": "සක්රීය නියෝජිතයන්",
"activityKitDisabledBody": "අගුළු තිරයේ සක්රීය නියෝජිතයන් බැලීමට සැකසුම් තුළ සජීවී ක්රියාකාරකම් සක්රීය කරන්න."
+ },
+ "transcriptionModel": {
+ "title": "හඬ පෙළට හරවන ආකෘතිය",
+ "noneChosen": "කිසිවක් තෝරා නැත",
+ "emptyTitle": "හඬ පෙළට හැරවීමේ ආකෘති නොමැත",
+ "emptyDescription": "දැනට Kilo Gateway හඬ පෙළට හැරවීමේ ආකෘති ලබා දෙන්නේ නැත.",
+ "loadFailed": "හඬ පෙළට හැරවීමේ ආකෘති පූරණය කළ නොහැකි විය."
}
}
diff --git a/apps/mobile/src/i18n/locales/sk.json b/apps/mobile/src/i18n/locales/sk.json
index 9716424c8c..fda9fce5de 100644
--- a/apps/mobile/src/i18n/locales/sk.json
+++ b/apps/mobile/src/i18n/locales/sk.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Prepis cez Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Prepisujte hlasový vstup modelom Kilo Gateway namiesto rozpoznávania reči v zariadení. Nahrávka sa odošle do Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Žiadosť o zlúčenie nie je dostupná",
@@ -2610,7 +2613,13 @@
"languageNotInstalledMessage": "Offline súbory reči pre jazyk {{language}} nie sú v tomto telefóne nainštalované. Stiahnite ich a potom skúste hlasový vstup znova.",
"downloadOfflineModel": "Stiahnuť",
"offlineModelDownloadScheduled": "Offline súbory reči sa stiahnu na pozadí. Skúste hlasový vstup znova neskôr.",
- "listeningStopped": "Počúvanie sa skončilo."
+ "listeningStopped": "Počúvanie sa skončilo.",
+ "transcribing": "Prepisuje sa…",
+ "gatewayUnreachable": "Nepodarilo sa spojiť s Kilo Gateway. Skontroluj pripojenie a skús to znova.",
+ "gatewayTimeout": "Prepis trval príliš dlho. Skús to znova.",
+ "gatewayModelUnavailable": "Tento model prepisu nie je dostupný. Vyberte iný v Predvoľbách.",
+ "gatewaySignInRequired": "Prihláste sa, aby ste mohli používať prepis cez Kilo Gateway.",
+ "gatewayNoModel": "Najprv vyberte model prepisu v Predvoľbách."
},
"share": {
"title": "Zdieľanie do aplikácie Kilo",
@@ -2994,5 +3003,12 @@
"needsInput": "Čaká na odpoveď",
"channelName": "Aktívne agenty",
"activityKitDisabledBody": "Zapni v nastaveniach živé aktivity, aby sa aktívne agenty zobrazovali na zamknutej obrazovke."
+ },
+ "transcriptionModel": {
+ "title": "Model prepisu",
+ "noneChosen": "Nie je vybraný žiadny",
+ "emptyTitle": "Žiadne modely prepisu",
+ "emptyDescription": "Kilo Gateway teraz neponúka žiadne modely prepisu.",
+ "loadFailed": "Nepodarilo sa načítať modely prepisu."
}
}
diff --git a/apps/mobile/src/i18n/locales/sl.json b/apps/mobile/src/i18n/locales/sl.json
index 819d9e8188..8d76d03e7d 100644
--- a/apps/mobile/src/i18n/locales/sl.json
+++ b/apps/mobile/src/i18n/locales/sl.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Prepisovanje prek Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Glasovni vnos prepiši z modelom Kilo Gateway namesto prepoznavanja govora naprave. Posnetje se pošlje v Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Zahtevek za združitev ni na voljo",
@@ -2610,7 +2613,13 @@
"languageNotInstalledMessage": "Datoteke govora za delo brez povezave za {{language}} niso nameščene na tem telefonu. Prenesi jih, nato poskusi glasovni vnos znova.",
"downloadOfflineModel": "Prenesi",
"offlineModelDownloadScheduled": "Datoteke govora za delo brez povezave bodo prenesene v ozadju. Poskusi glasovni vnos znova pozneje.",
- "listeningStopped": "Poslušanje je ustavljeno."
+ "listeningStopped": "Poslušanje je ustavljeno.",
+ "transcribing": "Prepisovanje...",
+ "gatewayUnreachable": "Kilo Gateway ni dosegljiv. Preveri povezavo in poskusi znova.",
+ "gatewayTimeout": "Prepisovanje je predolgo trajalo. Poskusi znova.",
+ "gatewayModelUnavailable": "Ta model za prepisovanje ni na voljo. Izberite drugega v Nastavitvah.",
+ "gatewaySignInRequired": "Prijavite se za uporabo prepisovanja prek Kilo Gateway.",
+ "gatewayNoModel": "Najprej izberite model za prepisovanje v Nastavitvah."
},
"share": {
"title": "Deljenje v Kilo",
@@ -2994,5 +3003,12 @@
"needsInput": "Čaka na vnos",
"channelName": "Aktivni agenti",
"activityKitDisabledBody": "V nastavitvah vklopi dejavnosti v živo, da se aktivni agenti prikažejo na zaklenjenem zaslonu."
+ },
+ "transcriptionModel": {
+ "title": "Model za prepisovanje",
+ "noneChosen": "Ni izbran",
+ "emptyTitle": "Ni modelov za prepisovanje",
+ "emptyDescription": "Kilo Gateway trenutno ne ponuja modelov za prepisovanje.",
+ "loadFailed": "Modelov za prepisovanje ni bilo mogoče naložiti."
}
}
diff --git a/apps/mobile/src/i18n/locales/so.json b/apps/mobile/src/i18n/locales/so.json
index ac8dd5177a..92c4a93fe2 100644
--- a/apps/mobile/src/i18n/locales/so.json
+++ b/apps/mobile/src/i18n/locales/so.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Qoraal u beddelidda codka ee Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Codka ku beddel qoraal moodeelka Kilo Gateway adigoo aan isticmaalayn aqoonsiga hadalka ee qalabka. Duubistaada waxaa loo diraa Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Codsiga isku-darka lama heli karo",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "Faylasha hadalka khadka la'aanta ee {{language}} lama rakibin telefoonkan. Soo deji iyaga, ka dibna markale isku day gelinta codka.",
"downloadOfflineModel": "Soo deji",
"offlineModelDownloadScheduled": "Faylasha hadalka khadka la'aanta waxay soo deji doonaan gadaasha. Markale isku day gelinta codka goor dambe.",
- "listeningStopped": "Dhageysigu wuu joogsaday."
+ "listeningStopped": "Dhageysigu wuu joogsaday.",
+ "transcribing": "Qoraal u beddelidda...",
+ "gatewayUnreachable": "Kilo Gateway lama heli karin. Hubi xiriirkaaga oo mar kale isku day.",
+ "gatewayTimeout": "Qoraal u beddelidda waxay qaadatay waqti aad u dheer. Mar kale isku day.",
+ "gatewayModelUnavailable": "Moodeelkan qoraal u beddelidda lagama heli karo. Mid kale ka dooro Dookhyada.",
+ "gatewaySignInRequired": "Soo gal si aad u isticmaasho qoraal u beddelidda Kilo Gateway.",
+ "gatewayNoModel": "Marka hore moodeel qoraal u beddelidda ka dooro Dookhyada."
},
"share": {
"title": "U wadaag Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "Jawaab sugaya",
"channelName": "Wakiillada firfircoon",
"activityKitDisabledBody": "Ka daar hawlaha tooska ah dejinta si aad wakiillada firfircoon ugu aragto shaashadda qufulka."
+ },
+ "transcriptionModel": {
+ "title": "Moodeelka qoraal u beddelidda",
+ "noneChosen": "Waxba lama dooran",
+ "emptyTitle": "Ma jiraan moodeello qoraal u beddelid",
+ "emptyDescription": "Kilo Gateway hadda ma bixiyo moodeello qoraal u beddelid.",
+ "loadFailed": "Moodeellada qoraal u beddelidda lama soo rari karin."
}
}
diff --git a/apps/mobile/src/i18n/locales/sq.json b/apps/mobile/src/i18n/locales/sq.json
index f9e9556dc4..1de214d84a 100644
--- a/apps/mobile/src/i18n/locales/sq.json
+++ b/apps/mobile/src/i18n/locales/sq.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Transkriptim me Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Transkripto diktimin me një model Kilo Gateway në vend të njohjes së të folurit të pajisjes. Regjistrimi yt dërgohet te Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Kërkesa për bashkim nuk është e disponueshme",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "Skedarët e të folurit jashtë interneti për {{language}} nuk janë të instaluar në këtë telefon. Shkarkoji dhe pastaj provo diktimin përsëri.",
"downloadOfflineModel": "Shkarko",
"offlineModelDownloadScheduled": "Skedarët e të folurit jashtë interneti do të shkarkohen në sfond. Provo diktimin përsëri më vonë.",
- "listeningStopped": "Dëgjimi u ndal."
+ "listeningStopped": "Dëgjimi u ndal.",
+ "transcribing": "Duke transkriptuar...",
+ "gatewayUnreachable": "Kilo Gateway nuk u arrit dot. Kontrollo lidhjen dhe provo përsëri.",
+ "gatewayTimeout": "Transkriptimi zgjati tepër. Provo përsëri.",
+ "gatewayModelUnavailable": "Ky model transkriptimi nuk është i disponueshëm. Zgjidh një tjetër te Preferencat.",
+ "gatewaySignInRequired": "Hyr për të përdorur transkriptimin me Kilo Gateway.",
+ "gatewayNoModel": "Së pari zgjidh një model transkriptimi te Preferencat."
},
"share": {
"title": "Ndarja në Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "Pret përgjigje",
"channelName": "Agjentët aktivë",
"activityKitDisabledBody": "Aktivizo aktivitetet në kohë reale në cilësimet e pajisjes për të parë agjentët aktivë në ekranin e kyçjes."
+ },
+ "transcriptionModel": {
+ "title": "Modeli i transkriptimit",
+ "noneChosen": "Asgjë nuk u zgjodh",
+ "emptyTitle": "Nuk ka modele transkriptimi",
+ "emptyDescription": "Kilo Gateway nuk ofron tani asnjë model transkriptimi.",
+ "loadFailed": "Modelet e transkriptimit nuk u ngarkuan."
}
}
diff --git a/apps/mobile/src/i18n/locales/sr.json b/apps/mobile/src/i18n/locales/sr.json
index 0831be6001..1e7369a91a 100644
--- a/apps/mobile/src/i18n/locales/sr.json
+++ b/apps/mobile/src/i18n/locales/sr.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Transkripcija preko Kilo Gateway-a",
+ "gatewayTranscriptionSubtitle": "Prepiši glasovni unos modelom Kilo Gateway-a umesto prepoznavanja govora na uređaju. Snimak se šalje na Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Zahtev za spajanje nije dostupan",
@@ -2589,7 +2592,13 @@
"languageNotInstalledMessage": "Datoteke govora za vanmrežni rad za {{language}} nisu instalirane na ovom telefonu. Preuzmi ih, a zatim pokušaj glasovni unos ponovo.",
"downloadOfflineModel": "Preuzmi",
"offlineModelDownloadScheduled": "Datoteke govora za vanmrežni rad biće preuzete u pozadini. Pokušaj glasovni unos ponovo kasnije.",
- "listeningStopped": "Slušanje je zaustavljeno."
+ "listeningStopped": "Slušanje je zaustavljeno.",
+ "transcribing": "Transkribujem…",
+ "gatewayUnreachable": "Nije bilo moguće povezati se sa Kilo Gateway-om. Proveri vezu i pokušaj ponovo.",
+ "gatewayTimeout": "Transkripcija je predugo trajala. Pokušaj ponovo.",
+ "gatewayModelUnavailable": "Ovaj model za transkripciju nije dostupan. Izaberi drugi u Podešavanjima.",
+ "gatewaySignInRequired": "Prijavi se da koristiš transkripciju preko Kilo Gateway-a.",
+ "gatewayNoModel": "Prvo izaberi model za transkripciju u Podešavanjima."
},
"share": {
"title": "Deljenje u aplikaciji Kilo",
@@ -2972,5 +2981,12 @@
"needsInput": "Čeka unos",
"channelName": "Aktivni agenti",
"activityKitDisabledBody": "Uključi aktivnosti uživo u podešavanjima da vidiš aktivne agente na zaključanom ekranu."
+ },
+ "transcriptionModel": {
+ "title": "Model za transkripciju",
+ "noneChosen": "Nije izabran",
+ "emptyTitle": "Nema modela za transkripciju",
+ "emptyDescription": "Kilo Gateway trenutno ne nudi modele za transkripciju.",
+ "loadFailed": "Nije moguće učitati modele za transkripciju."
}
}
diff --git a/apps/mobile/src/i18n/locales/sv.json b/apps/mobile/src/i18n/locales/sv.json
index 8aeb9bae98..172454dd16 100644
--- a/apps/mobile/src/i18n/locales/sv.json
+++ b/apps/mobile/src/i18n/locales/sv.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Transkribering via Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Transkribera röstinmatningen med en Kilo Gateway-modell i stället för enhetens taligenkänning. Din inspelning skickas till Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "PR:en är inte tillgänglig",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "De offlinebaserade talfilerna för {{language}} är inte installerade på den här telefonen. Ladda ned dem och prova röstinmatning igen.",
"downloadOfflineModel": "Ladda ned",
"offlineModelDownloadScheduled": "De offlinebaserade talfilerna laddas ned i bakgrunden. Prova röstinmatning igen senare.",
- "listeningStopped": "Lyssningen stoppades."
+ "listeningStopped": "Lyssningen stoppades.",
+ "transcribing": "Transkriberar…",
+ "gatewayUnreachable": "Det gick inte att nå Kilo Gateway. Kontrollera anslutningen och försök igen.",
+ "gatewayTimeout": "Transkriberingen tog för lång tid. Försök igen.",
+ "gatewayModelUnavailable": "Den här transkriberingsmodellen är inte tillgänglig. Välj en annan i Inställningar.",
+ "gatewaySignInRequired": "Logga in för att använda transkribering via Kilo Gateway.",
+ "gatewayNoModel": "Välj först en transkriberingsmodell i Inställningar."
},
"share": {
"title": "Dela till Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "Väntar på svar",
"channelName": "Aktiva agenter",
"activityKitDisabledBody": "Aktivera liveaktiviteter i Inställningar för att se aktiva agenter på låsskärmen."
+ },
+ "transcriptionModel": {
+ "title": "Transkriberingsmodell",
+ "noneChosen": "Ingen vald",
+ "emptyTitle": "Inga transkriberingsmodeller",
+ "emptyDescription": "Kilo Gateway erbjuder inga transkriberingsmodeller just nu.",
+ "loadFailed": "Det gick inte att läsa in transkriberingsmodellerna."
}
}
diff --git a/apps/mobile/src/i18n/locales/sw.json b/apps/mobile/src/i18n/locales/sw.json
index 2577449e27..f52f6b6924 100644
--- a/apps/mobile/src/i18n/locales/sw.json
+++ b/apps/mobile/src/i18n/locales/sw.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Unukuzi wa sauti kupitia Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Badilisha uingizaji wa sauti kuwa maandishi kwa modeli ya Kilo Gateway badala ya utambuzi wa sauti wa kifaa. Rekodi yako imetumwa kwa Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Ombi la kuunganisha halipatikani",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "Mafaili ya sauti ya nje ya mtandao ya {{language}} hayajasakinishwa kwenye simu hii. Pakua mafaili hayo, kisha ujaribu tena uingizaji kwa sauti.",
"downloadOfflineModel": "Pakua",
"offlineModelDownloadScheduled": "Mafaili ya sauti ya nje ya mtandao yatapakuliwa nyuma ya pazia. Jaribu tena uingizaji kwa sauti baadaye.",
- "listeningStopped": "Usikilizaji umesimama."
+ "listeningStopped": "Usikilizaji umesimama.",
+ "transcribing": "Inanukuuza...",
+ "gatewayUnreachable": "Kilo Gateway haikupatikana. Angalia muunganisho wako na ujaribu tena.",
+ "gatewayTimeout": "Unukuzi umechukua muda mrefu sana. Jaribu tena.",
+ "gatewayModelUnavailable": "Modeli hii ya unukuzi haipatikani. Chagua nyingine katika Mapendeleo.",
+ "gatewaySignInRequired": "Ingia ili utumie unukuzi wa Kilo Gateway.",
+ "gatewayNoModel": "Kwanza chagua modeli ya unukuzi katika Mapendeleo."
},
"share": {
"title": "Kushiriki kwenye Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "Inasubiri jibu",
"channelName": "Mawakala wanaofanya kazi",
"activityKitDisabledBody": "Washa shughuli za wakati halisi kwenye mipangilio ili uone mawakala wanaofanya kazi kwenye skrini iliyofungwa."
+ },
+ "transcriptionModel": {
+ "title": "Modeli ya unukuzi",
+ "noneChosen": "Hakuna iliyochaguliwa",
+ "emptyTitle": "Hakuna modeli za unukuzi",
+ "emptyDescription": "Kilo Gateway haina modeli za unukuzi kwa sasa.",
+ "loadFailed": "Imeshindikana kupakia modeli za unukuzi."
}
}
diff --git a/apps/mobile/src/i18n/locales/ta.json b/apps/mobile/src/i18n/locales/ta.json
index 9e8e01f075..6a11c1bc2b 100644
--- a/apps/mobile/src/i18n/locales/ta.json
+++ b/apps/mobile/src/i18n/locales/ta.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Kilo Gateway மூலம் பேச்சு உரையாக்கம்",
+ "gatewayTranscriptionSubtitle": "சாதனத்தின் பேச்சு அடையாளப்படுத்தலுக்குப் பதிலாக Kilo Gateway மாதிரியைப் பயன்படுத்தி குரல் உள்ளீட்டை உரையாக்குங்கள். உங்கள் பதிவு Kilo Gateway-க்கு அனுப்பப்படுகிறது.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "இணைப்புக் கோரிக்கை கிடைக்கவில்லை",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "{{language}}க்கான ஆஃப்லைன் பேச்சு கோப்புகள் இந்தத் தொலைபேசியில் நிறுவப்படவில்லை. அவற்றைப் பதிவிறக்கவும், பிறகு குரல் உள்ளீட்டை மீண்டும் முயற்சிக்கவும்.",
"downloadOfflineModel": "பதிவிறக்கு",
"offlineModelDownloadScheduled": "ஆஃப்லைன் பேச்சு கோப்புகள் பின்னணியில் பதிவிறக்கம் செய்யப்படும். பிறகு குரல் உள்ளீட்டை மீண்டும் முயற்சிக்கவும்.",
- "listeningStopped": "கேட்பது நிறுத்தப்பட்டது."
+ "listeningStopped": "கேட்பது நிறுத்தப்பட்டது.",
+ "transcribing": "உரையாக்கம் நடைபெறுகிறது...",
+ "gatewayUnreachable": "Kilo Gateway-ஐ அணுக முடியவில்லை. உங்கள் இணைப்பைச் சரிபார்த்து மீண்டும் முயற்சிக்கவும்.",
+ "gatewayTimeout": "உரையாக்கம் மிக அதிக நேரம் எடுத்தது. மீண்டும் முயற்சிக்கவும்.",
+ "gatewayModelUnavailable": "இந்த உரையாக்க மாதிரி கிடைக்கவில்லை. விருப்பத்தேர்வுகளில் வேறொன்றைத் தேர்ந்தெடுக்கவும்.",
+ "gatewaySignInRequired": "Kilo Gateway உரையாக்கத்தைப் பயன்படுத்த உள்நுழையவும்.",
+ "gatewayNoModel": "முதலில் விருப்பத்தேர்வுகளில் உரையாக்க மாதிரியைத் தேர்ந்தெடுக்கவும்."
},
"share": {
"title": "Kilo-வுக்குப் பகிர்தல்",
@@ -2950,5 +2959,12 @@
"needsInput": "உள்ளீடு தேவை",
"channelName": "செயலில் உள்ள ஏஜெண்டுகள்",
"activityKitDisabledBody": "பூட்டுத் திரையில் செயலில் உள்ள ஏஜெண்டுகளைப் பார்க்க, அமைப்புகளில் நேரலைச் செயல்பாடுகளை இயக்கவும்."
+ },
+ "transcriptionModel": {
+ "title": "உரையாக்க மாதிரி",
+ "noneChosen": "எதுவும் தேர்ந்தெடுக்கப்படவில்லை",
+ "emptyTitle": "உரையாக்க மாதிரிகள் இல்லை",
+ "emptyDescription": "Kilo Gateway தற்போது உரையாக்க மாதிரிகளை வழங்கவில்லை.",
+ "loadFailed": "உரையாக்க மாதிரிகளை ஏற்ற முடியவில்லை."
}
}
diff --git a/apps/mobile/src/i18n/locales/te.json b/apps/mobile/src/i18n/locales/te.json
index 5def0623bd..a18a315883 100644
--- a/apps/mobile/src/i18n/locales/te.json
+++ b/apps/mobile/src/i18n/locales/te.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Kilo Gateway ద్వారా మాటలను వచనంగా మార్చడం",
+ "gatewayTranscriptionSubtitle": "పరికరం యొక్క మాటల గుర్తింపుకు బదులు Kilo Gateway మోడల్తో వాయిస్ ఇన్పుట్ను వచనంగా మార్చండి. మీ రికార్డింగ్ Kilo Gatewayకు పంపబడుతుంది.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "పుల్ రిక్వెస్ట్ అందుబాటులో లేదు",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "{{language}} భాష ఆఫ్లైన్ స్పీచ్ ఫైల్లు ఈ ఫోన్లో ఇన్స్టాల్ చేయలేదు. వాటిని డౌన్లోడ్ చేసి, తర్వాత వాయిస్ ఇన్పుట్ను మళ్లీ ప్రయత్నించండి.",
"downloadOfflineModel": "డౌన్లోడ్",
"offlineModelDownloadScheduled": "ఆఫ్లైన్ స్పీచ్ ఫైల్లు నేపథ్యంలో డౌన్లోడ్ అవుతాయి. తర్వాత వాయిస్ ఇన్పుట్ను మళ్లీ ప్రయత్నించండి.",
- "listeningStopped": "వినడం ఆగిపోయింది."
+ "listeningStopped": "వినడం ఆగిపోయింది.",
+ "transcribing": "వచనంగా మారుస్తోంది...",
+ "gatewayUnreachable": "Kilo Gatewayను చేరుకోలేకపోయాం. మీ కనెక్షన్ను తనిఖీ చేసి మళ్లీ ప్రయత్నించండి.",
+ "gatewayTimeout": "వచనంగా మార్చడానికి చాలా సమయం పట్టింది. మళ్లీ ప్రయత్నించండి.",
+ "gatewayModelUnavailable": "ఈ వచన మార్పిడి మోడల్ అందుబాటులో లేదు. ప్రాధాన్యతలలో మరొకదాన్ని ఎంచుకోండి.",
+ "gatewaySignInRequired": "Kilo Gateway మార్పిడిని ఉపయోగించడానికి సైన్ ఇన్ చేయండి.",
+ "gatewayNoModel": "ముందుగా ప్రాధాన్యతలలో వచనంగా మార్చే మోడల్ను ఎంచుకోండి."
},
"share": {
"title": "Kiloలో పంచుకోవడం",
@@ -2950,5 +2959,12 @@
"needsInput": "మీ స్పందన అవసరం",
"channelName": "పనిచేస్తున్న ఏజెంట్లు",
"activityKitDisabledBody": "లాక్ స్క్రీన్పై పనిచేస్తున్న ఏజెంట్లను చూడటానికి సెట్టింగ్లలో లైవ్ యాక్టివిటీస్ను ఆన్ చేయండి."
+ },
+ "transcriptionModel": {
+ "title": "వచనంగా మార్చే మోడల్",
+ "noneChosen": "ఏదీ ఎంచుకోలేదు",
+ "emptyTitle": "వచనంగా మార్చే మోడల్లు లేవు",
+ "emptyDescription": "Kilo Gateway ప్రస్తుతం వచనంగా మార్చే మోడల్లను అందించడం లేదు.",
+ "loadFailed": "వచనంగా మార్చే మోడల్లను లోడ్ చేయలేకపోయాం."
}
}
diff --git a/apps/mobile/src/i18n/locales/th.json b/apps/mobile/src/i18n/locales/th.json
index 30707fe6ef..5dca0e7a53 100644
--- a/apps/mobile/src/i18n/locales/th.json
+++ b/apps/mobile/src/i18n/locales/th.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "การถอดเสียงผ่าน Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "ถอดเสียงจากการพิมพ์ด้วยเสียงด้วยโมเดลของ Kilo Gateway แทนการรู้จำเสียงของอุปกรณ์ ระบบจะส่งการบันทึกเสียงของคุณไปยัง Kilo Gateway",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "เปิด PR ไม่ได้",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "ไฟล์เสียงพูดออฟไลน์สำหรับ{{language}}ยังไม่ได้ติดตั้งในโทรศัพท์เครื่องนี้ ดาวน์โหลดไฟล์ก่อน แล้วลองใช้การพิมพ์ด้วยเสียงอีกครั้ง",
"downloadOfflineModel": "ดาวน์โหลด",
"offlineModelDownloadScheduled": "ไฟล์เสียงพูดออฟไลน์จะดาวน์โหลดในเบื้องหลัง ลองใช้การพิมพ์ด้วยเสียงอีกครั้งในภายหลัง",
- "listeningStopped": "หยุดฟังแล้ว"
+ "listeningStopped": "หยุดฟังแล้ว",
+ "transcribing": "กำลังถอดเสียง...",
+ "gatewayUnreachable": "ไม่สามารถเชื่อมต่อ Kilo Gateway ได้ ตรวจสอบการเชื่อมต่อของคุณแล้วลองอีกครั้ง",
+ "gatewayTimeout": "การถอดเสียงใช้เวลานานเกินไป ลองอีกครั้ง",
+ "gatewayModelUnavailable": "โมเดลถอดเสียงนี้ไม่พร้อมใช้งาน เลือกโมเดลอื่นในการตั้งค่า",
+ "gatewaySignInRequired": "เข้าสู่ระบบเพื่อใช้การถอดเสียงผ่าน Kilo Gateway",
+ "gatewayNoModel": "เลือกโมเดลถอดเสียงในการตั้งค่าก่อน"
},
"share": {
"title": "แชร์ไปยัง Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "รอข้อมูลจากคุณ",
"channelName": "เอเจนต์ที่กำลังทำงาน",
"activityKitDisabledBody": "เปิดกิจกรรมสดในการตั้งค่าเพื่อดูเอเจนต์ที่กำลังทำงานบนหน้าจอล็อก"
+ },
+ "transcriptionModel": {
+ "title": "โมเดลถอดเสียง",
+ "noneChosen": "ยังไม่ได้เลือก",
+ "emptyTitle": "ไม่มีโมเดลถอดเสียง",
+ "emptyDescription": "ขณะนี้ Kilo Gateway ไม่มีโมเดลถอดเสียงให้บริการ",
+ "loadFailed": "ไม่สามารถโหลดโมเดลถอดเสียงได้"
}
}
diff --git a/apps/mobile/src/i18n/locales/tr.json b/apps/mobile/src/i18n/locales/tr.json
index b6f8c64ae3..e6f30a0a91 100644
--- a/apps/mobile/src/i18n/locales/tr.json
+++ b/apps/mobile/src/i18n/locales/tr.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Kilo Gateway ile sesi metne dönüştürme",
+ "gatewayTranscriptionSubtitle": "Sesli girişi cihazın konuşma tanıması yerine bir Kilo Gateway modeliyle metne dönüştürün. Kaydınız Kilo Gateway'e gönderilir.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"notifications": {
"liveActivities": "Canlı etkinlikler",
@@ -2134,7 +2137,13 @@
"languageNotInstalledMessage": "{{language}} dilinin çevrimdışı konuşma dosyaları bu telefonda yüklü değil. Dosyaları indir, ardından sesli girişi tekrar dene.",
"downloadOfflineModel": "İndir",
"offlineModelDownloadScheduled": "Çevrimdışı konuşma dosyaları arka planda indirilecek. Sesli girişi daha sonra tekrar dene.",
- "listeningStopped": "Dinleme durdu."
+ "listeningStopped": "Dinleme durdu.",
+ "transcribing": "Metne dönüştürülüyor...",
+ "gatewayUnreachable": "Kilo Gateway'e ulaşılamadı. Bağlantını kontrol et ve tekrar dene.",
+ "gatewayTimeout": "Metne dönüştürme çok uzun sürdü. Tekrar dene.",
+ "gatewayModelUnavailable": "Bu metne dönüştürme modeli kullanılamıyor. Tercihler'den başkasını seç.",
+ "gatewaySignInRequired": "Kilo Gateway ile dönüştürmeyi kullanmak için oturum açın.",
+ "gatewayNoModel": "Önce Tercihler'den bir metne dönüştürme modeli seçin."
},
"share": {
"title": "Kilo ile paylaş",
@@ -2950,5 +2959,12 @@
"needsInput": "Yanıt bekliyor",
"channelName": "Etkin ajanlar",
"activityKitDisabledBody": "Etkin ajanları kilit ekranında görmek için ayarlardan Canlı Etkinlikler özelliğini aç."
+ },
+ "transcriptionModel": {
+ "title": "Metne dönüştürme modeli",
+ "noneChosen": "Hiçbiri seçilmedi",
+ "emptyTitle": "Metne dönüştürme modeli yok",
+ "emptyDescription": "Kilo Gateway şu anda metne dönüştürme modeli sunmuyor.",
+ "loadFailed": "Metne dönüştürme modelleri yüklenemedi."
}
}
diff --git a/apps/mobile/src/i18n/locales/uk.json b/apps/mobile/src/i18n/locales/uk.json
index de30cc28ff..7d07e3f751 100644
--- a/apps/mobile/src/i18n/locales/uk.json
+++ b/apps/mobile/src/i18n/locales/uk.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Розпізнавання мовлення через Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Розпізнайте голосове введення за допомогою моделі Kilo Gateway замість розпізнавання мовлення на пристрої. Запис надсилається до Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"notifications": {
"liveActivities": "Дії наживо",
@@ -2162,7 +2165,13 @@
"languageNotInstalledMessage": "Офлайн-файли мовлення для {{language}} не встановлені на цьому телефоні. Завантажте їх, а потім спробуйте голосове введення ще раз.",
"downloadOfflineModel": "Завантажити",
"offlineModelDownloadScheduled": "Офлайн-файли мовлення буде завантажено у фоновому режимі. Спробуйте голосове введення пізніше.",
- "listeningStopped": "Запис мовлення зупинено."
+ "listeningStopped": "Запис мовлення зупинено.",
+ "transcribing": "Розпізнаємо…",
+ "gatewayUnreachable": "Не вдалося зв'язатися з Kilo Gateway. Перевірте з'єднання та спробуйте ще раз.",
+ "gatewayTimeout": "Розпізнавання тривало задовго. Спробуйте ще раз.",
+ "gatewayModelUnavailable": "Ця модель розпізнавання мовлення недоступна. Виберіть іншу в Налаштуваннях.",
+ "gatewaySignInRequired": "Увійдіть, щоб використовувати розпізнавання через Kilo Gateway.",
+ "gatewayNoModel": "Спершу виберіть модель розпізнавання мовлення в Налаштуваннях."
},
"share": {
"title": "Надсилання в Kilo",
@@ -2994,5 +3003,12 @@
"needsInput": "Очікує відповіді",
"channelName": "Активні агенти",
"activityKitDisabledBody": "Увімкніть «Дії наживо» в «Параметрах», щоб бачити активних агентів на замкненому екрані."
+ },
+ "transcriptionModel": {
+ "title": "Модель розпізнавання мовлення",
+ "noneChosen": "Не вибрано",
+ "emptyTitle": "Немає моделей розпізнавання мовлення",
+ "emptyDescription": "Зараз Kilo Gateway не пропонує моделей розпізнавання мовлення.",
+ "loadFailed": "Не вдалося завантажити моделі розпізнавання мовлення."
}
}
diff --git a/apps/mobile/src/i18n/locales/ur.json b/apps/mobile/src/i18n/locales/ur.json
index 8d9848007f..edb5f34d1a 100644
--- a/apps/mobile/src/i18n/locales/ur.json
+++ b/apps/mobile/src/i18n/locales/ur.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Kilo Gateway کے ذریعے آواز کو متن میں بدلنا",
+ "gatewayTranscriptionSubtitle": "ڈیوائس کی آواز کی پہچان کے بجائے Kilo Gateway ماڈل کے ذریعے آواز سے لکھنے کو متن میں بدلیں۔ آپ کی ریکارڈنگ Kilo Gateway کو بھیجی جاتی ہے۔",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "پل ریکویسٹ دستیاب نہیں",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "{{language}} کی آف لائن تقریر کی فائلیں اس فون پر انسٹال نہیں ہیں۔ انہیں ڈاؤن لوڈ کریں، پھر آواز سے لکھنا دوبارہ آزمانے کی کوشش کریں۔",
"downloadOfflineModel": "ڈاؤن لوڈ",
"offlineModelDownloadScheduled": "آف لائن تقریر کی فائلیں پس منظر میں ڈاؤن لوڈ ہوں گی۔ بعد میں آواز سے لکھنا دوبارہ آزمانے کی کوشش کریں۔",
- "listeningStopped": "سننے کا عمل رک گیا۔"
+ "listeningStopped": "سننے کا عمل رک گیا۔",
+ "transcribing": "متن میں بدلا جا رہا ہے...",
+ "gatewayUnreachable": "Kilo Gateway سے رابطہ نہیں ہو سکا۔ اپنا کنکشن چیک کریں اور دوبارہ کوشش کریں۔",
+ "gatewayTimeout": "متن میں بدلنے میں بہت وقت لگ گیا۔ دوبارہ کوشش کریں۔",
+ "gatewayModelUnavailable": "یہ ماڈل دستیاب نہیں ہے۔ ترجیحات میں دوسرا منتخب کریں۔",
+ "gatewaySignInRequired": "Kilo Gateway کی تبدیلی استعمال کرنے کے لیے سائن ان کریں۔",
+ "gatewayNoModel": "پہلے ترجیحات میں متن میں بدلنے کا ماڈل منتخب کریں۔"
},
"share": {
"title": "Kilo پر شیئر کریں",
@@ -2950,5 +2959,12 @@
"needsInput": "ان پٹ درکار",
"channelName": "فعال ایجنٹس",
"activityKitDisabledBody": "لاک اسکرین پر فعال ایجنٹس دیکھنے کے لیے ترتیبات میں لائیو سرگرمیاں فعال کریں۔"
+ },
+ "transcriptionModel": {
+ "title": "متن میں بدلنے کا ماڈل",
+ "noneChosen": "کوئی نہیں چنا گیا",
+ "emptyTitle": "متن میں بدلنے کا کوئی ماڈل موجود نہیں",
+ "emptyDescription": "Kilo Gateway فی الحال متن میں بدلنے کا کوئی ماڈل نہیں دیتا۔",
+ "loadFailed": "متن میں بدلنے والے ماڈلز لوڈ نہیں ہو سکے۔"
}
}
diff --git a/apps/mobile/src/i18n/locales/uz.json b/apps/mobile/src/i18n/locales/uz.json
index 202403b1df..43282f734b 100644
--- a/apps/mobile/src/i18n/locales/uz.json
+++ b/apps/mobile/src/i18n/locales/uz.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Nutqni Kilo Gateway orqali matnga aylantirish",
+ "gatewayTranscriptionSubtitle": "Qurilmaning nutqni tanishining o'rniga Kilo Gateway modeli yordamida ovozli kiritishni matnga aylantiring. Yozuvingiz Kilo Gatewayga yuboriladi.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "PR mavjud emas",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "{{language}} tilining oflayn nutq fayllari bu telefonda o'rnatilmagan. Ularni yuklab oling, so'ngra ovozli kiritishni qayta sinab ko'ring.",
"downloadOfflineModel": "Yuklab olish",
"offlineModelDownloadScheduled": "Oflayn nutq fayllari fonda yuklab olinadi. Ovozli kiritishni keyinroq qayta sinab ko'ring.",
- "listeningStopped": "Tinglash to'xtatildi."
+ "listeningStopped": "Tinglash to'xtatildi.",
+ "transcribing": "Matnga aylantirilmoqda...",
+ "gatewayUnreachable": "Kilo Gatewayga ulanib bo'lmadi. Ulanishni tekshiring va qayta urinib ko'ring.",
+ "gatewayTimeout": "Matnga aylantirish juda uzoq davom etdi. Qayta urinib ko'ring.",
+ "gatewayModelUnavailable": "Ushbu matnga aylantirish modeli mavjud emas. Shaxsiy sozlamalardan boshqasini tanlang.",
+ "gatewaySignInRequired": "Kilo Gateway orqali aylantirishdan foydalanish uchun tizimga kiring.",
+ "gatewayNoModel": "Avval Shaxsiy sozlamalardan matnga aylantirish modelini tanlang."
},
"share": {
"title": "Kiloga yuborish",
@@ -2950,5 +2959,12 @@
"needsInput": "Javob kutilmoqda",
"channelName": "Faol agentlar",
"activityKitDisabledBody": "Qulflangan ekranda faol agentlarni ko'rish uchun sozlamalarda jonli faoliyatlarni yoqing."
+ },
+ "transcriptionModel": {
+ "title": "Matnga aylantirish modeli",
+ "noneChosen": "Hech narsa tanlanmagan",
+ "emptyTitle": "Matnga aylantirish modellari yo'q",
+ "emptyDescription": "Kilo Gateway hozircha matnga aylantirish modellarini taklif qilmayapti.",
+ "loadFailed": "Matnga aylantirish modellarini yuklab bo'lmadi."
}
}
diff --git a/apps/mobile/src/i18n/locales/vi.json b/apps/mobile/src/i18n/locales/vi.json
index f20953e3cf..1fe38bb2ad 100644
--- a/apps/mobile/src/i18n/locales/vi.json
+++ b/apps/mobile/src/i18n/locales/vi.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Chuyển giọng nói thành văn bản qua Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Sử dụng mô hình Kilo Gateway để chuyển nội dung nhập bằng giọng nói thành văn bản thay vì nhận dạng giọng nói của thiết bị. Bản ghi của bạn được gửi tới Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"notifications": {
"liveActivities": "Hoạt động trực tiếp",
@@ -2134,7 +2137,13 @@
"languageNotInstalledMessage": "Các tệp giọng nói ngoại tuyến cho {{language}} chưa được cài đặt trên điện thoại này. Hãy tải các tệp đó xuống, rồi thử nhập bằng giọng nói lại.",
"downloadOfflineModel": "Tải xuống",
"offlineModelDownloadScheduled": "Các tệp giọng nói ngoại tuyến sẽ được tải xuống trong nền. Hãy thử nhập bằng giọng nói lại sau.",
- "listeningStopped": "Đã dừng nghe."
+ "listeningStopped": "Đã dừng nghe.",
+ "transcribing": "Đang chuyển thành văn bản...",
+ "gatewayUnreachable": "Không thể kết nối tới Kilo Gateway. Hãy kiểm tra kết nối và thử lại.",
+ "gatewayTimeout": "Việc chuyển giọng nói thành văn bản mất quá nhiều thời gian. Hãy thử lại.",
+ "gatewayModelUnavailable": "Mô hình này hiện không khả dụng. Hãy chọn mô hình khác trong Tùy chọn.",
+ "gatewaySignInRequired": "Đăng nhập để dùng tính năng chuyển giọng nói qua Kilo Gateway.",
+ "gatewayNoModel": "Trước tiên, hãy chọn một mô hình chuyển giọng nói trong Tùy chọn."
},
"share": {
"title": "Chia sẻ đến Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "Cần phản hồi",
"channelName": "Tác nhân đang hoạt động",
"activityKitDisabledBody": "Bật Hoạt động trực tiếp trong Cài đặt để xem các tác nhân đang hoạt động trên màn hình khóa."
+ },
+ "transcriptionModel": {
+ "title": "Mô hình chuyển giọng nói thành văn bản",
+ "noneChosen": "Chưa chọn",
+ "emptyTitle": "Không có mô hình chuyển giọng nói thành văn bản",
+ "emptyDescription": "Hiện Kilo Gateway không cung cấp mô hình chuyển giọng nói thành văn bản.",
+ "loadFailed": "Không thể tải các mô hình chuyển giọng nói thành văn bản."
}
}
diff --git a/apps/mobile/src/i18n/locales/yo.json b/apps/mobile/src/i18n/locales/yo.json
index e403eda157..45ee5abc5b 100644
--- a/apps/mobile/src/i18n/locales/yo.json
+++ b/apps/mobile/src/i18n/locales/yo.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Yíyí ohùn sí ọ̀rọ̀ pẹ̀lú Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Yí títẹ ọ̀rọ̀ pẹ̀lú ohùn sí ọ̀rọ̀-kíkọ pẹ̀lú àwòṣe Kilo Gateway dípò mímọ̀ ọ̀rọ̀ ẹ̀rọ náà. A ó fi àkọsílẹ̀ rẹ ránṣẹ́ sí Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Ìbéèrè ìṣọ̀kan kò sí",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "Àwọn fáìlù ọ̀rọ̀ tí kì í ṣiṣẹ́ pẹ̀lú ayélujára fún {{language}} kò tíì sí lórí fónù yìí. Gbà wọ́n sílẹ̀, lẹ́yìn náà tún gbìyànjú títẹ ọ̀rọ̀ pẹ̀lú ohùn.",
"downloadOfflineModel": "Gbà sílẹ̀",
"offlineModelDownloadScheduled": "Àwọn fáìlù ọ̀rọ̀ tí kì í ṣiṣẹ́ pẹ̀lú ayélujára yóò gbà sílẹ̀ ní ẹ̀yẹ. Tún gbìyànjú títẹ ọ̀rọ̀ pẹ̀lú ohùn lẹ́yìn ìgbà díẹ̀.",
- "listeningStopped": "A ti dá fífetí sílẹ̀ dúró."
+ "listeningStopped": "A ti dá fífetí sílẹ̀ dúró.",
+ "transcribing": "Ń yí padà sí ọ̀rọ̀...",
+ "gatewayUnreachable": "A kò lè dé ọ̀dọ̀ Kilo Gateway. Ṣàyẹ̀wò àsopọ̀ rẹ kí o sì gbìyànjú lẹ́ẹ̀kan sí i.",
+ "gatewayTimeout": "Ìyípadà ohùn sí ọ̀rọ̀ gba àkókò jù. Gbìyànjú lẹ́ẹ̀kan sí i.",
+ "gatewayModelUnavailable": "Àwòṣe ìyípadà yìí kò sí. Yan òmíràn nínú Àwọn ààyò.",
+ "gatewaySignInRequired": "Wọlé láti lo ìyípadà ohùn sí ọ̀rọ̀ pẹ̀lú Kilo Gateway.",
+ "gatewayNoModel": "Ní àkọ́kọ́, yan àwòṣe ìyípadà ohùn sí ọ̀rọ̀ nínú Àwọn ààyò."
},
"share": {
"title": "Pín sí Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "Nílò èsì rẹ",
"channelName": "Àwọn aṣojú tó ń ṣiṣẹ́",
"activityKitDisabledBody": "Tan àwọn ìgbòkègbodò ìsinsìnyí nínú àwọn ètò láti rí àwọn aṣojú tó ń ṣiṣẹ́ lórí ojú ìwé títìpa."
+ },
+ "transcriptionModel": {
+ "title": "Àwòṣe ìyípadà ohùn sí ọ̀rọ̀",
+ "noneChosen": "Kò sí tí a yan",
+ "emptyTitle": "Kò sí àwòṣe ìyípadà ohùn sí ọ̀rọ̀",
+ "emptyDescription": "Kilo Gateway kò pèsè àwòṣe ìyípadà ohùn sí ọ̀rọ̀ ní báyìí.",
+ "loadFailed": "A kò lè gbé àwòṣe ìyípadà ohùn sí ọ̀rọ̀ wọlé."
}
}
diff --git a/apps/mobile/src/i18n/locales/zh-Hans.json b/apps/mobile/src/i18n/locales/zh-Hans.json
index 3c1a6ad47e..e4ae6417d3 100644
--- a/apps/mobile/src/i18n/locales/zh-Hans.json
+++ b/apps/mobile/src/i18n/locales/zh-Hans.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "使用 Kilo Gateway 转写",
+ "gatewayTranscriptionSubtitle": "使用 Kilo Gateway 模型转写语音输入,而不使用设备上的语音识别。您的录音将发送至 Kilo Gateway。",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"notifications": {
"liveActivities": "实时活动",
@@ -2134,7 +2137,13 @@
"languageNotInstalledMessage": "此手机尚未安装 {{language}} 的离线语音文件。请下载这些文件,然后重试语音输入。",
"downloadOfflineModel": "下载",
"offlineModelDownloadScheduled": "离线语音文件将在后台下载。稍后请重试语音输入。",
- "listeningStopped": "已停止收听。"
+ "listeningStopped": "已停止收听。",
+ "transcribing": "正在转写…",
+ "gatewayUnreachable": "无法连接到 Kilo Gateway。请检查网络连接后重试。",
+ "gatewayTimeout": "转写耗时过长。请重试。",
+ "gatewayModelUnavailable": "此转写模型不可用。请在偏好设置中选择其他模型。",
+ "gatewaySignInRequired": "登录后可使用 Kilo Gateway 转写。",
+ "gatewayNoModel": "请先在偏好设置中选择转写模型。"
},
"share": {
"title": "分享到 Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "等待输入",
"channelName": "运行中的智能体",
"activityKitDisabledBody": "请在设置中开启实时活动,在锁定屏幕上查看运行中的智能体。"
+ },
+ "transcriptionModel": {
+ "title": "转写模型",
+ "noneChosen": "未选择",
+ "emptyTitle": "没有转写模型",
+ "emptyDescription": "Kilo Gateway 当前没有提供转写模型。",
+ "loadFailed": "无法加载转写模型。"
}
}
diff --git a/apps/mobile/src/i18n/locales/zh-Hant.json b/apps/mobile/src/i18n/locales/zh-Hant.json
index a079d54e27..79846c8058 100644
--- a/apps/mobile/src/i18n/locales/zh-Hant.json
+++ b/apps/mobile/src/i18n/locales/zh-Hant.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "使用 Kilo Gateway 轉錄",
+ "gatewayTranscriptionSubtitle": "使用 Kilo Gateway 模型將語音輸入轉錄成文字,而不使用裝置上的語音辨識。您的錄音將傳送至 Kilo Gateway。",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"notifications": {
"liveActivities": "即時動態",
@@ -2134,7 +2137,13 @@
"languageNotInstalledMessage": "此手機尚未安裝 {{language}} 的離線語音檔案。請下載這些檔案,然後再試一次語音輸入。",
"downloadOfflineModel": "下載",
"offlineModelDownloadScheduled": "離線語音檔案將在背景下載。請稍後再試一次語音輸入。",
- "listeningStopped": "已停止聆聽。"
+ "listeningStopped": "已停止聆聽。",
+ "transcribing": "正在轉錄…",
+ "gatewayUnreachable": "無法連線至 Kilo Gateway。請檢查連線後再試一次。",
+ "gatewayTimeout": "轉錄花費太長時間。請再試一次。",
+ "gatewayModelUnavailable": "這個轉錄模型無法使用。請在偏好設定中選擇其他模型。",
+ "gatewaySignInRequired": "登入後即可使用 Kilo Gateway 轉錄。",
+ "gatewayNoModel": "請先在偏好設定中選擇轉錄模型。"
},
"share": {
"title": "分享到 Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "等待輸入",
"channelName": "執行中的代理程式",
"activityKitDisabledBody": "請在「設定」中開啟「即時動態」,即可在鎖定畫面查看執行中的代理程式。"
+ },
+ "transcriptionModel": {
+ "title": "轉錄模型",
+ "noneChosen": "未選擇",
+ "emptyTitle": "沒有轉錄模型",
+ "emptyDescription": "Kilo Gateway 目前沒有提供轉錄模型。",
+ "loadFailed": "無法載入轉錄模型。"
}
}
diff --git a/apps/mobile/src/i18n/locales/zu.json b/apps/mobile/src/i18n/locales/zu.json
index 758642990a..989f1c3231 100644
--- a/apps/mobile/src/i18n/locales/zu.json
+++ b/apps/mobile/src/i18n/locales/zu.json
@@ -247,7 +247,10 @@
"featureFlagsBuild": "v{{version}}",
"featureFlagApplied": "remote · ≥ {{min}}",
"featureFlagSkipped": "default · < {{min}}",
- "featureFlagNotLoaded": "default · not loaded"
+ "featureFlagNotLoaded": "default · not loaded",
+ "gatewayTranscription": "Ukuguqulwa kwenkulumo ibe umbhalo nge-Kilo Gateway",
+ "gatewayTranscriptionSubtitle": "Guqula ukufaka ngezwi kube umbhalo usebenzisa imodeli ye-Kilo Gateway kunokubona inkulumo kwedivayisi. Ukuqopha kwakho kuthunyelwa ku-Kilo Gateway.",
+ "transcriptionModel": "$t(transcriptionModel.title)"
},
"prReview": {
"pullRequestUnavailable": "Isicelo sokuhlanganisa asitholakali",
@@ -2568,7 +2571,13 @@
"languageNotInstalledMessage": "Amafayela okukhuluma e-offline e-{{language}} awafakwanga kule foni. Landa la mafayela, bese uzame futhi ukufaka ngezwi.",
"downloadOfflineModel": "Landa",
"offlineModelDownloadScheduled": "Amafayela okukhuluma e-offline azolandwa ngemuva. Zama futhi ukufaka ngezwi kamuva.",
- "listeningStopped": "Ukulalela kumisiwe."
+ "listeningStopped": "Ukulalela kumisiwe.",
+ "transcribing": "Kuguqulwa ibe umbhalo...",
+ "gatewayUnreachable": "I-Kilo Gateway ayifinyelelwanga. Hlola ukuxhumana kwakho bese uzama futhi.",
+ "gatewayTimeout": "Ukuguqula inkulumo kuthathe isikhathi eside kakhulu. Zama futhi.",
+ "gatewayModelUnavailable": "Le modeli yokuguqula inkulumo ayitholakali. Khetha enye Ku-Okuncamelayo.",
+ "gatewaySignInRequired": "Ngena ngemvume ukuze usebenzise ukuguqula inkulumo nge-Kilo Gateway.",
+ "gatewayNoModel": "Okokuqala, khetha imodeli yokuguqula inkulumo Ku-Okuncamelayo."
},
"share": {
"title": "Yabelana ku-Kilo",
@@ -2950,5 +2959,12 @@
"needsInput": "Kudinga impendulo",
"channelName": "Ama-ejenti asebenzayo",
"activityKitDisabledBody": "Vula imisebenzi ebukhoma kuzilungiselelo ukuze ubone ama-ejenti asebenzayo esikrinini esikhiyiwe."
+ },
+ "transcriptionModel": {
+ "title": "Imodeli yokuguqula inkulumo",
+ "noneChosen": "Akukho okukhethiwe",
+ "emptyTitle": "Azikho izimodeli zokuguqula inkulumo",
+ "emptyDescription": "I-Kilo Gateway ayinikezi zimodeli zokuguqula inkulumo okwamanje.",
+ "loadFailed": "Azikwazanga ukulayisha izimodeli zokuguqula inkulumo."
}
}
diff --git a/apps/mobile/src/lib/agent-attachments/upload-task.test.ts b/apps/mobile/src/lib/agent-attachments/upload-task.test.ts
index 3aac260473..f1a94c4cc9 100644
--- a/apps/mobile/src/lib/agent-attachments/upload-task.test.ts
+++ b/apps/mobile/src/lib/agent-attachments/upload-task.test.ts
@@ -1,3 +1,4 @@
+/* eslint-disable class-methods-use-this -- the File mock mirrors the instance-only native File surface. */
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { trpcClient } from '@/lib/trpc';
@@ -17,16 +18,15 @@ vi.mock('@/lib/trpc', () => ({
},
}));
-vi.mock('expo-file-system/legacy', () => ({
- createUploadTask: vi.fn(() => ({
- uploadAsync: vi.fn().mockResolvedValue({ status: 200 }),
- })),
- FileSystemUploadType: { BINARY_CONTENT: 'binary-content' },
- getInfoAsync: vi.fn().mockResolvedValue({
- exists: true,
- isDirectory: false,
- size: 100,
- }),
+const createUploadTaskMock = vi.fn((..._args: unknown[]) => ({
+ uploadAsync: vi.fn().mockResolvedValue({ status: 200, body: '', headers: {} }),
+ cancel: vi.fn(),
+}));
+vi.mock('expo-file-system', () => ({
+ UploadType: { BINARY_CONTENT: 'binary-content', MULTIPART: 'multipart' },
+ File: class {
+ createUploadTask = (...args: unknown[]): unknown => createUploadTaskMock(...args);
+ },
}));
describe('uploadOne', () => {
@@ -96,8 +96,6 @@ describe('uploadOne', () => {
});
it('does not create an upload task when cancelled after the presign', async () => {
- const { createUploadTask } = await import('expo-file-system/legacy');
-
await expect(
uploadOne({
attachmentId: 'att-3',
@@ -111,6 +109,6 @@ describe('uploadOne', () => {
})
).rejects.toThrow('Upload cancelled');
- expect(createUploadTask).not.toHaveBeenCalled();
+ expect(createUploadTaskMock).not.toHaveBeenCalled();
});
});
diff --git a/apps/mobile/src/lib/agent-attachments/upload-task.ts b/apps/mobile/src/lib/agent-attachments/upload-task.ts
index 4bb6ee1bff..9676cf5dbb 100644
--- a/apps/mobile/src/lib/agent-attachments/upload-task.ts
+++ b/apps/mobile/src/lib/agent-attachments/upload-task.ts
@@ -1,4 +1,4 @@
-import { createUploadTask, FileSystemUploadType, getInfoAsync } from 'expo-file-system/legacy';
+import { File, UploadType } from 'expo-file-system';
import { i18n } from '@/i18n';
import { trpcClient } from '@/lib/trpc';
@@ -17,11 +17,12 @@ export function normalizeFilename(name: string, extension: AgentAttachmentExtens
return `${name}.${extension}`;
}
+// eslint-disable-next-line require-await -- the modern File API exposes size synchronously; the public signature stays async for callers.
export async function measureLocalSize(uri: string): Promise {
try {
- const info = await getInfoAsync(uri);
- if (info.exists && !info.isDirectory) {
- return info.size;
+ const file = new File(uri);
+ if (file.exists) {
+ return file.size;
}
} catch {
return null;
@@ -46,7 +47,7 @@ export async function uploadOne(args: {
contentLength: number;
localUri: string;
onProgress: (progress: number | null) => void;
- onTask?: (task: { cancelAsync: () => Promise }) => void;
+ onTask?: (task: { cancel: () => void }) => void;
onAdmitted?: (key: string) => void;
isCancelled?: () => boolean;
}): Promise {
@@ -83,32 +84,26 @@ export async function uploadOne(args: {
// ledger row, so a remove/leave that races the PUT must still release it.
onAdmitted?.(result.key);
- // Per-chip determinate progress via `createUploadTask` (the
- // main-module `createUploadTask` throws at runtime in SDK 55, so we
- // import from `expo-file-system/legacy`). A signed-URL PUT only
- // reports progress when the response advertises Content-Length; we
- // fall back to `null` (indeterminate) when the server omits it.
- const task = createUploadTask(
- result.signedUrl,
- localUri,
- {
- uploadType: FileSystemUploadType.BINARY_CONTENT,
- httpMethod: 'PUT',
- headers: { 'Content-Type': contentType },
- },
- progress => {
- const total = progress.totalBytesExpectedToSend;
- if (total > 0) {
- onProgress(progress.totalBytesSent / total);
+ // Per-chip determinate progress via the modern `File.createUploadTask`. A
+ // signed-URL PUT only reports progress when the response advertises
+ // Content-Length; we fall back to `null` (indeterminate) when the server
+ // omits it.
+ const task = new File(localUri).createUploadTask(result.signedUrl, {
+ uploadType: UploadType.BINARY_CONTENT,
+ httpMethod: 'PUT',
+ headers: { 'Content-Type': contentType },
+ onProgress: ({ bytesSent, totalBytes }) => {
+ if (totalBytes > 0) {
+ onProgress(bytesSent / totalBytes);
} else {
onProgress(null);
}
- }
- );
+ },
+ });
onTask?.(task);
const uploadResult = await task.uploadAsync();
- if (!uploadResult || uploadResult.status < 200 || uploadResult.status >= 300) {
- throw new Error(`Upload failed with status ${uploadResult?.status ?? 'no response'}`);
+ if (uploadResult.status < 200 || uploadResult.status >= 300) {
+ throw new Error(`Upload failed with status ${uploadResult.status}`);
}
return { key: result.key };
}
diff --git a/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.test.ts b/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.test.ts
index f867dc0653..c4e7be8b80 100644
--- a/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.test.ts
+++ b/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.test.ts
@@ -23,7 +23,7 @@ import { useAgentAttachmentUpload } from './use-agent-attachment-upload';
//
// The hook tests below drive the upload FSM through `useAgentAttachmentUpload`
// with `uploadOne` as a manually-resolved promise. `uploadOne` is mocked so
-// `expo-file-system/legacy` and the tRPC client never load in the node env.
+// the upload-task module and the tRPC client never load in the node env.
const hoisted = vi.hoisted(() => {
let idCounter = 0;
@@ -34,7 +34,7 @@ const hoisted = vi.hoisted(() => {
announceForA11y: vi.fn(),
announcingToastError: vi.fn(),
measureLocalSize: vi.fn(),
- cancelAsync: vi.fn(),
+ cancel: vi.fn<() => void>(),
fileDelete: vi.fn(),
captureException: vi.fn(),
deletedUris: new Set(),
@@ -43,7 +43,6 @@ const hoisted = vi.hoisted(() => {
vi.mock('expo-crypto', () => ({ randomUUID: hoisted.randomUUID }));
vi.mock('@sentry/react-native', () => ({ captureException: hoisted.captureException }));
-vi.mock('expo-file-system/legacy', () => ({ deleteAsync: vi.fn() }));
vi.mock('expo-image-manipulator', () => ({
SaveFormat: { PNG: 'png', WEBP: 'webp', JPEG: 'jpeg' },
manipulateAsync: vi.fn(),
@@ -1005,7 +1004,7 @@ describe('useAgentAttachmentUpload — announcement ownership (Row 3.3)', () =>
hoisted.announceForA11y.mockReset();
hoisted.announcingToastError.mockReset();
hoisted.measureLocalSize.mockReset();
- hoisted.cancelAsync.mockReset();
+ hoisted.cancel.mockReset();
hoisted.fileDelete.mockReset();
hoisted.captureException.mockReset();
hoisted.deletedUris.clear();
@@ -1019,15 +1018,15 @@ describe('useAgentAttachmentUpload — announcement ownership (Row 3.3)', () =>
rejectUpload = reject;
});
// The mock mirrors `uploadOne`'s real contract: it admits the key through
- // `onAdmitted` and hands the created task's `cancelAsync` back through
+ // `onAdmitted` and hands the created task's `cancel` back through
// `onTask` before the upload settles.
hoisted.uploadOne.mockImplementation(
async (args: {
- onTask?: (task: { cancelAsync: () => Promise }) => void;
+ onTask?: (task: { cancel: () => void }) => void;
onAdmitted?: (key: string) => void;
}) => {
args.onAdmitted?.('org/2026/08/uuid/doc.pdf');
- args.onTask?.({ cancelAsync: hoisted.cancelAsync });
+ args.onTask?.({ cancel: hoisted.cancel });
const result = await controlled;
return result;
}
@@ -1222,7 +1221,7 @@ describe('useAgentAttachmentUpload — announcement ownership (Row 3.3)', () =>
// Restore replaces the occupied chip and cancels its in-flight upload.
expect(hookApi().attachments.map(item => item.filename)).toEqual([restoredName]);
expect(hookApi().attachments[0]?.id).not.toBe(discardedId);
- expect(hoisted.cancelAsync).toHaveBeenCalledTimes(1);
+ expect(hoisted.cancel).toHaveBeenCalledTimes(1);
// The stale upload later resolves: it must not announce, toast, or write
// the discarded chip back into the attachments list.
@@ -1288,7 +1287,7 @@ describe('useAgentAttachmentUpload — announcement ownership (Row 3.3)', () =>
await settle();
});
- expect(hoisted.cancelAsync).toHaveBeenCalledTimes(1);
+ expect(hoisted.cancel).toHaveBeenCalledTimes(1);
expect(hoisted.fileDelete).toHaveBeenCalledTimes(1);
expect(hoisted.fileDelete).toHaveBeenCalledWith('file:///cache/doc.pdf');
renderer.unmount();
@@ -1297,10 +1296,9 @@ describe('useAgentAttachmentUpload — announcement ownership (Row 3.3)', () =>
it('deletes the cache-owned file when removed during the presign window (onTask not yet called)', async () => {
// Simulate the presign window: `uploadOne` captures `onTask` but does not
// hand the task back before the upload settles, so `task` stays undefined.
- let capturedOnTask: ((task: { cancelAsync: () => Promise }) => void) | undefined =
- undefined;
+ let capturedOnTask: ((task: { cancel: () => void }) => void) | undefined = undefined;
hoisted.uploadOne.mockImplementation(
- async (args: { onTask?: (task: { cancelAsync: () => Promise }) => void }) => {
+ async (args: { onTask?: (task: { cancel: () => void }) => void }) => {
capturedOnTask = args.onTask;
// Never resolves: the upload stays in the presign window, so the task
// is never handed back through `onTask`.
@@ -1322,10 +1320,10 @@ describe('useAgentAttachmentUpload — announcement ownership (Row 3.3)', () =>
await settle();
});
- // The task was never handed back, so no cancelAsync; the finally cleanup
+ // The task was never handed back, so no cancel; the finally cleanup
// must still delete the cache-owned file.
expect(capturedOnTask).toBeDefined();
- expect(hoisted.cancelAsync).not.toHaveBeenCalled();
+ expect(hoisted.cancel).not.toHaveBeenCalled();
expect(hoisted.fileDelete).toHaveBeenCalledTimes(1);
expect(hoisted.fileDelete).toHaveBeenCalledWith('file:///cache/doc.pdf');
renderer.unmount();
@@ -1336,7 +1334,7 @@ describe('useAgentAttachmentUpload — announcement ownership (Row 3.3)', () =>
let cancelledAfterPresign = false;
hoisted.uploadOne.mockImplementation(
async (args: {
- onTask?: (task: { cancelAsync: () => Promise }) => void;
+ onTask?: (task: { cancel: () => void }) => void;
isCancelled?: () => boolean;
}) => {
const result = await new Promise<{ key: string }>(resolve => {
@@ -1347,7 +1345,7 @@ describe('useAgentAttachmentUpload — announcement ownership (Row 3.3)', () =>
throw new Error('Upload cancelled');
}
createTaskAfterPresign += 1;
- args.onTask?.({ cancelAsync: hoisted.cancelAsync });
+ args.onTask?.({ cancel: hoisted.cancel });
return result;
}
);
@@ -1370,13 +1368,15 @@ describe('useAgentAttachmentUpload — announcement ownership (Row 3.3)', () =>
expect(cancelledAfterPresign).toBe(true);
expect(createTaskAfterPresign).toBe(0);
- expect(hoisted.cancelAsync).not.toHaveBeenCalled();
+ expect(hoisted.cancel).not.toHaveBeenCalled();
expect(hookApi().attachments).toHaveLength(0);
renderer.unmount();
});
- it('deletes the cache-owned file even when cancelAsync rejects', async () => {
- hoisted.cancelAsync.mockRejectedValue(new Error('cancel failed'));
+ it('deletes the cache-owned file even when cancel throws', async () => {
+ hoisted.cancel.mockImplementationOnce(() => {
+ throw new Error('cancel failed');
+ });
const renderer = await mountHook();
await addDocument();
const id = hookApi().attachments[0]?.id;
@@ -1389,7 +1389,7 @@ describe('useAgentAttachmentUpload — announcement ownership (Row 3.3)', () =>
await settle();
});
- expect(hoisted.cancelAsync).toHaveBeenCalledTimes(1);
+ expect(hoisted.cancel).toHaveBeenCalledTimes(1);
expect(hoisted.fileDelete).toHaveBeenCalledTimes(1);
expect(hoisted.fileDelete).toHaveBeenCalledWith('file:///cache/doc.pdf');
renderer.unmount();
@@ -1410,7 +1410,7 @@ describe('useAgentAttachmentUpload — announcement ownership (Row 3.3)', () =>
await settle();
});
- expect(hoisted.cancelAsync).toHaveBeenCalledTimes(1);
+ expect(hoisted.cancel).toHaveBeenCalledTimes(1);
expect(hoisted.fileDelete).not.toHaveBeenCalled();
renderer.unmount();
});
@@ -1424,7 +1424,7 @@ describe('useAgentAttachmentUpload — announcement ownership (Row 3.3)', () =>
await settle();
});
- expect(hoisted.cancelAsync).toHaveBeenCalledTimes(1);
+ expect(hoisted.cancel).toHaveBeenCalledTimes(1);
expect(hoisted.fileDelete).toHaveBeenCalledTimes(1);
renderer.unmount();
});
@@ -1438,7 +1438,7 @@ describe('useAgentAttachmentUpload — announcement ownership (Row 3.3)', () =>
await settle();
});
- expect(hoisted.cancelAsync).toHaveBeenCalledTimes(1);
+ expect(hoisted.cancel).toHaveBeenCalledTimes(1);
expect(hoisted.fileDelete).toHaveBeenCalledTimes(1);
// The cancelled upload later rejects: unmount invalidated the live id, so
@@ -1485,7 +1485,7 @@ describe('useAgentAttachmentUpload — release of admitted keys (Steps 4/5)', ()
hoisted.announceForA11y.mockReset();
hoisted.announcingToastError.mockReset();
hoisted.measureLocalSize.mockReset();
- hoisted.cancelAsync.mockReset();
+ hoisted.cancel.mockReset();
hoisted.fileDelete.mockReset();
hoisted.deletedUris.clear();
hoisted.measureLocalSize.mockResolvedValue(1024);
@@ -1497,11 +1497,11 @@ describe('useAgentAttachmentUpload — release of admitted keys (Steps 4/5)', ()
});
hoisted.uploadOne.mockImplementation(
async (args: {
- onTask?: (task: { cancelAsync: () => Promise }) => void;
+ onTask?: (task: { cancel: () => void }) => void;
onAdmitted?: (key: string) => void;
}) => {
args.onAdmitted?.('org/2026/08/uuid/doc.pdf');
- args.onTask?.({ cancelAsync: hoisted.cancelAsync });
+ args.onTask?.({ cancel: hoisted.cancel });
const result = await controlled;
return result;
}
diff --git a/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.ts b/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.ts
index 3ca798ea47..f54f8cda5a 100644
--- a/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.ts
+++ b/apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.ts
@@ -246,8 +246,8 @@ export function useAgentAttachmentUpload(
// Cancel handles for in-flight uploads, keyed by attachment id. Each handle
// cancels the upload task and deletes a cache-owned partial file. The entry
// is removed when the upload settles (in `startUpload`'s finally) or when a
- // cancel runs.
- const cancelHandlesRef = useRef(new Map Promise>());
+ // cancel runs. The modern task cancels synchronously, so a handle returns void.
+ const cancelHandlesRef = useRef(new Map void>());
const cancelUpload = useCallback((id: string) => {
const handle = cancelHandlesRef.current.get(id);
@@ -255,7 +255,11 @@ export function useAgentAttachmentUpload(
return;
}
cancelHandlesRef.current.delete(id);
- void runBestEffort(handle);
+ try {
+ handle();
+ } catch {
+ // Best-effort: a failed cancel must not block removal.
+ }
}, []);
useEffect(() => {
@@ -322,14 +326,12 @@ export function useAgentAttachmentUpload(
// cache-owned file and blocks task creation after the signed URL
// returns.
let cancelled = false;
- let task: { cancelAsync: () => Promise } | undefined = undefined;
- cancelHandlesRef.current.set(attachment.id, async () => {
+ let task: { cancel: () => void } | undefined = undefined;
+ cancelHandlesRef.current.set(attachment.id, () => {
cancelled = true;
progressCoalescer.cancel();
try {
- if (task) {
- await task.cancelAsync();
- }
+ task?.cancel();
} finally {
deleteCacheOwnedFile(attachment.localUri);
}
diff --git a/apps/mobile/src/lib/hooks/use-transcription-models.test.ts b/apps/mobile/src/lib/hooks/use-transcription-models.test.ts
new file mode 100644
index 0000000000..ff21c9dae9
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-transcription-models.test.ts
@@ -0,0 +1,145 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { fetchTranscriptionModels } from './use-transcription-models';
+
+// Stub native/expo modules so pure-node Vitest can resolve the
+// module graph when importing from use-transcription-models.ts
+// (same stub set as use-available-models.test.ts).
+vi.mock('expo-secure-store', () => ({}));
+vi.mock('@tanstack/react-query', () => ({}));
+vi.mock('@/lib/config', () => ({ API_BASE_URL: 'https://api.example.com' }));
+
+const getAuthTokenForRequest = vi.hoisted(() => vi.fn<() => Promise>());
+vi.mock('@/lib/auth/token-owner', () => ({ getAuthTokenForRequest }));
+
+const fetchMock = vi.hoisted(() => vi.fn());
+vi.stubGlobal('fetch', fetchMock);
+
+const GATEWAY_BODY = {
+ data: [
+ {
+ id: 'openai/gpt-4o-transcribe',
+ name: 'OpenAI: GPT-4o Transcribe',
+ pricing: { prompt: '0.000001', completion: '0.000006' },
+ },
+ { id: 'kilo/whisper-large-v3', name: 'Whisper Large v3' },
+ ],
+};
+
+beforeEach(() => {
+ fetchMock.mockReset();
+ getAuthTokenForRequest.mockReset();
+ getAuthTokenForRequest.mockResolvedValue('token-1');
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe('fetchTranscriptionModels', () => {
+ it('parses the gateway response into shared model options', async () => {
+ fetchMock.mockResolvedValue(Response.json(GATEWAY_BODY));
+
+ const models = await fetchTranscriptionModels();
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ 'https://api.example.com/api/gateway/transcription-models',
+ expect.objectContaining({ signal: expect.any(AbortSignal) })
+ );
+ expect(models).toHaveLength(2);
+ expect(models[0]).toMatchObject({
+ id: 'openai/gpt-4o-transcribe',
+ // The shared mapper strips the "OpenAI: " vendor prefix.
+ name: 'GPT-4o Transcribe',
+ pricing: { prompt: '0.000001', completion: '0.000006' },
+ variants: [],
+ isPreferred: false,
+ });
+ expect(models[1]).toMatchObject({ id: 'kilo/whisper-large-v3', name: 'Whisper Large v3' });
+ });
+
+ it('sorts and marks preferred models through the shared mapper', async () => {
+ fetchMock.mockResolvedValue(
+ Response.json({
+ data: [
+ { id: 'a/second', name: 'Second' },
+ { id: 'a/first', name: 'First', preferredIndex: 0 },
+ ],
+ })
+ );
+
+ const models = await fetchTranscriptionModels();
+
+ expect(models.map(model => model.id)).toEqual(['a/first', 'a/second']);
+ expect(models[0]?.isPreferred).toBe(true);
+ });
+
+ it('sends the auth token and the organization header only when set', async () => {
+ // A fresh Response per call: a Response body can only be read once.
+ fetchMock
+ .mockResolvedValueOnce(Response.json(GATEWAY_BODY))
+ .mockResolvedValueOnce(Response.json(GATEWAY_BODY))
+ .mockResolvedValueOnce(Response.json(GATEWAY_BODY));
+
+ await fetchTranscriptionModels('org-1');
+ const call = fetchMock.mock.calls[0];
+ expect(call?.[1]?.headers).toEqual({
+ Accept: 'application/json',
+ Authorization: 'Bearer token-1',
+ 'X-KiloCode-OrganizationId': 'org-1',
+ });
+
+ await fetchTranscriptionModels();
+ const anonymousCall = fetchMock.mock.calls[1];
+ expect(anonymousCall?.[1]?.headers).toEqual({
+ Accept: 'application/json',
+ Authorization: 'Bearer token-1',
+ });
+
+ getAuthTokenForRequest.mockResolvedValue(null);
+ await fetchTranscriptionModels();
+ const unauthenticatedCall = fetchMock.mock.calls[2];
+ expect(unauthenticatedCall?.[1]?.headers).toEqual({ Accept: 'application/json' });
+ });
+
+ it('propagates an HTTP error from the gateway', async () => {
+ fetchMock.mockResolvedValue(
+ Response.json({ error: 'Failed' }, { status: 500, statusText: 'Internal Server Error' })
+ );
+
+ await expect(fetchTranscriptionModels()).rejects.toThrow(
+ 'Failed to fetch transcription models: 500 Internal Server Error'
+ );
+ });
+
+ it('propagates a network failure', async () => {
+ fetchMock.mockRejectedValue(new TypeError('Network request failed'));
+
+ await expect(fetchTranscriptionModels()).rejects.toThrow('Network request failed');
+ });
+
+ it('rejects with a timeout error when the request exceeds 15s', async () => {
+ vi.useFakeTimers();
+ fetchMock.mockImplementation(async (_url, init) => {
+ await new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () => {
+ reject(new Error('Aborted'));
+ });
+ });
+ return Response.json(GATEWAY_BODY);
+ });
+
+ const pending = fetchTranscriptionModels();
+ const expectation = expect(pending).rejects.toThrow(
+ 'Timed out fetching transcription models after 15000ms'
+ );
+ await vi.advanceTimersByTimeAsync(15_000);
+ await expectation;
+ });
+
+ it('rejects a body that is not the shared OpenRouter contract', async () => {
+ fetchMock.mockResolvedValue(Response.json({ models: [] }));
+
+ await expect(fetchTranscriptionModels()).rejects.toThrow();
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/use-transcription-models.ts b/apps/mobile/src/lib/hooks/use-transcription-models.ts
new file mode 100644
index 0000000000..588e383cf0
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-transcription-models.ts
@@ -0,0 +1,78 @@
+import { useQuery } from '@tanstack/react-query';
+import { useMemo } from 'react';
+
+import { getAuthTokenForRequest } from '@/lib/auth/token-owner';
+import { API_BASE_URL } from '@/lib/config';
+import {
+ type ModelOption,
+ OpenRouterModelsResponseSchema,
+ toModelOptions,
+} from '@/lib/hooks/use-available-models';
+
+const TRANSCRIPTION_MODELS_TIMEOUT_MS = 15_000;
+
+const TRANSCRIPTION_MODELS_PATH = '/api/gateway/transcription-models';
+
+/**
+ * Fetch the transcription models the gateway offers. The endpoint answers
+ * unfiltered when auth is missing (its catch-then-fallback), so this never
+ * gates on a token; the organization header only narrows the list to the
+ * caller's policy. Parses with the shared OpenRouter wire contract and maps
+ * with the shared option mapper so the picker rows shape-match every other
+ * model list in the app.
+ */
+export async function fetchTranscriptionModels(organizationId?: string): Promise {
+ const token = await getAuthTokenForRequest();
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => {
+ controller.abort();
+ }, TRANSCRIPTION_MODELS_TIMEOUT_MS);
+
+ try {
+ const response = await fetch(`${API_BASE_URL}${TRANSCRIPTION_MODELS_PATH}`, {
+ signal: controller.signal,
+ headers: {
+ Accept: 'application/json',
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ ...(organizationId ? { 'X-KiloCode-OrganizationId': organizationId } : {}),
+ },
+ });
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch transcription models: ${response.status} ${response.statusText}`
+ );
+ }
+
+ const data = OpenRouterModelsResponseSchema.parse(await response.json());
+ return toModelOptions(data);
+ } catch (error) {
+ if (controller.signal.aborted) {
+ throw new Error(
+ `Timed out fetching transcription models after ${TRANSCRIPTION_MODELS_TIMEOUT_MS}ms`,
+ { cause: error }
+ );
+ }
+
+ throw error;
+ } finally {
+ clearTimeout(timeoutId);
+ }
+}
+
+/**
+ * Transcription models offered by the Kilo gateway, for the settings picker.
+ * Always enabled: the endpoint answers with an empty list when it cannot
+ * narrow to the caller, and the picker renders that as its empty state.
+ */
+export function useTranscriptionModels(organizationId?: string) {
+ const { data, isLoading, isError, error, refetch } = useQuery({
+ queryKey: ['transcription-models', organizationId] as const,
+ queryFn: fetchTranscriptionModels.bind(null, organizationId),
+ staleTime: 60_000,
+ enabled: true,
+ });
+
+ const models = useMemo(() => data ?? [], [data]);
+
+ return { models, isLoading, isError, error, refetch };
+}
diff --git a/apps/mobile/src/lib/share-navigation.test.ts b/apps/mobile/src/lib/share-navigation.test.ts
index 7671f098e1..3fa6fec75b 100644
--- a/apps/mobile/src/lib/share-navigation.test.ts
+++ b/apps/mobile/src/lib/share-navigation.test.ts
@@ -21,16 +21,6 @@ import {
vi.mock('expo-crypto', () => ({
randomUUID: () => 'id-test',
}));
-vi.mock('expo-file-system/legacy', () => ({
- cacheDirectory: 'file:///cache/',
- copyAsync: vi.fn(async () => {
- await Promise.resolve();
- }),
- deleteAsync: vi.fn(async () => {
- await Promise.resolve();
- }),
-}));
-
const expoFileSystemMock = vi.hoisted(() => {
const files = new Map();
const File = vi.fn(function FileMock(_base: unknown, ...rest: unknown[]) {
diff --git a/apps/mobile/src/lib/share-payload.normalize.test.ts b/apps/mobile/src/lib/share-payload.normalize.test.ts
index fca537177e..3fa21c14e6 100644
--- a/apps/mobile/src/lib/share-payload.normalize.test.ts
+++ b/apps/mobile/src/lib/share-payload.normalize.test.ts
@@ -12,16 +12,6 @@ vi.mock('expo-crypto', () => {
};
});
-vi.mock('expo-file-system/legacy', () => ({
- cacheDirectory: 'file:///cache/',
- copyAsync: vi.fn(async () => {
- await Promise.resolve();
- }),
- deleteAsync: vi.fn(async () => {
- await Promise.resolve();
- }),
-}));
-
const expoFileSystemMock = vi.hoisted(() => {
const files = new Map();
const File = vi.fn(function FileMock(_base: unknown, ...rest: unknown[]) {
diff --git a/apps/mobile/src/lib/share-payload.test.ts b/apps/mobile/src/lib/share-payload.test.ts
index c620dcd9ee..9ee45f9791 100644
--- a/apps/mobile/src/lib/share-payload.test.ts
+++ b/apps/mobile/src/lib/share-payload.test.ts
@@ -39,17 +39,6 @@ vi.mock('expo-crypto', () => {
};
});
-vi.mock('expo-file-system/legacy', () => ({
- cacheDirectory: 'file:///cache/',
- copyAsync: vi.fn(async () => {
- await Promise.resolve();
- }),
- deleteAsync: vi.fn(async () => {
- await Promise.resolve();
- }),
- getInfoAsync: vi.fn(async () => ({ exists: true, isDirectory: false })),
-}));
-
// The drafts module (lazy-required by share-payload) imports the native
// encrypted-kv chain; the fake below mirrors the real upsert/list semantics
// (same harness as drafts.test.ts).
@@ -437,7 +426,7 @@ describe('share payload durable persistence', () => {
});
await persistSharePayloadsNow();
__resetSharePayloadStoreForTests();
- __setCheckFileExistsForTests(async uri => uri.includes('present'));
+ __setCheckFileExistsForTests(uri => uri.includes('present'));
await restoreSharePayloads('u1');
const restored = peekSharePayload(id);
expect(restored?.files.map(file => file.name)).toEqual(['present.jpg']);
diff --git a/apps/mobile/src/lib/share-payload.ts b/apps/mobile/src/lib/share-payload.ts
index 0a3e405090..aa87ae4f4d 100644
--- a/apps/mobile/src/lib/share-payload.ts
+++ b/apps/mobile/src/lib/share-payload.ts
@@ -1,6 +1,6 @@
import { CLOUD_AGENT_PROMPT_MAX_LENGTH } from '@kilocode/cloud-agent-sdk/limits';
import * as Crypto from 'expo-crypto';
-import { cacheDirectory, copyAsync, deleteAsync, getInfoAsync } from 'expo-file-system/legacy';
+import { File, Paths } from 'expo-file-system';
import { type ShareIntent } from 'expo-share-intent';
import { type AgentAttachmentCandidate } from '@/lib/agent-attachments/use-agent-attachment-upload';
@@ -38,7 +38,7 @@ type ShareIntentLike = Pick;
type CopyToCache = (args: { from: string; fileName: string }) => Promise;
-type DeleteCachedFile = (uri: string) => Promise;
+type DeleteCachedFile = (uri: string) => void | Promise;
const payloads = new Map();
const insertionOrder: ShareId[] = [];
@@ -59,9 +59,13 @@ export function getSharePersistUserId(): string | null {
return sharePersistUserId;
}
-async function defaultDeleteCachedFile(uri: string): Promise {
+function defaultDeleteCachedFile(uri: string): void {
try {
- await deleteAsync(uri, { idempotent: true });
+ // The modern File API deletes synchronously; a missing file is a no-op.
+ const file = new File(uri);
+ if (file.exists) {
+ file.delete();
+ }
} catch {
// Best-effort hygiene; ignore delete failures.
}
@@ -69,12 +73,13 @@ async function defaultDeleteCachedFile(uri: string): Promise {
let deleteCachedFile: DeleteCachedFile = defaultDeleteCachedFile;
-type FileExists = (uri: string) => Promise;
+type FileExists = (uri: string) => boolean;
-async function defaultFileExists(uri: string): Promise {
+function defaultFileExists(uri: string): boolean {
try {
- const info = await getInfoAsync(uri);
- return info.exists && !info.isDirectory;
+ // `File.exists` is false for a directory path, so the `!isDirectory` guard
+ // the legacy `getInfoAsync` needed is implicit.
+ return new File(uri).exists;
} catch {
return false;
}
@@ -253,29 +258,21 @@ export async function restoreSharePayloads(userId: string): Promise {
}
// Then reconcile each file against the filesystem.
- await Promise.all(
- [...payloads.values()].map(async payload => {
- const kept: AgentAttachmentCandidate[] = [];
- const failed = [...payload.failedFiles];
- const checks = await Promise.all(
- payload.files.map(async file => {
- const exists = await checkFileExists(file.uri);
- return { file, exists };
- })
- );
- for (const { file, exists } of checks) {
- if (exists) {
- kept.push(file);
- } else {
- failed.push(file.name);
- }
+ for (const payload of payloads.values()) {
+ const kept: AgentAttachmentCandidate[] = [];
+ const failed = [...payload.failedFiles];
+ for (const file of payload.files) {
+ if (checkFileExists(file.uri)) {
+ kept.push(file);
+ } else {
+ failed.push(file.name);
}
- payload.files.length = 0;
- payload.files.push(...kept);
- payload.failedFiles.length = 0;
- payload.failedFiles.push(...failed);
- })
- );
+ }
+ payload.files.length = 0;
+ payload.files.push(...kept);
+ payload.failedFiles.length = 0;
+ payload.failedFiles.push(...failed);
+ }
}
/** Test-only: wipe the module store between cases. */
@@ -313,15 +310,11 @@ export function composeShareText(shareIntent: ShareIntentLike): string {
}
async function defaultCopyToCache(args: { from: string; fileName: string }): Promise {
- const root = cacheDirectory;
- if (!root) {
- throw new Error('cacheDirectory is unavailable');
- }
const safeName = args.fileName.replaceAll(/[/\\]/g, '_') || 'shared-file';
- const destination = `${root}share-${Crypto.randomUUID()}-${safeName}`;
- await copyAsync({ from: args.from, to: destination });
- registerTempFile(destination);
- return destination;
+ const destination = new File(Paths.cache, `share-${Crypto.randomUUID()}-${safeName}`);
+ await new File(args.from).copy(destination);
+ registerTempFile(destination.uri);
+ return destination.uri;
}
export async function normalizeShareIntent(
diff --git a/apps/mobile/src/lib/share-prefill.test.ts b/apps/mobile/src/lib/share-prefill.test.ts
index 11be303654..4e2ef6bcc7 100644
--- a/apps/mobile/src/lib/share-prefill.test.ts
+++ b/apps/mobile/src/lib/share-prefill.test.ts
@@ -20,13 +20,6 @@ vi.mock('expo-crypto', () => {
};
});
-vi.mock('expo-file-system/legacy', () => ({
- cacheDirectory: 'file:///cache/',
- copyAsync: vi.fn(async () => {
- await Promise.resolve();
- }),
-}));
-
const expoFileSystemMock = vi.hoisted(() => {
const files = new Map();
const File = vi.fn(function FileMock(_base: unknown, ...rest: unknown[]) {
diff --git a/apps/mobile/src/lib/storage-keys.ts b/apps/mobile/src/lib/storage-keys.ts
index 6ab3436d05..7b3ade71e4 100644
--- a/apps/mobile/src/lib/storage-keys.ts
+++ b/apps/mobile/src/lib/storage-keys.ts
@@ -38,6 +38,10 @@ export const HIDE_BALANCE_KEY = 'hide-balance';
export const LIVE_ACTIVITY_KEY = 'live-activity-enabled';
/** Return key in the agent composer sends/start instead of inserting a newline. */
export const RETURN_SENDS_MESSAGE_KEY = 'return-sends-message';
+/** Master switch for gateway transcription of voice input (off = device speech recognition). */
+export const GATEWAY_TRANSCRIPTION_ENABLED_KEY = 'gateway-transcription-enabled';
+/** Persisted `{ id, name }` of the chosen gateway transcription model (null = none chosen). */
+export const GATEWAY_TRANSCRIPTION_MODEL_KEY = 'gateway-transcription-model';
/** Revocable per-host list of markdown link hosts that open without an Alert. */
export const TRUSTED_HOSTS_KEY = 'trusted-hosts';
export const PR_REVIEW_FOOTER_KEY = 'pr-review-footer-enabled';
diff --git a/apps/mobile/src/lib/voice-input/gateway/gateway-transcription-client.test.ts b/apps/mobile/src/lib/voice-input/gateway/gateway-transcription-client.test.ts
new file mode 100644
index 0000000000..4b855d1ca4
--- /dev/null
+++ b/apps/mobile/src/lib/voice-input/gateway/gateway-transcription-client.test.ts
@@ -0,0 +1,262 @@
+/* eslint-disable require-await, @typescript-eslint/require-await -- the fake upload resolves immediately, so the mock implementations settle without await (same as gateway-voice-input-engine.test.tsx) */
+import {
+ classifyTranscriptionFailure,
+ transcribeRecording,
+ TRANSCRIPTION_REQUEST_TIMEOUT_MS,
+} from './gateway-transcription-client';
+/* eslint-disable class-methods-use-this -- the File mock mirrors the instance-only native File surface. */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('@/lib/config', () => ({ API_BASE_URL: 'https://api.example.com' }));
+
+const createUploadTaskMock = vi.fn();
+const fileUris: string[] = [];
+vi.mock('expo-file-system', () => ({
+ UploadType: { BINARY_CONTENT: 0, MULTIPART: 1 },
+ File: class {
+ createUploadTask = (...args: unknown[]): unknown => createUploadTaskMock(...args);
+ constructor(uri: string) {
+ fileUris.push(uri);
+ }
+ },
+}));
+
+/** The last createUploadTask call, captured for request-shape assertions. */
+function lastTaskCall(): {
+ url: string;
+ fileUri: string;
+ options: {
+ uploadType: number;
+ fieldName: string;
+ mimeType: string;
+ parameters: Record;
+ headers: Record;
+ httpMethod: string;
+ };
+} {
+ const call = createUploadTaskMock.mock.calls.at(-1) as [
+ string,
+ {
+ uploadType: number;
+ fieldName: string;
+ mimeType: string;
+ parameters: Record;
+ headers: Record;
+ httpMethod: string;
+ },
+ ];
+ return { url: call[0], fileUri: fileUris.at(-1) ?? '', options: call[1] };
+}
+
+/** Replace the upload outcome for subsequent calls. */
+function mockUpload(implementation: () => Promise<{ status: number; body: string }>): void {
+ createUploadTaskMock.mockImplementation(() => ({
+ uploadAsync: implementation,
+ cancel: vi.fn(),
+ }));
+}
+
+const BASE_INPUT = {
+ recordingUri: 'file:///recordings/rec.m4a',
+ model: { id: 'whisper-large-v3', name: 'Whisper Large v3' },
+ organizationId: 'org-1' as const,
+ authToken: 'token-1',
+};
+
+beforeEach(() => {
+ mockUpload(async () => ({ status: 200, body: JSON.stringify({ text: 'hello' }) }));
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe('transcribeRecording', () => {
+ it('sends the multipart task with the model field, the m4a file part and the gateway headers', async () => {
+ const result = await transcribeRecording(BASE_INPUT);
+
+ expect(result).toEqual({ ok: true, text: 'hello' });
+ expect(createUploadTaskMock).toHaveBeenCalledTimes(1);
+ const { url, fileUri, options } = lastTaskCall();
+ expect(url).toBe('https://api.example.com/api/gateway/audio/transcriptions');
+ expect(fileUri).toBe('file:///recordings/rec.m4a');
+ expect(options.httpMethod).toBe('POST');
+ expect(options.uploadType).toBe(1);
+ expect(options.fieldName).toBe('file');
+ expect(options.mimeType).toBe('audio/mp4');
+ expect(options.parameters.model).toBe('whisper-large-v3');
+ expect(options.headers).toEqual({
+ Authorization: 'Bearer token-1',
+ 'X-KILOCODE-FEATURE': 'mobile-voice-input',
+ 'X-KiloCode-OrganizationId': 'org-1',
+ });
+ });
+
+ it('omits the organization header when no organization is set', async () => {
+ await transcribeRecording({ ...BASE_INPUT, organizationId: null });
+
+ expect(lastTaskCall().options.headers).toEqual({
+ Authorization: 'Bearer token-1',
+ 'X-KILOCODE-FEATURE': 'mobile-voice-input',
+ });
+ });
+
+ it('appends the language when non-empty and omits it when empty', async () => {
+ await transcribeRecording({ ...BASE_INPUT, language: 'en-US' });
+ expect(lastTaskCall().options.parameters.language).toBe('en-US');
+
+ await transcribeRecording({ ...BASE_INPUT, language: ' ' });
+ expect(lastTaskCall().options.parameters.language).toBeUndefined();
+ });
+
+ it('cancels the task on timeout and classifies it as a timeout', async () => {
+ vi.useFakeTimers();
+ // The upload stays pending until the timeout cancels the task; the mock's
+ // cancel rejects the native promise the way the real task does.
+ let rejectUpload: ((error: Error) => void) | undefined = undefined;
+ const cancel = vi.fn(() => {
+ rejectUpload?.(new Error('Task cancelled'));
+ });
+ createUploadTaskMock.mockImplementation(() => ({
+ uploadAsync: async () =>
+ new Promise((_resolve, reject) => {
+ rejectUpload = reject;
+ }),
+ cancel,
+ }));
+
+ const pending = transcribeRecording(BASE_INPUT);
+ await vi.advanceTimersByTimeAsync(TRANSCRIPTION_REQUEST_TIMEOUT_MS);
+ const result = await pending;
+
+ expect(cancel).toHaveBeenCalledTimes(1);
+ expect(result).toEqual({ ok: false, isTimeout: true, isNetworkError: false });
+ expect(classifyTranscriptionFailure(result)).toBe('timeout');
+ });
+
+ it('classifies a task rejection as unreachable', async () => {
+ mockUpload(async () => {
+ throw new Error('Network request failed');
+ });
+
+ const result = await transcribeRecording(BASE_INPUT);
+
+ expect(result).toEqual({ ok: false, isTimeout: false, isNetworkError: true });
+ expect(classifyTranscriptionFailure(result)).toBe('unreachable');
+ });
+
+ it('classifies a 404 as model-unavailable', async () => {
+ mockUpload(async () => ({ status: 404, body: '{}' }));
+
+ const result = await transcribeRecording(BASE_INPUT);
+
+ expect(result).toEqual({ ok: false, status: 404, isTimeout: false, isNetworkError: false });
+ expect(classifyTranscriptionFailure(result)).toBe('model-unavailable');
+ });
+
+ it('classifies a 200 with an unparseable body as invalid-response', async () => {
+ mockUpload(async () => ({ status: 200, body: JSON.stringify({ transcript: 'wrong shape' }) }));
+
+ const result = await transcribeRecording(BASE_INPUT);
+
+ expect(result).toEqual({ ok: false, status: 200, isTimeout: false, isNetworkError: false });
+ expect(classifyTranscriptionFailure(result)).toBe('invalid-response');
+ });
+
+ it('classifies a 200 with empty text as no-speech', async () => {
+ mockUpload(async () => ({ status: 200, body: JSON.stringify({ text: ' ' }) }));
+
+ const result = await transcribeRecording(BASE_INPUT);
+
+ expect(result).toEqual({ ok: true, text: '' });
+ expect(classifyTranscriptionFailure(result)).toBe('no-speech');
+ });
+
+ it('trims the returned text on success', async () => {
+ mockUpload(async () => ({ status: 200, body: JSON.stringify({ text: ' hello world ' }) }));
+
+ const result = await transcribeRecording(BASE_INPUT);
+
+ expect(result).toEqual({ ok: true, text: 'hello world' });
+ expect(classifyTranscriptionFailure(result)).toBe('success');
+ });
+});
+
+describe('classifyTranscriptionFailure', () => {
+ it('classifies success with text as success (happy state)', () => {
+ expect(classifyTranscriptionFailure({ ok: true, text: 'hello' })).toBe('success');
+ });
+
+ it('classifies success with blank text as no-speech (empty state)', () => {
+ expect(classifyTranscriptionFailure({ ok: true, text: '' })).toBe('no-speech');
+ });
+
+ it('classifies a network error as unreachable (retryable state)', () => {
+ expect(
+ classifyTranscriptionFailure({ ok: false, isTimeout: false, isNetworkError: true })
+ ).toBe('unreachable');
+ });
+
+ it('classifies a timeout as timeout (retryable state)', () => {
+ expect(
+ classifyTranscriptionFailure({ ok: false, isTimeout: true, isNetworkError: false })
+ ).toBe('timeout');
+ });
+
+ it('classifies other server statuses as server (retryable state)', () => {
+ expect(
+ classifyTranscriptionFailure({
+ ok: false,
+ status: 500,
+ isTimeout: false,
+ isNetworkError: false,
+ })
+ ).toBe('server');
+ expect(
+ classifyTranscriptionFailure({
+ ok: false,
+ status: 429,
+ isTimeout: false,
+ isNetworkError: false,
+ })
+ ).toBe('server');
+ });
+
+ it('classifies 400/404/410/422 as model-unavailable (non-retryable state)', () => {
+ for (const status of [400, 404, 410, 422]) {
+ expect(
+ classifyTranscriptionFailure({
+ ok: false,
+ status,
+ isTimeout: false,
+ isNetworkError: false,
+ })
+ ).toBe('model-unavailable');
+ }
+ });
+
+ it('classifies 401/403 as auth (non-retryable state)', () => {
+ expect(
+ classifyTranscriptionFailure({
+ ok: false,
+ status: 401,
+ isTimeout: false,
+ isNetworkError: false,
+ })
+ ).toBe('auth');
+ expect(
+ classifyTranscriptionFailure({
+ ok: false,
+ status: 403,
+ isTimeout: false,
+ isNetworkError: false,
+ })
+ ).toBe('auth');
+ });
+
+ it('classifies a failed outcome with no status and no flags as invalid-response', () => {
+ expect(
+ classifyTranscriptionFailure({ ok: false, isTimeout: false, isNetworkError: false })
+ ).toBe('invalid-response');
+ });
+});
diff --git a/apps/mobile/src/lib/voice-input/gateway/gateway-transcription-client.ts b/apps/mobile/src/lib/voice-input/gateway/gateway-transcription-client.ts
new file mode 100644
index 0000000000..06608bef0c
--- /dev/null
+++ b/apps/mobile/src/lib/voice-input/gateway/gateway-transcription-client.ts
@@ -0,0 +1,188 @@
+import { File, UploadType } from 'expo-file-system';
+import { z } from 'zod';
+
+import { API_BASE_URL } from '@/lib/config';
+
+/**
+ * The single Kilo gateway entry point for voice transcription. Every call to
+ * the gateway for a recording lives here so the request shape (multipart file
+ * part, feature header, organization scope) is one place to review.
+ *
+ * The upload runs through expo-file-system's native task, not `fetch`: React
+ * Native 0.86's fetch serializes multipart bodies itself and rejects
+ * React Native's classic URI-based file part ("Unsupported FormDataPart
+ * implementation"), while the native uploader streams the recording from disk
+ * without loading it into JS memory.
+ */
+
+export const TRANSCRIPTION_REQUEST_TIMEOUT_MS = 30_000;
+
+const GATEWAY_TRANSCRIPTIONS_PATH = '/api/gateway/audio/transcriptions';
+
+/** Wire contract for the transcription response. Untrusted upstream at the entry boundary. */
+const TranscriptionResponseSchema = z.object({ text: z.string() });
+
+export type TranscribeRecordingInput = {
+ recordingUri: string;
+ model: { id: string; name: string };
+ /** BCP-47 language hint; omitted from the request when empty. */
+ language?: string | null;
+ organizationId: string | null | undefined;
+ authToken: string;
+ /** Caller-owned abort (e.g. unmount); aborting discards the request. */
+ signal?: AbortSignal;
+};
+
+/**
+ * Result of one transcription attempt. A failure carries just enough to
+ * classify it: the HTTP status when the gateway answered, whether our own
+ * timeout fired, and whether the request never left the device.
+ */
+export type TranscribeRecordingResult =
+ | { ok: true; text: string }
+ | { ok: false; status?: number; isTimeout: boolean; isNetworkError: boolean };
+
+export type TranscriptionClassification =
+ | 'success'
+ | 'no-speech'
+ | 'unreachable'
+ | 'timeout'
+ | 'model-unavailable'
+ | 'auth'
+ | 'server'
+ | 'invalid-response';
+
+const MODEL_UNAVAILABLE_STATUSES = new Set([400, 404, 410, 422]);
+const AUTH_STATUSES = new Set([401, 403]);
+
+/**
+ * Wire headers for the transcription upload. The organization header is
+ * present only when the request is scoped to an organization.
+ */
+type TranscriptionUploadHeaders = {
+ Authorization: string;
+ 'X-KILOCODE-FEATURE': string;
+ 'X-KiloCode-OrganizationId'?: string;
+};
+
+/** Upload form fields. The language field is present only when provided. */
+type TranscriptionUploadParameters = {
+ model: string;
+ language?: string;
+};
+
+/**
+ * Transcribe one recording through the Kilo gateway. The recording streams
+ * from disk as the multipart file part; the caller owns the auth token and
+ * passes it in; nothing here mints a token.
+ */
+export async function transcribeRecording({
+ recordingUri,
+ model,
+ language,
+ organizationId,
+ authToken,
+ signal,
+}: TranscribeRecordingInput): Promise {
+ const headers: TranscriptionUploadHeaders = {
+ Authorization: `Bearer ${authToken}`,
+ 'X-KILOCODE-FEATURE': 'mobile-voice-input',
+ };
+ if (organizationId && organizationId !== '') {
+ headers['X-KiloCode-OrganizationId'] = organizationId;
+ }
+
+ const trimmedLanguage = language?.trim() ?? '';
+ const parameters: TranscriptionUploadParameters = { model: model.id };
+ if (trimmedLanguage !== '') {
+ parameters.language = trimmedLanguage;
+ }
+
+ const recordingFile = new File(recordingUri);
+ const task = recordingFile.createUploadTask(`${API_BASE_URL}${GATEWAY_TRANSCRIPTIONS_PATH}`, {
+ uploadType: UploadType.MULTIPART,
+ fieldName: 'file',
+ mimeType: 'audio/mp4',
+ parameters,
+ headers,
+ httpMethod: 'POST',
+ // The task cancels the native request on caller abort and rejects with
+ // an AbortError, which the catch below maps to the caller's outcome.
+ signal,
+ });
+
+ // The timeout aborts through its own controller so the catch can tell our
+ // timeout apart from a caller abort: a closure-assigned boolean stays
+ // control-flow-narrowed to its initializer for the type checker.
+ const timeoutAbort = new AbortController();
+ const timeoutId = setTimeout(() => {
+ timeoutAbort.abort();
+ task.cancel();
+ }, TRANSCRIPTION_REQUEST_TIMEOUT_MS);
+
+ try {
+ const response = await task.uploadAsync();
+
+ if (response.status < 200 || response.status >= 300) {
+ // A gateway refusal carries its status.
+ return { ok: false, status: response.status, isTimeout: false, isNetworkError: false };
+ }
+
+ try {
+ // A body that is not `{ text: string }` is an invalid response, not a
+ // crash — the gateway is untrusted upstream.
+ const parsed = TranscriptionResponseSchema.parse(JSON.parse(response.body) as unknown);
+ return { ok: true, text: parsed.text.trim() };
+ } catch {
+ return { ok: false, status: response.status, isTimeout: false, isNetworkError: false };
+ }
+ } catch {
+ if (timeoutAbort.signal.aborted) {
+ // Our own timeout fired; the rejection is that cancellation surfacing.
+ return { ok: false, isTimeout: true, isNetworkError: false };
+ }
+ if (signal?.aborted) {
+ // The caller aborted (e.g. unmount). It knows it cancelled and owns
+ // the outcome; the shape still maps to invalid-response if surfaced.
+ return { ok: false, isTimeout: false, isNetworkError: false };
+ }
+ // A task rejection without a status: the upload never completed.
+ return { ok: false, isTimeout: false, isNetworkError: true };
+ } finally {
+ clearTimeout(timeoutId);
+ }
+}
+
+/**
+ * Map a transcription outcome onto one user-facing state. The caller (the
+ * voice-input controller) turns each value into copy: success → insert the
+ * text; no-speech → the empty state; unreachable/timeout/server → a retryable
+ * error; model-unavailable/auth → a non-retryable error with its own guidance;
+ * invalid-response → an unexpected gateway body.
+ */
+export function classifyTranscriptionFailure(
+ input:
+ | { ok: true; text: string }
+ | { ok: false; status?: number; isTimeout: boolean; isNetworkError: boolean }
+): TranscriptionClassification {
+ if (input.ok) {
+ return input.text.trim() === '' ? 'no-speech' : 'success';
+ }
+ if (input.isTimeout) {
+ return 'timeout';
+ }
+ if (input.isNetworkError) {
+ return 'unreachable';
+ }
+ const { status } = input;
+ if (status !== undefined && MODEL_UNAVAILABLE_STATUSES.has(status)) {
+ return 'model-unavailable';
+ }
+ if (status !== undefined && AUTH_STATUSES.has(status)) {
+ return 'auth';
+ }
+ if (status !== undefined && status >= 400) {
+ return 'server';
+ }
+ return 'invalid-response';
+}
diff --git a/apps/mobile/src/lib/voice-input/gateway/gateway-transcription-preference.test.ts b/apps/mobile/src/lib/voice-input/gateway/gateway-transcription-preference.test.ts
new file mode 100644
index 0000000000..eb0d104eaa
--- /dev/null
+++ b/apps/mobile/src/lib/voice-input/gateway/gateway-transcription-preference.test.ts
@@ -0,0 +1,153 @@
+import type * as gatewayTranscriptionPreference from './gateway-transcription-preference';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const store = vi.hoisted(() => new Map());
+const { captureException, toastError } = vi.hoisted(() => ({
+ captureException: vi.fn(),
+ toastError: vi.fn(),
+}));
+
+vi.mock('expo-secure-store', () => ({
+ getItemAsync: vi.fn(async (key: string) => {
+ await Promise.resolve();
+ return store.get(key) ?? null;
+ }),
+ setItemAsync: vi.fn(async (key: string, value: string) => {
+ await Promise.resolve();
+ store.set(key, value);
+ }),
+ deleteItemAsync: vi.fn(async (key: string) => {
+ await Promise.resolve();
+ store.delete(key);
+ }),
+}));
+vi.mock('@sentry/react-native', () => ({ captureException }));
+vi.mock('sonner-native', () => ({ toast: { error: toastError } }));
+
+const ENABLED_KEY = 'gateway-transcription-enabled';
+const MODEL_KEY = 'gateway-transcription-model';
+
+// eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+function flushPreferences(): Promise {
+ // The module-scope preload reads SecureStore in the background; two
+ // macrotask rounds let the read, parse and emit settle before assertions.
+ return new Promise(resolve => {
+ setImmediate(() => {
+ setImmediate(resolve);
+ });
+ });
+}
+
+/**
+ * Fresh module instance per import: the stores are module-scoped and
+ * preload() runs at import time, so the disk contents must be arranged
+ * before the import for a test to observe them.
+ */
+// eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+function importPreferenceModule(): Promise {
+ return import('./gateway-transcription-preference');
+}
+
+beforeEach(() => {
+ store.clear();
+ vi.resetModules();
+ captureException.mockReset();
+ toastError.mockReset();
+});
+
+describe('gateway transcription enabled preference', () => {
+ it('reads false by default when nothing is persisted', async () => {
+ const mod = await importPreferenceModule();
+ await flushPreferences();
+
+ expect(mod.isGatewayTranscriptionEnabled()).toBe(false);
+ });
+
+ it('round-trips through SecureStore across a module reload', async () => {
+ const mod = await importPreferenceModule();
+ await flushPreferences();
+
+ mod.setGatewayTranscriptionEnabled(true);
+ expect(mod.isGatewayTranscriptionEnabled()).toBe(true);
+ await flushPreferences();
+ expect(store.get(ENABLED_KEY)).toBe('true');
+
+ // A fresh module (next launch) reads the persisted value.
+ vi.resetModules();
+ const reloaded = await importPreferenceModule();
+ await flushPreferences();
+ expect(reloaded.isGatewayTranscriptionEnabled()).toBe(true);
+ });
+
+ it('persists false when toggled off after being on', async () => {
+ store.set(ENABLED_KEY, 'true');
+ const mod = await importPreferenceModule();
+ await flushPreferences();
+
+ mod.setGatewayTranscriptionEnabled(false);
+ await flushPreferences();
+
+ expect(mod.isGatewayTranscriptionEnabled()).toBe(false);
+ expect(store.get(ENABLED_KEY)).toBe('false');
+ });
+});
+
+describe('gateway transcription model', () => {
+ it('round-trips the chosen model and persists its JSON', async () => {
+ const mod = await importPreferenceModule();
+ await flushPreferences();
+ expect(mod.readGatewayTranscriptionModel()).toBeNull();
+
+ const model: gatewayTranscriptionPreference.GatewayTranscriptionModel = {
+ id: 'whisper-large-v3',
+ name: 'Whisper Large v3',
+ };
+ mod.writeGatewayTranscriptionModel(model);
+ expect(mod.readGatewayTranscriptionModel()).toEqual(model);
+ await flushPreferences();
+ expect(store.get(MODEL_KEY)).toBe(JSON.stringify(model));
+
+ // A fresh module (next launch) reads the persisted model.
+ vi.resetModules();
+ const reloaded = await importPreferenceModule();
+ await flushPreferences();
+ expect(reloaded.readGatewayTranscriptionModel()).toEqual(model);
+ });
+
+ it('reads a corrupt persisted model as null', async () => {
+ store.set(MODEL_KEY, 'not-json');
+ const mod = await importPreferenceModule();
+ await flushPreferences();
+
+ expect(mod.readGatewayTranscriptionModel()).toBeNull();
+ });
+
+ it('reads a persisted model that is not an id/name object as null', async () => {
+ store.set(MODEL_KEY, '42');
+ const mod = await importPreferenceModule();
+ await flushPreferences();
+
+ expect(mod.readGatewayTranscriptionModel()).toBeNull();
+ });
+
+ it('reads a model missing the name field as null', async () => {
+ store.set(MODEL_KEY, JSON.stringify({ id: 'whisper-large-v3' }));
+ const mod = await importPreferenceModule();
+ await flushPreferences();
+
+ expect(mod.readGatewayTranscriptionModel()).toBeNull();
+ });
+
+ it('clears the model back to null', async () => {
+ const mod = await importPreferenceModule();
+ await flushPreferences();
+ mod.writeGatewayTranscriptionModel({ id: 'whisper-large-v3', name: 'Whisper Large v3' });
+ await flushPreferences();
+
+ mod.writeGatewayTranscriptionModel(null);
+ await flushPreferences();
+
+ expect(mod.readGatewayTranscriptionModel()).toBeNull();
+ expect(store.get(MODEL_KEY)).toBe('null');
+ });
+});
diff --git a/apps/mobile/src/lib/voice-input/gateway/gateway-transcription-preference.ts b/apps/mobile/src/lib/voice-input/gateway/gateway-transcription-preference.ts
new file mode 100644
index 0000000000..21bdd89323
--- /dev/null
+++ b/apps/mobile/src/lib/voice-input/gateway/gateway-transcription-preference.ts
@@ -0,0 +1,99 @@
+import { useSyncExternalStore } from 'react';
+import { z } from 'zod';
+
+import { createSecureStorePreference } from '@/lib/hooks/secure-store-preference';
+import {
+ GATEWAY_TRANSCRIPTION_ENABLED_KEY,
+ GATEWAY_TRANSCRIPTION_MODEL_KEY,
+} from '@/lib/storage-keys';
+
+/**
+ * The persisted gateway transcription model choice. `name` is kept beside `id`
+ * so the settings row can show the human-readable label without a second
+ * lookup after restart.
+ */
+export type GatewayTranscriptionModel = { id: string; name: string };
+
+/** Wire contract for the persisted model JSON. Untrusted disk content at the parse boundary. */
+const GatewayTranscriptionModelSchema = z.object({ id: z.string(), name: z.string() });
+
+/**
+ * Default-off preference: gateway transcription is opt-in because it sends
+ * the recording to the Kilo gateway instead of the device's speech
+ * recognition.
+ */
+const enabledStore = createSecureStorePreference({
+ key: GATEWAY_TRANSCRIPTION_ENABLED_KEY,
+ defaultValue: false,
+ parse: raw => raw === 'true',
+ serialize: value => (value ? 'true' : 'false'),
+});
+
+const modelStore = createSecureStorePreference({
+ key: GATEWAY_TRANSCRIPTION_MODEL_KEY,
+ defaultValue: null,
+ parse: raw => {
+ if (raw === null) {
+ return null;
+ }
+ try {
+ return GatewayTranscriptionModelSchema.parse(JSON.parse(raw));
+ } catch {
+ // A corrupt persisted value never blocks start: fall back to
+ // "no model chosen" instead of crashing the voice flow.
+ return null;
+ }
+ },
+ serialize: value => JSON.stringify(value),
+});
+
+// Warm both disk reads at module scope so the settings row and the first
+// toggle see the persisted value without waiting for a React mount.
+enabledStore.preload();
+modelStore.preload();
+
+export function isGatewayTranscriptionEnabled(): boolean {
+ return enabledStore.get();
+}
+
+/** Non-React subscription for module-scope consumers (e.g. the native binding). */
+export function subscribeToGatewayTranscriptionEnabled(listener: () => void): () => void {
+ return enabledStore.subscribe(listener);
+}
+
+export function setGatewayTranscriptionEnabled(value: boolean): void {
+ enabledStore.set(value);
+}
+
+export function readGatewayTranscriptionModel(): GatewayTranscriptionModel | null {
+ return modelStore.get();
+}
+
+export function writeGatewayTranscriptionModel(model: GatewayTranscriptionModel | null): void {
+ modelStore.set(model);
+}
+
+/** Settings UI binding for the gateway-transcription switch. */
+export function useGatewayTranscriptionPreference() {
+ const gatewayTranscriptionEnabled = useSyncExternalStore(
+ enabledStore.subscribe,
+ enabledStore.get
+ );
+ const hasLoaded = useSyncExternalStore(enabledStore.subscribe, enabledStore.getHasLoaded);
+ return { gatewayTranscriptionEnabled, hasLoaded, setGatewayTranscriptionEnabled };
+}
+
+/** Settings UI binding for the chosen transcription model. */
+export function useGatewayTranscriptionModel(): GatewayTranscriptionModel | null {
+ return useSyncExternalStore(modelStore.subscribe, modelStore.get);
+}
+
+/**
+ * Settings UI binding for whether the stored model choice has finished its
+ * SecureStore read. The picker gates its rows on this so the check mark never
+ * renders from a half-read store (a pending read reports "no model", which
+ * would draw every row unchecked until the read lands).
+ */
+export function useGatewayTranscriptionModelLoaded(): boolean {
+ return useSyncExternalStore(modelStore.subscribe, modelStore.getHasLoaded);
+}
diff --git a/apps/mobile/src/lib/voice-input/gateway/gateway-voice-input-engine.test.ts b/apps/mobile/src/lib/voice-input/gateway/gateway-voice-input-engine.test.ts
new file mode 100644
index 0000000000..5e070c20a2
--- /dev/null
+++ b/apps/mobile/src/lib/voice-input/gateway/gateway-voice-input-engine.test.ts
@@ -0,0 +1,452 @@
+/* eslint-disable max-lines -- the engine suite covers every session path (happy, error mapping, abort, prep races) on one fake harness. */
+/* eslint-disable require-await, @typescript-eslint/require-await -- the engine's fake deps resolve immediately, so they settle without await */
+import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
+
+import { type VoiceInputNativeEvent } from '../voice-input-controller';
+import {
+ type TranscribeRecordingInput,
+ type TranscribeRecordingResult,
+} from './gateway-transcription-client';
+import {
+ createGatewayVoiceInputEngine,
+ type GatewayRecorder,
+ type GatewayVoiceInputEngineDeps,
+} from './gateway-voice-input-engine';
+
+vi.mock('expo-file-system', () => ({
+ UploadType: { BINARY_CONTENT: 0, MULTIPART: 1 },
+ File: class {
+ createUploadTask = vi.fn();
+ },
+}));
+
+vi.mock('@/lib/config', () => ({ API_BASE_URL: 'https://api.example.com' }));
+
+const audioMock = vi.hoisted(() => ({
+ getRecordingPermissionsAsync: vi.fn(),
+ requestRecordingPermissionsAsync: vi.fn(),
+}));
+vi.mock('expo-audio', () => audioMock);
+
+type RecordedEvent = { event: keyof VoiceInputNativeEvent; payload: unknown };
+
+const START_OPTIONS = {
+ continuous: false,
+ interimResults: true,
+ lang: 'en-US',
+ maxAlternatives: 1,
+ requiresOnDeviceRecognition: false,
+} as const;
+
+/** Let every queued microtask/timer continuation of the engine run. */
+async function flush(): Promise {
+ for (let i = 0; i < 4; i += 1) {
+ // eslint-disable-next-line no-await-in-loop -- each macrotask turn lets the engine's chained continuations settle
+ await new Promise(resolve => {
+ setTimeout(resolve, 0);
+ });
+ }
+}
+
+type FakeRecorder = GatewayRecorder & {
+ prepareToRecordAsync: Mock<() => Promise>;
+ record: Mock<() => void>;
+ stop: Mock<() => Promise>;
+ release: Mock<() => void>;
+};
+
+function makeRecorder(): FakeRecorder {
+ const recorder = {
+ uri: null as string | null,
+ prepareToRecordAsync: vi.fn(async (): Promise => undefined),
+ record: vi.fn((): void => {
+ recorder.uri = 'file:///recordings/recording.m4a';
+ }),
+ stop: vi.fn(async (): Promise => undefined),
+ release: vi.fn((): void => undefined),
+ };
+ return recorder;
+}
+
+type UploadMock = Mock<(input: TranscribeRecordingInput) => Promise>;
+
+function buildEngine(overrides: Partial = {}): {
+ engine: ReturnType;
+ events: RecordedEvent[];
+ recorder: FakeRecorder;
+ upload: UploadMock;
+ deleteRecording: Mock<(uri: string) => Promise>;
+} {
+ const recorder = makeRecorder();
+ const events: RecordedEvent[] = [];
+ const upload = vi.fn(
+ async (): Promise => ({ ok: true, text: 'hello world' })
+ );
+ const deleteRecording = vi.fn(async (): Promise => undefined);
+ const deps: GatewayVoiceInputEngineDeps = {
+ setAudioMode: vi.fn(async (): Promise => undefined),
+ createRecorder: vi.fn(() => recorder),
+ readModelId: vi.fn(async () => ({ id: 'whisper-large-v3', name: 'Whisper Large v3' })),
+ readAuthToken: vi.fn(async (): Promise => 'token-1'),
+ readOrganizationId: vi.fn(async (): Promise => 'org-1'),
+ deleteRecording,
+ upload,
+ ...overrides,
+ };
+ const engine = createGatewayVoiceInputEngine(deps);
+ for (const event of [
+ 'start',
+ 'transcribing',
+ 'result',
+ 'nomatch',
+ 'error',
+ 'end',
+ ] as (keyof VoiceInputNativeEvent)[]) {
+ engine.addListener(event, payload => {
+ events.push({ event, payload });
+ });
+ }
+ return { engine, events, recorder, upload, deleteRecording };
+}
+
+async function startAndStop(
+ engine: ReturnType
+): Promise {
+ engine.start(START_OPTIONS);
+ await flush();
+ engine.stop();
+}
+
+beforeEach(() => {
+ audioMock.getRecordingPermissionsAsync.mockReset();
+ audioMock.requestRecordingPermissionsAsync.mockReset();
+});
+
+describe('createGatewayVoiceInputEngine', () => {
+ it('emits start, then transcribing synchronously on stop, then the final result and end', async () => {
+ const { engine, events, recorder, upload } = buildEngine();
+
+ engine.start(START_OPTIONS);
+ await flush();
+ expect(events.map(entry => entry.event)).toEqual(['start']);
+
+ engine.stop();
+ // The transcribing signal fires before any await so the UI flips to
+ // "Transcribing…" without a gap.
+ expect(events.map(entry => entry.event)).toEqual(['start', 'transcribing']);
+
+ await flush();
+ expect(events.map(entry => entry.event)).toEqual(['start', 'transcribing', 'result', 'end']);
+ const result = events[2]?.payload as VoiceInputNativeEvent['result'];
+ expect(result.isFinal).toBe(true);
+ expect(result.results[0]?.transcript).toBe('hello world');
+ expect(recorder.stop).toHaveBeenCalledTimes(1);
+ expect(recorder.release).toHaveBeenCalledTimes(1);
+ expect(upload).toHaveBeenCalledTimes(1);
+ const input = upload.mock.calls[0]?.[0];
+ expect(input?.recordingUri).toBe('file:///recordings/recording.m4a');
+ expect(input?.model).toEqual({ id: 'whisper-large-v3', name: 'Whisper Large v3' });
+ expect(input?.language).toBe('en-US');
+ expect(input?.authToken).toBe('token-1');
+ expect(input?.organizationId).toBe('org-1');
+ });
+
+ it('maps an unavailable model to gateway-model-unavailable and terminalizes with end', async () => {
+ const { engine, events } = buildEngine({
+ upload: async (): Promise => ({
+ ok: false,
+ status: 404,
+ isTimeout: false,
+ isNetworkError: false,
+ }),
+ });
+
+ await startAndStop(engine);
+ await flush();
+
+ expect(events.map(entry => entry.event)).toEqual(['start', 'transcribing', 'error', 'end']);
+ const errorPayload = events[2]?.payload as VoiceInputNativeEvent['error'];
+ expect(errorPayload.error).toBe('gateway-model-unavailable');
+ });
+
+ it('maps unreachable, timeout, server and invalid-response to their gateway codes', async () => {
+ const cases: [TranscribeRecordingResult, string][] = [
+ [{ ok: false, isTimeout: false, isNetworkError: true }, 'gateway-unreachable'],
+ [{ ok: false, isTimeout: true, isNetworkError: false }, 'gateway-timeout'],
+ [{ ok: false, status: 503, isTimeout: false, isNetworkError: false }, 'gateway-server'],
+ [
+ { ok: false, status: 200, isTimeout: false, isNetworkError: false },
+ 'gateway-invalid-response',
+ ],
+ ];
+ for (const [result, code] of cases) {
+ const { engine, events } = buildEngine({ upload: async () => result });
+ // eslint-disable-next-line no-await-in-loop -- each case needs its own fully settled engine session
+ await startAndStop(engine);
+ // eslint-disable-next-line no-await-in-loop -- the upload continuation must drain before asserting
+ await flush();
+ const errorPayload = events[2]?.payload as VoiceInputNativeEvent['error'];
+ expect(errorPayload.error).toBe(code);
+ expect(events[3]?.event).toBe('end');
+ }
+ });
+
+ it('maps an empty transcription to the existing no-speech error', async () => {
+ const { engine, events } = buildEngine({
+ upload: async (): Promise => ({ ok: true, text: ' ' }),
+ });
+
+ await startAndStop(engine);
+ await flush();
+
+ expect(events.map(entry => entry.event)).toEqual(['start', 'transcribing', 'error', 'end']);
+ const errorPayload = events[2]?.payload as VoiceInputNativeEvent['error'];
+ expect(errorPayload.error).toBe('no-speech');
+ });
+
+ it('emits gateway-no-model without uploading when no model is chosen', async () => {
+ const { engine, events, upload } = buildEngine({ readModelId: async () => null });
+
+ await startAndStop(engine);
+ await flush();
+
+ expect(events.map(entry => entry.event)).toEqual(['start', 'transcribing', 'error', 'end']);
+ const errorPayload = events[2]?.payload as VoiceInputNativeEvent['error'];
+ expect(errorPayload.error).toBe('gateway-no-model');
+ expect(upload).not.toHaveBeenCalled();
+ });
+
+ it('emits gateway-auth without uploading when no auth token is available', async () => {
+ const { engine, events, upload } = buildEngine({
+ readAuthToken: async () => null,
+ });
+
+ await startAndStop(engine);
+ await flush();
+
+ const errorPayload = events[2]?.payload as VoiceInputNativeEvent['error'];
+ expect(errorPayload.error).toBe('gateway-auth');
+ expect(upload).not.toHaveBeenCalled();
+ });
+
+ it('emits client error and end when recording prep fails', async () => {
+ const { engine, events } = buildEngine({
+ setAudioMode: async () => {
+ throw new Error('audio mode refused');
+ },
+ });
+
+ engine.start(START_OPTIONS);
+ await flush();
+
+ expect(events.map(entry => entry.event)).toEqual(['error', 'end']);
+ const errorPayload = events[0]?.payload as VoiceInputNativeEvent['error'];
+ expect(errorPayload.error).toBe('client');
+ });
+
+ it('abort during upload emits end with no result and aborts the upload signal', async () => {
+ const uploadInputs: TranscribeRecordingInput[] = [];
+ const uploadResolvers: ((value: TranscribeRecordingResult) => void)[] = [];
+ const { engine, events } = buildEngine({
+ upload: async input => {
+ uploadInputs.push(input);
+ return new Promise(resolve => {
+ uploadResolvers.push(resolve);
+ });
+ },
+ });
+
+ await startAndStop(engine);
+ await flush();
+ const capturedSignal = uploadInputs[0]?.signal;
+ expect(capturedSignal).toBeDefined();
+ expect(events.map(entry => entry.event)).toEqual(['start', 'transcribing']);
+
+ engine.abort();
+ expect(events.map(entry => entry.event)).toEqual(['start', 'transcribing', 'end']);
+ expect(capturedSignal?.aborted).toBe(true);
+
+ // The late upload answer must not resurrect the session.
+ uploadResolvers[0]?.({ ok: true, text: 'too late' });
+ await flush();
+ expect(events.map(entry => entry.event)).toEqual(['start', 'transcribing', 'end']);
+ });
+
+ it('abort during recording stops and releases the recorder without a result', async () => {
+ const { engine, events, recorder } = buildEngine();
+
+ engine.start(START_OPTIONS);
+ await flush();
+ engine.abort();
+
+ expect(events.map(entry => entry.event)).toEqual(['start', 'end']);
+ await flush();
+ expect(recorder.stop).toHaveBeenCalledTimes(1);
+ expect(recorder.release).toHaveBeenCalledTimes(1);
+ });
+
+ it('stop during prep still terminalizes with start, transcribing, result and end', async () => {
+ const prepState: { resolve?: () => void } = {};
+ const recorder = makeRecorder();
+ recorder.prepareToRecordAsync.mockImplementation(
+ async () =>
+ new Promise(resolve => {
+ prepState.resolve = resolve;
+ })
+ );
+ const { engine, events } = buildEngine({ createRecorder: () => recorder });
+
+ engine.start(START_OPTIONS);
+ await flush();
+ engine.stop();
+ prepState.resolve?.();
+ await flush();
+
+ expect(events.map(entry => entry.event)).toEqual(['start', 'transcribing', 'result', 'end']);
+ });
+
+ it('stop and abort are no-ops when no session is active', async () => {
+ const { engine, events } = buildEngine();
+
+ engine.stop();
+ engine.abort();
+ await flush();
+
+ expect(events).toEqual([]);
+ });
+
+ it('a second start after terminalization runs a fresh session', async () => {
+ const { engine, events } = buildEngine();
+
+ await startAndStop(engine);
+ await flush();
+ engine.start(START_OPTIONS);
+ await flush();
+ engine.stop();
+ await flush();
+
+ expect(events.map(entry => entry.event)).toEqual([
+ 'start',
+ 'transcribing',
+ 'result',
+ 'end',
+ 'start',
+ 'transcribing',
+ 'result',
+ 'end',
+ ]);
+ });
+
+ it('is always available and never continuous or on-device', () => {
+ const { engine } = buildEngine();
+ expect(engine.isRecognitionAvailable()).toBe(true);
+ expect(engine.supportsContinuousRecognition()).toBe(false);
+ expect(engine.supportsOnDevice()).toBe(false);
+ });
+
+ it('delegates permissions to the expo-audio recording permission', async () => {
+ audioMock.getRecordingPermissionsAsync.mockResolvedValue({
+ status: 'granted',
+ granted: true,
+ canAskAgain: false,
+ expires: 'never',
+ });
+ audioMock.requestRecordingPermissionsAsync.mockResolvedValue({
+ status: 'denied',
+ granted: false,
+ canAskAgain: true,
+ expires: 'never',
+ });
+ const { engine } = buildEngine();
+
+ await expect(engine.getPermissions()).resolves.toEqual({
+ granted: true,
+ canAskAgain: false,
+ });
+ await expect(engine.requestPermissions()).resolves.toEqual({
+ granted: false,
+ canAskAgain: true,
+ });
+ });
+
+ it('remove() detaches a listener', async () => {
+ const { engine } = buildEngine();
+ const seen: string[] = [];
+ const subscription = engine.addListener('start', () => {
+ seen.push('start');
+ });
+ subscription.remove();
+
+ engine.start(START_OPTIONS);
+ await flush();
+
+ expect(seen).toEqual([]);
+ });
+});
+
+describe('recording file cleanup', () => {
+ it('deletes the recording file after a successful upload', async () => {
+ const { engine, deleteRecording } = buildEngine();
+
+ await startAndStop(engine);
+ await flush();
+
+ expect(deleteRecording).toHaveBeenCalledWith('file:///recordings/recording.m4a');
+ });
+
+ it('deletes the recording file after a classified upload failure', async () => {
+ const { engine, deleteRecording } = buildEngine({
+ upload: async (): Promise => ({
+ ok: false,
+ status: 503,
+ isTimeout: false,
+ isNetworkError: false,
+ }),
+ });
+
+ await startAndStop(engine);
+ await flush();
+
+ expect(deleteRecording).toHaveBeenCalledWith('file:///recordings/recording.m4a');
+ });
+
+ it('deletes the recording file on a model short-circuit', async () => {
+ const { engine, deleteRecording } = buildEngine({ readModelId: async () => null });
+
+ await startAndStop(engine);
+ await flush();
+
+ expect(deleteRecording).toHaveBeenCalledWith('file:///recordings/recording.m4a');
+ });
+
+ it('deletes the recording file when an upload is aborted', async () => {
+ const uploadResolvers: ((value: TranscribeRecordingResult) => void)[] = [];
+ const { engine, deleteRecording } = buildEngine({
+ upload: async () =>
+ new Promise(resolve => {
+ uploadResolvers.push(resolve);
+ }),
+ });
+
+ await startAndStop(engine);
+ await flush();
+ expect(deleteRecording).not.toHaveBeenCalled();
+
+ engine.abort();
+ uploadResolvers[0]?.({ ok: true, text: 'too late' });
+ await flush();
+
+ expect(deleteRecording).toHaveBeenCalledWith('file:///recordings/recording.m4a');
+ });
+
+ it('deletes the recording file when recording is aborted before upload', async () => {
+ const { engine, deleteRecording } = buildEngine();
+
+ engine.start(START_OPTIONS);
+ await flush();
+ engine.abort();
+ await flush();
+
+ expect(deleteRecording).toHaveBeenCalledWith('file:///recordings/recording.m4a');
+ });
+});
diff --git a/apps/mobile/src/lib/voice-input/gateway/gateway-voice-input-engine.ts b/apps/mobile/src/lib/voice-input/gateway/gateway-voice-input-engine.ts
new file mode 100644
index 0000000000..13c759fd3d
--- /dev/null
+++ b/apps/mobile/src/lib/voice-input/gateway/gateway-voice-input-engine.ts
@@ -0,0 +1,419 @@
+/* eslint-disable max-lines -- one session state machine: start, stop, upload, abort, and recording cleanup share the session lifecycle. */
+import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from 'expo-audio';
+
+import {
+ type VoiceInputNative,
+ type VoiceInputNativeEvent,
+ type VoiceInputNativePermission,
+ type VoiceInputNativeStartOptions,
+} from '../voice-input-controller';
+import {
+ classifyTranscriptionFailure,
+ transcribeRecording,
+ type TranscribeRecordingInput,
+ type TranscribeRecordingResult,
+} from './gateway-transcription-client';
+
+/**
+ * The slice of an expo-audio recorder the engine drives. `AudioModule.AudioRecorder`
+ * satisfies it structurally; tests fake it. `release()` detaches the native
+ * object — every path that stops owning a recorder must call it exactly once.
+ */
+export type GatewayRecorder = {
+ readonly uri: string | null;
+ prepareToRecordAsync(): Promise;
+ record(): void;
+ stop(): Promise;
+ release(): void;
+};
+
+export type GatewayVoiceInputEngineDeps = {
+ setAudioMode(mode: { allowsRecording: true }): Promise;
+ createRecorder(): GatewayRecorder;
+ /**
+ * The transcription model to use: the stored choice, else the first model
+ * the gateway catalogue offers, else null when none can be resolved.
+ */
+ readModelId(): Promise<{ id: string; name: string } | null>;
+ readAuthToken(): Promise;
+ readOrganizationId(): Promise;
+ /**
+ * Best-effort delete of a recording file once the engine is done with it.
+ * `release()` frees the native object, not the file on disk. A failure must
+ * never change the session outcome. Declared as a property (not a method) so
+ * the engine can hold a detached reference; void-returning is allowed because
+ * the modern File API deletes synchronously.
+ */
+ deleteRecording: (uri: string) => void | Promise;
+ /**
+ * Defaults to the real gateway client; tests inject a fake. Declared as a
+ * property (not a method) so the engine can hold a detached reference.
+ */
+ upload?: ((input: TranscribeRecordingInput) => Promise) | undefined;
+};
+
+type RecorderHandle = {
+ released: boolean;
+ recorder: GatewayRecorder;
+};
+
+type GatewaySession = {
+ /** Monotonic id; every async continuation checks it still owns the session. */
+ id: number;
+ /** BCP-47 hint carried from `start()` into the transcription upload. */
+ languageTag: string;
+ /** The recorder we currently own, or null once `stop()` has taken it. */
+ handle: RecorderHandle | null;
+ /** Set when `stop()` arrives while the recorder is still preparing. */
+ stopRequested: boolean;
+ /** Owns the in-flight upload; `abort()` cancels it. */
+ uploadController: AbortController | null;
+};
+
+type AnyListener = (event: VoiceInputNativeEvent[keyof VoiceInputNativeEvent]) => void;
+
+/**
+ * Release a recorder exactly once. `SharedObject.release()` throws on a
+ * second call, and the abort/finish paths can race for the same handle.
+ */
+function releaseRecorder(handle: RecorderHandle): void {
+ if (handle.released) {
+ return;
+ }
+ handle.released = true;
+ try {
+ handle.recorder.release();
+ } catch {
+ // The native object may already be gone; nothing left to free.
+ }
+}
+
+/**
+ * Delete a recording file, best-effort. A missing or undeletable file must
+ * never change the session outcome, so every failure is swallowed. A null or
+ * empty URI means the recorder never produced a file.
+ */
+async function deleteRecordingFile(
+ deleteRecording: (uri: string) => void | Promise,
+ uri: string | null
+): Promise {
+ if (uri === null || uri === '') {
+ return;
+ }
+ try {
+ await deleteRecording(uri);
+ } catch {
+ // Cleanup is best-effort; the session outcome is already decided.
+ }
+}
+
+/**
+ * Free a recorder and delete its file once the engine stops owning it. The URI
+ * is read before `release()`: a released shared object refuses the read.
+ */
+async function releaseAndDeleteRecording(
+ handle: RecorderHandle,
+ deleteRecording: (uri: string) => void | Promise
+): Promise {
+ let uri: string | null = null;
+ try {
+ uri = handle.recorder.uri;
+ } catch {
+ // A recorder that refuses a URI read has no file to delete.
+ }
+ releaseRecorder(handle);
+ await deleteRecordingFile(deleteRecording, uri);
+}
+
+/**
+ * Discard an aborted recording: end capture, free the native object, then
+ * delete the file. A recorder that refuses to stop (sync throw or rejection)
+ * still gets released and its file deleted, exactly once.
+ */
+async function discardRecording(
+ handle: RecorderHandle,
+ deleteRecording: (uri: string) => void | Promise
+): Promise {
+ try {
+ await handle.recorder.stop();
+ } catch {
+ // The session is already gone; the cleanup below is what matters.
+ }
+ await releaseAndDeleteRecording(handle, deleteRecording);
+}
+
+/**
+ * Gateway transcription engine: record with expo-audio, upload the file to
+ * the Kilo gateway, and emit the transcript as a single final result. It
+ * implements the same `VoiceInputNative` protocol as the OS binding so the
+ * controller cannot tell them apart.
+ *
+ * Event protocol per session: `start` → (`stop()`) `transcribing` →
+ * `result`|`error` → `end`. `abort()` ends the session without a result.
+ * Every failure path terminalizes with `error` + `end` so the controller's
+ * session never hangs; the recorder's native object is released on every
+ * path that stops owning it.
+ */
+export function createGatewayVoiceInputEngine(deps: GatewayVoiceInputEngineDeps): VoiceInputNative {
+ const upload = deps.upload ?? transcribeRecording;
+ const { deleteRecording } = deps;
+ const listeners = new Map>();
+ let session: GatewaySession | null = null;
+ let sessionSeq = 0;
+
+ const emit = (
+ event: K,
+ payload: VoiceInputNativeEvent[K]
+ ): void => {
+ const set = listeners.get(event);
+ if (!set) {
+ return;
+ }
+ for (const listener of set) {
+ (listener as (event: VoiceInputNativeEvent[K]) => void)(payload);
+ }
+ };
+
+ /** True once another session (or `abort()`) has taken ownership. */
+ const stale = (current: GatewaySession): boolean => session !== current;
+
+ const endSession = (current: GatewaySession): void => {
+ if (session === current) {
+ session = null;
+ }
+ emit('end', null);
+ };
+
+ const fail = (current: GatewaySession, code: string): void => {
+ emit('error', { error: code, message: `gateway-voice-input: ${code}` });
+ endSession(current);
+ };
+
+ const startPrep = async (current: GatewaySession): Promise => {
+ try {
+ await deps.setAudioMode({ allowsRecording: true });
+ if (stale(current)) {
+ return;
+ }
+ const recorder = deps.createRecorder();
+ const handle: RecorderHandle = { recorder, released: false };
+ try {
+ await recorder.prepareToRecordAsync();
+ } catch {
+ await releaseAndDeleteRecording(handle, deleteRecording);
+ if (!stale(current)) {
+ fail(current, 'client');
+ }
+ return;
+ }
+ if (stale(current)) {
+ await releaseAndDeleteRecording(handle, deleteRecording);
+ return;
+ }
+ // Only hand the recorder to `stop()`/`abort()` once it can actually
+ // record; while preparing, `stop()` sets `stopRequested` instead.
+ current.handle = handle;
+ recorder.record();
+ emit('start', null);
+ if (current.stopRequested) {
+ // `stop()` arrived while we were still preparing: run the upload
+ // path now so the session still terminalizes.
+ emit('transcribing', null);
+ current.handle = null;
+ void finishSession(current, handle);
+ }
+ } catch {
+ if (stale(current)) {
+ return;
+ }
+ if (current.handle) {
+ const handle = current.handle;
+ current.handle = null;
+ await releaseAndDeleteRecording(handle, deleteRecording);
+ }
+ fail(current, 'client');
+ }
+ };
+
+ const finishSession = async (current: GatewaySession, handle: RecorderHandle): Promise => {
+ const { recorder } = handle;
+ try {
+ await recorder.stop();
+ } catch {
+ await releaseAndDeleteRecording(handle, deleteRecording);
+ if (!stale(current)) {
+ fail(current, 'client');
+ }
+ return;
+ }
+ const uri = recorder.uri;
+ releaseRecorder(handle);
+ if (stale(current)) {
+ // A newer session owns the controller; delete the file without emitting.
+ await deleteRecordingFile(deleteRecording, uri);
+ return;
+ }
+ if (uri === null || uri === '') {
+ fail(current, 'client');
+ return;
+ }
+ try {
+ const model = await deps.readModelId();
+ if (stale(current)) {
+ return;
+ }
+ if (model === null) {
+ fail(current, 'gateway-no-model');
+ return;
+ }
+ let authToken: string | null = null;
+ let organizationId: string | null = null;
+ try {
+ authToken = await deps.readAuthToken();
+ organizationId = await deps.readOrganizationId();
+ } catch {
+ if (!stale(current)) {
+ fail(current, 'client');
+ }
+ return;
+ }
+ if (stale(current)) {
+ return;
+ }
+ if (authToken === null || authToken === '') {
+ // Without a token the gateway will answer 401; tell the user to sign in
+ // instead of burning an upload round-trip.
+ fail(current, 'gateway-auth');
+ return;
+ }
+ const controller = new AbortController();
+ current.uploadController = controller;
+ let result: TranscribeRecordingResult | undefined = undefined;
+ try {
+ result = await upload({
+ recordingUri: uri,
+ model,
+ language: current.languageTag,
+ organizationId,
+ authToken,
+ signal: controller.signal,
+ });
+ } catch {
+ if (controller.signal.aborted || stale(current)) {
+ return;
+ }
+ fail(current, 'client');
+ return;
+ }
+ if (controller.signal.aborted) {
+ // `abort()` cancelled the upload and already emitted `end`.
+ return;
+ }
+ if (stale(current)) {
+ return;
+ }
+ const classification = classifyTranscriptionFailure(result);
+ if (classification === 'success' && result.ok) {
+ emit('result', {
+ isFinal: true,
+ results: [{ transcript: result.text, confidence: 1, segments: [] }],
+ });
+ endSession(current);
+ return;
+ }
+ if (classification === 'no-speech') {
+ // Reuse the OS recognizer's empty-recording copy: same user-facing state.
+ fail(current, 'no-speech');
+ return;
+ }
+ // 'unreachable' | 'timeout' | 'model-unavailable' | 'auth' | 'server' |
+ // 'invalid-response' → 'gateway-unreachable' | 'gateway-timeout' |
+ // 'gateway-model-unavailable' | 'gateway-auth' | 'gateway-server' |
+ // 'gateway-invalid-response' — the codes voice-input-state classifies.
+ fail(current, `gateway-${classification}`);
+ } finally {
+ // The upload no longer needs the file; delete it on every terminal path.
+ await deleteRecordingFile(deleteRecording, uri);
+ }
+ };
+
+ return {
+ addListener(event, listener) {
+ const boxed = listener as AnyListener;
+ let set = listeners.get(event);
+ if (!set) {
+ set = new Set();
+ listeners.set(event, set);
+ }
+ set.add(boxed);
+ return {
+ remove: (): void => {
+ listeners.get(event)?.delete(boxed);
+ },
+ };
+ },
+ getPermissions: async (): Promise => {
+ const response = await getRecordingPermissionsAsync();
+ return { granted: response.granted, canAskAgain: response.canAskAgain };
+ },
+ requestPermissions: async (): Promise => {
+ const response = await requestRecordingPermissionsAsync();
+ return { granted: response.granted, canAskAgain: response.canAskAgain };
+ },
+ // The gateway path works wherever the network does — that is the point
+ // of the setting, and it is what makes the mic button appear on devices
+ // whose OS recognizer is missing.
+ isRecognitionAvailable: () => true,
+ // One recording, one upload: no continuous mode, nothing on-device.
+ supportsContinuousRecognition: () => false,
+ supportsOnDevice: () => false,
+ start: (options: VoiceInputNativeStartOptions): void => {
+ // Any previous session's upload (if still in flight) owns its own abort
+ // controller and cannot emit into this one.
+ sessionSeq += 1;
+ const current: GatewaySession = {
+ handle: null,
+ id: sessionSeq,
+ languageTag: options.lang,
+ stopRequested: false,
+ uploadController: null,
+ };
+ session = current;
+ void startPrep(current);
+ },
+ stop: (): void => {
+ const current = session;
+ if (!current) {
+ return;
+ }
+ if (!current.handle) {
+ // Still preparing; `startPrep` runs the upload path when it lands.
+ current.stopRequested = true;
+ return;
+ }
+ // Synchronous first signal: the UI flips to "Transcribing…" before the
+ // recorder stop / upload awaits begin.
+ emit('transcribing', null);
+ const handle = current.handle;
+ current.handle = null;
+ void finishSession(current, handle);
+ },
+ abort: (): void => {
+ const current = session;
+ session = null;
+ if (!current) {
+ return;
+ }
+ current.stopRequested = true;
+ current.uploadController?.abort();
+ const handle = current.handle;
+ current.handle = null;
+ if (handle) {
+ // Discard the recording: end capture, free the native object, delete
+ // the file.
+ void discardRecording(handle, deleteRecording);
+ }
+ emit('end', null);
+ },
+ };
+}
diff --git a/apps/mobile/src/lib/voice-input/gateway/native-gateway-voice-input.test.ts b/apps/mobile/src/lib/voice-input/gateway/native-gateway-voice-input.test.ts
new file mode 100644
index 0000000000..94fb471448
--- /dev/null
+++ b/apps/mobile/src/lib/voice-input/gateway/native-gateway-voice-input.test.ts
@@ -0,0 +1,250 @@
+/* eslint-disable max-classes-per-file -- the File mock sits beside the FakeAudioRecorder mock in one suite. */
+/* eslint-disable require-await, @typescript-eslint/require-await -- the binding's fakes resolve immediately, so they settle without await */
+import { setAudioModeAsync } from 'expo-audio';
+import * as SecureStore from 'expo-secure-store';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ gatewayVoiceInputNative,
+ resolveGatewayTranscriptionModelId,
+} from './native-gateway-voice-input';
+
+vi.mock('@/lib/config', () => ({ API_BASE_URL: 'https://api.example.com' }));
+vi.mock('expo-file-system', () => ({
+ UploadType: { BINARY_CONTENT: 0, MULTIPART: 1 },
+ File: class {
+ uri: string;
+ constructor(uri: string) {
+ this.uri = uri;
+ }
+ },
+}));
+vi.mock('@/lib/auth/token-owner', () => ({
+ getAuthTokenForRequest: vi.fn(async (): Promise => 'token-1'),
+}));
+vi.mock('expo-secure-store', () => ({
+ getItemAsync: vi.fn(async (): Promise => null),
+ setItemAsync: vi.fn(async (): Promise => undefined),
+ deleteItemAsync: vi.fn(async (): Promise => undefined),
+}));
+const storedModel = vi.hoisted(() => ({
+ current: null as { id: string; name: string } | null,
+}));
+vi.mock('./gateway-transcription-preference', () => ({
+ readGatewayTranscriptionModel: vi.fn(() => storedModel.current),
+}));
+const transcriptionModels = vi.hoisted(() => ({
+ fetchTranscriptionModels: vi.fn(async (): Promise<{ id: string; name: string }[]> => []),
+}));
+vi.mock('@/lib/hooks/use-transcription-models', () => ({
+ fetchTranscriptionModels: transcriptionModels.fetchTranscriptionModels,
+}));
+
+const platformMock = vi.hoisted(() => ({ OS: 'ios' as string }));
+vi.mock('react-native', () => ({ Platform: platformMock }));
+
+/** The full nested preset the binding hands to `prepareToRecordAsync`. */
+const HIGH_QUALITY = vi.hoisted(() => ({
+ extension: '.m4a',
+ sampleRate: 44_100,
+ numberOfChannels: 2,
+ bitRate: 128_000,
+ android: { outputFormat: 'mpeg4', audioEncoder: 'aac' },
+ ios: {
+ outputFormat: 'aac ',
+ audioQuality: 127,
+ linearPCMBitDepth: 16,
+ linearPCMIsBigEndian: false,
+ linearPCMIsFloat: false,
+ },
+ web: { mimeType: 'audio/webm', bitsPerSecond: 128_000 },
+}));
+
+/** Options handed to each `new AudioModule.AudioRecorder(...)` plus the receiver. */
+const recorderBox = vi.hoisted(() => ({
+ calls: [] as Record[],
+ instances: [] as { preparedWith: unknown }[],
+}));
+
+// Mirrors the real expo-audio 57 surface: the native `AudioRecorder`
+// constructor takes flattened options, and the package installs a shim on
+// `AudioRecorder.prototype.prepareToRecordAsync` that flattens the shared
+// nested preset per platform before the native prepare call.
+vi.mock('expo-audio', () => {
+ class FakeAudioRecorder {
+ uri: string | null = null;
+ stopped = false;
+ released = false;
+ preparedWith: unknown = null;
+
+ constructor(options: Record) {
+ recorderBox.calls.push(options);
+ recorderBox.instances.push(this);
+ }
+
+ async prepareToRecordAsync(options?: Record): Promise {
+ this.preparedWith = options ?? null;
+ }
+
+ record(): void {
+ this.uri = 'file:///recordings/recording.m4a';
+ }
+
+ async stop(): Promise {
+ this.stopped = true;
+ }
+
+ release(): void {
+ this.released = true;
+ }
+ }
+ return {
+ AudioModule: { AudioRecorder: FakeAudioRecorder },
+ AudioQuality: { MAX: 127 },
+ RecordingPresets: { HIGH_QUALITY },
+ setAudioModeAsync: vi.fn(async (): Promise => undefined),
+ getRecordingPermissionsAsync: vi.fn(async () => ({ granted: true, canAskAgain: false })),
+ requestRecordingPermissionsAsync: vi.fn(async () => ({ granted: true, canAskAgain: false })),
+ };
+});
+
+const START_OPTIONS = {
+ continuous: false,
+ interimResults: true,
+ lang: 'en-US',
+ maxAlternatives: 1,
+ requiresOnDeviceRecognition: false,
+} as const;
+
+/** Let every queued microtask/timer continuation of the engine run. */
+async function flush(): Promise {
+ for (let i = 0; i < 4; i += 1) {
+ // eslint-disable-next-line no-await-in-loop -- each macrotask turn lets the engine's chained continuations settle
+ await new Promise(resolve => {
+ setTimeout(resolve, 0);
+ });
+ }
+}
+
+describe('gatewayVoiceInputNative recorder construction', () => {
+ beforeEach(() => {
+ recorderBox.calls.length = 0;
+ recorderBox.instances.length = 0;
+ platformMock.OS = 'ios';
+ });
+
+ it('pairs allowsRecording with playsInSilentMode so expo-audio iOS accepts the recording mode', async () => {
+ // expo-audio's native iOS validation throws InvalidAudioModeException
+ // when allowsRecording is set while the stored playsInSilentMode is
+ // false — and false is the native default. Without the pair the gateway
+ // recorder never starts on iOS, so every gateway session dies at start.
+ gatewayVoiceInputNative.start(START_OPTIONS);
+ await flush();
+
+ expect(vi.mocked(setAudioModeAsync)).toHaveBeenCalledWith({
+ allowsRecording: true,
+ playsInSilentMode: true,
+ });
+ });
+
+ it('constructs the recorder with the same shared options on iOS and Android', async () => {
+ platformMock.OS = 'ios';
+ gatewayVoiceInputNative.start(START_OPTIONS);
+ await flush();
+
+ platformMock.OS = 'android';
+ gatewayVoiceInputNative.start(START_OPTIONS);
+ await flush();
+
+ expect(recorderBox.calls).toHaveLength(2);
+ // One implementation for both platforms: the construction options are
+ // byte-identical, no per-platform record is applied at construction.
+ expect(recorderBox.calls[0]).toStrictEqual(recorderBox.calls[1]);
+ const [options] = recorderBox.calls;
+ expect(options).toStrictEqual({
+ extension: '.m4a',
+ sampleRate: 44_100,
+ numberOfChannels: 2,
+ bitRate: 128_000,
+ isMeteringEnabled: false,
+ // iOS-only constructor requirement (no Android equivalent field): the
+ // iOS deserializer rejects the construction without a top-level
+ // `audioQuality`; Android ignores the unknown key.
+ audioQuality: 127,
+ });
+ expect(options).not.toHaveProperty('ios');
+ expect(options).not.toHaveProperty('android');
+ expect(options).not.toHaveProperty('web');
+ expect(options).not.toHaveProperty('outputFormat');
+ });
+
+ it.each(['ios', 'android'] as const)(
+ 'hands the full nested preset to prepareToRecordAsync on %s, where the expo-audio shim flattens it',
+ async os => {
+ platformMock.OS = os;
+
+ gatewayVoiceInputNative.start(START_OPTIONS);
+ await flush();
+
+ expect(recorderBox.instances).toHaveLength(1);
+ // Identity: the binding passes the shared preset object through to the
+ // shimmed prototype method, which applies the platform record on both.
+ expect(recorderBox.instances[0]?.preparedWith).toBe(HIGH_QUALITY);
+ }
+ );
+});
+
+describe('resolveGatewayTranscriptionModelId', () => {
+ beforeEach(() => {
+ storedModel.current = null;
+ transcriptionModels.fetchTranscriptionModels.mockReset();
+ vi.mocked(SecureStore.getItemAsync).mockResolvedValue(null);
+ });
+
+ it('returns the stored model without reading the catalogue', async () => {
+ storedModel.current = { id: 'stored-model', name: 'Stored Model' };
+
+ await expect(resolveGatewayTranscriptionModelId()).resolves.toEqual({
+ id: 'stored-model',
+ name: 'Stored Model',
+ });
+ expect(transcriptionModels.fetchTranscriptionModels).not.toHaveBeenCalled();
+ });
+
+ it('scopes the catalogue read to the stored organization', async () => {
+ vi.mocked(SecureStore.getItemAsync).mockResolvedValueOnce('org-42');
+ transcriptionModels.fetchTranscriptionModels.mockResolvedValue([
+ { id: 'org-model', name: 'Org Model' },
+ ]);
+
+ await expect(resolveGatewayTranscriptionModelId()).resolves.toEqual({
+ id: 'org-model',
+ name: 'Org Model',
+ });
+ expect(transcriptionModels.fetchTranscriptionModels).toHaveBeenCalledWith('org-42');
+ });
+
+ it('falls back to the first catalogue entry when none is stored', async () => {
+ transcriptionModels.fetchTranscriptionModels.mockResolvedValue([
+ { id: 'first-model', name: 'First Model' },
+ { id: 'second-model', name: 'Second Model' },
+ ]);
+
+ await expect(resolveGatewayTranscriptionModelId()).resolves.toEqual({
+ id: 'first-model',
+ name: 'First Model',
+ });
+ });
+
+ it('reads an empty catalogue as no model', async () => {
+ transcriptionModels.fetchTranscriptionModels.mockResolvedValue([]);
+
+ await expect(resolveGatewayTranscriptionModelId()).resolves.toBeNull();
+ });
+
+ it('reads an unreachable catalogue as no model', async () => {
+ transcriptionModels.fetchTranscriptionModels.mockRejectedValue(new Error('offline'));
+
+ await expect(resolveGatewayTranscriptionModelId()).resolves.toBeNull();
+ });
+});
diff --git a/apps/mobile/src/lib/voice-input/gateway/native-gateway-voice-input.ts b/apps/mobile/src/lib/voice-input/gateway/native-gateway-voice-input.ts
new file mode 100644
index 0000000000..a630a71b02
--- /dev/null
+++ b/apps/mobile/src/lib/voice-input/gateway/native-gateway-voice-input.ts
@@ -0,0 +1,124 @@
+import { AudioModule, AudioQuality, RecordingPresets, setAudioModeAsync } from 'expo-audio';
+import { File } from 'expo-file-system';
+import * as SecureStore from 'expo-secure-store';
+
+import { getAuthTokenForRequest } from '@/lib/auth/token-owner';
+import { fetchTranscriptionModels } from '@/lib/hooks/use-transcription-models';
+import { ORGANIZATION_STORAGE_KEY } from '@/lib/storage-keys';
+
+import { createGatewayVoiceInputEngine, type GatewayRecorder } from './gateway-voice-input-engine';
+import {
+ type GatewayTranscriptionModel,
+ readGatewayTranscriptionModel,
+} from './gateway-transcription-preference';
+
+const HIGH_QUALITY = RecordingPresets.HIGH_QUALITY;
+
+/** The organization scope the voice flow reads and uploads under; null is personal. */
+async function readStoredOrganizationId(): Promise {
+ const value = await SecureStore.getItemAsync(ORGANIZATION_STORAGE_KEY);
+ return value;
+}
+
+/**
+ * The model the gateway engine transcribes with: the stored choice, else the
+ * first model the gateway catalogue offers, else null. A catalogue that
+ * cannot be reached reads as "no model", which surfaces the actionable picker
+ * message on the first dictation instead of an upload that must fail.
+ */
+export async function resolveGatewayTranscriptionModelId(): Promise {
+ const stored = readGatewayTranscriptionModel();
+ if (stored !== null) {
+ return stored;
+ }
+ try {
+ // Scope the catalogue read to the selected organization: the upload that
+ // follows carries the same organization header, so an unscoped default
+ // could pick a model the scoped upload then rejects.
+ const organizationId = await readStoredOrganizationId();
+ const models = await fetchTranscriptionModels(organizationId ?? undefined);
+ return models[0] ?? null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * The recorder is constructed with the preset's common keys only and receives
+ * the full preset at prepare time: expo-audio installs a cross-platform shim
+ * on `AudioRecorder.prototype.prepareToRecordAsync` (its internal
+ * `createRecordingOptions`, not exported) that flattens the shared
+ * `RecordingPresets` shape — common keys plus the active platform's `ios` or
+ * `android` record — before the native call, identically on both platforms.
+ * The one constructor asymmetry is `audioQuality`: the iOS deserializer
+ * requires it top-level when constructing `AudioRecorder` (an iOS-only
+ * field with no Android equivalent — the Android record converter ignores the
+ * unknown key). Its value only configures the transient pre-prepare recorder;
+ * the prepare call rebuilds the recorder from the preset's real platform
+ * record.
+ */
+// The type is intentionally inferred: the native constructor takes the
+// flattened options shape, whose top-level `audioQuality` the exported
+// `RecordingOptions` type keeps nested under `ios` only.
+const RECORDER_BOOTSTRAP_OPTIONS = {
+ extension: HIGH_QUALITY.extension,
+ sampleRate: HIGH_QUALITY.sampleRate,
+ numberOfChannels: HIGH_QUALITY.numberOfChannels,
+ bitRate: HIGH_QUALITY.bitRate,
+ isMeteringEnabled: HIGH_QUALITY.isMeteringEnabled ?? false,
+ audioQuality: AudioQuality.MAX,
+};
+
+/**
+ * The gateway half of the voice-input native binding: expo-audio's recorder,
+ * the transcription client, and the persisted preference/auth reads, wired
+ * into the engine. expo-audio 57 exports recording only through the
+ * `useAudioRecorder` hook, but the hook is a thin wrapper over the
+ * constructible `AudioModule.AudioRecorder` shared object — so the engine gets
+ * an imperative factory here (with explicit `release()`) and no React provider
+ * is needed at the app root.
+ */
+export const gatewayVoiceInputNative = createGatewayVoiceInputEngine({
+ setAudioMode: async mode => {
+ // expo-audio's iOS validation (AudioUtils.validateAudioMode) throws
+ // InvalidAudioModeException when allowsRecording is set while the stored
+ // playsInSilentMode is false — and the native default is false. Pair the
+ // two here so the gateway recorder can actually record on iOS; the field
+ // is iOS-only and Android ignores it.
+ await setAudioModeAsync({ ...mode, playsInSilentMode: true });
+ },
+ createRecorder: (): GatewayRecorder => {
+ const recorder = new AudioModule.AudioRecorder(RECORDER_BOOTSTRAP_OPTIONS);
+ return {
+ get uri(): string | null {
+ return recorder.uri;
+ },
+ // The shimmed `prepareToRecordAsync` flattens the nested preset into the
+ // active platform's native options shape before the native call.
+ prepareToRecordAsync: async () => {
+ await recorder.prepareToRecordAsync(HIGH_QUALITY);
+ },
+ record: () => {
+ recorder.record();
+ },
+ stop: async () => {
+ await recorder.stop();
+ },
+ release: () => {
+ recorder.release();
+ },
+ };
+ },
+ readModelId: resolveGatewayTranscriptionModelId,
+ readAuthToken: getAuthTokenForRequest,
+ readOrganizationId: readStoredOrganizationId,
+ deleteRecording: (uri: string) => {
+ // `release()` frees the native object, not the file: delete the recording
+ // so dictation does not accumulate audio on disk. The modern File API
+ // deletes synchronously; a missing file is a no-op.
+ const file = new File(uri);
+ if (file.exists) {
+ file.delete();
+ }
+ },
+});
diff --git a/apps/mobile/src/lib/voice-input/native-voice-input.ts b/apps/mobile/src/lib/voice-input/native-voice-input.ts
index 612e183a56..6f98d25262 100644
--- a/apps/mobile/src/lib/voice-input/native-voice-input.ts
+++ b/apps/mobile/src/lib/voice-input/native-voice-input.ts
@@ -5,6 +5,13 @@ import {
type ExpoSpeechRecognitionResultEvent,
} from 'expo-speech-recognition';
+import {
+ isGatewayTranscriptionEnabled,
+ subscribeToGatewayTranscriptionEnabled,
+} from './gateway/gateway-transcription-preference';
+import { gatewayVoiceInputNative } from './gateway/native-gateway-voice-input';
+import { createSelectingVoiceInputNative } from './voice-input-engine-select';
+import { resolveVoiceInputEngineName } from './voice-input-engine-mode';
import {
createVoiceInputController,
type VoiceInputNative,
@@ -39,7 +46,7 @@ function bindListener(
module: ExpoSpeechRecognitionModuleType,
event: K,
listener: (event: VoiceInputNativeEvent[K]) => void
-): { remove(): void } {
+) {
// The native module's addListener is generic over its full event map, so
// when called with our narrower event union the listener parameter is
// widened to an intersection of every native listener type. Per-event
@@ -64,6 +71,11 @@ function bindListener(
listener as (event: ExpoSpeechRecognitionErrorEvent) => void
);
}
+ if (event === 'transcribing') {
+ // The OS recognizer has no transcribing phase; only the gateway engine
+ // emits it, and the selector registers listeners on the chosen engine.
+ return { remove: (): void => undefined };
+ }
return module.addListener('end', listener as (event: null) => void);
}
@@ -99,4 +111,19 @@ const native: VoiceInputNative = {
},
};
-export const voiceInputController = createVoiceInputController(native);
+// Exactly one engine runs per session, chosen by the live gateway switch:
+// off sends every dictation to the OS recogniser, on sends every dictation
+// to the Kilo gateway. The OS binding above is the `os` half of the selector.
+const selectingNative = createSelectingVoiceInputNative(
+ { os: native, gateway: gatewayVoiceInputNative },
+ () => resolveVoiceInputEngineName(isGatewayTranscriptionEnabled())
+);
+
+export const voiceInputController = createVoiceInputController(selectingNative);
+
+// Availability is captured once at controller construction, so a gateway
+// toggle must recompute it: on an OS-unavailable device, enabling gateway
+// transcription has to surface the mic button without an app restart.
+subscribeToGatewayTranscriptionEnabled(() => {
+ voiceInputController.refreshAvailability();
+});
diff --git a/apps/mobile/src/lib/voice-input/use-voice-input-actions.ts b/apps/mobile/src/lib/voice-input/use-voice-input-actions.ts
index 295024bb6d..3ce7b36992 100644
--- a/apps/mobile/src/lib/voice-input/use-voice-input-actions.ts
+++ b/apps/mobile/src/lib/voice-input/use-voice-input-actions.ts
@@ -1,5 +1,6 @@
import { AccessibilityInfo, Alert, Linking, Platform } from 'react-native';
import * as Haptics from 'expo-haptics';
+import { router } from 'expo-router';
import { ExpoSpeechRecognitionModule } from 'expo-speech-recognition';
import { toast } from 'sonner-native';
@@ -28,6 +29,7 @@ import {
import { resolveVoiceInputRecognitionMode } from './voice-input-recognition-mode';
import { readVoiceNetworkConsent, writeVoiceNetworkConsent } from './voice-network-consent';
import { resolveOwnerVoiceInputView } from './voice-input-view-state';
+import { isGatewayTranscriptionEnabled } from './gateway/gateway-transcription-preference';
type VoiceInputControllerLike = {
abort: (owner?: string) => Promise;
@@ -75,16 +77,42 @@ export function runVoiceInputListeningFeedback(
}
}
+/**
+ * One stable toast id for every voice-input message. sonner-native updates a
+ * visible toast in place when a new toast carries the same id, so two errors
+ * in a row never render as two stacked toasts whose copy overlaps. A
+ * dismiss-then-add pair would animate both at once (the outgoing toast still
+ * on screen as the new one lands), which is why the replacement rides the id,
+ * not a dismiss.
+ */
+const VOICE_INPUT_TOAST_ID = 'voice-input-feedback';
+
export function showFeedback(feedback: VoiceInputFeedback): void {
const presentation = resolveVoiceInputFeedbackPresentation(feedback);
if (presentation.kind === 'alert') {
+ // The alert is the message now; clear the toast channel with it.
+ toast.dismiss(VOICE_INPUT_TOAST_ID);
+ if (presentation.destination === 'transcription-model-picker') {
+ Alert.alert(presentation.title, presentation.message, [
+ { text: i18n.t('common.cancel'), style: 'cancel' },
+ {
+ text: i18n.t('transcriptionModel.title'),
+ onPress: () => {
+ router.push('/(app)/transcription-model-picker');
+ },
+ },
+ ]);
+ return;
+ }
Alert.alert(presentation.title, presentation.message, [
{ text: i18n.t('common.cancel'), style: 'cancel' },
{ text: i18n.t('common.openSettings'), onPress: () => void Linking.openSettings() },
]);
return;
}
- toast.error(presentation.message);
+ // One stable toast id so a later message replaces an earlier one in place:
+ // the user reads exactly one voice-input message at a time.
+ toast.error(presentation.message, { id: VOICE_INPUT_TOAST_ID });
}
export function shouldAbortVoiceInputForOwner(
@@ -118,6 +146,12 @@ export function createVoiceInputActions(config: VoiceInputActionsConfig): VoiceI
const snapshot = controller.getSnapshot();
const view = resolveOwnerVoiceInputView(snapshot, owner);
+ if (view.isActive && snapshot.status === 'transcribing') {
+ // The upload can hang; the tap cancels it instead of starting a new one.
+ await controller.abort(owner);
+ return;
+ }
+
if (view.isActive && snapshot.status === 'listening') {
void fireHaptic(Haptics.ImpactFeedbackStyle.Medium);
await controller.stop(owner);
@@ -128,18 +162,7 @@ export function createVoiceInputActions(config: VoiceInputActionsConfig): VoiceI
return;
}
- const supportsOnDeviceByService = controller.supportsOnDevice();
- const userId = getUserId();
- const consent = userId ? await readVoiceNetworkConsent(userId) : 'unset';
const languageTag = await resolveVoiceInputStartLanguageTag(i18n.language);
- // The service-level check alone is not enough: on-device recognition also
- // needs the offline model for the resolved language. `requiresOnDeviceRecognition`
- // without it fails on every attempt (`language-not-supported`) — that is
- // the German-locale bug — so the mode gate refines the service check with
- // the per-language installation state.
- const supportsOnDevice =
- supportsOnDeviceByService && (await isVoiceInputLanguageInstalledOnDevice(languageTag));
- const mode = resolveVoiceInputRecognitionMode(supportsOnDevice, consent);
const startWith = async (requiresOnDeviceRecognition: boolean): Promise => {
const startOptions: VoiceInputStartOptions = {
@@ -153,6 +176,29 @@ export function createVoiceInputActions(config: VoiceInputActionsConfig): VoiceI
await controller.start(startOptions);
};
+ if (isGatewayTranscriptionEnabled()) {
+ // Gateway mode: the switch itself is the consent to send the recording
+ // to the Kilo gateway, so no OS network-recognition disclosure applies.
+ // The chosen model is resolved by the engine (the stored choice, else
+ // the first model the gateway catalogue offers).
+ await startWith(false);
+ return;
+ }
+
+ // Device mode: the OS recogniser runs, so the consent flow below decides
+ // the recognition mode.
+ const supportsOnDeviceByService = controller.supportsOnDevice();
+ const userId = getUserId();
+ const consent = userId ? await readVoiceNetworkConsent(userId) : 'unset';
+ // The service-level check alone is not enough: on-device recognition also
+ // needs the offline model for the resolved language. `requiresOnDeviceRecognition`
+ // without it fails on every attempt (`language-not-supported`) — that is
+ // the German-locale bug — so the mode gate refines the service check with
+ // the per-language installation state.
+ const supportsOnDevice =
+ supportsOnDeviceByService && (await isVoiceInputLanguageInstalledOnDevice(languageTag));
+ const mode = resolveVoiceInputRecognitionMode(supportsOnDevice, consent);
+
if (mode === 'on-device') {
await startWith(true);
return;
diff --git a/apps/mobile/src/lib/voice-input/use-voice-input-feedback.test.ts b/apps/mobile/src/lib/voice-input/use-voice-input-feedback.test.ts
index 860c83cf35..bde53f4ab6 100644
--- a/apps/mobile/src/lib/voice-input/use-voice-input-feedback.test.ts
+++ b/apps/mobile/src/lib/voice-input/use-voice-input-feedback.test.ts
@@ -6,7 +6,10 @@ const hapticsMock = vi.hoisted(() => ({ impactAsync: vi.fn().mockResolvedValue(u
const accessibilityMock = vi.hoisted(() => ({ announceForAccessibility: vi.fn() }));
const alertMock = vi.hoisted(() => ({ alert: vi.fn() }));
const linkingMock = vi.hoisted(() => ({ openSettings: vi.fn() }));
-const toastMock = vi.hoisted(() => ({ error: vi.fn() }));
+const toastMock = vi.hoisted(() => ({
+ error: vi.fn(),
+ dismiss: vi.fn(),
+}));
vi.mock('expo-haptics', () => ({
ImpactFeedbackStyle: { Light: 'light', Medium: 'medium' },
@@ -20,6 +23,11 @@ vi.mock('expo-speech-recognition', () => ({
}));
vi.mock('sonner-native', () => ({ toast: toastMock }));
vi.mock('expo-secure-store', () => ({}));
+vi.mock('expo-router', () => ({ router: { push: vi.fn() } }));
+vi.mock('./gateway/gateway-transcription-preference', () => ({
+ isGatewayTranscriptionEnabled: () => false,
+ readGatewayTranscriptionModel: () => null,
+}));
vi.mock('react-native', () => ({
AccessibilityInfo: accessibilityMock,
Alert: alertMock,
@@ -64,7 +72,8 @@ describe('voice input feedback side effects', () => {
expect(alertMock.alert).not.toHaveBeenCalled();
expect(toastMock.error).toHaveBeenCalledWith(
- 'No speech detected. Tap the microphone to try again.'
+ 'No speech detected. Tap the microphone to try again.',
+ { id: 'voice-input-feedback' }
);
});
diff --git a/apps/mobile/src/lib/voice-input/use-voice-input.test.ts b/apps/mobile/src/lib/voice-input/use-voice-input.test.ts
index 976991d13e..fb855f0dd6 100644
--- a/apps/mobile/src/lib/voice-input/use-voice-input.test.ts
+++ b/apps/mobile/src/lib/voice-input/use-voice-input.test.ts
@@ -36,6 +36,8 @@ const platformMock = vi.hoisted(() => ({ OS: 'ios' }));
const toastMock = vi.hoisted(() => ({
error: vi.fn(),
+ info: vi.fn(),
+ dismiss: vi.fn(),
success: vi.fn(),
}));
@@ -50,6 +52,15 @@ const getSupportedLocalesMock = vi.hoisted(() =>
})
);
+const routerMock = vi.hoisted(() => ({
+ push: vi.fn(),
+}));
+
+const gatewayPreferenceMock = vi.hoisted(() => ({
+ isGatewayTranscriptionEnabled: vi.fn<() => boolean>(() => false),
+ readGatewayTranscriptionModel: vi.fn<() => { id: string; name: string } | null>(() => null),
+}));
+
const triggerOfflineModelDownloadMock = vi.hoisted(() =>
vi
.fn<(options: { locale: string }) => Promise<{ status: string; message: string }>>()
@@ -65,6 +76,12 @@ vi.mock('expo-localization', () => ({
getLocales: localizationMock.getLocales,
}));
+vi.mock('expo-router', () => ({
+ router: routerMock,
+}));
+
+vi.mock('./gateway/gateway-transcription-preference', () => gatewayPreferenceMock);
+
vi.mock('expo-speech-recognition', () => ({
ExpoSpeechRecognitionModule: {
getSupportedLocales: getSupportedLocalesMock,
@@ -181,6 +198,8 @@ describe('useVoiceInput integration', () => {
locales: ['en-US', 'nl-NL'],
installedLocales: ['en-US'],
});
+ gatewayPreferenceMock.isGatewayTranscriptionEnabled.mockReturnValue(false);
+ gatewayPreferenceMock.readGatewayTranscriptionModel.mockReturnValue(null);
__resetVoiceInputLanguageTagCacheForTests();
});
@@ -283,6 +302,60 @@ describe('useVoiceInput integration', () => {
expect(mockController.start).not.toHaveBeenCalled();
});
+ it('gateway mode: starts without the consent disclosure and ignores any stored model', async () => {
+ const { actions } = buildActions({ userId: 'user-1' });
+ mockController.setSnapshot(idleSnapshot());
+ gatewayPreferenceMock.isGatewayTranscriptionEnabled.mockReturnValue(true);
+ voiceNetworkConsentMock.readVoiceNetworkConsent.mockResolvedValue('unset');
+
+ await actions.toggle();
+
+ // The gateway engine resolves the model itself (the stored choice,
+ // else the first catalogue entry), so the action layer starts the
+ // session without a model precondition.
+ expect(mockController.start).toHaveBeenCalledTimes(1);
+ expect(mockController.start.mock.calls[0]?.[0]?.requiresOnDeviceRecognition).toBe(false);
+ expect(alertMock.alert).not.toHaveBeenCalled();
+ expect(toastMock.error).not.toHaveBeenCalled();
+ expect(voiceNetworkConsentMock.readVoiceNetworkConsent).not.toHaveBeenCalled();
+ });
+
+ it('gateway mode while listening: stops the session through the controller', async () => {
+ const { actions, owner } = buildActions();
+ mockController.setSnapshot(activeSnapshot(owner, 'listening'));
+ gatewayPreferenceMock.isGatewayTranscriptionEnabled.mockReturnValue(true);
+
+ await actions.toggle();
+
+ expect(hapticsMock.impactAsync).toHaveBeenCalledWith('medium');
+ expect(mockController.stop).toHaveBeenCalledWith(owner);
+ expect(mockController.start).not.toHaveBeenCalled();
+ });
+
+ it('gateway mode while transcribing: aborts the hung upload instead of starting', async () => {
+ const { actions, owner } = buildActions();
+ mockController.setSnapshot(activeSnapshot(owner, 'transcribing'));
+ gatewayPreferenceMock.isGatewayTranscriptionEnabled.mockReturnValue(true);
+
+ await actions.toggle();
+
+ expect(mockController.abort).toHaveBeenCalledWith(owner);
+ expect(mockController.start).not.toHaveBeenCalled();
+ expect(mockController.stop).not.toHaveBeenCalled();
+ });
+
+ it('device mode: keeps the OS consent flow', async () => {
+ const { actions } = buildActions({ userId: 'user-1' });
+ mockController.setSnapshot(idleSnapshot());
+ mockController.supportsOnDevice.mockReturnValue(false);
+ voiceNetworkConsentMock.readVoiceNetworkConsent.mockResolvedValue('unset');
+
+ await actions.toggle();
+
+ expect(mockController.start).not.toHaveBeenCalled();
+ expect(alertMock.alert).toHaveBeenCalledTimes(1);
+ });
+
it('starts with on-device recognition when on-device is supported regardless of consent', async () => {
const { actions } = buildActions({ userId: 'user-1' });
mockController.setSnapshot(idleSnapshot());
diff --git a/apps/mobile/src/lib/voice-input/voice-input-controller-lifecycle.test.ts b/apps/mobile/src/lib/voice-input/voice-input-controller-lifecycle.test.ts
index 0fb3b487ae..67fad97200 100644
--- a/apps/mobile/src/lib/voice-input/voice-input-controller-lifecycle.test.ts
+++ b/apps/mobile/src/lib/voice-input/voice-input-controller-lifecycle.test.ts
@@ -6,6 +6,7 @@ import {
makeStartOptions,
type VoiceInputNativeHarness,
} from './voice-input-controller-test-helpers';
+import { type VoiceInputFeedback } from './voice-input-state';
async function isPending(promise: Promise): Promise {
const sentinel = Symbol('pending');
@@ -82,12 +83,7 @@ describe('createVoiceInputController - lifecycle', () => {
it('ignores duplicate or late events after terminalization', async () => {
const { harness, controller } = build();
const drafts: string[] = [];
- const feedback: {
- action: 'none' | 'open-settings';
- availability: 'available' | 'unavailable';
- message: string;
- retryable: boolean;
- }[] = [];
+ const feedback: VoiceInputFeedback[] = [];
await controller.start(
makeStartOptions({
owner: 'A',
@@ -174,12 +170,7 @@ describe('createVoiceInputController - lifecycle', () => {
harness.mocks.stop = ((): void => {
throw new Error('boom');
}) as typeof harness.mocks.stop;
- const feedback: {
- action: 'none' | 'open-settings';
- availability: 'available' | 'unavailable';
- message: string;
- retryable: boolean;
- }[] = [];
+ const feedback: VoiceInputFeedback[] = [];
await controller.start(
makeStartOptions({
onFeedback: (f): void => {
@@ -265,12 +256,7 @@ describe('createVoiceInputController - lifecycle', () => {
it('initiates an expected abort when active, terminalizes safely, and resolves', async () => {
const { harness, controller } = build();
- const feedback: {
- action: 'none' | 'open-settings';
- availability: 'available' | 'unavailable';
- message: string;
- retryable: boolean;
- }[] = [];
+ const feedback: VoiceInputFeedback[] = [];
await controller.start(
makeStartOptions({
owner: 'A',
diff --git a/apps/mobile/src/lib/voice-input/voice-input-controller-test-helpers.ts b/apps/mobile/src/lib/voice-input/voice-input-controller-test-helpers.ts
index 01708166cd..b2bf53f841 100644
--- a/apps/mobile/src/lib/voice-input/voice-input-controller-test-helpers.ts
+++ b/apps/mobile/src/lib/voice-input/voice-input-controller-test-helpers.ts
@@ -8,6 +8,7 @@ import {
type VoiceInputNativeStartOptions,
type VoiceInputStartOptions,
} from './voice-input-controller';
+import { type VoiceInputFeedback } from './voice-input-state';
type AnyListener = (event: VoiceInputNativeEvent[keyof VoiceInputNativeEvent]) => void;
@@ -173,25 +174,10 @@ export function makeStartOptions(
}
export function recordFeedback(): {
- feedback: {
- action: 'none' | 'open-settings';
- availability: 'available' | 'unavailable';
- message: string;
- retryable: boolean;
- }[];
- onFeedback: (fb: {
- action: 'none' | 'open-settings';
- availability: 'available' | 'unavailable';
- message: string;
- retryable: boolean;
- }) => void;
+ feedback: VoiceInputFeedback[];
+ onFeedback: (fb: VoiceInputFeedback) => void;
} {
- const feedback: {
- action: 'none' | 'open-settings';
- availability: 'available' | 'unavailable';
- message: string;
- retryable: boolean;
- }[] = [];
+ const feedback: VoiceInputFeedback[] = [];
return {
feedback,
onFeedback: (fb): void => {
diff --git a/apps/mobile/src/lib/voice-input/voice-input-controller-transcribing.test.ts b/apps/mobile/src/lib/voice-input/voice-input-controller-transcribing.test.ts
new file mode 100644
index 0000000000..190d6d46cd
--- /dev/null
+++ b/apps/mobile/src/lib/voice-input/voice-input-controller-transcribing.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { createVoiceInputController } from './voice-input-controller';
+import {
+ createVoiceInputNativeHarness,
+ makeStartOptions,
+ recordFeedback,
+} from './voice-input-controller-test-helpers';
+
+describe('voice-input controller - transcribing status', () => {
+ it('sets status to transcribing on the event and terminalizes on end', async () => {
+ const harness = createVoiceInputNativeHarness();
+ const controller = createVoiceInputController(harness.native);
+ const statuses: string[] = [];
+ const unsubscribe = controller.subscribe(snapshot => {
+ statuses.push(snapshot.status);
+ });
+
+ await controller.start(makeStartOptions());
+ harness.emit('start', null);
+ expect(controller.getSnapshot().status).toBe('listening');
+
+ const settled = controller.stop('owner1');
+ // `stop()` sets 'stopping'; the engine's synchronous `transcribing`
+ // emission must take over so the row shows the upload phase.
+ harness.emit('transcribing', null);
+ expect(controller.getSnapshot().status).toBe('transcribing');
+
+ harness.emit('result', {
+ isFinal: true,
+ results: [{ transcript: 'hi', confidence: 1, segments: [] }],
+ });
+ harness.emit('end', null);
+ await expect(settled).resolves.toBe(true);
+
+ expect(controller.getSnapshot().status).toBe('idle');
+ expect(statuses).toContain('transcribing');
+ unsubscribe();
+ });
+
+ it('terminalizes as a failure when the engine errors after transcribing', async () => {
+ const harness = createVoiceInputNativeHarness();
+ const controller = createVoiceInputController(harness.native);
+ const { feedback, onFeedback } = recordFeedback();
+
+ await controller.start(makeStartOptions({ onFeedback }));
+ harness.emit('start', null);
+ const settled = controller.stop('owner1');
+ harness.emit('transcribing', null);
+ harness.emit('error', { error: 'gateway-unreachable', message: 'boom' });
+ harness.emit('end', null);
+
+ await expect(settled).resolves.toBe(false);
+ expect(feedback).toHaveLength(1);
+ expect(feedback[0]?.retryable).toBe(true);
+ expect(controller.getSnapshot().status).toBe('idle');
+ });
+
+ it('ignores a transcribing event once the session is terminalized', async () => {
+ const harness = createVoiceInputNativeHarness();
+ const controller = createVoiceInputController(harness.native);
+
+ await controller.start(makeStartOptions());
+ harness.emit('start', null);
+ const settled = controller.stop('owner1');
+ harness.emit('transcribing', null);
+ harness.emit('end', null);
+ await settled;
+ const notify = vi.fn((): void => undefined);
+ controller.subscribe(notify);
+
+ // A late engine emission must not resurrect a status.
+ harness.emit('transcribing', null);
+ expect(controller.getSnapshot().status).toBe('idle');
+ expect(notify).not.toHaveBeenCalled();
+ });
+});
+
+describe('voice-input controller - refreshAvailability', () => {
+ it('flips availability when the native answer changes and notifies subscribers', () => {
+ const harness = createVoiceInputNativeHarness({ isAvailable: false });
+ const controller = createVoiceInputController(harness.native);
+ expect(controller.getSnapshot().availability).toBe('unavailable');
+
+ const seen: string[] = [];
+ const unsubscribe = controller.subscribe(snapshot => {
+ seen.push(snapshot.availability);
+ });
+
+ // Gateway transcription enabled on an OS-unavailable device: the mic
+ // button must appear without an app restart.
+ harness.controls.isAvailable = true;
+ controller.refreshAvailability();
+ expect(controller.getSnapshot().availability).toBe('available');
+ expect(seen).toEqual(['available']);
+
+ // Unchanged value notifies nobody.
+ controller.refreshAvailability();
+ expect(seen).toEqual(['available']);
+ unsubscribe();
+ });
+
+ it('is exposed on the returned controller object', () => {
+ const harness = createVoiceInputNativeHarness();
+ const controller = createVoiceInputController(harness.native);
+ expect(typeof controller.refreshAvailability).toBe('function');
+ });
+});
diff --git a/apps/mobile/src/lib/voice-input/voice-input-controller.ts b/apps/mobile/src/lib/voice-input/voice-input-controller.ts
index 4130857d59..09df8b8ffb 100644
--- a/apps/mobile/src/lib/voice-input/voice-input-controller.ts
+++ b/apps/mobile/src/lib/voice-input/voice-input-controller.ts
@@ -26,6 +26,8 @@ import {
export type VoiceInputNativeEvent = {
start: null;
+ /** Emitted when the recording stopped and the upload/processing phase began. */
+ transcribing: null;
result: ExpoSpeechRecognitionResultEvent;
nomatch: null;
error: { code?: number; error: string; message: string };
@@ -104,6 +106,23 @@ export function createVoiceInputController(native: VoiceInputNative) {
}
};
+ /**
+ * Recompute `availability` from the current native binding and notify on
+ * change. The initial value is captured once at construction, so a change
+ * of the native binding's answer (e.g. gateway transcription mode toggled
+ * on for a device whose OS recognizer is unavailable) is invisible until
+ * this runs.
+ */
+ const refreshAvailability = (): void => {
+ const next: VoiceInputAvailability = native.isRecognitionAvailable()
+ ? 'available'
+ : 'unavailable';
+ if (next !== availability) {
+ availability = next;
+ notify();
+ }
+ };
+
const reportFeedback = (
feedback: VoiceInputFeedback,
onFeedback: (feedback: VoiceInputFeedback) => void
@@ -326,6 +345,7 @@ export function createVoiceInputController(native: VoiceInputNative) {
abort,
dispose,
getSnapshot: () => snapshot,
+ refreshAvailability,
start,
stop,
subscribe,
diff --git a/apps/mobile/src/lib/voice-input/voice-input-engine-mode.ts b/apps/mobile/src/lib/voice-input/voice-input-engine-mode.ts
new file mode 100644
index 0000000000..f4038c561b
--- /dev/null
+++ b/apps/mobile/src/lib/voice-input/voice-input-engine-mode.ts
@@ -0,0 +1,16 @@
+/**
+ * Which engine runs a voice session. The user picks exactly one in
+ * Preferences, and there is no fallback: the chosen engine owns the session
+ * for its whole life.
+ *
+ * - `os` — the operating system's speech recogniser.
+ * - `gateway` — the Kilo gateway transcription engine.
+ */
+export type VoiceInputEngineName = 'os' | 'gateway';
+
+/** Map the gateway-transcription preference onto the engine that runs. */
+export function resolveVoiceInputEngineName(
+ gatewayTranscriptionEnabled: boolean
+): VoiceInputEngineName {
+ return gatewayTranscriptionEnabled ? 'gateway' : 'os';
+}
diff --git a/apps/mobile/src/lib/voice-input/voice-input-engine-select.test.ts b/apps/mobile/src/lib/voice-input/voice-input-engine-select.test.ts
new file mode 100644
index 0000000000..cd501d550c
--- /dev/null
+++ b/apps/mobile/src/lib/voice-input/voice-input-engine-select.test.ts
@@ -0,0 +1,206 @@
+/* eslint-disable require-await, @typescript-eslint/require-await -- the fake engines resolve immediately, so they settle without await */
+import { describe, expect, it, type Mock, vi } from 'vitest';
+
+import {
+ type VoiceInputNative,
+ type VoiceInputNativeEvent,
+ type VoiceInputNativePermission,
+ type VoiceInputNativeStartOptions,
+} from './voice-input-controller';
+import { createSelectingVoiceInputNative } from './voice-input-engine-select';
+import { type VoiceInputEngineName } from './voice-input-engine-mode';
+
+const START_OPTIONS: VoiceInputNativeStartOptions = {
+ continuous: false,
+ interimResults: true,
+ lang: 'en-US',
+ maxAlternatives: 1,
+ requiresOnDeviceRecognition: false,
+};
+
+const GRANTED: VoiceInputNativePermission = { granted: true, canAskAgain: true };
+
+type FakeNative = VoiceInputNative & {
+ calls: string[];
+ emit(event: keyof VoiceInputNativeEvent, payload: unknown): void;
+};
+
+function makeEngine(
+ name: VoiceInputEngineName,
+ permission: VoiceInputNativePermission = GRANTED
+): FakeNative {
+ const calls: string[] = [];
+ const listeners = new Map>();
+ return {
+ calls,
+ addListener(event, listener) {
+ calls.push(`addListener:${event}`);
+ let set = listeners.get(event);
+ if (!set) {
+ set = new Set();
+ listeners.set(event, set);
+ }
+ const boxed = listener as unknown as Mock;
+ set.add(boxed);
+ return {
+ remove: (): void => {
+ listeners.get(event)?.delete(boxed);
+ },
+ };
+ },
+ getPermissions: vi.fn(async () => {
+ calls.push('getPermissions');
+ return permission;
+ }),
+ requestPermissions: vi.fn(async () => {
+ calls.push('requestPermissions');
+ return permission;
+ }),
+ isRecognitionAvailable: vi.fn(() => {
+ calls.push('isRecognitionAvailable');
+ return true;
+ }),
+ supportsContinuousRecognition: vi.fn(() => {
+ calls.push('supportsContinuousRecognition');
+ return name === 'os';
+ }),
+ supportsOnDevice: vi.fn(() => {
+ calls.push('supportsOnDevice');
+ return name === 'os';
+ }),
+ start: vi.fn((options: VoiceInputNativeStartOptions) => {
+ calls.push(`start:${options.lang}`);
+ }),
+ stop: vi.fn(() => {
+ calls.push('stop');
+ }),
+ abort: vi.fn(() => {
+ calls.push('abort');
+ }),
+ emit(event, payload): void {
+ for (const listener of listeners.get(event) ?? []) {
+ listener(payload);
+ }
+ },
+ };
+}
+
+function build(initial: VoiceInputEngineName) {
+ let engine = initial;
+ const os = makeEngine('os');
+ const gateway = makeEngine('gateway');
+ const native = createSelectingVoiceInputNative({ os, gateway }, () => engine);
+ return {
+ native,
+ os,
+ gateway,
+ setEngine: (next: VoiceInputEngineName): void => {
+ engine = next;
+ },
+ };
+}
+
+function sessionCalls(engine: FakeNative): string[] {
+ return engine.calls.filter(call => /^(start:|stop$|abort$)/.test(call));
+}
+
+describe('createSelectingVoiceInputNative', () => {
+ it('starts only the device engine in device mode', () => {
+ const { native, os, gateway } = build('os');
+ native.start(START_OPTIONS);
+
+ expect(sessionCalls(os)).toEqual(['start:en-US']);
+ expect(sessionCalls(gateway)).toEqual([]);
+ // No gateway listener is attached either: device mode never touches it.
+ expect(gateway.calls).toEqual([]);
+ });
+
+ it('starts only the gateway engine in gateway mode', () => {
+ const { native, os, gateway } = build('gateway');
+ native.start(START_OPTIONS);
+
+ expect(sessionCalls(gateway)).toEqual(['start:en-US']);
+ expect(sessionCalls(os)).toEqual([]);
+ expect(os.calls).toEqual([]);
+ });
+
+ it('routes stop and abort to the engine that owns the session', () => {
+ const { native, os, gateway } = build('gateway');
+ native.start(START_OPTIONS);
+
+ native.stop();
+ native.abort();
+
+ expect(sessionCalls(gateway)).toEqual(['start:en-US', 'stop', 'abort']);
+ expect(sessionCalls(os)).toEqual([]);
+ });
+
+ it('forwards events from the active engine and drops the idle engine', () => {
+ const { native, os, gateway } = build('gateway');
+ const seen: string[] = [];
+ native.addListener('result', event => {
+ seen.push(event.results[0]?.transcript ?? '');
+ });
+ native.addListener('end', () => {
+ seen.push('end');
+ });
+
+ native.start(START_OPTIONS);
+ os.emit('result', {
+ isFinal: true,
+ results: [{ transcript: 'idle', confidence: 1, segments: [] }],
+ });
+ gateway.emit('result', {
+ isFinal: true,
+ results: [{ transcript: 'active', confidence: 1, segments: [] }],
+ });
+ gateway.emit('end', null);
+
+ expect(seen).toEqual(['active', 'end']);
+ });
+
+ it('keeps the session on the engine chosen at start when the preference flips mid-session', () => {
+ const { native, os, gateway, setEngine } = build('os');
+ native.start(START_OPTIONS);
+ setEngine('gateway');
+
+ native.stop();
+ os.emit('end', null);
+
+ expect(sessionCalls(os)).toEqual(['start:en-US', 'stop']);
+ expect(sessionCalls(gateway)).toEqual([]);
+ });
+
+ it('probes permissions, availability and capabilities on the chosen engine', async () => {
+ const { native, os, gateway, setEngine } = build('gateway');
+
+ await native.getPermissions();
+ await native.requestPermissions();
+ native.isRecognitionAvailable();
+ native.supportsContinuousRecognition();
+ native.supportsOnDevice();
+
+ expect(gateway.calls).toEqual([
+ 'getPermissions',
+ 'requestPermissions',
+ 'isRecognitionAvailable',
+ 'supportsContinuousRecognition',
+ 'supportsOnDevice',
+ ]);
+ expect(os.calls).toEqual([]);
+
+ setEngine('os');
+ await native.getPermissions();
+ expect(os.calls).toEqual(['getPermissions']);
+ });
+
+ it('reuses one session slot: a second start replaces the first engine subscription', () => {
+ const { native, os, gateway } = build('os');
+ native.start(START_OPTIONS);
+ native.start(START_OPTIONS);
+
+ // The first session's listeners are removed before the second attaches.
+ expect(sessionCalls(os)).toEqual(['start:en-US', 'start:en-US']);
+ expect(sessionCalls(gateway)).toEqual([]);
+ });
+});
diff --git a/apps/mobile/src/lib/voice-input/voice-input-engine-select.ts b/apps/mobile/src/lib/voice-input/voice-input-engine-select.ts
new file mode 100644
index 0000000000..954f4af2ef
--- /dev/null
+++ b/apps/mobile/src/lib/voice-input/voice-input-engine-select.ts
@@ -0,0 +1,133 @@
+import { type VoiceInputNative, type VoiceInputNativeEvent } from './voice-input-controller';
+import { type VoiceInputEngineName } from './voice-input-engine-mode';
+
+/**
+ * Compose the OS recogniser and the gateway transcription engine behind the
+ * single `VoiceInputNative` the controller consumes, routing every call to
+ * the engine the user chose. The choice is read at `start()`; there is no
+ * fallback, so exactly one engine owns a session for its whole life and the
+ * other engine receives no call of any kind.
+ */
+export function createSelectingVoiceInputNative(
+ engines: Record,
+ getEngine: () => VoiceInputEngineName
+): VoiceInputNative {
+ type AnyVoiceInputListener = (event: VoiceInputNativeEvent[keyof VoiceInputNativeEvent]) => void;
+ const listeners = new Map>();
+
+ const forward = (
+ event: K,
+ payload: VoiceInputNativeEvent[K]
+ ): void => {
+ const set = listeners.get(event);
+ if (!set) {
+ return;
+ }
+ for (const listener of set) {
+ listener(payload);
+ }
+ };
+
+ /** The engine owning the current session, or null between sessions. */
+ let active: VoiceInputEngineName | null = null;
+ let engineSubscriptions: { remove(): void }[] = [];
+
+ const closeSession = (): void => {
+ active = null;
+ for (const subscription of engineSubscriptions) {
+ subscription.remove();
+ }
+ engineSubscriptions = [];
+ };
+
+ /** Attach to one engine for the session; events from the idle engine are dropped. */
+ const subscribeEngine = (name: VoiceInputEngineName): void => {
+ const engine = engines[name];
+ engineSubscriptions.push(
+ engine.addListener('start', () => {
+ if (active === name) {
+ forward('start', null);
+ }
+ }),
+ engine.addListener('transcribing', () => {
+ if (active === name) {
+ forward('transcribing', null);
+ }
+ }),
+ engine.addListener('result', event => {
+ if (active === name) {
+ forward('result', event);
+ }
+ }),
+ engine.addListener('nomatch', () => {
+ if (active === name) {
+ forward('nomatch', null);
+ }
+ }),
+ engine.addListener('error', event => {
+ if (active === name) {
+ forward('error', event);
+ }
+ }),
+ engine.addListener('end', () => {
+ if (active !== name) {
+ return;
+ }
+ forward('end', null);
+ closeSession();
+ })
+ );
+ };
+
+ return {
+ addListener(event, listener) {
+ let set = listeners.get(event);
+ if (!set) {
+ set = new Set();
+ listeners.set(event, set);
+ }
+ const boxed = listener as AnyVoiceInputListener;
+ set.add(boxed);
+ return {
+ remove: (): void => {
+ listeners.get(event)?.delete(boxed);
+ },
+ };
+ },
+ getPermissions: async () => {
+ const permission = await engines[getEngine()].getPermissions();
+ return permission;
+ },
+ requestPermissions: async () => {
+ const permission = await engines[getEngine()].requestPermissions();
+ return permission;
+ },
+ isRecognitionAvailable: () => engines[getEngine()].isRecognitionAvailable(),
+ supportsContinuousRecognition: () => engines[getEngine()].supportsContinuousRecognition(),
+ supportsOnDevice: () => engines[getEngine()].supportsOnDevice(),
+ start: options => {
+ closeSession();
+ const name = getEngine();
+ active = name;
+ subscribeEngine(name);
+ try {
+ engines[name].start(options);
+ } catch (error) {
+ closeSession();
+ throw error;
+ }
+ },
+ stop: () => {
+ if (active === null) {
+ return;
+ }
+ engines[active].stop();
+ },
+ abort: () => {
+ if (active === null) {
+ return;
+ }
+ engines[active].abort();
+ },
+ };
+}
diff --git a/apps/mobile/src/lib/voice-input/voice-input-feedback.test.ts b/apps/mobile/src/lib/voice-input/voice-input-feedback.test.ts
index a7b86af5c7..2d1dd9d36e 100644
--- a/apps/mobile/src/lib/voice-input/voice-input-feedback.test.ts
+++ b/apps/mobile/src/lib/voice-input/voice-input-feedback.test.ts
@@ -31,6 +31,24 @@ describe('resolveVoiceInputFeedbackPresentation', () => {
kind: 'alert',
title: 'Microphone access is off',
message: 'Microphone access is off. Enable it in Settings to use voice input.',
+ destination: 'system-settings',
+ });
+ });
+
+ it('returns an alert with the picker destination when the action is open-transcription-settings', () => {
+ const presentation: VoiceInputFeedbackPresentation = resolveVoiceInputFeedbackPresentation(
+ feedback({
+ action: 'open-transcription-settings',
+ message: "This transcription model isn't available. Pick another one in Preferences.",
+ retryable: false,
+ })
+ );
+
+ expect(presentation).toEqual({
+ kind: 'alert',
+ title: 'Transcription model',
+ message: "This transcription model isn't available. Pick another one in Preferences.",
+ destination: 'transcription-model-picker',
});
});
@@ -67,7 +85,13 @@ describe('resolveVoiceInputFeedbackPresentation', () => {
});
describe('shouldAnnounceListeningTransition', () => {
- const allStatuses: VoiceInputStatus[] = ['idle', 'starting', 'listening', 'stopping'];
+ const allStatuses: VoiceInputStatus[] = [
+ 'idle',
+ 'starting',
+ 'listening',
+ 'transcribing',
+ 'stopping',
+ ];
it('fires exactly once for the first transition into listening (null → listening)', () => {
expect(shouldAnnounceListeningTransition(null, 'listening')).toBe(true);
diff --git a/apps/mobile/src/lib/voice-input/voice-input-feedback.ts b/apps/mobile/src/lib/voice-input/voice-input-feedback.ts
index 3ef9cf96c1..8321e37e5a 100644
--- a/apps/mobile/src/lib/voice-input/voice-input-feedback.ts
+++ b/apps/mobile/src/lib/voice-input/voice-input-feedback.ts
@@ -3,24 +3,44 @@ import { i18n } from '@/i18n';
import { type VoiceInputFeedback, type VoiceInputStatus } from './voice-input-state';
export type VoiceInputFeedbackPresentation =
- | { kind: 'alert'; title: string; message: string }
+ | {
+ kind: 'alert';
+ title: string;
+ message: string;
+ /** Where the alert's action button leads. */
+ destination: 'system-settings' | 'transcription-model-picker';
+ }
| { kind: 'toast'; message: string };
/**
* Pure projection of a `VoiceInputFeedback` into the surface that should
- * display it. Open-settings feedback (permanent microphone denial) gets a
- * native alert with a Cancel/Open Settings affordance so the user can
- * recover without re-trying the gesture. Every other case is a transient
- * toast — retryable failures invite the user to try again, non-retryable
- * ones are informational only. Keeping this decision in a pure function
- * makes the policy unit-testable and isolates React Native's `Alert` and
- * `toast` from the rule.
+ * display it. Feedback with a follow-up destination gets a native alert with
+ * a Cancel affordance plus a button that opens that destination: permanent
+ * microphone denial opens the system settings, a gateway transcription
+ * problem that needs a different model opens the transcription model picker.
+ * Every other case is a transient toast — retryable failures invite the user
+ * to try again, non-retryable ones are informational only. Keeping this
+ * decision in a pure function makes the policy unit-testable and isolates
+ * React Native's `Alert` and `toast` from the rule.
*/
export function resolveVoiceInputFeedbackPresentation(
feedback: VoiceInputFeedback
): VoiceInputFeedbackPresentation {
+ if (feedback.action === 'open-transcription-settings') {
+ return {
+ kind: 'alert',
+ title: i18n.t('transcriptionModel.title'),
+ message: feedback.message,
+ destination: 'transcription-model-picker',
+ };
+ }
if (feedback.action === 'open-settings') {
- return { kind: 'alert', title: i18n.t('voiceInput.micOffTitle'), message: feedback.message };
+ return {
+ kind: 'alert',
+ title: i18n.t('voiceInput.micOffTitle'),
+ message: feedback.message,
+ destination: 'system-settings',
+ };
}
return { kind: 'toast', message: feedback.message };
}
diff --git a/apps/mobile/src/lib/voice-input/voice-input-listeners.ts b/apps/mobile/src/lib/voice-input/voice-input-listeners.ts
index 5c09c04c62..3e17f2bed8 100644
--- a/apps/mobile/src/lib/voice-input/voice-input-listeners.ts
+++ b/apps/mobile/src/lib/voice-input/voice-input-listeners.ts
@@ -46,6 +46,15 @@ export function installVoiceInputListeners(
controller.notify();
};
+ const onTranscribing: (event: VoiceInputNativeEvent['transcribing']) => void = () => {
+ const current = controller.getSession();
+ if (!current || current.terminalized || current.expectedAbort) {
+ return;
+ }
+ controller.setStatus('transcribing');
+ controller.notify();
+ };
+
const onResult: (event: VoiceInputNativeEvent['result']) => void = event => {
const current = controller.getSession();
if (!current || current.terminalized || current.expectedAbort) {
@@ -101,6 +110,7 @@ export function installVoiceInputListeners(
return [
native.addListener('start', onStart),
+ native.addListener('transcribing', onTranscribing),
native.addListener('result', onResult),
native.addListener('nomatch', onNomatch),
native.addListener('error', onError),
diff --git a/apps/mobile/src/lib/voice-input/voice-input-state-gateway.test.ts b/apps/mobile/src/lib/voice-input/voice-input-state-gateway.test.ts
new file mode 100644
index 0000000000..9c504002db
--- /dev/null
+++ b/apps/mobile/src/lib/voice-input/voice-input-state-gateway.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from 'vitest';
+
+import { classifyVoiceInputError } from './voice-input-state';
+
+describe('classifyVoiceInputError - gateway codes', () => {
+ it('maps gateway-unreachable to retryable feedback with its own copy', () => {
+ expect(classifyVoiceInputError('gateway-unreachable')).toEqual({
+ action: 'none',
+ availability: 'available',
+ message: "Couldn't reach the Kilo gateway. Check your connection and try again.",
+ retryable: true,
+ });
+ });
+
+ it('maps gateway-timeout to retryable feedback with its own copy', () => {
+ expect(classifyVoiceInputError('gateway-timeout')).toEqual({
+ action: 'none',
+ availability: 'available',
+ message: 'Transcription took too long. Try again.',
+ retryable: true,
+ });
+ });
+
+ it('maps gateway-model-unavailable to non-retryable feedback that opens the transcription settings', () => {
+ expect(classifyVoiceInputError('gateway-model-unavailable')).toEqual({
+ action: 'open-transcription-settings',
+ availability: 'available',
+ message: "This transcription model isn't available. Pick another one in Preferences.",
+ retryable: false,
+ });
+ });
+
+ it('maps gateway-auth to non-retryable sign-in feedback', () => {
+ expect(classifyVoiceInputError('gateway-auth')).toEqual({
+ action: 'none',
+ availability: 'available',
+ message: 'Sign in to use gateway transcription.',
+ retryable: false,
+ });
+ });
+
+ it('maps gateway-no-model to non-retryable feedback that opens the transcription settings', () => {
+ expect(classifyVoiceInputError('gateway-no-model')).toEqual({
+ action: 'open-transcription-settings',
+ availability: 'available',
+ message: 'Choose a transcription model in Preferences first.',
+ retryable: false,
+ });
+ });
+
+ it('maps gateway-server to the generic retryable stopped feedback', () => {
+ expect(classifyVoiceInputError('gateway-server')).toEqual({
+ action: 'none',
+ availability: 'available',
+ message: 'Voice input stopped. Tap the microphone to try again.',
+ retryable: true,
+ });
+ });
+
+ it('maps gateway-invalid-response to the generic retryable stopped feedback', () => {
+ expect(classifyVoiceInputError('gateway-invalid-response')).toEqual({
+ action: 'none',
+ availability: 'available',
+ message: 'Voice input stopped. Tap the microphone to try again.',
+ retryable: true,
+ });
+ });
+});
diff --git a/apps/mobile/src/lib/voice-input/voice-input-state.ts b/apps/mobile/src/lib/voice-input/voice-input-state.ts
index 43179dd02c..98d37f25ce 100644
--- a/apps/mobile/src/lib/voice-input/voice-input-state.ts
+++ b/apps/mobile/src/lib/voice-input/voice-input-state.ts
@@ -1,10 +1,10 @@
import { i18n } from '@/i18n';
-export type VoiceInputStatus = 'idle' | 'starting' | 'listening' | 'stopping';
+export type VoiceInputStatus = 'idle' | 'starting' | 'listening' | 'transcribing' | 'stopping';
export type VoiceInputAvailability = 'available' | 'unavailable';
export type VoiceInputFeedback = {
- action: 'none' | 'open-settings';
+ action: 'none' | 'open-settings' | 'open-transcription-settings';
availability: VoiceInputAvailability;
message: string;
retryable: boolean;
@@ -130,6 +130,54 @@ export function classifyVoiceInputError(code: string): VoiceInputFeedback {
retryable: true,
};
}
+ case 'gateway-unreachable': {
+ return {
+ action: 'none',
+ availability: 'available',
+ message: i18n.t('voiceInput.gatewayUnreachable'),
+ retryable: true,
+ };
+ }
+ case 'gateway-timeout': {
+ return {
+ action: 'none',
+ availability: 'available',
+ message: i18n.t('voiceInput.gatewayTimeout'),
+ retryable: true,
+ };
+ }
+ case 'gateway-model-unavailable': {
+ return {
+ action: 'open-transcription-settings',
+ availability: 'available',
+ message: i18n.t('voiceInput.gatewayModelUnavailable'),
+ retryable: false,
+ };
+ }
+ case 'gateway-auth': {
+ return {
+ action: 'none',
+ availability: 'available',
+ message: i18n.t('voiceInput.gatewaySignInRequired'),
+ retryable: false,
+ };
+ }
+ case 'gateway-no-model': {
+ return {
+ action: 'open-transcription-settings',
+ availability: 'available',
+ message: i18n.t('voiceInput.gatewayNoModel'),
+ retryable: false,
+ };
+ }
+ case 'gateway-server': {
+ return {
+ action: 'none',
+ availability: 'available',
+ message: i18n.t('voiceInput.stopped'),
+ retryable: true,
+ };
+ }
case 'busy': {
return {
action: 'none',
diff --git a/apps/mobile/src/lib/voice-input/voice-input-view-state.test.ts b/apps/mobile/src/lib/voice-input/voice-input-view-state.test.ts
index 4be5736272..d833f3ba8a 100644
--- a/apps/mobile/src/lib/voice-input/voice-input-view-state.test.ts
+++ b/apps/mobile/src/lib/voice-input/voice-input-view-state.test.ts
@@ -119,4 +119,24 @@ describe('resolveVoiceInputControlState', () => {
showListeningStatus: true,
});
});
+
+ it('transcribing + not disabled => Stop label, stop icon, not busy, enabled, status shown', () => {
+ expect(resolveVoiceInputControlState('transcribing', false)).toEqual({
+ accessibilityLabel: 'Stop voice input',
+ busy: false,
+ disabled: false,
+ icon: 'stop',
+ showListeningStatus: true,
+ });
+ });
+
+ it('transcribing + external disabled => Stop label, stop icon, not busy, disabled, status shown', () => {
+ expect(resolveVoiceInputControlState('transcribing', true)).toEqual({
+ accessibilityLabel: 'Stop voice input',
+ busy: false,
+ disabled: true,
+ icon: 'stop',
+ showListeningStatus: true,
+ });
+ });
});
diff --git a/apps/mobile/src/lib/voice-input/voice-input-view-state.ts b/apps/mobile/src/lib/voice-input/voice-input-view-state.ts
index d420d1946f..fea355e43f 100644
--- a/apps/mobile/src/lib/voice-input/voice-input-view-state.ts
+++ b/apps/mobile/src/lib/voice-input/voice-input-view-state.ts
@@ -78,6 +78,16 @@ export function resolveVoiceInputControlState(
showListeningStatus: true,
};
}
+ case 'transcribing': {
+ // The upload can hang; keep the button tappable so the user can cancel.
+ return {
+ accessibilityLabel: i18n.t('voiceInput.stop'),
+ busy: false,
+ disabled,
+ icon: 'stop',
+ showListeningStatus: true,
+ };
+ }
case 'stopping': {
return {
accessibilityLabel: i18n.t('voiceInput.stop'),
diff --git a/apps/web/src/app/api/openrouter/audio/transcriptions/route.test.ts b/apps/web/src/app/api/openrouter/audio/transcriptions/route.test.ts
index 0699f7e901..ecb220aa3b 100644
--- a/apps/web/src/app/api/openrouter/audio/transcriptions/route.test.ts
+++ b/apps/web/src/app/api/openrouter/audio/transcriptions/route.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterAll } from '@jest/globals';
import { getUserFromAuth } from '@/lib/user/server';
import { getBalanceAndOrgSettings } from '@/lib/organizations/organization-usage';
+import { isFreeModel } from '@/lib/ai-gateway/is-free-model';
import type { User } from '@kilocode/db/schema';
import { emitApiMetricsForResponse } from '@/lib/ai-gateway/o11y/api-metrics.server';
import type { OrganizationSettings } from '@/lib/organizations/organization-types';
@@ -17,6 +18,9 @@ jest.mock('@/lib/organizations/organization-usage');
jest.mock('@/lib/ai-gateway/o11y/api-metrics.server', () => ({
emitApiMetricsForResponse: jest.fn(),
}));
+jest.mock('@/lib/ai-gateway/is-free-model', () => ({
+ isFreeModel: jest.fn(),
+}));
jest.mock('@/lib/ai-gateway/llm-proxy-helpers', () => {
const actual = jest.requireActual('@/lib/ai-gateway/llm-proxy-helpers');
return {
@@ -27,6 +31,7 @@ jest.mock('@/lib/ai-gateway/llm-proxy-helpers', () => {
const mockedGetUserFromAuth = jest.mocked(getUserFromAuth);
const mockedGetBalanceAndOrgSettings = jest.mocked(getBalanceAndOrgSettings);
+const mockedIsFreeModel = jest.mocked(isFreeModel);
const mockedEmitApiMetricsForResponse = jest.mocked(emitApiMetricsForResponse);
const mockedFetch = jest.fn() as jest.MockedFunction;
const originalFetch = globalThis.fetch;
@@ -43,6 +48,22 @@ function makeRequest(body: unknown, headers: Record = {}) {
});
}
+function makeMultipartRequest(
+ fields: Record,
+ file: { blob: Blob; filename: string } | null
+) {
+ const form = new FormData();
+ for (const [key, value] of Object.entries(fields)) {
+ form.append(key, value);
+ }
+ if (file) form.append('file', file.blob, file.filename);
+ return new Request('http://localhost:3000/api/gateway/v1/audio/transcriptions', {
+ method: 'POST',
+ headers: { 'x-forwarded-for': '127.0.0.1' },
+ body: form,
+ });
+}
+
function setUserAuth() {
mockedGetUserFromAuth.mockResolvedValue({
user: {
@@ -188,4 +209,192 @@ describe('POST /api/gateway/v1/audio/transcriptions', () => {
expect(response.status).toBe(401);
expect(mockedFetch).not.toHaveBeenCalled();
});
+
+ it('lets a zero balance through for a free model', async () => {
+ setUserAuth();
+ mockedGetBalanceAndOrgSettings.mockResolvedValue({
+ balance: 0,
+ settings: undefined,
+ plan: undefined,
+ });
+ mockedIsFreeModel.mockResolvedValue(true);
+ mockedFetch.mockResolvedValue(makeUpstreamResponse({ text: 'hello world' }));
+
+ const { POST } = await import('./route');
+ const response = await POST(
+ makeRequest({
+ model: 'fake-transcribe',
+ input_audio: { data: 'UklGRiQA', format: 'wav' },
+ }) as never
+ );
+
+ expect(response.status).toBe(200);
+ expect(mockedIsFreeModel).toHaveBeenCalledWith('fake-transcribe');
+ expect(mockedFetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('blocks a zero balance for a paid model before proxying', async () => {
+ setUserAuth();
+ mockedGetBalanceAndOrgSettings.mockResolvedValue({
+ balance: 0,
+ settings: undefined,
+ plan: undefined,
+ });
+ mockedIsFreeModel.mockResolvedValue(false);
+
+ const { POST } = await import('./route');
+ const response = await POST(
+ makeRequest({
+ model: 'openai/gpt-4o-mini-transcribe',
+ input_audio: { data: 'UklGRiQA', format: 'wav' },
+ }) as never
+ );
+
+ expect(response.status).toBe(402);
+ expect(mockedFetch).not.toHaveBeenCalled();
+ });
+
+ it('proxies multipart transcription requests with the model and file fields', async () => {
+ setUserAuth();
+ mockedFetch.mockResolvedValue(makeUpstreamResponse({ text: 'hello world' }));
+
+ const { POST } = await import('./route');
+ const response = await POST(
+ makeMultipartRequest(
+ { model: 'openai/gpt-4o-mini-transcribe', language: 'en' },
+ { blob: new Blob(['UklGRiQA'], { type: 'audio/wav' }), filename: 'speech.wav' }
+ ) as never
+ );
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({ text: 'hello world' });
+ expect(mockedFetch).toHaveBeenCalledTimes(1);
+
+ const [, init] = mockedFetch.mock.calls[0];
+ const headers = init?.headers as Headers;
+ expect(headers.get('Authorization')).toMatch(/^Bearer /);
+ expect(headers.get('HTTP-Referer')).toBe('https://kilocode.ai');
+ // No explicit Content-Type: fetch sets multipart/form-data with the boundary.
+ expect(headers.get('Content-Type')).toBeNull();
+
+ const upstreamForm = init?.body as FormData;
+ expect(upstreamForm).toBeInstanceOf(FormData);
+ expect(upstreamForm.get('model')).toBe('openai/gpt-4o-mini-transcribe');
+ expect(upstreamForm.get('language')).toBe('en');
+ const upstreamFile = upstreamForm.get('file') as File;
+ expect(upstreamFile.name).toBe('speech.wav');
+ expect(upstreamFile.size).toBe(8);
+ });
+
+ it('forwards the organization provider policy on multipart requests', async () => {
+ setUserAuth();
+ mockedGetBalanceAndOrgSettings.mockResolvedValue({
+ balance: 1000,
+ settings: {
+ provider_allow_list: ['openai'],
+ model_deny_list: [],
+ data_collection: 'deny',
+ } satisfies OrganizationSettings,
+ plan: 'enterprise',
+ });
+ mockedFetch.mockResolvedValue(makeUpstreamResponse({ text: 'hello world' }));
+
+ const { POST } = await import('./route');
+ const response = await POST(
+ makeMultipartRequest(
+ { model: 'openai/gpt-4o-mini-transcribe' },
+ { blob: new Blob(['UklGRiQA'], { type: 'audio/wav' }), filename: 'speech.wav' }
+ ) as never
+ );
+
+ expect(response.status).toBe(200);
+
+ const [, init] = mockedFetch.mock.calls[0];
+ const upstreamForm = init?.body as FormData;
+ expect(JSON.parse(upstreamForm.get('provider') as string)).toEqual({
+ only: ['openai'],
+ data_collection: 'deny',
+ });
+ });
+
+ it('attaches the safety identifier to multipart upstream requests', async () => {
+ setUserAuth();
+ mockedFetch.mockResolvedValue(makeUpstreamResponse({ text: 'hello world' }));
+
+ const { POST } = await import('./route');
+ const response = await POST(
+ makeMultipartRequest(
+ { model: 'openai/gpt-4o-mini-transcribe' },
+ { blob: new Blob(['UklGRiQA'], { type: 'audio/wav' }), filename: 'speech.wav' }
+ ) as never
+ );
+
+ expect(response.status).toBe(200);
+
+ const [, init] = mockedFetch.mock.calls[0];
+ const upstreamForm = init?.body as FormData;
+ const safetyIdentifier = upstreamForm.get('safety_identifier');
+ expect(safetyIdentifier).toBeTruthy();
+ expect(upstreamForm.get('user')).toBe(safetyIdentifier);
+ });
+
+ it('rejects multipart requests without a model field', async () => {
+ setUserAuth();
+
+ const { POST } = await import('./route');
+ const response = await POST(
+ makeMultipartRequest({}, { blob: new Blob(['UklGRiQA']), filename: 'speech.wav' }) as never
+ );
+
+ expect(response.status).toBe(400);
+ expect(mockedFetch).not.toHaveBeenCalled();
+ });
+
+ it('rejects multipart requests without a file part', async () => {
+ setUserAuth();
+
+ const { POST } = await import('./route');
+ const response = await POST(
+ makeMultipartRequest({ model: 'openai/gpt-4o-mini-transcribe' }, null) as never
+ );
+
+ expect(response.status).toBe(400);
+ expect(mockedFetch).not.toHaveBeenCalled();
+ });
+
+ it('rejects a malformed multipart body with a controlled 400', async () => {
+ setUserAuth();
+
+ const { POST } = await import('./route');
+ // The content type claims multipart/form-data, but the boundary cannot be
+ // parsed. `request.formData()` rejects; the route must still answer 400.
+ const request = new Request('http://localhost:3000/api/gateway/v1/audio/transcriptions', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ 'x-forwarded-for': '127.0.0.1',
+ },
+ body: 'not a multipart body',
+ });
+ const response = await POST(request as never);
+
+ expect(response.status).toBe(400);
+ expect(mockedFetch).not.toHaveBeenCalled();
+ });
+
+ it('passes an upstream 404 through for multipart requests', async () => {
+ setUserAuth();
+ mockedFetch.mockResolvedValue(makeUpstreamResponse({ error: 'model not found' }, 404));
+
+ const { POST } = await import('./route');
+ const response = await POST(
+ makeMultipartRequest(
+ { model: 'openai/gpt-4o-mini-transcribe' },
+ { blob: new Blob(['UklGRiQA']), filename: 'speech.wav' }
+ ) as never
+ );
+
+ expect(response.status).toBe(404);
+ expect(await response.json()).toEqual({ error: 'model not found' });
+ });
});
diff --git a/apps/web/src/app/api/openrouter/audio/transcriptions/route.ts b/apps/web/src/app/api/openrouter/audio/transcriptions/route.ts
index b64c1f1741..3a28268c55 100644
--- a/apps/web/src/app/api/openrouter/audio/transcriptions/route.ts
+++ b/apps/web/src/app/api/openrouter/audio/transcriptions/route.ts
@@ -4,7 +4,7 @@ import { generateProviderSpecificHash } from '@/lib/ai-gateway/providerHash';
import type { MicrodollarUsageContext } from '@/lib/ai-gateway/processUsage.types';
import { validateFeatureHeader, FEATURE_HEADER } from '@/lib/feature-detection';
import { getTranscriptionProvider } from '@/lib/ai-gateway/providers/get-provider';
-import { debugSaveProxyRequest } from '@/lib/debugUtils';
+import { debugSaveLog, debugSaveProxyRequest } from '@/lib/debugUtils';
import { captureException, setTag, startInactiveSpan } from '@sentry/nextjs';
import { getUserFromAuth } from '@/lib/user/server';
import { KILO_GATEWAY_AUDIENCE } from '@kilocode/worker-utils/internal-service-token-audiences';
@@ -22,15 +22,19 @@ import {
wrapInSafeNextResponse,
} from '@/lib/ai-gateway/llm-proxy-helpers';
import { ATTRIBUTION_HEADERS } from '@/lib/ai-gateway/providers/openrouter/attribution-headers';
+import type { OpenRouterProviderConfig } from '@/lib/ai-gateway/providers/openrouter/types';
import { ProxyErrorType } from '@/lib/proxy-error-types';
import { getBalanceAndOrgSettings } from '@/lib/organizations/organization-usage';
+import { isFreeModel } from '@/lib/ai-gateway/is-free-model';
import { emitApiMetricsForResponse } from '@/lib/ai-gateway/o11y/api-metrics.server';
import { normalizeModelId } from '@/lib/ai-gateway/model-utils';
import {
buildUpstreamBody,
extractTranscriptionPromptInfo,
TranscriptionRequestSchema,
+ type TranscriptionRequest,
} from '@/lib/ai-gateway/transcriptions/transcription-request';
+import type { PromptInfo } from '@/lib/ai-gateway/processUsage.types';
import type { Provider } from '@/lib/ai-gateway/providers/types';
import { resolveOrganizationMemberModelDecision } from '@/lib/organizations/effective-model-access.server';
@@ -39,13 +43,17 @@ export const maxDuration = 800;
const PAID_MODEL_AUTH_REQUIRED = 'PAID_MODEL_AUTH_REQUIRED';
async function transcriptionProxyRequest(params: {
- body: Record;
+ body: Record | FormData;
provider: Provider;
signal?: AbortSignal;
}) {
const { body, provider, signal } = params;
+ // No explicit Content-Type for a multipart body: fetch sets the boundary.
+ const isMultipartBody = body instanceof FormData;
const headers = new Headers();
- headers.set('Content-Type', 'application/json');
+ if (!isMultipartBody) {
+ headers.set('Content-Type', 'application/json');
+ }
headers.set('Authorization', `Bearer ${provider.apiKey}`);
for (const [key, value] of Object.entries(ATTRIBUTION_HEADERS)) {
@@ -58,19 +66,63 @@ async function transcriptionProxyRequest(params: {
return await fetch(`${provider.apiUrl}/audio/transcriptions`, {
method: 'POST',
headers,
- body: JSON.stringify(body),
+ body: isMultipartBody ? body : JSON.stringify(body),
// @ts-expect-error see https://github.com/node-fetch/node-fetch/issues/1769
duplex: 'half',
signal: combined,
});
}
-export async function POST(request: NextRequest): Promise> {
- const requestStartedAt = performance.now();
+/** Prompt info attributed from a multipart upload: file name and size. */
+function extractMultipartPromptInfo(file: File, language: string | null): PromptInfo {
+ const languagePart = language ? ` language=${language.slice(0, 32)}` : '';
+ return {
+ system_prompt_prefix: '',
+ system_prompt_length: 0,
+ user_prompt_prefix: `audio/${file.name} size=${file.size}${languagePart}`.slice(0, 100),
+ };
+}
+
+function extractMultipartLanguage(formData: FormData): string | null {
+ const language = formData.get('language');
+ return typeof language === 'string' && language.trim().length > 0 ? language.trim() : null;
+}
- const requestBodyText = await request.text();
- debugSaveProxyRequest(requestBodyText);
+/**
+ * Either a JSON transcription body or a multipart upload. The request body
+ * stream is single-consumption, so the shape is resolved once, from the
+ * content-type header, before anything reads the body.
+ */
+type ParsedTranscriptionRequest =
+ | { kind: 'json'; body: TranscriptionRequest }
+ | { kind: 'multipart'; file: File; model: string; language: string | null };
+async function parseMultipartTranscriptionRequest(
+ request: NextRequest
+): Promise {
+ let formData: FormData;
+ try {
+ formData = await request.formData();
+ } catch (error) {
+ // A malformed body or a missing boundary rejects instead of returning form
+ // data. Treat it as an invalid request so POST answers the controlled 400
+ // rather than surfacing an unhandled 500. Never log the body: it is audio.
+ captureException(error, { tags: { source: 'transcription-proxy' } });
+ return null;
+ }
+ const modelField = formData.get('model');
+ if (typeof modelField !== 'string' || modelField.trim().length === 0) return null;
+ const filePart = formData.get('file');
+ if (!filePart || typeof filePart === 'string') return null;
+ return {
+ kind: 'multipart',
+ file: filePart,
+ model: modelField.trim(),
+ language: extractMultipartLanguage(formData),
+ };
+}
+
+function parseJsonTranscriptionRequest(requestBodyText: string): ParsedTranscriptionRequest | null {
let parsed: unknown;
try {
parsed = JSON.parse(requestBodyText);
@@ -79,7 +131,7 @@ export async function POST(request: NextRequest): Promise> {
+ const requestStartedAt = performance.now();
+
+ const isMultipartRequest = (request.headers.get('content-type') ?? '')
+ .toLowerCase()
+ .startsWith('multipart/form-data');
+
+ let requestBodyText: string | undefined;
+ let parsedRequest: ParsedTranscriptionRequest | null;
+ if (isMultipartRequest) {
+ parsedRequest = await parseMultipartTranscriptionRequest(request);
+ } else {
+ requestBodyText = await request.text();
+ debugSaveProxyRequest(requestBodyText);
+ parsedRequest = parseJsonTranscriptionRequest(requestBodyText);
}
+ if (!parsedRequest) return invalidRequestResponse();
- const body = result.data;
- const requestedModel = body.model.trim();
+ const requestedModel =
+ parsedRequest.kind === 'json' ? parsedRequest.body.model.trim() : parsedRequest.model;
const requestedModelLowerCased = requestedModel.toLowerCase();
const ipAddress = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
@@ -141,7 +214,17 @@ export async function POST(request: NextRequest): Promise decision.eligibleProviderRoutes?.has(route))
: [...decision.eligibleProviderRoutes];
if (only.length === 0) return modelNotAllowedResponse();
- body.provider = { ...body.provider, ...providerConfig, only };
+ providerPolicy = { ...providerConfig, only };
} else if (providerConfig) {
- body.provider = { ...body.provider, ...providerConfig };
+ providerPolicy = providerConfig;
}
} else if (providerConfig) {
- body.provider = { ...body.provider, ...providerConfig };
+ providerPolicy = providerConfig;
}
sentryRootSpan()?.setAttribute(
@@ -213,9 +302,28 @@ export async function POST(request: NextRequest): Promise | FormData;
+ if (parsedRequest.kind === 'multipart') {
+ const upstreamForm = new FormData();
+ upstreamForm.append('file', parsedRequest.file, parsedRequest.file.name);
+ upstreamForm.append('model', requestedModel);
+ if (parsedRequest.language) upstreamForm.append('language', parsedRequest.language);
+ const safetyIdentifier = generateProviderSpecificHash(user.id, provider);
+ upstreamForm.append('safety_identifier', safetyIdentifier);
+ upstreamForm.append('user', safetyIdentifier);
+ if (providerPolicy) {
+ upstreamForm.append('provider', JSON.stringify(providerPolicy));
+ }
+ upstreamBody = upstreamForm;
+ } else {
+ parsedRequest.body.safety_identifier = generateProviderSpecificHash(user.id, provider);
+ parsedRequest.body.user = parsedRequest.body.safety_identifier;
+ if (providerPolicy) {
+ parsedRequest.body.provider = { ...parsedRequest.body.provider, ...providerPolicy };
+ }
+ upstreamBody = buildUpstreamBody(parsedRequest.body);
+ }
const response = await transcriptionProxyRequest({
body: upstreamBody,
diff --git a/apps/web/src/lib/ai-gateway/is-free-model.ts b/apps/web/src/lib/ai-gateway/is-free-model.ts
index 1723643cf4..779c761af2 100644
--- a/apps/web/src/lib/ai-gateway/is-free-model.ts
+++ b/apps/web/src/lib/ai-gateway/is-free-model.ts
@@ -4,6 +4,7 @@ import { isPublicIdExperimented } from '@/lib/ai-gateway/experiments/membership'
import {
isLocalFakeDeterministicModel,
isLocalFakeLlmEnabled,
+ isLocalFakeTranscriptionModel,
} from '@/lib/ai-gateway/local-fake-llm';
/**
@@ -18,7 +19,8 @@ import {
*/
export async function isFreeModel(model: string): Promise {
return (
- (isLocalFakeDeterministicModel(model) && isLocalFakeLlmEnabled()) ||
+ ((isLocalFakeDeterministicModel(model) || isLocalFakeTranscriptionModel(model)) &&
+ isLocalFakeLlmEnabled()) ||
isKiloExclusiveFreeModel(model) ||
model === KILO_AUTO_FREE_MODEL.id ||
(model ?? '').endsWith(':free') ||
diff --git a/apps/web/src/lib/ai-gateway/local-fake-llm.test.ts b/apps/web/src/lib/ai-gateway/local-fake-llm.test.ts
index eb0a59dbf3..eca60e38ae 100644
--- a/apps/web/src/lib/ai-gateway/local-fake-llm.test.ts
+++ b/apps/web/src/lib/ai-gateway/local-fake-llm.test.ts
@@ -4,8 +4,11 @@ import {
appendLocalFakeDeterministicCatalogModels,
getLocalFakeDeterministicCatalogEntry,
getLocalFakeLlmProvider,
+ getLocalFakeTranscriptionModelsUrl,
+ getLocalFakeTranscriptionProvider,
isLocalFakeDeterministicModel,
isLocalFakeLlmEnabled,
+ isLocalFakeTranscriptionModel,
LOCAL_FAKE_DETERMINISTIC_MODEL_ID,
} from '@/lib/ai-gateway/local-fake-llm';
import type { OpenRouterModel } from '@/lib/organizations/organization-types';
@@ -36,6 +39,14 @@ describe('local fake deterministic model', () => {
expect(isLocalFakeDeterministicModel('kilo-auto/efficient')).toBe(false);
});
+ test('matches the fake transcription catalog ids, bare and kilo-prefixed', () => {
+ expect(isLocalFakeTranscriptionModel('fake-transcribe')).toBe(true);
+ expect(isLocalFakeTranscriptionModel('fake-transcribe-broken')).toBe(true);
+ expect(isLocalFakeTranscriptionModel('kilo/fake-transcribe')).toBe(true);
+ expect(isLocalFakeTranscriptionModel('openai/whisper-1')).toBe(false);
+ expect(isLocalFakeTranscriptionModel(null)).toBe(false);
+ });
+
test('is enabled only in local development with an absolute FAKE_LLM_URL', () => {
const enabled = replaceEnv({ NODE_ENV: 'development', FAKE_LLM_URL: 'http://localhost:8811' });
expect(isLocalFakeLlmEnabled()).toBe(true);
@@ -113,13 +124,34 @@ describe('local fake deterministic model', () => {
env.restore();
});
+ test('builds a transcription provider and models URL pointed at FAKE_LLM_URL', () => {
+ expect(getLocalFakeTranscriptionProvider()).toBeNull();
+ expect(getLocalFakeTranscriptionModelsUrl()).toBeNull();
+
+ const env = replaceEnv({ NODE_ENV: 'development', FAKE_LLM_URL: 'http://localhost:8811/' });
+ expect(getLocalFakeTranscriptionProvider()).toMatchObject({
+ id: 'openrouter',
+ apiUrl: 'http://localhost:8811/api/openrouter',
+ apiKey: 'local-fake-llm',
+ });
+ expect(getLocalFakeTranscriptionModelsUrl()).toBe(
+ 'http://localhost:8811/api/openrouter/models?output_modalities=transcription'
+ );
+ env.restore();
+ });
+
test('isFreeModel is true only when the local fake LLM is enabled', async () => {
expect(await isFreeModel(LOCAL_FAKE_DETERMINISTIC_MODEL_ID)).toBe(false);
expect(await isFreeModel('kilo/fake-deterministic')).toBe(false);
+ expect(await isFreeModel('fake-transcribe')).toBe(false);
+ expect(await isFreeModel('fake-transcribe-broken')).toBe(false);
const env = replaceEnv({ NODE_ENV: 'development', FAKE_LLM_URL: 'http://localhost:8811' });
expect(await isFreeModel(LOCAL_FAKE_DETERMINISTIC_MODEL_ID)).toBe(true);
expect(await isFreeModel('kilo/fake-deterministic')).toBe(true);
+ expect(await isFreeModel('fake-transcribe')).toBe(true);
+ expect(await isFreeModel('fake-transcribe-broken')).toBe(true);
+ expect(await isFreeModel('openai/whisper-1')).toBe(false);
expect(await isFreeModel('anthropic/claude-sonnet-4')).toBe(false);
env.restore();
});
diff --git a/apps/web/src/lib/ai-gateway/local-fake-llm.ts b/apps/web/src/lib/ai-gateway/local-fake-llm.ts
index adf6cb674e..34c6191920 100644
--- a/apps/web/src/lib/ai-gateway/local-fake-llm.ts
+++ b/apps/web/src/lib/ai-gateway/local-fake-llm.ts
@@ -1,9 +1,19 @@
import { buildDirectProvider } from '@/lib/ai-gateway/experiments/build-direct-provider';
+import { OPENROUTER } from '@/lib/ai-gateway/providers/openrouter-definition';
import type { Provider } from '@/lib/ai-gateway/providers/types';
import type { OpenRouterModel } from '@/lib/organizations/organization-types';
export const LOCAL_FAKE_DETERMINISTIC_MODEL_ID = 'fake-deterministic';
+/**
+ * Speech-to-text catalog ids served by the local fake LLM
+ * (`services/cloud-agent-next/test/e2e/fake-llm-server.ts`). Keep in sync with
+ * its `TRANSCRIPTION_MODELS`.
+ */
+export const LOCAL_FAKE_TRANSCRIPTION_MODEL_IDS = ['fake-transcribe', 'fake-transcribe-broken'];
+
+export const LOCAL_FAKE_LLM_API_KEY = 'local-fake-llm';
+
function parseAbsoluteHttpUrl(value: string | undefined): URL | null {
if (!value) return null;
try {
@@ -22,6 +32,11 @@ export function isLocalFakeDeterministicModel(id: string | undefined | null): bo
);
}
+export function isLocalFakeTranscriptionModel(id: string | undefined | null): boolean {
+ if (!id) return false;
+ return LOCAL_FAKE_TRANSCRIPTION_MODEL_IDS.some(model => id === model || id === `kilo/${model}`);
+}
+
export function isLocalFakeLlmEnabled(): boolean {
if (process.env.NODE_ENV !== 'development') return false;
if (process.env.VERCEL) return false;
@@ -79,8 +94,29 @@ export function getLocalFakeLlmProvider(): Provider | null {
{
base_url: `${baseUrl}/api/openrouter`,
internal_id: LOCAL_FAKE_DETERMINISTIC_MODEL_ID,
- api_key: 'local-fake-llm',
+ api_key: LOCAL_FAKE_LLM_API_KEY,
},
null
);
}
+
+/**
+ * OpenRouter-shaped provider that points speech-to-text traffic at the local
+ * fake LLM, for e2e runs without a real OpenRouter key.
+ */
+export function getLocalFakeTranscriptionProvider(): Provider | null {
+ const url = parseAbsoluteHttpUrl(process.env.FAKE_LLM_URL);
+ if (!isLocalFakeLlmEnabled() || !url) return null;
+ return {
+ ...OPENROUTER,
+ apiUrl: `${url.href.replace(/\/$/, '')}/api/openrouter`,
+ apiKey: LOCAL_FAKE_LLM_API_KEY,
+ };
+}
+
+/** Full transcription-models catalog URL on the local fake LLM, or null. */
+export function getLocalFakeTranscriptionModelsUrl(): string | null {
+ const url = parseAbsoluteHttpUrl(process.env.FAKE_LLM_URL);
+ if (!isLocalFakeLlmEnabled() || !url) return null;
+ return `${url.href.replace(/\/$/, '')}/api/openrouter/models?output_modalities=transcription`;
+}
diff --git a/apps/web/src/lib/ai-gateway/providers/get-provider.local-fake.test.ts b/apps/web/src/lib/ai-gateway/providers/get-provider.local-fake.test.ts
index 8d4865d951..7bf7e05513 100644
--- a/apps/web/src/lib/ai-gateway/providers/get-provider.local-fake.test.ts
+++ b/apps/web/src/lib/ai-gateway/providers/get-provider.local-fake.test.ts
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test } from '@jest/globals';
-import { getProvider } from '@/lib/ai-gateway/providers/get-provider';
+import { getProvider, getTranscriptionProvider } from '@/lib/ai-gateway/providers/get-provider';
import { OPENROUTER, VERCEL_AI_GATEWAY } from '@/lib/ai-gateway/providers/provider-definitions';
import { shouldRouteToVercel } from '@/lib/ai-gateway/providers/vercel';
import type { GatewayRequest } from '@/lib/ai-gateway/providers/openrouter/types';
@@ -114,6 +114,20 @@ describe('getProvider local fake deterministic routing', () => {
missingUrl.restore();
});
+ test('routes transcription requests to FAKE_LLM_URL only when enabled', async () => {
+ expect(await getTranscriptionProvider()).toEqual({ provider: OPENROUTER, userByok: null });
+
+ const env = replaceEnv({ NODE_ENV: 'development', FAKE_LLM_URL: 'http://localhost:8811' });
+ const { provider, userByok } = await getTranscriptionProvider();
+ expect(provider).toMatchObject({
+ id: 'openrouter',
+ apiUrl: 'http://localhost:8811/api/openrouter',
+ apiKey: 'local-fake-llm',
+ });
+ expect(userByok).toBeNull();
+ env.restore();
+ });
+
describe.each(['minimax/minimax-m3:free', 'minimax/minimax-m2.7:free'])('%s', modelId => {
test.each([
{ routeToVercel: false, provider: OPENROUTER },
diff --git a/apps/web/src/lib/ai-gateway/providers/get-provider.ts b/apps/web/src/lib/ai-gateway/providers/get-provider.ts
index 3c9aff49a0..ce748bc30b 100644
--- a/apps/web/src/lib/ai-gateway/providers/get-provider.ts
+++ b/apps/web/src/lib/ai-gateway/providers/get-provider.ts
@@ -35,6 +35,7 @@ import { decryptApiKey } from '@/lib/ai-gateway/byok/encryption';
import { BYOK_ENCRYPTION_KEY } from '@/lib/config.server';
import {
getLocalFakeLlmProvider,
+ getLocalFakeTranscriptionProvider,
isLocalFakeDeterministicModel,
isLocalFakeLlmEnabled,
} from '@/lib/ai-gateway/local-fake-llm';
@@ -350,5 +351,11 @@ export async function getTranscriptionProvider(): Promise<{
provider: Provider;
userByok: BYOKResult[] | null;
}> {
+ if (isLocalFakeLlmEnabled()) {
+ const localFakeProvider = getLocalFakeTranscriptionProvider();
+ if (localFakeProvider) {
+ return { provider: localFakeProvider, userByok: null };
+ }
+ }
return { provider: OPENROUTER, userByok: null };
}
diff --git a/apps/web/src/lib/ai-gateway/providers/openrouter-definition.ts b/apps/web/src/lib/ai-gateway/providers/openrouter-definition.ts
new file mode 100644
index 0000000000..166944ce39
--- /dev/null
+++ b/apps/web/src/lib/ai-gateway/providers/openrouter-definition.ts
@@ -0,0 +1,18 @@
+import { getEnvVariable } from '@/lib/dotenvx';
+import type { Provider } from '@/lib/ai-gateway/providers/types';
+
+/**
+ * Self-contained OpenRouter provider definition. Kept in a leaf module so
+ * importers that only need `OPENROUTER` (for example the local fake LLM) do
+ * not pull the rest of the provider graph into a circular dependency.
+ */
+export const OPENROUTER = {
+ id: 'openrouter',
+ apiUrl: 'https://openrouter.ai/api/v1',
+ apiUrlOverrides: {},
+ apiKey: getEnvVariable('OPENROUTER_API_KEY'),
+ apiKeyHeader: null,
+ supportedChatApis: ['chat_completions', 'messages', 'responses'],
+ responseTransforms: null,
+ async transformRequest() {},
+} as const satisfies Provider;
diff --git a/apps/web/src/lib/ai-gateway/providers/openrouter/index.test.ts b/apps/web/src/lib/ai-gateway/providers/openrouter/index.test.ts
index 9df639875f..54568ab442 100644
--- a/apps/web/src/lib/ai-gateway/providers/openrouter/index.test.ts
+++ b/apps/web/src/lib/ai-gateway/providers/openrouter/index.test.ts
@@ -350,4 +350,16 @@ describe('OpenRouter transcription model fetcher', () => {
expect.any(Object)
);
});
+
+ it('never caches the transcription catalogue so an unreachable gateway surfaces as a failure', async () => {
+ // The Data Cache would keep serving a stale catalogue while the gateway
+ // is down, and the mobile picker would list dead models instead of its
+ // load-failed retry state.
+ await getOpenRouterTranscriptionModels();
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({ cache: 'no-store' })
+ );
+ });
});
diff --git a/apps/web/src/lib/ai-gateway/providers/openrouter/index.ts b/apps/web/src/lib/ai-gateway/providers/openrouter/index.ts
index f1584f2267..9e951d45fb 100644
--- a/apps/web/src/lib/ai-gateway/providers/openrouter/index.ts
+++ b/apps/web/src/lib/ai-gateway/providers/openrouter/index.ts
@@ -4,6 +4,10 @@ import {
preferredModels,
} from '@/lib/ai-gateway/models';
import { isFreeModel } from '@/lib/ai-gateway/is-free-model';
+import {
+ getLocalFakeTranscriptionModelsUrl,
+ LOCAL_FAKE_LLM_API_KEY,
+} from '@/lib/ai-gateway/local-fake-llm';
import { OPENROUTER } from '@/lib/ai-gateway/providers/provider-definitions';
import type { OpenRouterModel } from '@/lib/organizations/organization-types';
import {
@@ -286,14 +290,21 @@ export async function getEnhancedOpenRouterModels(): Promise {
- const response = await fetch(`${OPENROUTER.apiUrl}/models?output_modalities=transcription`, {
- method: 'GET',
- headers: {
- Authorization: `Bearer ${OPENROUTER.apiKey}`,
- ...ATTRIBUTION_HEADERS,
- },
- next: { revalidate: 60 },
- });
+ const localFakeModelsUrl = getLocalFakeTranscriptionModelsUrl();
+ const response = await fetch(
+ localFakeModelsUrl ?? `${OPENROUTER.apiUrl}/models?output_modalities=transcription`,
+ {
+ method: 'GET',
+ headers: {
+ Authorization: `Bearer ${localFakeModelsUrl ? LOCAL_FAKE_LLM_API_KEY : OPENROUTER.apiKey}`,
+ ...ATTRIBUTION_HEADERS,
+ },
+ // Never serve a cached catalogue: a cached answer would mask an
+ // unreachable gateway and the mobile picker would list stale models
+ // instead of showing its load-failed retry state.
+ cache: 'no-store',
+ }
+ );
if (!response.ok) {
const errorMessage = `Failed to fetch OpenRouter transcription models: ${response.status} ${response.statusText}`;
diff --git a/apps/web/src/lib/ai-gateway/providers/provider-definitions.ts b/apps/web/src/lib/ai-gateway/providers/provider-definitions.ts
index 861b210d6d..3d3cfc864f 100644
--- a/apps/web/src/lib/ai-gateway/providers/provider-definitions.ts
+++ b/apps/web/src/lib/ai-gateway/providers/provider-definitions.ts
@@ -6,17 +6,9 @@ import {
type ProviderId,
} from '@/lib/ai-gateway/providers/types';
import { applyVercelSettings } from '@/lib/ai-gateway/providers/vercel';
+import { OPENROUTER } from './openrouter-definition';
-export const OPENROUTER = {
- id: 'openrouter',
- apiUrl: 'https://openrouter.ai/api/v1',
- apiUrlOverrides: {},
- apiKey: getEnvVariable('OPENROUTER_API_KEY'),
- apiKeyHeader: null,
- supportedChatApis: ['chat_completions', 'messages', 'responses'],
- responseTransforms: null,
- async transformRequest() {},
-} as const satisfies Provider;
+export { OPENROUTER };
export const ALIBABA = {
id: 'alibaba',
diff --git a/apps/web/src/lib/feature-detection.ts b/apps/web/src/lib/feature-detection.ts
index 3508461b45..dd0b8779ba 100644
--- a/apps/web/src/lib/feature-detection.ts
+++ b/apps/web/src/lib/feature-detection.ts
@@ -44,6 +44,7 @@ export const FEATURE_VALUES = [
'kiloclaw-embedding',
'openclaw-embedding',
'gastown',
+ 'mobile-voice-input',
] as const;
const featureSchema = z.enum(FEATURE_VALUES);
diff --git a/apps/web/src/tests/openrouter-models.test.ts b/apps/web/src/tests/openrouter-models.test.ts
index 35d6cbb199..95bbbb9437 100644
--- a/apps/web/src/tests/openrouter-models.test.ts
+++ b/apps/web/src/tests/openrouter-models.test.ts
@@ -5,6 +5,7 @@ import { GET as gatewayV1ModelsGET } from '../app/api/gateway/v1/models/route';
import { GET as transcriptionModelsGET } from '../app/api/gateway/transcription-models/route';
import {
getEnhancedOpenRouterModels,
+ getOpenRouterTranscriptionModels,
getRawOpenRouterModels,
} from '@/lib/ai-gateway/providers/openrouter';
import { isFreeModel } from '@/lib/ai-gateway/is-free-model';
@@ -944,6 +945,47 @@ describe('final Enkrypt serialization boundaries', () => {
);
});
+describe('getOpenRouterTranscriptionModels', () => {
+ function mockTranscriptionFetch() {
+ const fetchMock = jest
+ .fn, Parameters>()
+ .mockResolvedValue(createMockResponse({ jsonData: mockOpenRouterModels }));
+ global.fetch = fetchMock as unknown as typeof fetch;
+ return fetchMock;
+ }
+
+ test('hits the local fake LLM models URL in local fake mode', async () => {
+ const nextEnv = {
+ ...process.env,
+ NODE_ENV: 'development',
+ FAKE_LLM_URL: 'http://localhost:8811',
+ };
+ const env = jest.replaceProperty(process, 'env', nextEnv as NodeJS.ProcessEnv);
+ const fetchMock = mockTranscriptionFetch();
+
+ const result = await getOpenRouterTranscriptionModels();
+
+ expect(fetchMock.mock.calls[0]?.[0]).toBe(
+ 'http://localhost:8811/api/openrouter/models?output_modalities=transcription'
+ );
+ const headers = fetchMock.mock.calls[0]?.[1]?.headers as Record;
+ expect(headers.Authorization).toBe('Bearer local-fake-llm');
+ expect(result.data).toBeDefined();
+ env.restore();
+ });
+
+ test('hits OpenRouter outside local fake mode', async () => {
+ const fetchMock = mockTranscriptionFetch();
+
+ const result = await getOpenRouterTranscriptionModels();
+
+ expect(fetchMock.mock.calls[0]?.[0]).toBe(
+ 'https://openrouter.ai/api/v1/models?output_modalities=transcription'
+ );
+ expect(result.data).toBeDefined();
+ });
+});
+
afterEach(() => {
global.fetch = originalFetch;
jest.restoreAllMocks();
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fcd13dc5c3..1dda60f9b4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -454,6 +454,9 @@ importers:
expo-application:
specifier: ~57.0.2
version: 57.0.2(expo@57.0.21)
+ expo-audio:
+ specifier: ~57.0.4
+ version: 57.0.4(expo-asset@57.0.16(expo@57.0.21)(react-native@0.86.3(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3))(expo@57.0.21)(react-native@0.86.3(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
expo-battery:
specifier: ~57.0.2
version: 57.0.2(expo@57.0.21)(react@19.2.3)
@@ -13085,6 +13088,14 @@ packages:
react: '*'
react-native: '*'
+ expo-audio@57.0.4:
+ resolution: {integrity: sha512-TLP8rt1UvUDzgxGnyQ0TR9hV6tNP/UJQdDu7mSK+dgEydMFoptq55D3hcUoB1gF39f3/3AUuWfOtpGM+4N4X1A==}
+ peerDependencies:
+ expo: '*'
+ expo-asset: '*'
+ react: '*'
+ react-native: '*'
+
expo-battery@57.0.2:
resolution: {integrity: sha512-3++v5kSUj4aOKKTkLtJwU57UOrC0GORj4eM2Tcg2mJjdUNHHxxBes4x6w/D5LAOQmvgVsHF1Z5CP8TfQe4rsQw==}
peerDependencies:
@@ -31089,6 +31100,13 @@ snapshots:
- typescript
optional: true
+ expo-audio@57.0.4(expo-asset@57.0.16(expo@57.0.21)(react-native@0.86.3(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3))(expo@57.0.21)(react-native@0.86.3(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
+ dependencies:
+ expo: 57.0.21(@babel/core@7.29.7)(@expo/metro-runtime@57.0.15)(bufferutil@4.1.0)(expo-router@57.0.20)(expo-widgets@57.0.18)(react-dom@19.2.6(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(react-native@0.86.3(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
+ expo-asset: 57.0.16(expo@57.0.21)(react-native@0.86.3(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)
+ react: 19.2.3
+ react-native: 0.86.3(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
+
expo-battery@57.0.2(expo@57.0.21)(react@19.2.3):
dependencies:
expo: 57.0.21(@babel/core@7.29.7)(@expo/metro-runtime@57.0.15)(bufferutil@4.1.0)(expo-router@57.0.20)(expo-widgets@57.0.18)(react-dom@19.2.6(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(react-native@0.86.3(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
diff --git a/services/cloud-agent-next/test/e2e/fake-llm-server.ts b/services/cloud-agent-next/test/e2e/fake-llm-server.ts
index def28fbbc8..c55cd8a4fd 100644
--- a/services/cloud-agent-next/test/e2e/fake-llm-server.ts
+++ b/services/cloud-agent-next/test/e2e/fake-llm-server.ts
@@ -87,6 +87,8 @@ type ServerState = {
nextRequestId: number;
/** Count of dispatched completions, exposed for fail-fast scenario assertions. */
chatCompletionRequests: number;
+ /** Count of dispatched audio/transcriptions calls, exposed for fail-fast scenario assertions. */
+ transcriptionRequests: number;
scenarios: Map;
};
@@ -182,6 +184,40 @@ export function stripKiloPromptWrapping(text: string): string {
return text.replace(/[\s\S]*?<\/environment_details>/gi, '').trim();
}
+/**
+ * Extract a top-level field's value from a `multipart/form-data` body.
+ *
+ * node:http ships no formData parser and the harness must not gain a runtime
+ * dependency for one, so this does the minimal boundary split the gateway
+ * proxy path needs: parts are separated by `--` lines, each part
+ * carries a `Content-Disposition` header whose `name=""` selects it,
+ * and the value is everything between the header block and the next
+ * delimiter. Binary file parts survive as mangled utf8 — irrelevant, since
+ * only text fields (currently `model`) are read.
+ *
+ * Returns the decoded value, or null when the field is absent.
+ */
+export function extractMultipartField(
+ body: string,
+ boundary: string,
+ field: string
+): string | null {
+ const delimiter = `--${boundary}`;
+ for (const part of body.split(delimiter)) {
+ const headerEnd = part.indexOf('\r\n\r\n');
+ if (headerEnd < 0) continue;
+ const disposition = part
+ .slice(0, headerEnd)
+ .split('\r\n')
+ .find(line => /^content-disposition:/i.test(line));
+ if (!disposition) continue;
+ if (disposition.match(/name="([^"]*)"/)?.[1] !== field) continue;
+ // Drop the `\r\n` that frames the value against the next delimiter.
+ return part.slice(headerEnd + 4).replace(/\r\n$/, '');
+ }
+ return null;
+}
+
function isRecord(value: unknown): value is Record {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
@@ -425,6 +461,27 @@ function modelsCatalogue(): { data: Array } {
return { data: [FAKE_MODEL] };
}
+/**
+ * Speech-to-text catalogue served for `GET /api/openrouter/models` with
+ * `output_modalities=transcription`. Two ids: the happy path the mobile e2e
+ * scenarios address, and a broken one whose transcription request 404s so the
+ * non-retryable unhappy state is provable without an upstream key.
+ */
+const TRANSCRIPTION_MODELS = [
+ {
+ id: 'fake-transcribe',
+ name: 'Fake Transcribe',
+ context_length: 128000,
+ pricing: { prompt: '0', completion: '0' },
+ },
+ {
+ id: 'fake-transcribe-broken',
+ name: 'Broken Transcriber',
+ context_length: 128000,
+ pricing: { prompt: '0', completion: '0' },
+ },
+];
+
// ---------------------------------------------------------------------------
// SSE framing helpers
// ---------------------------------------------------------------------------
@@ -983,6 +1040,87 @@ async function handleChatCompletions(
}
}
+/**
+ * `POST /api/openrouter/audio/transcriptions` — the speech-to-text leg the
+ * Kilo gateway proxy dials. Accepts the two shapes that reach it: a
+ * `multipart/form-data` body (mobile proxy path) or the JSON
+ * `{ model, input_audio: { data, format } }` the web proxy forwards.
+ * `fake-transcribe-broken` 404s so scenarios can drive the non-retryable
+ * unhappy state; every other model returns the fixed transcript.
+ */
+async function handleAudioTranscriptions(
+ req: IncomingMessage,
+ res: ServerResponse,
+ state: ServerState
+): Promise {
+ state.transcriptionRequests += 1;
+ const reqLogId = ++state.nextRequestId;
+ const startedAt = Date.now();
+
+ const contentType = req.headers['content-type'] ?? '';
+ const multipart = contentType.startsWith('multipart/form-data');
+ const raw = await readBody(req);
+
+ let model: string | null = null;
+ let invalidBody: string | null = null;
+ if (multipart) {
+ const boundary = contentType.match(/boundary=(?:"([^"]+)"|([^;\s]+))/);
+ const delimiter = boundary?.[1] ?? boundary?.[2];
+ if (delimiter) {
+ model = extractMultipartField(raw, delimiter, 'model');
+ } else {
+ invalidBody = 'multipart body is missing a boundary';
+ }
+ } else {
+ try {
+ const body: unknown = JSON.parse(raw);
+ if (isRecord(body) && typeof body.model === 'string') model = body.model;
+ else invalidBody = 'model field is required';
+ } catch {
+ invalidBody = 'invalid JSON body';
+ }
+ }
+
+ logEvent('request.start', {
+ reqId: reqLogId,
+ route: 'POST /api/openrouter/audio/transcriptions',
+ mode: multipart ? 'multipart' : 'json',
+ model: model ?? undefined,
+ });
+
+ const fail = (status: number, message: string, type: string, reason: string): void => {
+ writeJsonError(res, status, message, type);
+ logEvent('request.end', {
+ reqId: reqLogId,
+ status,
+ reason,
+ durationMs: Date.now() - startedAt,
+ });
+ };
+
+ if (invalidBody) {
+ fail(400, invalidBody, 'invalid_request', 'invalid-body');
+ return;
+ }
+ if (model === null) {
+ fail(400, 'model field is required', 'invalid_request', 'missing-model');
+ return;
+ }
+ if (model === 'fake-transcribe-broken') {
+ fail(404, `model not found: ${model}`, 'model_not_found', 'model-not-found');
+ return;
+ }
+
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ text: 'Gateway transcription online' }));
+ logEvent('request.end', {
+ reqId: reqLogId,
+ status: 200,
+ reason: 'finished',
+ durationMs: Date.now() - startedAt,
+ });
+}
+
function handleRelease(req: IncomingMessage, res: ServerResponse, state: ServerState): void {
const url = new URL(req.url ?? '/', 'http://localhost');
const tag = url.searchParams.get('tag');
@@ -1033,8 +1171,13 @@ function handleGateStatus(req: IncomingMessage, res: ServerResponse, state: Serv
res.end(JSON.stringify({ tag, engaged }));
}
-function handleModels(res: ServerResponse): void {
+function handleModels(req: IncomingMessage, res: ServerResponse): void {
+ const url = new URL(req.url ?? '/', 'http://localhost');
res.writeHead(200, { 'Content-Type': 'application/json' });
+ if (url.searchParams.get('output_modalities') === 'transcription') {
+ res.end(JSON.stringify({ data: TRANSCRIPTION_MODELS }));
+ return;
+ }
res.end(JSON.stringify(modelsCatalogue()));
}
@@ -1064,7 +1207,12 @@ async function handleModelValidation(req: IncomingMessage, res: ServerResponse):
function handleRequestCounts(res: ServerResponse, state: ServerState): void {
res.writeHead(200, { 'Content-Type': 'application/json' });
- res.end(JSON.stringify({ chatCompletions: state.chatCompletionRequests }));
+ res.end(
+ JSON.stringify({
+ chatCompletions: state.chatCompletionRequests,
+ transcriptions: state.transcriptionRequests,
+ })
+ );
}
function handleScenarioStatus(req: IncomingMessage, res: ServerResponse, state: ServerState): void {
@@ -1117,6 +1265,7 @@ export async function startFakeLlmServer(opts?: {
liveResponses: new Set(),
nextRequestId: 0,
chatCompletionRequests: 0,
+ transcriptionRequests: 0,
scenarios: new Map(),
};
@@ -1127,7 +1276,7 @@ export async function startFakeLlmServer(opts?: {
const route = `${req.method ?? 'GET'} ${url.pathname}`;
if (route === 'GET /api/openrouter/models') {
- handleModels(res);
+ handleModels(req, res);
return;
}
if (
@@ -1156,6 +1305,17 @@ export async function startFakeLlmServer(opts?: {
});
return;
}
+ if (route === 'POST /api/openrouter/audio/transcriptions') {
+ handleAudioTranscriptions(req, res, state).catch(err => {
+ console.error('fake-llm audio/transcriptions error:', err);
+ if (!res.headersSent) {
+ writeJsonError(res, 500, 'internal error', 'server_error');
+ } else {
+ res.end();
+ }
+ });
+ return;
+ }
if (route === 'POST /test/release') {
handleRelease(req, res, state);
return;
diff --git a/services/cloud-agent-next/test/integration/session/admission-recovery.test.ts b/services/cloud-agent-next/test/integration/session/admission-recovery.test.ts
index 48a1cb39b7..57797b5dc0 100644
--- a/services/cloud-agent-next/test/integration/session/admission-recovery.test.ts
+++ b/services/cloud-agent-next/test/integration/session/admission-recovery.test.ts
@@ -337,6 +337,7 @@ describe('forward-only clone reporting admission', () => {
async failFirstWrite => {
const input = cloneRegistrationInput(new Date().toISOString());
const release = Promise.withResolvers();
+ const ownAnchorMetadata: unknown[] = [];
let shouldFail = failFirstWrite;
let anchorAttempts = 0;
vi.mocked(sessionReports.ensureCloneSessionReport).mockImplementation(async metadata => {
@@ -347,6 +348,7 @@ describe('forward-only clone reporting admission', () => {
// Only this session's anchor attempts may gate the slow-write simulation or
// count toward the anchor budget.
if (metadata?.identity.sessionId !== input.identity.sessionId) return;
+ ownAnchorMetadata.push(metadata);
anchorAttempts += 1;
await release.promise;
if (shouldFail) {
@@ -389,13 +391,12 @@ describe('forward-only clone reporting admission', () => {
await progress;
expect(reports).toEqual([]);
expect(await listPendingSessionMessages(instance.ctx.storage)).toEqual([]);
- expect(sessionReports.ensureCloneSessionReport).toHaveBeenCalledWith(
+ expect(ownAnchorMetadata[0]).toEqual(
expect.objectContaining({
auth: expect.objectContaining({ kiloSessionId: destinationKiloSessionId }),
clone: input.clone,
initialMessage: { id: firstMessageId },
- }),
- expect.anything()
+ })
);
release.resolve();
await vi.waitFor(() => expect(reports).toHaveLength(2));
diff --git a/services/cloud-agent-next/test/unit/fake-llm-server.test.ts b/services/cloud-agent-next/test/unit/fake-llm-server.test.ts
index ff8a836313..8e441d221f 100644
--- a/services/cloud-agent-next/test/unit/fake-llm-server.test.ts
+++ b/services/cloud-agent-next/test/unit/fake-llm-server.test.ts
@@ -13,6 +13,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { streamEventSchema } from '../e2e/client.js';
import {
extractLastUserMessageText,
+ extractMultipartField,
parseDirective,
startFakeLlmServer,
stripKiloPromptWrapping,
@@ -135,6 +136,30 @@ describe('stripKiloPromptWrapping', () => {
});
});
+describe('extractMultipartField', () => {
+ const boundary = '----kilo-fake-llm-form';
+
+ function multipart(parts: string[]): string {
+ return parts.map(part => `--${boundary}\r\n${part}\r\n`).join('') + `--${boundary}--\r\n`;
+ }
+
+ it('extracts the model field from a record with model and file parts', () => {
+ const body = multipart([
+ 'Content-Disposition: form-data; name="model"\r\n\r\nfake-transcribe',
+ 'Content-Disposition: form-data; name="file"; filename="audio.wav"\r\nContent-Type: audio/wav\r\n\r\nRIFFbinary-audio-bytes',
+ ]);
+ expect(extractMultipartField(body, boundary, 'model')).toBe('fake-transcribe');
+ expect(extractMultipartField(body, boundary, 'file')).toBe('RIFFbinary-audio-bytes');
+ });
+
+ it('returns null when the model part is missing', () => {
+ const body = multipart([
+ 'Content-Disposition: form-data; name="file"; filename="audio.wav"\r\n\r\nRIFF',
+ ]);
+ expect(extractMultipartField(body, boundary, 'model')).toBeNull();
+ });
+});
+
// ---------------------------------------------------------------------------
// End-to-end HTTP tests against an ephemeral server
// ---------------------------------------------------------------------------
@@ -338,16 +363,81 @@ describe('fake-llm-server HTTP', () => {
await expect(organizationAvailable.json()).resolves.toEqual({ valid: true });
});
- it('reports chat completion request counts for fail-fast assertions', async () => {
+ it('reports chat completion and transcription request counts for fail-fast assertions', async () => {
const h = await start();
const before = await fetch(`${h.url}/test/requests`);
- await expect(before.json()).resolves.toEqual({ chatCompletions: 0 });
+ await expect(before.json()).resolves.toEqual({ chatCompletions: 0, transcriptions: 0 });
const response = await postChat(h.url, '__fake__:echo:hello');
expect(response.status).toBe(200);
const after = await fetch(`${h.url}/test/requests`);
- await expect(after.json()).resolves.toEqual({ chatCompletions: 1 });
+ await expect(after.json()).resolves.toEqual({ chatCompletions: 1, transcriptions: 0 });
+ });
+
+ it('serves the transcription catalogue when output_modalities=transcription', async () => {
+ const h = await start();
+ const res = await fetch(`${h.url}/api/openrouter/models?output_modalities=transcription`);
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ data: Array<{
+ id: string;
+ name: string;
+ context_length: number;
+ pricing: { prompt: string; completion: string };
+ }>;
+ };
+ expect(body.data.map(m => m.id)).toEqual(['fake-transcribe', 'fake-transcribe-broken']);
+ expect(body.data[0]).toMatchObject({
+ name: 'Fake Transcribe',
+ context_length: 128000,
+ pricing: { prompt: '0', completion: '0' },
+ });
+
+ // Without the query the chat catalogue is unchanged.
+ const plain = await fetch(`${h.url}/api/openrouter/models`);
+ const plainBody = (await plain.json()) as { data: Array<{ id: string }> };
+ expect(plainBody.data.some(m => m.id === 'fake-deterministic')).toBe(true);
+ expect(plainBody.data.some(m => m.id === 'fake-transcribe')).toBe(false);
+ });
+
+ async function postMultipartTranscription(url: string, model: string): Promise {
+ const form = new FormData();
+ form.set('model', model);
+ form.set('file', new Blob([new Uint8Array([1, 2, 3, 4])], { type: 'audio/wav' }), 'clip.wav');
+ return fetch(`${url}/api/openrouter/audio/transcriptions`, { method: 'POST', body: form });
+ }
+
+ it('transcribes multipart audio with the happy-path model', async () => {
+ const h = await start();
+ const res = await postMultipartTranscription(h.url, 'fake-transcribe');
+ expect(res.status).toBe(200);
+ await expect(res.json()).resolves.toEqual({ text: 'Gateway transcription online' });
+ const counts = await fetch(`${h.url}/test/requests`);
+ await expect(counts.json()).resolves.toEqual({ chatCompletions: 0, transcriptions: 1 });
+ });
+
+ it('returns 404 for the broken transcription model', async () => {
+ const h = await start();
+ const res = await postMultipartTranscription(h.url, 'fake-transcribe-broken');
+ expect(res.status).toBe(404);
+ const body = (await res.json()) as { error: { message: string; code: number } };
+ expect(body.error.message).toBe('model not found: fake-transcribe-broken');
+ expect(body.error.code).toBe(404);
+ });
+
+ it('transcribes JSON input_audio bodies forwarded by the web proxy', async () => {
+ const h = await start();
+ const res = await fetch(`${h.url}/api/openrouter/audio/transcriptions`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ model: 'fake-transcribe',
+ input_audio: { data: 'aGVsbG8=', format: 'wav' },
+ }),
+ });
+ expect(res.status).toBe(200);
+ await expect(res.json()).resolves.toEqual({ text: 'Gateway transcription online' });
});
it('returns HTTP 404 for routes outside the fake gateway contract', async () => {
diff --git a/services/cloud-agent-next/test/unit/wrapper/auto-commit.test.ts b/services/cloud-agent-next/test/unit/wrapper/auto-commit.test.ts
index a4bd5c218e..c8a20060cb 100644
--- a/services/cloud-agent-next/test/unit/wrapper/auto-commit.test.ts
+++ b/services/cloud-agent-next/test/unit/wrapper/auto-commit.test.ts
@@ -711,7 +711,8 @@ describe('runAutoCommit', () => {
mockHasGitUpstream.mockReset();
await fs.rm(root, { recursive: true, force: true });
}
- }
+ },
+ 30_000
);
it('commits and pushes with the isolated worktree environment instead of wrapper credentials', async () => {
@@ -770,7 +771,7 @@ describe('runAutoCommit', () => {
mockHasGitUpstream.mockReset();
await fs.rm(root, { recursive: true, force: true });
}
- });
+ }, 30_000);
it('aborts the generation request on caller cancellation without staging, committing, or pushing', async () => {
vi.useFakeTimers();
diff --git a/services/cloud-agent-next/vitest.config.ts b/services/cloud-agent-next/vitest.config.ts
index b64091215d..0e598f5b69 100644
--- a/services/cloud-agent-next/vitest.config.ts
+++ b/services/cloud-agent-next/vitest.config.ts
@@ -1,7 +1,23 @@
-import { defineConfig } from 'vitest/config';
+import { readFileSync } from 'node:fs';
+import { defineConfig, type Plugin } from 'vitest/config';
+
+// Mirrors the wrangler.jsonc Text rule for `**/*.sql`: drizzle/migrations.js
+// imports migration SQL as modules. `vitest related` analyzes every test file's
+// import graph regardless of vi.mock factories, so without this loader the
+// .sql files fail to parse as JavaScript and the command exits 1.
+const sqlAsText: Plugin = {
+ name: 'wrangler-sql-as-text',
+ enforce: 'pre',
+ load(id) {
+ const file = id.split('?')[0];
+ if (!file.endsWith('.sql')) return null;
+ return `export default ${JSON.stringify(readFileSync(file, 'utf8'))};`;
+ },
+};
// Unit tests - run in Node (fast, supports vi.mock and global mocking)
export default defineConfig({
+ plugins: [sqlAsText],
test: {
name: 'unit',
globals: true,
diff --git a/services/cloud-agent-next/wrapper/src/lifecycle.test.ts b/services/cloud-agent-next/wrapper/src/lifecycle.test.ts
index 3a3e192f16..ae60ab07e2 100644
--- a/services/cloud-agent-next/wrapper/src/lifecycle.test.ts
+++ b/services/cloud-agent-next/wrapper/src/lifecycle.test.ts
@@ -18,6 +18,23 @@ function wait(ms: number): Promise {
return new Promise(resolve => setTimeout(resolve, ms));
}
+/**
+ * The stable-idle drain emits `complete` only after `finalizeDrain` probes the
+ * workspace branch with a git subprocess, so a fixed short wait races that
+ * spawn. Poll for the event instead of assuming it lands inside the wait.
+ */
+async function waitForStreamEvent(
+ events: IngestEvent[],
+ streamEventType: IngestEvent['streamEventType'],
+ timeoutMs = 10_000
+): Promise {
+ const start = Date.now();
+ while (!events.some(event => event.streamEventType === streamEventType)) {
+ if (Date.now() - start > timeoutMs) return;
+ await wait(25);
+ }
+}
+
describe('wrapper lifecycle drain races', () => {
it('clears aborted state when activity cancels an aborted drain', async () => {
const state = new WrapperState();
@@ -57,9 +74,9 @@ describe('wrapper lifecycle drain races', () => {
lifecycle.onSessionIdle();
await wait(3_050);
-
+ await waitForStreamEvent(events, 'complete');
expect(events.map(event => event.streamEventType)).toContain('complete');
- });
+ }, 15_000);
it('does not complete, close, or clear a session when reset interrupts an active drain', async () => {
const state = new WrapperState();
@@ -118,8 +135,9 @@ describe('wrapper lifecycle drain races', () => {
expect(events.map(event => event.streamEventType)).not.toContain('complete');
await wait(150);
+ await waitForStreamEvent(events, 'complete');
expect(events.map(event => event.streamEventType)).toContain('complete');
- });
+ }, 15_000);
it('requires a fresh stable idle interval after root activity', async () => {
const state = new WrapperState();
@@ -153,6 +171,7 @@ describe('wrapper lifecycle drain races', () => {
expect(events.map(event => event.streamEventType)).not.toContain('complete');
await wait(500);
+ await waitForStreamEvent(events, 'complete');
expect(events.filter(event => event.streamEventType === 'complete')).toHaveLength(1);
- }, 10_000);
+ }, 20_000);
});
diff --git a/services/cloud-agent-next/wrapper/src/restore-session.test.ts b/services/cloud-agent-next/wrapper/src/restore-session.test.ts
index b761141db8..de43be3c5e 100644
--- a/services/cloud-agent-next/wrapper/src/restore-session.test.ts
+++ b/services/cloud-agent-next/wrapper/src/restore-session.test.ts
@@ -105,6 +105,20 @@ function writeSlowMockKilo(binDir: string, startedMarker?: string): void {
fs.writeFileSync(kiloPath, script, { mode: 0o755 });
}
+/**
+ * Poll for a file's existence instead of watching events: fs.watch delivery
+ * on macOS lags or drops events when the full suite runs under load.
+ */
+async function waitForFile(filePath: string, timeoutMs: number): Promise {
+ const deadline = Date.now() + timeoutMs;
+ while (!fs.existsSync(filePath)) {
+ if (Date.now() > deadline) {
+ throw new Error(`File did not appear within ${timeoutMs}ms: ${filePath}`);
+ }
+ await new Promise(resolve => setTimeout(resolve, 10));
+ }
+}
+
function writeSignalTerminatedMockKilo(binDir: string): void {
const script = '#!/bin/sh\nkill -TERM $$\n';
const kiloPath = path.join(binDir, 'kilo');
@@ -882,16 +896,6 @@ await Bun.write(process.env.RESTORE_CAPTURE_PATH, JSON.stringify({
it('terminates kilo import when the workspace deadline is aborted after import starts', async () => {
mockFetchOk(makeSnapshot([]));
writeSlowMockKilo(binDir, path.join(workspace, 'import-started'));
- const importStarted = Promise.withResolvers();
- const watcher = fs.watch(
- workspace,
- { signal: AbortSignal.timeout(3_000) },
- (_event, filename) => {
- if (filename === 'import-started') importStarted.resolve();
- }
- );
- watcher.on('error', importStarted.reject);
- watcher.on('close', () => importStarted.reject(new Error('Import did not start')));
const controller = new AbortController();
const restoring = restoreSession(SESSION_ID, workspace, undefined, {
importTimeoutMs: 5_000,
@@ -900,7 +904,7 @@ await Bun.write(process.env.RESTORE_CAPTURE_PATH, JSON.stringify({
});
try {
- await importStarted.promise;
+ await waitForFile(path.join(workspace, 'import-started'), 10_000);
const startedAt = Date.now();
controller.abort();
const result = await restoring;
@@ -915,7 +919,6 @@ await Bun.write(process.env.RESTORE_CAPTURE_PATH, JSON.stringify({
expect(snapshotDirectories()).toEqual([]);
} finally {
controller.abort();
- watcher.close();
await restoring;
}
});
diff --git a/tools/i18n/check-catalogs.mjs b/tools/i18n/check-catalogs.mjs
index 00e7c6f6ba..9bbdcba0a9 100644
--- a/tools/i18n/check-catalogs.mjs
+++ b/tools/i18n/check-catalogs.mjs
@@ -109,9 +109,11 @@ const ENGLISH_IDENTICAL_ALLOWLIST = new Set([
'agentChat.prBadge.label',
'share.reviewPrSubtitle',
// Format-only strings with no translatable words: a placeholder-only screen
- // title and a placeholder-plus-UTC time-range label.
+ // title, a placeholder-plus-UTC time-range label, and a pure $t() reference
+ // that names the transcription-model section title.
'prReview.screen.title',
'securityAgent.auditReport.periodUtc',
+ 'preferences.transcriptionModel',
]);
/** The supported tags, read from the one source of truth. */