Voice UX: Kann sprechen ohne Kokoro-Branding (#75)
* Voice UX: simple speak toggle, onboarding pick, chat voice change Replace Kokoro-branded settings with a Kann sprechen / Spricht nicht toggle, onboarding voice personalization, and local chat commands for voice changes. German voice presets (Eva, Bernd) surface as friendly names; Kokoro stays under the hood via KOKORO_BASE_URL. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * Fix import order in voice onboarding and settings Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
parent
c746c51c78
commit
8ca23ad609
@ -196,9 +196,7 @@ export default function VoiceSettings() {
|
||||
<>
|
||||
{isKokoro ? (
|
||||
<Text style={styles.help}>
|
||||
{t(
|
||||
"No API key. Start Kokoro-FastAPI on port 8880, then tap Connect local Kokoro.",
|
||||
)}
|
||||
{t("Local voice runs on your server. Tap Connect when it is ready.")}
|
||||
</Text>
|
||||
) : (
|
||||
<TextInput
|
||||
@ -226,9 +224,7 @@ export default function VoiceSettings() {
|
||||
? isKokoro
|
||||
? t("Reconnect")
|
||||
: t("Replace key")
|
||||
: isKokoro
|
||||
? t("Connect local Kokoro")
|
||||
: t("Connect")}
|
||||
: t("Connect")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{credential ? (
|
||||
|
||||
@ -51,6 +51,12 @@ export async function completeOnboarding(page: Page, testInfo?: TestInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
// Voice step: skip when shown
|
||||
const voiceHeading = page.getByRole("heading", { name: /Pick a voice/i });
|
||||
if (await voiceHeading.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await page.getByRole("button", { name: /^Skip$/i }).click();
|
||||
}
|
||||
|
||||
// Optional Server integrations step (needsSetup). Skip when shown, then the
|
||||
// first bot is created automatically — land in Chief's chat with no form.
|
||||
const integrations = page.getByRole("heading", { name: "Server integrations", exact: true });
|
||||
|
||||
@ -606,5 +606,21 @@
|
||||
"Nothing is still running": "Nichts läuft mehr",
|
||||
"Credentials are encrypted and never sent to the model.": "Zugangsdaten werden verschlüsselt und dem Modell nie gesendet.",
|
||||
"Rename section": "Abschnitt umbenennen",
|
||||
"Could not rename section": "Abschnitt konnte nicht umbenannt werden"
|
||||
"Could not rename section": "Abschnitt konnte nicht umbenannt werden",
|
||||
"Can speak": "Kann sprechen",
|
||||
"Does not speak": "Spricht nicht",
|
||||
"Your Oo reads replies aloud when this is on.": "Dein Oo liest Antworten laut vor, wenn das an ist.",
|
||||
"Speaking is on.": "Sprechen ist an.",
|
||||
"Speaking is off.": "Sprechen ist aus.",
|
||||
"Could not enable speaking": "Sprechen konnte nicht aktiviert werden",
|
||||
"Could not update speaking": "Sprechen konnte nicht aktualisiert werden",
|
||||
"Other voice providers": "Andere Sprachanbieter",
|
||||
"Provider": "Anbieter",
|
||||
"Loading voice settings…": "Spracheinstellungen werden geladen…",
|
||||
"Pick a voice": "Stimme wählen",
|
||||
"How should your Oo sound when reading replies aloud?": "Wie soll dein Oo klingen, wenn er Antworten vorliest?",
|
||||
"Loading voices…": "Stimmen werden geladen…",
|
||||
"Voice is not available yet. You can turn it on later in settings.": "Stimme ist noch nicht verfügbar. Du kannst sie später in den Einstellungen aktivieren.",
|
||||
"Voice set to {0}.": "Stimme auf {0} gesetzt.",
|
||||
"Could not update voice": "Stimme konnte nicht aktualisiert werden"
|
||||
}
|
||||
|
||||
107
apps/web/src/components/onboarding/OnboardingVoiceStep.tsx
Normal file
107
apps/web/src/components/onboarding/OnboardingVoiceStep.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import type { VoiceInfo } from "@rakazo/contracts";
|
||||
import { defaultVoiceForLocale, preferVoicesForLocale } from "@rakazo/core";
|
||||
import { Button } from "@rakazo/ui-web";
|
||||
import { useEffect, useState } from "react";
|
||||
import { rpc } from "../../lib/rpc";
|
||||
import { readUiLocale } from "../../lib/ui-locale";
|
||||
|
||||
/** Must match packages/adapters KokoroVoiceProvider placeholder. */
|
||||
export const KOKORO_LOCAL_TOKEN = "local-kokoro";
|
||||
|
||||
type OnboardingVoiceStepProps = {
|
||||
selectedVoiceId: string;
|
||||
onSelectedVoiceIdChange: (voiceId: string) => void;
|
||||
onBack: () => void;
|
||||
onSkip: () => void;
|
||||
onContinue: () => void;
|
||||
};
|
||||
|
||||
export function OnboardingVoiceStep({
|
||||
selectedVoiceId,
|
||||
onSelectedVoiceIdChange,
|
||||
onBack,
|
||||
onSkip,
|
||||
onContinue,
|
||||
}: OnboardingVoiceStepProps) {
|
||||
const [voiceOptions, setVoiceOptions] = useState<VoiceInfo[]>([]);
|
||||
const [voiceLoading, setVoiceLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setVoiceLoading(true);
|
||||
void (async () => {
|
||||
try {
|
||||
await rpc.voice.connect({ provider: "kokoro", apiKey: KOKORO_LOCAL_TOKEN });
|
||||
const listed = await rpc.voice.voices({ provider: "kokoro" });
|
||||
if (cancelled) return;
|
||||
const locale = readUiLocale();
|
||||
const ordered = preferVoicesForLocale(listed, locale).slice(0, 6);
|
||||
setVoiceOptions(ordered);
|
||||
const defaultVoice = defaultVoiceForLocale(listed, locale);
|
||||
if (defaultVoice) onSelectedVoiceIdChange(defaultVoice.id);
|
||||
} catch {
|
||||
if (!cancelled) setVoiceOptions([]);
|
||||
} finally {
|
||||
if (!cancelled) setVoiceLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [onSelectedVoiceIdChange]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
||||
<Trans>Pick a voice</Trans>
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<Trans>How should your Oo sound when reading replies aloud?</Trans>
|
||||
</p>
|
||||
{voiceLoading ? (
|
||||
<p className="mt-6 text-sm text-muted-foreground">
|
||||
<Trans>Loading voices…</Trans>
|
||||
</p>
|
||||
) : voiceOptions.length > 0 ? (
|
||||
<div className="mt-6 grid grid-cols-2 gap-2">
|
||||
{voiceOptions.map((voice) => (
|
||||
<button
|
||||
key={voice.id}
|
||||
type="button"
|
||||
onClick={() => onSelectedVoiceIdChange(voice.id)}
|
||||
aria-pressed={selectedVoiceId === voice.id}
|
||||
className={`rounded-xl border-2 px-3 py-3 text-left text-sm font-medium transition-all ${
|
||||
selectedVoiceId === voice.id
|
||||
? "border-primary bg-primary/10 text-foreground shadow-sm"
|
||||
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<span className="block text-foreground">{voice.label}</span>
|
||||
{voice.description ? (
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">{voice.description}</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-6 text-sm text-muted-foreground">
|
||||
<Trans>Voice is not available yet. You can turn it on later in settings.</Trans>
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={onBack}>
|
||||
<Trans>Back</Trans>
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onSkip}>
|
||||
<Trans>Skip</Trans>
|
||||
</Button>
|
||||
<Button disabled={!selectedVoiceId} onClick={onContinue}>
|
||||
<Trans>Continue</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -121,6 +121,20 @@ export function resolveUiLocale(options: ResolveUiLocaleOptions = {}): UiLocale
|
||||
return "en";
|
||||
}
|
||||
|
||||
export function readUiLocale(
|
||||
storage: Pick<Storage, "getItem"> | null = typeof localStorage !== "undefined"
|
||||
? localStorage
|
||||
: null,
|
||||
): UiLocale {
|
||||
if (!storage) return "en";
|
||||
try {
|
||||
const stored = storage.getItem(UI_LOCALE_STORAGE_KEY);
|
||||
return isUiLocale(stored) ? stored : "en";
|
||||
} catch {
|
||||
return "en";
|
||||
}
|
||||
}
|
||||
|
||||
export function persistUiLocale(
|
||||
locale: UiLocale,
|
||||
storage: Pick<Storage, "setItem"> | null = typeof localStorage !== "undefined"
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import type { IntegrationSetupState, Me } from "@rakazo/contracts";
|
||||
import type { IntegrationSetupState } from "@rakazo/contracts";
|
||||
import { Button, Input } from "@rakazo/ui-web";
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@ -9,6 +9,10 @@ import {
|
||||
type OnboardingEmojiConfig,
|
||||
OnboardingOoDesigner,
|
||||
} from "../components/onboarding/OnboardingOoDesigner";
|
||||
import {
|
||||
KOKORO_LOCAL_TOKEN,
|
||||
OnboardingVoiceStep,
|
||||
} from "../components/onboarding/OnboardingVoiceStep";
|
||||
import {
|
||||
computeDiscoveryItems,
|
||||
countConfiguredIntegrations,
|
||||
@ -142,8 +146,10 @@ export function OnboardingPage() {
|
||||
const navigate = useNavigate();
|
||||
const fieldId = useId();
|
||||
const [step, setStep] = useState<
|
||||
"loading" | "emoji" | "preferences" | "language" | "integrations" | "bot"
|
||||
"loading" | "emoji" | "preferences" | "language" | "voice" | "integrations" | "bot"
|
||||
>("loading");
|
||||
const [selectedVoiceId, setSelectedVoiceId] = useState("");
|
||||
const [voiceSkipped, setVoiceSkipped] = useState(false);
|
||||
const [integrationSetup, setIntegrationSetup] = useState<IntegrationSetupState | null>(null);
|
||||
const needsIntegrationSetup = integrationSetup?.needsSetup ?? false;
|
||||
const [integrationServers, setIntegrationServers] = useState<string[]>([]);
|
||||
@ -184,7 +190,10 @@ export function OnboardingPage() {
|
||||
const discoveryPhase: OnboardingDiscoveryPhase =
|
||||
step === "loading"
|
||||
? "loading"
|
||||
: step === "emoji" || step === "preferences" || step === "language"
|
||||
: step === "emoji" ||
|
||||
step === "preferences" ||
|
||||
step === "language" ||
|
||||
step === "voice"
|
||||
? "emoji"
|
||||
: step === "integrations"
|
||||
? "integrations"
|
||||
@ -231,6 +240,16 @@ export function OnboardingPage() {
|
||||
ctxParts.length > 0 ? `## User Context\n${ctxParts.map((p) => `- ${p}`).join("\n")}` : "";
|
||||
await rpc.bots.update({ botId: bot.id, instructions: userCtx }).catch(() => null);
|
||||
|
||||
if (selectedVoiceId && !voiceSkipped) {
|
||||
await rpc.voice
|
||||
.connect({ provider: "kokoro", apiKey: KOKORO_LOCAL_TOKEN, voiceId: selectedVoiceId })
|
||||
.catch(() => null);
|
||||
await rpc.voice.setVoice({ voiceId: selectedVoiceId, provider: "kokoro" }).catch(() => null);
|
||||
await rpc.bots
|
||||
.update({ botId: bot.id, voiceId: selectedVoiceId, autoSpeak: true })
|
||||
.catch(() => null);
|
||||
}
|
||||
|
||||
const me = await rpc.me().catch(() => null);
|
||||
if (me) setBootstrapMe(me);
|
||||
const harvested = await persistUserProfileHarvest(
|
||||
@ -263,6 +282,11 @@ export function OnboardingPage() {
|
||||
void createFirstBot();
|
||||
}, [step]);
|
||||
|
||||
function goAfterVoice(skipped: boolean) {
|
||||
setVoiceSkipped(skipped);
|
||||
setStep(needsIntegrationSetup ? "integrations" : "bot");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-background px-6 py-12" data-rakazo-surface="onboarding">
|
||||
<OnboardingDiscoveryShell
|
||||
@ -271,12 +295,12 @@ export function OnboardingPage() {
|
||||
step === "emoji" ||
|
||||
step === "preferences" ||
|
||||
step === "language" ||
|
||||
step === "voice" ||
|
||||
step === "integrations"
|
||||
}
|
||||
>
|
||||
{error && step !== "bot" ? <p className="mb-4 text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
{/* Step: Emoji Configuration */}
|
||||
{step === "emoji" ? (
|
||||
<OnboardingOoDesigner
|
||||
config={emojiConfig}
|
||||
@ -285,7 +309,6 @@ export function OnboardingPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Step: Preferences */}
|
||||
{step === "preferences" ? (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
||||
@ -412,7 +435,6 @@ export function OnboardingPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Step: Language */}
|
||||
{step === "language" ? (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
||||
@ -429,7 +451,7 @@ export function OnboardingPage() {
|
||||
onClick={() => {
|
||||
persistUiLocale(code as UiLocale);
|
||||
setCurrentLocale(code);
|
||||
setStep(needsIntegrationSetup ? "integrations" : "bot");
|
||||
setStep("voice");
|
||||
}}
|
||||
className={`rounded-xl border-2 px-3 py-3 text-sm font-medium text-left transition-all ${
|
||||
currentLocale === code
|
||||
@ -445,14 +467,23 @@ export function OnboardingPage() {
|
||||
<Button variant="ghost" size="sm" onClick={() => setStep("preferences")}>
|
||||
<Trans>Back</Trans>
|
||||
</Button>
|
||||
<Button onClick={() => setStep(needsIntegrationSetup ? "integrations" : "bot")}>
|
||||
<Button onClick={() => setStep("voice")}>
|
||||
<Trans>Skip · English</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Step: Integrations */}
|
||||
{step === "voice" ? (
|
||||
<OnboardingVoiceStep
|
||||
selectedVoiceId={selectedVoiceId}
|
||||
onSelectedVoiceIdChange={setSelectedVoiceId}
|
||||
onBack={() => setStep("language")}
|
||||
onSkip={() => goAfterVoice(true)}
|
||||
onContinue={() => goAfterVoice(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === "integrations" ? (
|
||||
<div>
|
||||
<h2 className="mb-4 text-lg font-medium text-foreground">
|
||||
@ -469,7 +500,6 @@ export function OnboardingPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Step: Bot creation error */}
|
||||
{step === "bot" && error ? (
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
|
||||
@ -263,7 +263,12 @@ export function SettingsOverlay({
|
||||
/>
|
||||
) : null}
|
||||
{section === "voice" ? (
|
||||
<VoiceSettingsOverlay embedded onClose={requestClose} onBusyChange={setVoiceBusy} />
|
||||
<VoiceSettingsOverlay
|
||||
embedded
|
||||
onClose={requestClose}
|
||||
onBusyChange={setVoiceBusy}
|
||||
activeBotId={activeBotId}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -57,6 +57,8 @@ import {
|
||||
serializeComposerPrompt,
|
||||
speechFromBlocks,
|
||||
truncateSlashDescription,
|
||||
matchVoiceByName,
|
||||
parseVoiceChatCommand,
|
||||
userVisibleMessages,
|
||||
} from "@rakazo/core";
|
||||
import {
|
||||
@ -2339,6 +2341,50 @@ export function ShellPage() {
|
||||
if (permissionRequest) void permissionRequest.then(flushPendingBrowserNotifications);
|
||||
}
|
||||
const trimmed = plan.trimmed;
|
||||
if (botTarget && !groupTarget && plan.shouldSend && attachments.length === 0) {
|
||||
const voiceCmd = parseVoiceChatCommand(trimmed);
|
||||
if (voiceCmd) {
|
||||
setSendError(null);
|
||||
setAttachmentNotice(null);
|
||||
try {
|
||||
const localVoiceToken = "local-kokoro";
|
||||
if (voiceCmd.kind === "speak_off") {
|
||||
await rpc.bots.update({ botId: botTarget, autoSpeak: false });
|
||||
await refreshBots();
|
||||
setAttachmentNotice(t`Speaking is off.`);
|
||||
return;
|
||||
}
|
||||
if (voiceCmd.kind === "speak_on") {
|
||||
await rpc.voice
|
||||
.connect({ provider: "kokoro", apiKey: localVoiceToken })
|
||||
.catch(() => undefined);
|
||||
await rpc.bots.update({ botId: botTarget, autoSpeak: true });
|
||||
await refreshBots();
|
||||
void rpc.voice.status().then(setVoiceStatus).catch(() => undefined);
|
||||
setAttachmentNotice(t`Speaking is on.`);
|
||||
return;
|
||||
}
|
||||
const voices = await rpc.voice.voices({});
|
||||
const match = matchVoiceByName(voices, voiceCmd.voiceName);
|
||||
if (!match) {
|
||||
setSendError(t`Voice "${voiceCmd.voiceName}" not found.`);
|
||||
return;
|
||||
}
|
||||
await rpc.voice
|
||||
.connect({ provider: "kokoro", apiKey: localVoiceToken })
|
||||
.catch(() => undefined);
|
||||
await rpc.voice.setVoice({ voiceId: match.id, provider: "kokoro" });
|
||||
await rpc.bots.update({ botId: botTarget, voiceId: match.id, autoSpeak: true });
|
||||
await refreshBots();
|
||||
void rpc.voice.status().then(setVoiceStatus).catch(() => undefined);
|
||||
setAttachmentNotice(t`Voice set to ${match.label}.`);
|
||||
return;
|
||||
} catch (error) {
|
||||
setSendError(error instanceof Error ? error.message : t`Could not update voice`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
setSending(true);
|
||||
setSendError(null);
|
||||
const dropDelayedSetup = () => {
|
||||
@ -2463,6 +2509,7 @@ export function ShellPage() {
|
||||
flushPendingBrowserNotifications,
|
||||
navigate,
|
||||
pendingAttachments,
|
||||
refreshBots,
|
||||
sending,
|
||||
t,
|
||||
],
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import type { VoiceCatalogEntry, VoiceCredential, VoiceInfo, VoiceStatus } from "@rakazo/contracts";
|
||||
import { preferVoicesForLocale } from "@rakazo/core";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
@ -11,25 +12,31 @@ import {
|
||||
Input,
|
||||
NativeSelect,
|
||||
NativeSelectOption,
|
||||
Switch,
|
||||
} from "@rakazo/ui-web";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { ChevronDownIcon, XIcon } from "lucide-react";
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { ooSound } from "../lib/oo-sound";
|
||||
import { readOoSoundEnabled } from "../lib/oo-sound-pref";
|
||||
import { rpc } from "../lib/rpc";
|
||||
import { readUiLocale } from "../lib/ui-locale";
|
||||
|
||||
/** Must match packages/adapters KokoroVoiceProvider placeholder. */
|
||||
const KOKORO_LOCAL_TOKEN = "local-kokoro";
|
||||
const LOCAL_PROVIDER_ID = "kokoro";
|
||||
|
||||
export function VoiceSettingsOverlay({
|
||||
onClose,
|
||||
embedded = false,
|
||||
onBusyChange,
|
||||
activeBotId,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
/** Render panel body only for the shared Settings shell. */
|
||||
embedded?: boolean;
|
||||
onBusyChange?: (busy: boolean) => void;
|
||||
/** Active bot whose autoSpeak toggle is controlled here. */
|
||||
activeBotId?: string;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
const apiKeyId = useId();
|
||||
@ -38,47 +45,64 @@ export function VoiceSettingsOverlay({
|
||||
const [credentials, setCredentials] = useState<VoiceCredential[]>([]);
|
||||
const [status, setStatus] = useState<VoiceStatus | null>(null);
|
||||
const [voices, setVoices] = useState<VoiceInfo[]>([]);
|
||||
const [provider, setProvider] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [voiceId, setVoiceId] = useState("");
|
||||
const [autoSpeak, setAutoSpeak] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pending, setPending] = useState<"connect" | "disconnect" | "voice" | "test" | null>(null);
|
||||
const [pending, setPending] = useState<
|
||||
"connect" | "disconnect" | "voice" | "test" | "speak" | null
|
||||
>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [soundEnabled, setSoundEnabled] = useState(() => readOoSoundEnabled());
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [advancedProvider, setAdvancedProvider] = useState("");
|
||||
const [advancedApiKey, setAdvancedApiKey] = useState("");
|
||||
|
||||
const busy = pending !== null;
|
||||
const uiLocale = readUiLocale();
|
||||
const localConnected = credentials.some((entry) => entry.provider === LOCAL_PROVIDER_ID);
|
||||
const advancedProviders = catalog.filter((entry) => entry.id !== LOCAL_PROVIDER_ID);
|
||||
const selectedAdvanced =
|
||||
advancedProviders.find((entry) => entry.id === advancedProvider) ?? advancedProviders[0];
|
||||
const advancedCredential = credentials.find((entry) => entry.provider === advancedProvider);
|
||||
|
||||
useEffect(() => {
|
||||
return () => onBusyChange?.(false);
|
||||
}, [onBusyChange]);
|
||||
|
||||
function markPending(next: "connect" | "disconnect" | "voice" | "test" | null) {
|
||||
function markPending(next: "connect" | "disconnect" | "voice" | "test" | "speak" | null) {
|
||||
setPending(next);
|
||||
onBusyChange?.(next !== null);
|
||||
}
|
||||
|
||||
async function refresh(nextProvider?: string) {
|
||||
async function refresh() {
|
||||
const [nextCatalog, nextCredentials, nextStatus] = await Promise.all([
|
||||
rpc.voice.catalog(),
|
||||
rpc.voice.credentials(),
|
||||
rpc.voice.status(),
|
||||
]);
|
||||
const selected = nextProvider || provider || nextStatus.provider || nextCatalog[0]?.id || "";
|
||||
setCatalog(nextCatalog);
|
||||
setCredentials(nextCredentials);
|
||||
setStatus(nextStatus);
|
||||
setProvider(selected);
|
||||
const cred = nextCredentials.find((entry) => entry.provider === selected);
|
||||
const activeVoice = cred?.voiceId ?? "";
|
||||
const localCred = nextCredentials.find((entry) => entry.provider === LOCAL_PROVIDER_ID);
|
||||
const activeVoice = localCred?.voiceId ?? nextStatus.voiceId ?? "";
|
||||
setVoiceId(activeVoice);
|
||||
if (cred) {
|
||||
const listed = await rpc.voice.voices({ provider: selected });
|
||||
if (localCred || nextStatus.ready) {
|
||||
const listed = await rpc.voice.voices({ provider: LOCAL_PROVIDER_ID }).catch(() => []);
|
||||
setVoices(listed);
|
||||
if (!activeVoice && listed[0]) setVoiceId(listed[0].id);
|
||||
} else {
|
||||
setVoices([]);
|
||||
}
|
||||
if (activeBotId) {
|
||||
const bots = await rpc.bots.list();
|
||||
const bot = bots.find((entry) => entry.id === activeBotId);
|
||||
setAutoSpeak(bot?.autoSpeak ?? false);
|
||||
}
|
||||
if (!advancedProvider) {
|
||||
const firstAdvanced = nextCatalog.find((entry) => entry.id !== LOCAL_PROVIDER_ID);
|
||||
if (firstAdvanced) setAdvancedProvider(firstAdvanced.id);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@ -87,49 +111,31 @@ export function VoiceSettingsOverlay({
|
||||
setError(err instanceof Error ? err.message : t`Could not load voice settings`),
|
||||
)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
}, [activeBotId]);
|
||||
|
||||
const selected = catalog.find((entry) => entry.id === provider) ?? catalog[0];
|
||||
const isKokoro = selected?.id === "kokoro";
|
||||
const credential = credentials.find((entry) => entry.provider === provider);
|
||||
const canConnect = isKokoro || apiKey.trim().length >= 8;
|
||||
const voiceOptions = useMemo(
|
||||
() => (voices.length ? voices : voiceId ? [{ id: voiceId, label: voiceId }] : []),
|
||||
[voices, voiceId],
|
||||
);
|
||||
const voiceOptions = useMemo(() => {
|
||||
const listed = preferVoicesForLocale(voices, uiLocale);
|
||||
if (listed.length) return listed;
|
||||
return voiceId ? [{ id: voiceId, label: voiceId }] : [];
|
||||
}, [voices, voiceId, uiLocale]);
|
||||
|
||||
async function connectKey() {
|
||||
if (!selected || !canConnect) return;
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
const speakingReady = Boolean(localConnected && voiceId && status?.ready);
|
||||
|
||||
async function ensureLocalVoice(): Promise<boolean> {
|
||||
if (localConnected) return true;
|
||||
markPending("connect");
|
||||
setError(null);
|
||||
try {
|
||||
await rpc.voice.connect({
|
||||
provider: selected.id,
|
||||
apiKey: isKokoro ? KOKORO_LOCAL_TOKEN : apiKey.trim(),
|
||||
provider: LOCAL_PROVIDER_ID,
|
||||
apiKey: KOKORO_LOCAL_TOKEN,
|
||||
voiceId: voiceId || undefined,
|
||||
});
|
||||
setApiKey("");
|
||||
await refresh(selected.id);
|
||||
setNotice(t`Connected ${selected.name}.`);
|
||||
await refresh();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t`Could not connect this voice provider`);
|
||||
} finally {
|
||||
markPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectProvider() {
|
||||
if (!credential) return;
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
markPending("disconnect");
|
||||
try {
|
||||
await rpc.voice.disconnect({ provider: credential.provider });
|
||||
setApiKey("");
|
||||
await refresh(credential.provider);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t`Could not disconnect this voice provider`);
|
||||
setError(err instanceof Error ? err.message : t`Could not enable speaking`);
|
||||
return false;
|
||||
} finally {
|
||||
markPending(null);
|
||||
}
|
||||
@ -137,12 +143,12 @@ export function VoiceSettingsOverlay({
|
||||
|
||||
async function chooseVoice(nextVoiceId: string) {
|
||||
setVoiceId(nextVoiceId);
|
||||
if (!credential) return;
|
||||
if (!localConnected) return;
|
||||
markPending("voice");
|
||||
setError(null);
|
||||
try {
|
||||
await rpc.voice.setVoice({ voiceId: nextVoiceId, provider: selected?.id });
|
||||
await refresh(selected?.id);
|
||||
await rpc.voice.setVoice({ voiceId: nextVoiceId, provider: LOCAL_PROVIDER_ID });
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t`Could not save that voice`);
|
||||
} finally {
|
||||
@ -150,6 +156,73 @@ export function VoiceSettingsOverlay({
|
||||
}
|
||||
}
|
||||
|
||||
async function setSpeakingEnabled(next: boolean) {
|
||||
setAutoSpeak(next);
|
||||
markPending("speak");
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
if (next) {
|
||||
const connected = await ensureLocalVoice();
|
||||
if (!connected) {
|
||||
setAutoSpeak(false);
|
||||
return;
|
||||
}
|
||||
const listed = await rpc.voice.voices({ provider: LOCAL_PROVIDER_ID }).catch(() => []);
|
||||
const nextVoice = voiceId || listed[0]?.id;
|
||||
if (nextVoice) {
|
||||
await rpc.voice.setVoice({ voiceId: nextVoice, provider: LOCAL_PROVIDER_ID });
|
||||
setVoiceId(nextVoice);
|
||||
}
|
||||
}
|
||||
if (activeBotId) {
|
||||
await rpc.bots.update({ botId: activeBotId, autoSpeak: next });
|
||||
}
|
||||
await refresh();
|
||||
setNotice(next ? t`Speaking is on.` : t`Speaking is off.`);
|
||||
} catch (err) {
|
||||
setAutoSpeak(!next);
|
||||
setError(err instanceof Error ? err.message : t`Could not update speaking`);
|
||||
} finally {
|
||||
markPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function connectAdvanced() {
|
||||
if (!selectedAdvanced || advancedApiKey.trim().length < 8) return;
|
||||
markPending("connect");
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
await rpc.voice.connect({
|
||||
provider: selectedAdvanced.id,
|
||||
apiKey: advancedApiKey.trim(),
|
||||
});
|
||||
setAdvancedApiKey("");
|
||||
await refresh();
|
||||
setNotice(t`Connected ${selectedAdvanced.name}.`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t`Could not connect this voice provider`);
|
||||
} finally {
|
||||
markPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectAdvanced() {
|
||||
if (!advancedCredential) return;
|
||||
markPending("disconnect");
|
||||
setError(null);
|
||||
try {
|
||||
await rpc.voice.disconnect({ provider: advancedCredential.provider });
|
||||
setAdvancedApiKey("");
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t`Could not disconnect this voice provider`);
|
||||
} finally {
|
||||
markPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function testVoice() {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
@ -188,203 +261,187 @@ export function VoiceSettingsOverlay({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-hidden px-6 py-6 sm:px-8 md:flex-row">
|
||||
<div className="flex min-h-0 shrink-0 flex-col md:w-[280px]">
|
||||
<div className="mb-3 text-ui-body text-muted-foreground">
|
||||
<Trans>Providers</Trans>
|
||||
</div>
|
||||
<div className="rk-scroll overflow-y-auto rounded-xl border border-border">
|
||||
{catalog.map((entry) => {
|
||||
const connected = credentials.some((cred) => cred.provider === entry.id);
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setProvider(entry.id);
|
||||
setApiKey("");
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
markPending("voice");
|
||||
void refresh(entry.id)
|
||||
.catch((err: unknown) =>
|
||||
setError(
|
||||
err instanceof Error ? err.message : t`Could not load voice settings`,
|
||||
),
|
||||
)
|
||||
.finally(() => markPending(null));
|
||||
}}
|
||||
className={`flex w-full items-center gap-3 border-b border-border px-3.5 py-3 text-start transition-colors last:border-0 disabled:pointer-events-none disabled:opacity-50 ${
|
||||
entry.id === provider ? "bg-muted" : "hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-ui-body text-foreground">
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-ui-body-sm text-muted-foreground/80">
|
||||
{entry.transcribe ? (
|
||||
<Trans>Speak + transcribe</Trans>
|
||||
) : (
|
||||
<Trans>Speak only</Trans>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
{connected ? (
|
||||
<span className="text-ui-body-sm text-success">
|
||||
<Trans>Connected</Trans>
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="rk-scroll min-h-0 flex-1 overflow-y-auto px-6 py-6 sm:px-8">
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>Loading voice settings…</Trans>
|
||||
</p>
|
||||
) : null}
|
||||
{error ? <p className="mb-4 text-sm text-destructive">{error}</p> : null}
|
||||
{notice ? <p className="mb-4 text-sm text-success">{notice}</p> : null}
|
||||
|
||||
<div className="rounded-xl border border-border px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-ui-body font-medium text-foreground">
|
||||
{autoSpeak ? <Trans>Can speak</Trans> : <Trans>Does not speak</Trans>}
|
||||
</p>
|
||||
<p className="mt-1 text-ui-body-sm text-muted-foreground">
|
||||
<Trans>Your Oo reads replies aloud when this is on.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autoSpeak}
|
||||
disabled={busy}
|
||||
aria-label={t`Speaking on or off`}
|
||||
onCheckedChange={(checked) => void setSpeakingEnabled(checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rk-scroll min-h-0 min-w-0 flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>Loading voice providers…</Trans>
|
||||
</p>
|
||||
) : null}
|
||||
{error ? <p className="mb-4 text-sm text-destructive">{error}</p> : null}
|
||||
{notice ? <p className="mb-4 text-sm text-success">{notice}</p> : null}
|
||||
{selected ? (
|
||||
<>
|
||||
{isKokoro ? (
|
||||
<div className="mt-5 rounded-xl border border-border px-4 py-4">
|
||||
<p className="text-ui-body font-medium text-foreground">
|
||||
<Trans>Local Kokoro (free)</Trans>
|
||||
</p>
|
||||
<p className="mt-2 text-ui-body-sm text-muted-foreground">
|
||||
<Trans>
|
||||
No paid API key. EvOo talks to Kokoro-FastAPI on your server (default
|
||||
http://127.0.0.1:8880). Set KOKORO_BASE_URL if it runs elsewhere.
|
||||
</Trans>
|
||||
</p>
|
||||
<p className="mt-3 font-mono text-xs text-muted-foreground">
|
||||
docker run -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-cpu:latest
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Field className="mt-5">
|
||||
{autoSpeak && speakingReady ? (
|
||||
<>
|
||||
<Field className="mt-6">
|
||||
<FieldLabel htmlFor={voiceSelectId}>
|
||||
<Trans>Voice</Trans>
|
||||
</FieldLabel>
|
||||
<NativeSelect
|
||||
id={voiceSelectId}
|
||||
className="w-full"
|
||||
value={voiceId}
|
||||
disabled={busy}
|
||||
onChange={(event) => void chooseVoice(event.target.value)}
|
||||
>
|
||||
{voiceOptions.map((voice) => (
|
||||
<NativeSelectOption key={voice.id} value={voice.id}>
|
||||
{voice.label}
|
||||
{voice.description ? ` · ${voice.description}` : ""}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="mt-4 rounded-full"
|
||||
disabled={busy || !status?.ready}
|
||||
onClick={() => void testVoice()}
|
||||
>
|
||||
{pending === "test" ? <Trans>Playing…</Trans> : <Trans>Hear a sample</Trans>}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="mt-8 rounded-xl border border-border px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-ui-body font-medium text-foreground">
|
||||
<Trans>Sound feedback</Trans>
|
||||
</p>
|
||||
<p className="mt-1 text-ui-body-sm text-muted-foreground">
|
||||
<Trans>
|
||||
Calm audio cues for finished work, voice phases, and handoffs. Respects reduced
|
||||
motion.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex shrink-0 items-center gap-2 text-ui-body-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={soundEnabled}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
const next = event.target.checked;
|
||||
setSoundEnabled(next);
|
||||
ooSound.setEnabled(next);
|
||||
if (next) ooSound.play("message-sent");
|
||||
}}
|
||||
className="size-4 rounded border-border"
|
||||
/>
|
||||
<Trans>On</Trans>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{advancedProviders.length > 0 ? (
|
||||
<div className="mt-8">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => setAdvancedOpen((open) => !open)}
|
||||
className="flex w-full items-center justify-between rounded-xl border border-border px-4 py-3 text-start text-ui-body text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
<span>
|
||||
<Trans>Other voice providers</Trans>
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={`size-4 shrink-0 transition-transform ${advancedOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{advancedOpen && selectedAdvanced ? (
|
||||
<div className="mt-3 rounded-xl border border-border px-4 py-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor={apiKeyId}>
|
||||
<Trans>Provider</Trans>
|
||||
</FieldLabel>
|
||||
<NativeSelect
|
||||
id={apiKeyId}
|
||||
className="w-full"
|
||||
value={advancedProvider}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
setAdvancedProvider(event.target.value);
|
||||
setAdvancedApiKey("");
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
}}
|
||||
>
|
||||
{advancedProviders.map((entry) => (
|
||||
<NativeSelectOption key={entry.id} value={entry.id}>
|
||||
{entry.name}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Field className="mt-4">
|
||||
<FieldLabel htmlFor={`${apiKeyId}-key`}>
|
||||
<Trans>API key</Trans>
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={apiKeyId}
|
||||
id={`${apiKeyId}-key`}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
placeholder={credential ? t`Paste a replacement key` : t`Paste your API key`}
|
||||
value={advancedApiKey}
|
||||
onChange={(event) => setAdvancedApiKey(event.target.value)}
|
||||
placeholder={
|
||||
advancedCredential ? t`Paste a replacement key` : t`Paste your API key`
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-3"
|
||||
disabled={busy || !canConnect}
|
||||
onClick={() => void connectKey()}
|
||||
>
|
||||
{pending === "connect" ? (
|
||||
<Trans>Connecting…</Trans>
|
||||
) : credential ? (
|
||||
isKokoro ? (
|
||||
<Trans>Reconnect</Trans>
|
||||
) : (
|
||||
<Trans>Replace key</Trans>
|
||||
)
|
||||
) : isKokoro ? (
|
||||
<Trans>Connect local Kokoro</Trans>
|
||||
) : (
|
||||
<Trans>Connect</Trans>
|
||||
)}
|
||||
</Button>
|
||||
{credential ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
disabled={busy}
|
||||
onClick={() => void disconnectProvider()}
|
||||
disabled={busy || advancedApiKey.trim().length < 8}
|
||||
onClick={() => void connectAdvanced()}
|
||||
>
|
||||
{pending === "disconnect" ? (
|
||||
<Trans>Disconnecting…</Trans>
|
||||
{pending === "connect" ? (
|
||||
<Trans>Connecting…</Trans>
|
||||
) : advancedCredential ? (
|
||||
<Trans>Replace key</Trans>
|
||||
) : (
|
||||
<Trans>Disconnect</Trans>
|
||||
<Trans>Connect</Trans>
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<div className="mt-8 rounded-xl border border-border px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-ui-body font-medium text-foreground">
|
||||
<Trans>Sound feedback</Trans>
|
||||
</p>
|
||||
<p className="mt-1 text-ui-body-sm text-muted-foreground">
|
||||
<Trans>
|
||||
Calm audio cues for finished work, voice phases, and handoffs. Respects
|
||||
reduced motion.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex shrink-0 items-center gap-2 text-ui-body-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={soundEnabled}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
const next = event.target.checked;
|
||||
setSoundEnabled(next);
|
||||
ooSound.setEnabled(next);
|
||||
if (next) ooSound.play("message-sent");
|
||||
}}
|
||||
className="size-4 rounded border-border"
|
||||
/>
|
||||
<Trans>On</Trans>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{credential ? (
|
||||
<>
|
||||
<Field className="mt-6">
|
||||
<FieldLabel htmlFor={voiceSelectId}>
|
||||
<Trans>Voice</Trans>
|
||||
</FieldLabel>
|
||||
<NativeSelect
|
||||
id={voiceSelectId}
|
||||
className="w-full"
|
||||
value={voiceId}
|
||||
disabled={busy}
|
||||
onChange={(event) => void chooseVoice(event.target.value)}
|
||||
>
|
||||
{voiceOptions.map((voice) => (
|
||||
<NativeSelectOption key={voice.id} value={voice.id}>
|
||||
{voice.label}
|
||||
{voice.description ? ` · ${voice.description}` : ""}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
{advancedCredential ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="mt-4 rounded-full"
|
||||
disabled={busy || !status?.ready}
|
||||
onClick={() => void testVoice()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
disabled={busy}
|
||||
onClick={() => void disconnectAdvanced()}
|
||||
>
|
||||
{pending === "test" ? <Trans>Playing…</Trans> : <Trans>Hear a sample</Trans>}
|
||||
{pending === "disconnect" ? (
|
||||
<Trans>Disconnecting…</Trans>
|
||||
) : (
|
||||
<Trans>Disconnect</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@ -413,7 +470,7 @@ export function VoiceSettingsOverlay({
|
||||
data-testid="voice-settings"
|
||||
aria-describedby={undefined}
|
||||
showCloseButton={false}
|
||||
className="flex h-[min(680px,calc(100%-2rem))] w-[920px] flex-col gap-0 overflow-hidden rounded-2xl p-0 sm:h-[min(680px,calc(100%-5rem))] sm:max-w-[calc(100%-5rem)]"
|
||||
className="flex h-[min(680px,calc(100%-2rem))] w-[min(720px,calc(100%-2rem))] flex-col gap-0 overflow-hidden rounded-2xl p-0 sm:h-[min(680px,calc(100%-5rem))] sm:max-w-[calc(100%-5rem)]"
|
||||
>
|
||||
{body}
|
||||
</DialogContent>
|
||||
|
||||
@ -805,7 +805,7 @@ export const builtinAgentTools: ConnectorTool[] = [
|
||||
{
|
||||
name: "update_bot",
|
||||
description:
|
||||
"Update this bot's own profile fields that the user sees in chat: name (header and list label), title (short role line), and description. Call this when the user asks you to rename yourself or change your title/description. Do not claim you updated the profile without calling this tool.",
|
||||
"Update this bot's own profile fields that the user sees in chat: name (header and list label), title (short role line), description, speaking voice, and whether replies are read aloud. Call this when the user asks you to rename yourself, change your title/description, pick a different voice (e.g. Eva, Bernd), or turn speaking on/off. Do not claim you updated the profile without calling this tool.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@ -821,6 +821,15 @@ export const builtinAgentTools: ConnectorTool[] = [
|
||||
type: "string",
|
||||
description: "Longer blurb describing what this bot does.",
|
||||
},
|
||||
voice_id: {
|
||||
type: "string",
|
||||
description:
|
||||
"Voice id from the connected provider (e.g. df_eva, dm_bernd). Match the user's requested voice name.",
|
||||
},
|
||||
auto_speak: {
|
||||
type: "boolean",
|
||||
description: "When true, read replies aloud. When false, stay silent.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -3151,13 +3151,24 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
return spawned;
|
||||
}
|
||||
if (name === "update_bot") {
|
||||
const patch: { name?: string; title?: string; description?: string } = {};
|
||||
const patch: {
|
||||
name?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
voiceId?: string | null;
|
||||
autoSpeak?: boolean;
|
||||
} = {};
|
||||
if (args.name !== undefined) patch.name = String(args.name);
|
||||
if (args.title !== undefined) patch.title = String(args.title);
|
||||
if (args.description !== undefined) patch.description = String(args.description);
|
||||
if (args.voice_id !== undefined) {
|
||||
const nextVoiceId = String(args.voice_id).trim();
|
||||
patch.voiceId = nextVoiceId.length > 0 ? nextVoiceId : null;
|
||||
}
|
||||
if (args.auto_speak !== undefined) patch.autoSpeak = Boolean(args.auto_speak);
|
||||
if (Object.keys(patch).length === 0) {
|
||||
return finish({
|
||||
error: "Provide at least one of name, title, or description.",
|
||||
error: "Provide at least one of name, title, description, voice_id, or auto_speak.",
|
||||
});
|
||||
}
|
||||
if (patch.name !== undefined) {
|
||||
@ -3198,7 +3209,14 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
const updated = await deps.prisma.bot.update({
|
||||
where: { id: bot.id },
|
||||
data: patch,
|
||||
select: { id: true, name: true, title: true, description: true },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
title: true,
|
||||
description: true,
|
||||
voiceId: true,
|
||||
autoSpeak: true,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await deps.events.append({
|
||||
@ -3223,6 +3241,8 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
name: updated.name,
|
||||
title: updated.title,
|
||||
description: updated.description,
|
||||
voiceId: updated.voiceId,
|
||||
autoSpeak: updated.autoSpeak,
|
||||
});
|
||||
}
|
||||
if (name === "message_user") {
|
||||
@ -3509,7 +3529,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
"create_space proposes a new privacy boundary inside the current organization. Use it when the user asks to create a space or separate data between teams or projects. It always pauses for explicit user approval; never claim the space exists before the tool succeeds.",
|
||||
"create_project creates a shared project group for 2–6 agents when the user asks for a new project. Include yourself plus relevant teammates in bot_ids. Creating the project is the whole action unless the user also asked to start work in it.",
|
||||
"spawn_bot creates a lasting regular bot (own chat, computer, memory) that appears in the user's bot list. If the user asked to create a bot, call spawn_bot once and stop. Do not run_subagent to demo it.",
|
||||
"update_bot updates this bot's own name (chat header / list label), title, and description. When the user asks you to rename yourself or change your title or description, call update_bot — do not claim you changed them without the tool.",
|
||||
"update_bot updates this bot's own name (chat header / list label), title, description, voice_id, and auto_speak. When the user asks you to rename yourself, change your title or description, pick a voice, or turn speaking on/off, call update_bot — do not claim you changed them without the tool.",
|
||||
"run_subagent is a short helper inside this turn only. It is not a bot, has no thread, and does not show in the list. Use it for parallel work you will summarize here.",
|
||||
botDirectory,
|
||||
"archive_bot safely archives a bot this bot created, and only that bot. Use it when the user asks to remove that bot or when it is finished and unused. The user can restore it or permanently delete it later. confirm_name must exactly match its name.",
|
||||
|
||||
@ -8,6 +8,7 @@ import type {
|
||||
VoiceSynthesizeRequest,
|
||||
VoiceVerifyResult,
|
||||
} from "@rakazo/adapter-kit";
|
||||
import { formatVoiceLabel, GERMAN_VOICE_PRESETS } from "@rakazo/core";
|
||||
import {
|
||||
readVoiceAudio,
|
||||
readVoiceJson,
|
||||
@ -24,6 +25,7 @@ const MODEL = "kokoro";
|
||||
|
||||
/** Curated presets when the server is down or returns bare voice ids. */
|
||||
export const KOKORO_FALLBACK_VOICES: VoiceInfo[] = [
|
||||
...GERMAN_VOICE_PRESETS,
|
||||
{ id: "af_heart", label: "Heart", description: "Warm · multilingual" },
|
||||
{ id: "af_bella", label: "Bella", description: "Expressive · multilingual" },
|
||||
{ id: "af_sky", label: "Sky", description: "Bright · multilingual" },
|
||||
@ -43,10 +45,7 @@ export function kokoroBaseUrl(): string {
|
||||
}
|
||||
|
||||
export function kokoroUnreachable(): string {
|
||||
return (
|
||||
"Kokoro lokal nicht erreichbar — starte Kokoro-FastAPI auf Port 8880, z. B. " +
|
||||
"docker run -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-cpu:latest"
|
||||
);
|
||||
return "Lokaler Sprachdienst nicht erreichbar. Prüfe, ob der Sprachserver auf Port 8880 läuft.";
|
||||
}
|
||||
|
||||
export class KokoroVoiceProvider implements VoiceProvider {
|
||||
@ -67,9 +66,9 @@ export class KokoroVoiceProvider implements VoiceProvider {
|
||||
if (res.ok) return { ok: true };
|
||||
return {
|
||||
ok: false,
|
||||
message: voiceHttpError(
|
||||
message: voiceHttpError(
|
||||
res.status,
|
||||
"Kokoro",
|
||||
"Local voice",
|
||||
"checking the local server",
|
||||
await readVoiceJson(res),
|
||||
),
|
||||
@ -86,7 +85,7 @@ export class KokoroVoiceProvider implements VoiceProvider {
|
||||
});
|
||||
const body = await readVoiceJson(res, { requireValid: res.ok });
|
||||
if (!res.ok) {
|
||||
throw new Error(voiceHttpError(res.status, "Kokoro", "listing voices", body));
|
||||
throw new Error(voiceHttpError(res.status, "Local voice", "listing voices", body));
|
||||
}
|
||||
const voices = voicesFrom(body);
|
||||
if (voices.length === 0) return [...KOKORO_FALLBACK_VOICES];
|
||||
@ -118,7 +117,7 @@ export class KokoroVoiceProvider implements VoiceProvider {
|
||||
} catch {
|
||||
throw new Error(kokoroUnreachable());
|
||||
}
|
||||
await requireOk(res, "Kokoro", "speaking");
|
||||
await requireOk(res, "Local voice", "speaking");
|
||||
return { bytes: await readVoiceAudio(res, signal), mimeType: "audio/mpeg" };
|
||||
}
|
||||
}
|
||||
@ -150,8 +149,3 @@ function extractVoiceIds(body: unknown): string[] {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function formatVoiceLabel(id: string): string {
|
||||
const suffix = id.replace(/^[a-z]{2}_/, "");
|
||||
if (!suffix) return id;
|
||||
return suffix.charAt(0).toUpperCase() + suffix.slice(1).replace(/_/g, " ");
|
||||
}
|
||||
|
||||
@ -168,7 +168,7 @@ describe("KokoroVoiceProvider", () => {
|
||||
const provider = new KokoroVoiceProvider();
|
||||
const result = await provider.verify(KOKORO_LOCAL_TOKEN, ctx);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toMatch(/Kokoro lokal nicht erreichbar/i);
|
||||
expect(result.message).toMatch(/Lokaler Sprachdienst nicht erreichbar/i);
|
||||
});
|
||||
|
||||
it("maps plain-string and object voice payloads", async () => {
|
||||
|
||||
@ -9,8 +9,8 @@ import { SCRIPTED_VOICE_CATALOG_ENTRY, ScriptedVoiceProvider } from "./scripted-
|
||||
export const VOICE_CATALOG = [
|
||||
{
|
||||
id: "kokoro",
|
||||
name: "Kokoro",
|
||||
description: "Free local TTS (Kokoro-82M). No paid API key — run Kokoro-FastAPI on :8880.",
|
||||
name: "Local voice",
|
||||
description: "Free local speech on your server.",
|
||||
transcribe: false,
|
||||
},
|
||||
{
|
||||
|
||||
@ -45,3 +45,5 @@ export * from "./text-direction.js";
|
||||
export * from "./thread-message-updates.js";
|
||||
export * from "./thread-subscription.js";
|
||||
export * from "./tool-activity.js";
|
||||
export * from "./voice-chat-command.js";
|
||||
export * from "./voice-labels.js";
|
||||
|
||||
61
packages/core/src/voice-chat-command.test.ts
Normal file
61
packages/core/src/voice-chat-command.test.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseVoiceChatCommand } from "./voice-chat-command.js";
|
||||
import { defaultVoiceForLocale, matchVoiceByName } from "./voice-labels.js";
|
||||
|
||||
describe("parseVoiceChatCommand", () => {
|
||||
it("parses slash voice commands", () => {
|
||||
expect(parseVoiceChatCommand("/stimme Eva")).toEqual({
|
||||
kind: "set_voice",
|
||||
voiceName: "Eva",
|
||||
});
|
||||
expect(parseVoiceChatCommand("/voice bernd")).toEqual({
|
||||
kind: "set_voice",
|
||||
voiceName: "bernd",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses natural German voice change requests", () => {
|
||||
expect(parseVoiceChatCommand("Wechsel deine Stimme zu Bernd")).toEqual({
|
||||
kind: "set_voice",
|
||||
voiceName: "Bernd",
|
||||
});
|
||||
expect(parseVoiceChatCommand("Sprich mit der Stimme Eva")).toEqual({
|
||||
kind: "set_voice",
|
||||
voiceName: "Eva",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses speak on/off toggles", () => {
|
||||
expect(parseVoiceChatCommand("Sprich nicht mehr")).toEqual({ kind: "speak_off" });
|
||||
expect(parseVoiceChatCommand("/sprechen")).toEqual({ kind: "speak_on" });
|
||||
expect(parseVoiceChatCommand("Read replies aloud")).toEqual({ kind: "speak_on" });
|
||||
});
|
||||
|
||||
it("returns null for normal chat", () => {
|
||||
expect(parseVoiceChatCommand("Summarize my inbox")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchVoiceByName", () => {
|
||||
const voices = [
|
||||
{ id: "df_eva", label: "Eva", description: "Deutsch · warm" },
|
||||
{ id: "dm_bernd", label: "Bernd", description: "Deutsch · klar" },
|
||||
];
|
||||
|
||||
it("matches label, id, and partial names", () => {
|
||||
expect(matchVoiceByName(voices, "Eva")?.id).toBe("df_eva");
|
||||
expect(matchVoiceByName(voices, "df_eva")?.id).toBe("df_eva");
|
||||
expect(matchVoiceByName(voices, "bernd")?.id).toBe("dm_bernd");
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultVoiceForLocale", () => {
|
||||
it("prefers Eva for German locale", () => {
|
||||
const voices = [
|
||||
{ id: "af_heart", label: "Heart" },
|
||||
{ id: "df_eva", label: "Eva" },
|
||||
{ id: "dm_bernd", label: "Bernd" },
|
||||
];
|
||||
expect(defaultVoiceForLocale(voices, "de")?.id).toBe("df_eva");
|
||||
});
|
||||
});
|
||||
57
packages/core/src/voice-chat-command.ts
Normal file
57
packages/core/src/voice-chat-command.ts
Normal file
@ -0,0 +1,57 @@
|
||||
export type VoiceChatCommand =
|
||||
| { kind: "set_voice"; voiceName: string }
|
||||
| { kind: "speak_on" }
|
||||
| { kind: "speak_off" };
|
||||
|
||||
const SET_VOICE_PATTERNS = [
|
||||
/^(?:\/stimme|\/voice)\s+(.+)$/i,
|
||||
/^(?:ändere|aendere|wechsel|nimm)\s+(?:deine\s+)?stimme\s+(?:zu|auf|in)\s+(.+)$/i,
|
||||
/^(?:change|switch|use)\s+(?:your\s+)?voice\s+(?:to\s+)?(.+)$/i,
|
||||
/^sprich\s+(?:mit\s+)?(?:der\s+)?stimme\s+(.+)$/i,
|
||||
/^(?:nutze|verwende|nimm)\s+(?:die\s+)?stimme\s+(.+)$/i,
|
||||
] as const;
|
||||
|
||||
const SPEAK_ON_PATTERNS = [
|
||||
/^(?:\/sprechen|\/speak)$/i,
|
||||
/^(?:kann\s+wieder\s+)?sprechen$/i,
|
||||
/^(?:lies|lese)\s+(?:antworten\s+)?(?:wieder\s+)?(?:laut\s+)?vor$/i,
|
||||
/^(?:turn\s+)?speak(?:ing)?\s+on$/i,
|
||||
/^read\s+(?:replies\s+)?aloud$/i,
|
||||
] as const;
|
||||
|
||||
const SPEAK_OFF_PATTERNS = [
|
||||
/^(?:\/schweigen|\/mute)$/i,
|
||||
/^(?:sprich\s+)?(?:nicht\s+mehr|nicht\s+wieder)$/i,
|
||||
/^sprich\s+nicht$/i,
|
||||
/^(?:turn\s+)?speak(?:ing)?\s+off$/i,
|
||||
/^stop\s+speaking$/i,
|
||||
/^schweig(?:e)?$/i,
|
||||
] as const;
|
||||
|
||||
function firstCapture(patterns: readonly RegExp[], text: string): string | undefined {
|
||||
for (const pattern of patterns) {
|
||||
const match = pattern.exec(text.trim());
|
||||
const captured = match?.[1]?.trim();
|
||||
if (captured) return captured;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function matchesAny(patterns: readonly RegExp[], text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
return patterns.some((pattern) => pattern.test(trimmed));
|
||||
}
|
||||
|
||||
/** Parse a chat message for local voice control before sending to the agent. */
|
||||
export function parseVoiceChatCommand(text: string): VoiceChatCommand | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (matchesAny(SPEAK_OFF_PATTERNS, trimmed)) return { kind: "speak_off" };
|
||||
if (matchesAny(SPEAK_ON_PATTERNS, trimmed)) return { kind: "speak_on" };
|
||||
|
||||
const voiceName = firstCapture(SET_VOICE_PATTERNS, trimmed);
|
||||
if (voiceName) return { kind: "set_voice", voiceName };
|
||||
|
||||
return null;
|
||||
}
|
||||
57
packages/core/src/voice-labels.ts
Normal file
57
packages/core/src/voice-labels.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import type { VoiceInfo } from "@rakazo/contracts";
|
||||
|
||||
/** Friendly German presets when Kokoro exposes df_/dm_ voices. */
|
||||
export const GERMAN_VOICE_PRESETS: VoiceInfo[] = [
|
||||
{ id: "df_eva", label: "Eva", description: "Deutsch · warm" },
|
||||
{ id: "dm_bernd", label: "Bernd", description: "Deutsch · klar" },
|
||||
{ id: "df_victoria", label: "Victoria", description: "Deutsch · freundlich" },
|
||||
{ id: "dm_martin", label: "Martin", description: "Deutsch · ruhig" },
|
||||
];
|
||||
|
||||
const PRESET_BY_ID = new Map(GERMAN_VOICE_PRESETS.map((voice) => [voice.id, voice]));
|
||||
|
||||
/** Strip engine prefix (df_eva → Eva) for display when no preset exists. */
|
||||
export function formatVoiceLabel(id: string): string {
|
||||
const preset = PRESET_BY_ID.get(id);
|
||||
if (preset) return preset.label;
|
||||
const suffix = id.replace(/^[a-z]{2}_/, "");
|
||||
if (!suffix) return id;
|
||||
return suffix.charAt(0).toUpperCase() + suffix.slice(1).replace(/_/g, " ");
|
||||
}
|
||||
|
||||
export function voiceDescription(id: string, fallback?: string): string | undefined {
|
||||
return PRESET_BY_ID.get(id)?.description ?? fallback;
|
||||
}
|
||||
|
||||
/** Prefer German voices when UI locale is de. */
|
||||
export function preferVoicesForLocale(voices: VoiceInfo[], locale: string): VoiceInfo[] {
|
||||
if (locale !== "de") return voices;
|
||||
const german = voices.filter((voice) => /^d[fm]_/.test(voice.id));
|
||||
if (german.length === 0) return voices;
|
||||
const rest = voices.filter((voice) => !/^d[fm]_/.test(voice.id));
|
||||
return [...german, ...rest];
|
||||
}
|
||||
|
||||
export function defaultVoiceForLocale(voices: VoiceInfo[], locale: string): VoiceInfo | undefined {
|
||||
const ordered = preferVoicesForLocale(voices, locale);
|
||||
if (locale === "de") {
|
||||
return ordered.find((voice) => voice.id === "df_eva") ?? ordered[0];
|
||||
}
|
||||
return ordered[0];
|
||||
}
|
||||
|
||||
/** Match a user-provided name (Eva, bernd, df_eva) against available voices. */
|
||||
export function matchVoiceByName(voices: VoiceInfo[], query: string): VoiceInfo | undefined {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return undefined;
|
||||
const byId = voices.find((voice) => voice.id.toLowerCase() === needle);
|
||||
if (byId) return byId;
|
||||
const byLabel = voices.find((voice) => voice.label.toLowerCase() === needle);
|
||||
if (byLabel) return byLabel;
|
||||
const suffix = needle.replace(/^[a-z]{2}_/, "");
|
||||
return voices.find((voice) => {
|
||||
const label = voice.label.toLowerCase();
|
||||
const idSuffix = voice.id.replace(/^[a-z]{2}_/, "").toLowerCase();
|
||||
return label.includes(suffix) || idSuffix.includes(suffix) || suffix.includes(idSuffix);
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user