cynarAI 459d7c99e4
Pick the voice from a carousel in the app language, local voice only (#166)
* Pick the voice from a carousel in the app language, local voice only

Voice settings now list only the voices that speak the app language
(German shows German voices, English shows English ones) as a horizontal
carousel: one card per voice, a play button next to the name, tap to
make it the active voice. Changing the app language moves the saved
voice to that language. The hosted voice providers (ElevenLabs, OpenAI,
Cartesia, Fish Audio) are removed; Kokoro on the server is the only
voice. E2E runs against a stub Kokoro server so the carousel is covered.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxoSHAyzMXvdtwcDJZcn57

* test(e2e): measure the workspace panel width after its transition settles

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxoSHAyzMXvdtwcDJZcn57

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 22:20:06 +02:00

65 lines
2.9 KiB
TypeScript

import { createHash } from "node:crypto";
import type { AiDataUse, AiRecipient } from "@rakazo/contracts";
import { BUILTIN_MEMORY_PROVIDER_ID } from "./builtin-memory-provider.js";
import { MEDIA_COMFYUI_PROVIDER_ID, MEDIA_TRANSCRIPTION_PROVIDER_ID } from "./media-settings.js";
import { localBaseUrl } from "./pi-local-provider.js";
import { listPiCatalog } from "./pi-models.js";
import { voiceCatalogEntry } from "./voice-factory.js";
const PRIVACY_URLS: Record<string, string> = {
cursor: "https://cursor.com/privacy",
openrouter: "https://openrouter.ai/privacy",
openai: "https://openai.com/policies/privacy-policy/",
"openai-codex": "https://openai.com/policies/privacy-policy/",
anthropic: "https://www.anthropic.com/legal/privacy",
google: "https://policies.google.com/privacy",
"vercel-ai-gateway": "https://vercel.com/legal/privacy-policy",
supermemory: "https://supermemory.ai/privacy",
};
/** Never expose endpoint paths, query parameters, user info, or credentials in a disclosure. */
export function aiRecipient(input: {
provider: string;
use: AiDataUse;
modelId?: string;
baseUrl?: string;
}): Omit<AiRecipient, "allowed"> | null {
if (input.provider === "scripted") return null;
const endpoint = input.baseUrl ?? (input.provider === "local" ? localBaseUrl() : undefined);
const origin = endpoint ? new URL(endpoint).origin : undefined;
const name = recipientName(input.provider, input.use);
// Bind custom connections to the full endpoint without exposing private URL components.
const key = createHash("sha256")
.update(JSON.stringify([input.use, input.provider, endpoint ?? "", input.modelId ?? ""]))
.digest("hex");
return {
key,
name: origin ? `${name} (${origin})` : name,
use: input.use,
detail: [
input.modelId ? `Model: ${input.modelId}.` : "",
["openrouter", "vercel-ai-gateway"].includes(input.provider)
? `${name} forwards requests to model providers using the routing and privacy settings configured for this connection.`
: "",
]
.filter(Boolean)
.join(" "),
privacyUrl: PRIVACY_URLS[input.provider],
};
}
function recipientName(provider: string, use: AiDataUse) {
// The transcription server also takes dictation, so it keeps its name for voice use.
if (provider === MEDIA_TRANSCRIPTION_PROVIDER_ID) return "Transcription";
if (use === "voice") return voiceCatalogEntry(provider)?.name ?? provider;
if (use === "memory") {
if (provider === "supermemory") return "Supermemory";
// Built-in memory keeps items in this deployment; its texts go to the embeddings server.
if (provider === BUILTIN_MEMORY_PROVIDER_ID) return "Embeddings";
return provider;
}
if (provider === "cursor") return "Cursor";
if (provider === MEDIA_COMFYUI_PROVIDER_ID) return "ComfyUI";
return listPiCatalog().find((entry) => entry.provider === provider)?.providerName ?? provider;
}