diff --git a/apps/web/e2e/first-run-onboarding-ux.spec.ts b/apps/web/e2e/first-run-onboarding-ux.spec.ts index 66a1a7d..d8e3476 100644 --- a/apps/web/e2e/first-run-onboarding-ux.spec.ts +++ b/apps/web/e2e/first-run-onboarding-ux.spec.ts @@ -52,7 +52,7 @@ test("German voice step lists German voices after choosing Deutsch", async ({ pa const germanVoice = page.getByRole("button", { name: /^(Eva|Bernd)$/i }); await expect(germanVoice.first()).toBeVisible({ timeout: 30_000 }); await expect(page.getByRole("button", { name: "Alloy", exact: true })).toHaveCount(0); - await expect(germanVoice.filter({ has: page.locator('[aria-pressed="true"]') }).first()).toBeVisible(); + await expect(page.getByRole("button", { name: /^(Eva|Bernd)$/i, pressed: true })).toBeVisible(); }); test("main conversation does not show a group subtitle for one Oo", async ({ page }) => { diff --git a/apps/web/src/components/onboarding/OnboardingVoiceStep.tsx b/apps/web/src/components/onboarding/OnboardingVoiceStep.tsx index 406c637..d72aaa5 100644 --- a/apps/web/src/components/onboarding/OnboardingVoiceStep.tsx +++ b/apps/web/src/components/onboarding/OnboardingVoiceStep.tsx @@ -1,9 +1,12 @@ import { Trans } from "@lingui/react/macro"; import type { VoiceInfo } from "@rakazo/contracts"; -import { voicesForLocale } from "@rakazo/core"; import { Button } from "@rakazo/ui-web"; import { useEffect, useRef, useState } from "react"; -import { defaultOnboardingVoiceId, offlineOnboardingVoices } from "../../lib/onboarding-voices"; +import { + defaultOnboardingVoiceId, + offlineOnboardingVoices, + onboardingVoicesFromList, +} from "../../lib/onboarding-voices"; import { rpc } from "../../lib/rpc"; import type { UiLocale } from "../../lib/ui-locale"; @@ -39,7 +42,7 @@ export function OnboardingVoiceStep({ await rpc.voice.connect({ provider: "kokoro", apiKey: KOKORO_LOCAL_TOKEN }); const listed = await rpc.voice.voices({ provider: "kokoro" }); if (cancelled) return; - const ordered = voicesForLocale(listed, uiLocale).slice(0, 6); + const ordered = onboardingVoicesFromList(listed, uiLocale); setVoiceOptions(ordered); const nextId = defaultOnboardingVoiceId(uiLocale, ordered); if (nextId) onSelectedVoiceIdChange(nextId); diff --git a/apps/web/src/lib/onboarding-voices.test.ts b/apps/web/src/lib/onboarding-voices.test.ts index d10cea4..29e053e 100644 --- a/apps/web/src/lib/onboarding-voices.test.ts +++ b/apps/web/src/lib/onboarding-voices.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { defaultOnboardingVoiceId, offlineOnboardingVoices } from "./onboarding-voices.js"; +import { + defaultOnboardingVoiceId, + offlineOnboardingVoices, + onboardingVoicesFromList, +} from "./onboarding-voices.js"; describe("offlineOnboardingVoices", () => { it("offers German voices for de", () => { @@ -18,6 +22,18 @@ describe("offlineOnboardingVoices", () => { }); }); +describe("onboardingVoicesFromList", () => { + it("uses offline German presets when the server only lists English voices", () => { + const englishOnly = [{ id: "af_alloy", label: "Alloy", description: "neutral" }]; + expect(onboardingVoicesFromList(englishOnly, "de").map((voice) => voice.id)).toEqual([ + "df_eva", + "dm_bernd", + "df_victoria", + "dm_martin", + ]); + }); +}); + describe("defaultOnboardingVoiceId", () => { it("prefers df_eva for German", () => { expect(defaultOnboardingVoiceId("de", offlineOnboardingVoices("de"))).toBe("df_eva"); diff --git a/apps/web/src/lib/onboarding-voices.ts b/apps/web/src/lib/onboarding-voices.ts index 694ced2..e97dca3 100644 --- a/apps/web/src/lib/onboarding-voices.ts +++ b/apps/web/src/lib/onboarding-voices.ts @@ -13,11 +13,41 @@ const ENGLISH_ONBOARDING_VOICES: VoiceInfo[] = [ const OFFLINE_CATALOG = [...GERMAN_VOICE_PRESETS, ...ENGLISH_ONBOARDING_VOICES]; +const KOKORO_LANGUAGE_PREFIXES: Record = { + de: "d", + en: "ab", + es: "e", + fr: "f", + hi: "h", + it: "i", + ja: "j", + pt: "p", + zh: "z", +}; + +function listedVoicesSpeakLocale(listed: VoiceInfo[], locale: string): boolean { + const language = locale.toLowerCase().split("-")[0] ?? ""; + const prefixes = KOKORO_LANGUAGE_PREFIXES[language]; + if (!prefixes) return listed.length > 0; + return listed.some((voice) => { + const prefix = /^([a-z])[fm]_/.exec(voice.id)?.[1]; + return Boolean(prefix && prefixes.includes(prefix)); + }); +} + /** Locale-aware voice choices for onboarding when listing from the server fails. */ export function offlineOnboardingVoices(locale: string): VoiceInfo[] { return voicesForLocale(OFFLINE_CATALOG, locale).slice(0, 6); } +/** Prefer server voices that speak the UI language; otherwise use offline presets. */ +export function onboardingVoicesFromList(listed: VoiceInfo[], locale: string): VoiceInfo[] { + if (listedVoicesSpeakLocale(listed, locale)) { + return voicesForLocale(listed, locale).slice(0, 6); + } + return offlineOnboardingVoices(locale); +} + export function defaultOnboardingVoiceId(locale: string, voices: VoiceInfo[]): string { return defaultVoiceForLocale(voices, locale)?.id ?? ""; } diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index e88ed00..af77d88 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -1004,9 +1004,11 @@ export function ShellPage() { return; } if (inProject && !botId) { + if (routeOnHeute.current) return; navigate(firstThreadRoute(bootstrap.bots, groupList, true), { replace: true }); return; } + if (routeOnHeute.current) return; const selectedBotId = bootstrap.thread?.botId ?? bootstrap.bots[0]?.id; if (selectedBotId && selectedBotId !== botId) { navigate(`/app/${selectedBotId}`, { replace: true });