Frontend. :)
This commit is contained in:
@@ -99,6 +99,7 @@ export function applyAccent(state: AccentState): void {
|
||||
const shades = shadesFor(base);
|
||||
const root = document.documentElement;
|
||||
for (const k of SHADE_KEYS) root.style.setProperty(`--ka-accent-${k}`, shades[k]);
|
||||
paintFavicon(base);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch { /* ignore */ }
|
||||
@@ -109,6 +110,47 @@ export function previewAccent(baseHex: string): void {
|
||||
const shades = shadesFor(baseHex);
|
||||
const root = document.documentElement;
|
||||
for (const k of SHADE_KEYS) root.style.setProperty(`--ka-accent-${k}`, shades[k]);
|
||||
paintFavicon(baseHex);
|
||||
}
|
||||
|
||||
function mixHex(hex: string, target: number, amount: number): string {
|
||||
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
||||
const n = m ? parseInt(m[1]!, 16) : 0x10b981;
|
||||
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
|
||||
const mix = (v: number) => Math.round(v + (target - v) * amount);
|
||||
const to = (v: number) => mix(v).toString(16).padStart(2, "0");
|
||||
return `#${to(r)}${to(g)}${to(b)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Favicon follows the accent: a mini flower (petals in the accent ramp,
|
||||
* amber core like the logo) swapped in as an SVG data URL, so the tab icon
|
||||
* always matches the platform dress.
|
||||
*/
|
||||
export function paintFavicon(baseHex: string): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const base = /^#?[0-9a-f]{6}$/i.test(baseHex.trim()) ? baseHex.trim().replace(/^#?/, "#") : "#10b981";
|
||||
const dark = mixHex(base, 0, 0.35);
|
||||
const light = mixHex(base, 255, 0.55);
|
||||
const petals = [8, 80, 151, 223, 295].map(
|
||||
(a) => `<ellipse cx="50" cy="28" rx="13" ry="20" fill="${base}" transform="rotate(${a} 50 50)"/>`,
|
||||
).join("");
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">` +
|
||||
`<rect width="100" height="100" rx="22" fill="${dark}"/>${petals}` +
|
||||
`<ellipse cx="50" cy="28" rx="13" ry="20" fill="${light}" opacity="0.45" transform="rotate(8 50 50)"/>` +
|
||||
`<circle cx="50" cy="50" r="10" fill="#fbbf24"/></svg>`;
|
||||
const url = `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||
for (const rel of ["icon", "apple-touch-icon"]) {
|
||||
let link = document.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`);
|
||||
if (!link) {
|
||||
link = document.createElement("link");
|
||||
link.rel = rel;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.type = rel === "icon" ? "image/svg+xml" : link.type;
|
||||
link.href = url;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the saved accent on load.
|
||||
|
||||
+20
-1
@@ -68,6 +68,21 @@ export interface OllamaHostModel {
|
||||
modifiedAt: string | null;
|
||||
}
|
||||
|
||||
export interface PlatformInfo {
|
||||
hostname: string;
|
||||
kernel: string;
|
||||
arch: string;
|
||||
uptimeS: number;
|
||||
appUptimeS: number;
|
||||
loadavg: number[];
|
||||
cpus: number;
|
||||
cpuModel: string | null;
|
||||
memTotal: number;
|
||||
memFree: number;
|
||||
runtime: string;
|
||||
nets: { name: string; address: string; family: string; internal: boolean }[];
|
||||
}
|
||||
|
||||
export interface AdminSettings {
|
||||
registrationEnabled: boolean;
|
||||
accessRequestEnabled: boolean;
|
||||
@@ -78,6 +93,7 @@ export interface AdminSettings {
|
||||
ollamaDefaultWeight: number;
|
||||
recoveryLimitMax: number;
|
||||
recoveryLimitWindowMin: number;
|
||||
voicePreviews: string[];
|
||||
}
|
||||
|
||||
export interface AdminSettingsPatch {
|
||||
@@ -90,6 +106,7 @@ export interface AdminSettingsPatch {
|
||||
ollamaDefaultWeight?: number;
|
||||
recoveryLimitMax?: number;
|
||||
recoveryLimitWindowMin?: number;
|
||||
voicePreviews?: string[];
|
||||
}
|
||||
|
||||
export interface OllamaNode {
|
||||
@@ -397,6 +414,8 @@ export const api = {
|
||||
req<OllamaNodeStatus>(`/api/admin/ollama/nodes/${encodeURIComponent(id)}/status`),
|
||||
ollamaNodeProbe: (host: string) =>
|
||||
req<OllamaNodeStatus>(`/api/admin/ollama/probe?host=${encodeURIComponent(host)}`),
|
||||
/** Admin: platform host facts (hostname, kernel, uptime, network…). */
|
||||
platform: () => req<{ platform: PlatformInfo }>("/api/admin/platform"),
|
||||
ollamaNodeStats: (id: string, window: string) =>
|
||||
req<{ stats: OllamaNodeStats }>(`/api/admin/ollama/nodes/${encodeURIComponent(id)}/stats?window=${encodeURIComponent(window)}`),
|
||||
|
||||
@@ -413,7 +432,7 @@ export const api = {
|
||||
deleteProvider: (provider: string) =>
|
||||
req<{ providers: UserProvider[] }>(`/api/me/providers/${provider}`, { method: "DELETE" }),
|
||||
|
||||
tools: () => req<{ tools: { name: string; description: string; available: boolean; reason: string | null }[]; reportsEnabled: boolean; usernameChangeEnabled: boolean }>("/api/tools"),
|
||||
tools: () => req<{ tools: { name: string; description: string; available: boolean; reason: string | null }[]; reportsEnabled: boolean; usernameChangeEnabled: boolean; voicePreviews: string[] }>("/api/tools"),
|
||||
|
||||
/** Send an image to the platform OCR tool. Returns editable text (never the raw image). */
|
||||
ocrImage: async (file: File): Promise<{ text: string; truncated: boolean; chars: number }> => {
|
||||
|
||||
@@ -551,6 +551,53 @@ const en = {
|
||||
"rec.save": "Save limits",
|
||||
"rec.saved": "Limits saved",
|
||||
"rec.invalid": "Check the values (attempts 1–100, window 1–1440 min).",
|
||||
"center.tabStats": "Stats",
|
||||
"stats.openFull": "Full stats page",
|
||||
"stats.poolTitle": "Pool load",
|
||||
"stats.poolDesc": "Generations per node, live from telemetry.",
|
||||
"stats.node": "Node",
|
||||
"stats.window": "Window",
|
||||
"stats.chart": "Chart type",
|
||||
"stats.hostTitle": "Platform host",
|
||||
"stats.hostDesc": "This server (Ollama hosts expose no such API).",
|
||||
"stats.hostname": "Host",
|
||||
"stats.kernel": "Kernel",
|
||||
"stats.cpu": "CPU",
|
||||
"stats.runtime": "Runtime",
|
||||
"stats.uptime": "Uptime",
|
||||
"stats.appUptime": "App uptime",
|
||||
"stats.load": "Load",
|
||||
"stats.memory": "Memory",
|
||||
"stats.network": "Network",
|
||||
"stats.diskNote": "Disk usage is not tracked — models live on the Ollama hosts.",
|
||||
"voice.mic": "Voice input",
|
||||
"voice.micOff": "Voice input needs a browser with speech recognition (Chrome, Edge).",
|
||||
"voice.stop": "Stop",
|
||||
"voice.cancel": "Discard",
|
||||
"voice.listening": "Listening…",
|
||||
"voice.speak": "Listen",
|
||||
"voice.stopSpeak": "Stop playback",
|
||||
"voice.title": "Voice",
|
||||
"voice.desc": "Spoken messages: playback voice, language and preview lines.",
|
||||
"voice.language": "Language",
|
||||
"voice.auto": "Auto-detect",
|
||||
"voice.voice": "Voice",
|
||||
"voice.defaultVoice": "Best match",
|
||||
"voice.rate": "Speed",
|
||||
"voice.pitch": "Pitch",
|
||||
"voice.preview": "Preview",
|
||||
"voice.previews": "Preview lines (set by the admin center)",
|
||||
"voice.noVoices": "No voices in this browser.",
|
||||
"vfeat.title": "Voice previews",
|
||||
"vfeat.desc": "Default playback lines users can preview in Settings → Voice.",
|
||||
"vfeat.save": "Save",
|
||||
"vfeat.saved": "Previews saved",
|
||||
"vfeat.reset": "Reset to defaults",
|
||||
"vfeat.add": "Add line",
|
||||
"vfeat.line": "Line {n}",
|
||||
"vfeat.remove": "Remove line {n}",
|
||||
"vfeat.invalid": "1–6 non-empty lines, 500 chars max.",
|
||||
|
||||
|
||||
"msg.copy": "Copy message",
|
||||
"msg.search": "Search the web",
|
||||
@@ -1188,6 +1235,53 @@ const es: Dict = {
|
||||
"rec.save": "Save limits",
|
||||
"rec.saved": "Limits saved",
|
||||
"rec.invalid": "Check the values (attempts 1–100, window 1–1440 min).",
|
||||
"center.tabStats": "Stats",
|
||||
"stats.openFull": "Full stats page",
|
||||
"stats.poolTitle": "Pool load",
|
||||
"stats.poolDesc": "Generations per node, live from telemetry.",
|
||||
"stats.node": "Node",
|
||||
"stats.window": "Window",
|
||||
"stats.chart": "Chart type",
|
||||
"stats.hostTitle": "Platform host",
|
||||
"stats.hostDesc": "This server (Ollama hosts expose no such API).",
|
||||
"stats.hostname": "Host",
|
||||
"stats.kernel": "Kernel",
|
||||
"stats.cpu": "CPU",
|
||||
"stats.runtime": "Runtime",
|
||||
"stats.uptime": "Uptime",
|
||||
"stats.appUptime": "App uptime",
|
||||
"stats.load": "Load",
|
||||
"stats.memory": "Memory",
|
||||
"stats.network": "Network",
|
||||
"stats.diskNote": "Disk usage is not tracked — models live on the Ollama hosts.",
|
||||
"voice.mic": "Voice input",
|
||||
"voice.micOff": "Voice input needs a browser with speech recognition (Chrome, Edge).",
|
||||
"voice.stop": "Stop",
|
||||
"voice.cancel": "Discard",
|
||||
"voice.listening": "Listening…",
|
||||
"voice.speak": "Listen",
|
||||
"voice.stopSpeak": "Stop playback",
|
||||
"voice.title": "Voice",
|
||||
"voice.desc": "Spoken messages: playback voice, language and preview lines.",
|
||||
"voice.language": "Language",
|
||||
"voice.auto": "Auto-detect",
|
||||
"voice.voice": "Voice",
|
||||
"voice.defaultVoice": "Best match",
|
||||
"voice.rate": "Speed",
|
||||
"voice.pitch": "Pitch",
|
||||
"voice.preview": "Preview",
|
||||
"voice.previews": "Preview lines (set by the admin center)",
|
||||
"voice.noVoices": "No voices in this browser.",
|
||||
"vfeat.title": "Voice previews",
|
||||
"vfeat.desc": "Default playback lines users can preview in Settings → Voice.",
|
||||
"vfeat.save": "Save",
|
||||
"vfeat.saved": "Previews saved",
|
||||
"vfeat.reset": "Reset to defaults",
|
||||
"vfeat.add": "Add line",
|
||||
"vfeat.line": "Line {n}",
|
||||
"vfeat.remove": "Remove line {n}",
|
||||
"vfeat.invalid": "1–6 non-empty lines, 500 chars max.",
|
||||
|
||||
|
||||
"msg.copy": "Copiar mensaje",
|
||||
"msg.search": "Buscar en la web",
|
||||
@@ -1820,6 +1914,53 @@ const fr: Dict = {
|
||||
"rec.save": "Save limits",
|
||||
"rec.saved": "Limits saved",
|
||||
"rec.invalid": "Check the values (attempts 1–100, window 1–1440 min).",
|
||||
"center.tabStats": "Stats",
|
||||
"stats.openFull": "Full stats page",
|
||||
"stats.poolTitle": "Pool load",
|
||||
"stats.poolDesc": "Generations per node, live from telemetry.",
|
||||
"stats.node": "Node",
|
||||
"stats.window": "Window",
|
||||
"stats.chart": "Chart type",
|
||||
"stats.hostTitle": "Platform host",
|
||||
"stats.hostDesc": "This server (Ollama hosts expose no such API).",
|
||||
"stats.hostname": "Host",
|
||||
"stats.kernel": "Kernel",
|
||||
"stats.cpu": "CPU",
|
||||
"stats.runtime": "Runtime",
|
||||
"stats.uptime": "Uptime",
|
||||
"stats.appUptime": "App uptime",
|
||||
"stats.load": "Load",
|
||||
"stats.memory": "Memory",
|
||||
"stats.network": "Network",
|
||||
"stats.diskNote": "Disk usage is not tracked — models live on the Ollama hosts.",
|
||||
"voice.mic": "Voice input",
|
||||
"voice.micOff": "Voice input needs a browser with speech recognition (Chrome, Edge).",
|
||||
"voice.stop": "Stop",
|
||||
"voice.cancel": "Discard",
|
||||
"voice.listening": "Listening…",
|
||||
"voice.speak": "Listen",
|
||||
"voice.stopSpeak": "Stop playback",
|
||||
"voice.title": "Voice",
|
||||
"voice.desc": "Spoken messages: playback voice, language and preview lines.",
|
||||
"voice.language": "Language",
|
||||
"voice.auto": "Auto-detect",
|
||||
"voice.voice": "Voice",
|
||||
"voice.defaultVoice": "Best match",
|
||||
"voice.rate": "Speed",
|
||||
"voice.pitch": "Pitch",
|
||||
"voice.preview": "Preview",
|
||||
"voice.previews": "Preview lines (set by the admin center)",
|
||||
"voice.noVoices": "No voices in this browser.",
|
||||
"vfeat.title": "Voice previews",
|
||||
"vfeat.desc": "Default playback lines users can preview in Settings → Voice.",
|
||||
"vfeat.save": "Save",
|
||||
"vfeat.saved": "Previews saved",
|
||||
"vfeat.reset": "Reset to defaults",
|
||||
"vfeat.add": "Add line",
|
||||
"vfeat.line": "Line {n}",
|
||||
"vfeat.remove": "Remove line {n}",
|
||||
"vfeat.invalid": "1–6 non-empty lines, 500 chars max.",
|
||||
|
||||
|
||||
"msg.copy": "Copier le message",
|
||||
"msg.search": "Rechercher sur le web",
|
||||
@@ -2440,6 +2581,53 @@ const ru: Dict = {
|
||||
"rec.save": "Save limits",
|
||||
"rec.saved": "Limits saved",
|
||||
"rec.invalid": "Check the values (attempts 1–100, window 1–1440 min).",
|
||||
"center.tabStats": "Stats",
|
||||
"stats.openFull": "Full stats page",
|
||||
"stats.poolTitle": "Pool load",
|
||||
"stats.poolDesc": "Generations per node, live from telemetry.",
|
||||
"stats.node": "Node",
|
||||
"stats.window": "Window",
|
||||
"stats.chart": "Chart type",
|
||||
"stats.hostTitle": "Platform host",
|
||||
"stats.hostDesc": "This server (Ollama hosts expose no such API).",
|
||||
"stats.hostname": "Host",
|
||||
"stats.kernel": "Kernel",
|
||||
"stats.cpu": "CPU",
|
||||
"stats.runtime": "Runtime",
|
||||
"stats.uptime": "Uptime",
|
||||
"stats.appUptime": "App uptime",
|
||||
"stats.load": "Load",
|
||||
"stats.memory": "Memory",
|
||||
"stats.network": "Network",
|
||||
"stats.diskNote": "Disk usage is not tracked — models live on the Ollama hosts.",
|
||||
"voice.mic": "Voice input",
|
||||
"voice.micOff": "Voice input needs a browser with speech recognition (Chrome, Edge).",
|
||||
"voice.stop": "Stop",
|
||||
"voice.cancel": "Discard",
|
||||
"voice.listening": "Listening…",
|
||||
"voice.speak": "Listen",
|
||||
"voice.stopSpeak": "Stop playback",
|
||||
"voice.title": "Voice",
|
||||
"voice.desc": "Spoken messages: playback voice, language and preview lines.",
|
||||
"voice.language": "Language",
|
||||
"voice.auto": "Auto-detect",
|
||||
"voice.voice": "Voice",
|
||||
"voice.defaultVoice": "Best match",
|
||||
"voice.rate": "Speed",
|
||||
"voice.pitch": "Pitch",
|
||||
"voice.preview": "Preview",
|
||||
"voice.previews": "Preview lines (set by the admin center)",
|
||||
"voice.noVoices": "No voices in this browser.",
|
||||
"vfeat.title": "Voice previews",
|
||||
"vfeat.desc": "Default playback lines users can preview in Settings → Voice.",
|
||||
"vfeat.save": "Save",
|
||||
"vfeat.saved": "Previews saved",
|
||||
"vfeat.reset": "Reset to defaults",
|
||||
"vfeat.add": "Add line",
|
||||
"vfeat.line": "Line {n}",
|
||||
"vfeat.remove": "Remove line {n}",
|
||||
"vfeat.invalid": "1–6 non-empty lines, 500 chars max.",
|
||||
|
||||
|
||||
"msg.copy": "Скопировать сообщение",
|
||||
"msg.search": "Искать в интернете",
|
||||
@@ -3065,6 +3253,53 @@ const it: Dict = {
|
||||
"rec.save": "Save limits",
|
||||
"rec.saved": "Limits saved",
|
||||
"rec.invalid": "Check the values (attempts 1–100, window 1–1440 min).",
|
||||
"center.tabStats": "Stats",
|
||||
"stats.openFull": "Full stats page",
|
||||
"stats.poolTitle": "Pool load",
|
||||
"stats.poolDesc": "Generations per node, live from telemetry.",
|
||||
"stats.node": "Node",
|
||||
"stats.window": "Window",
|
||||
"stats.chart": "Chart type",
|
||||
"stats.hostTitle": "Platform host",
|
||||
"stats.hostDesc": "This server (Ollama hosts expose no such API).",
|
||||
"stats.hostname": "Host",
|
||||
"stats.kernel": "Kernel",
|
||||
"stats.cpu": "CPU",
|
||||
"stats.runtime": "Runtime",
|
||||
"stats.uptime": "Uptime",
|
||||
"stats.appUptime": "App uptime",
|
||||
"stats.load": "Load",
|
||||
"stats.memory": "Memory",
|
||||
"stats.network": "Network",
|
||||
"stats.diskNote": "Disk usage is not tracked — models live on the Ollama hosts.",
|
||||
"voice.mic": "Voice input",
|
||||
"voice.micOff": "Voice input needs a browser with speech recognition (Chrome, Edge).",
|
||||
"voice.stop": "Stop",
|
||||
"voice.cancel": "Discard",
|
||||
"voice.listening": "Listening…",
|
||||
"voice.speak": "Listen",
|
||||
"voice.stopSpeak": "Stop playback",
|
||||
"voice.title": "Voice",
|
||||
"voice.desc": "Spoken messages: playback voice, language and preview lines.",
|
||||
"voice.language": "Language",
|
||||
"voice.auto": "Auto-detect",
|
||||
"voice.voice": "Voice",
|
||||
"voice.defaultVoice": "Best match",
|
||||
"voice.rate": "Speed",
|
||||
"voice.pitch": "Pitch",
|
||||
"voice.preview": "Preview",
|
||||
"voice.previews": "Preview lines (set by the admin center)",
|
||||
"voice.noVoices": "No voices in this browser.",
|
||||
"vfeat.title": "Voice previews",
|
||||
"vfeat.desc": "Default playback lines users can preview in Settings → Voice.",
|
||||
"vfeat.save": "Save",
|
||||
"vfeat.saved": "Previews saved",
|
||||
"vfeat.reset": "Reset to defaults",
|
||||
"vfeat.add": "Add line",
|
||||
"vfeat.line": "Line {n}",
|
||||
"vfeat.remove": "Remove line {n}",
|
||||
"vfeat.invalid": "1–6 non-empty lines, 500 chars max.",
|
||||
|
||||
|
||||
"msg.copy": "Copia messaggio",
|
||||
"msg.search": "Cerca nel web",
|
||||
@@ -3681,6 +3916,53 @@ const pt: Dict = {
|
||||
"rec.save": "Save limits",
|
||||
"rec.saved": "Limits saved",
|
||||
"rec.invalid": "Check the values (attempts 1–100, window 1–1440 min).",
|
||||
"center.tabStats": "Stats",
|
||||
"stats.openFull": "Full stats page",
|
||||
"stats.poolTitle": "Pool load",
|
||||
"stats.poolDesc": "Generations per node, live from telemetry.",
|
||||
"stats.node": "Node",
|
||||
"stats.window": "Window",
|
||||
"stats.chart": "Chart type",
|
||||
"stats.hostTitle": "Platform host",
|
||||
"stats.hostDesc": "This server (Ollama hosts expose no such API).",
|
||||
"stats.hostname": "Host",
|
||||
"stats.kernel": "Kernel",
|
||||
"stats.cpu": "CPU",
|
||||
"stats.runtime": "Runtime",
|
||||
"stats.uptime": "Uptime",
|
||||
"stats.appUptime": "App uptime",
|
||||
"stats.load": "Load",
|
||||
"stats.memory": "Memory",
|
||||
"stats.network": "Network",
|
||||
"stats.diskNote": "Disk usage is not tracked — models live on the Ollama hosts.",
|
||||
"voice.mic": "Voice input",
|
||||
"voice.micOff": "Voice input needs a browser with speech recognition (Chrome, Edge).",
|
||||
"voice.stop": "Stop",
|
||||
"voice.cancel": "Discard",
|
||||
"voice.listening": "Listening…",
|
||||
"voice.speak": "Listen",
|
||||
"voice.stopSpeak": "Stop playback",
|
||||
"voice.title": "Voice",
|
||||
"voice.desc": "Spoken messages: playback voice, language and preview lines.",
|
||||
"voice.language": "Language",
|
||||
"voice.auto": "Auto-detect",
|
||||
"voice.voice": "Voice",
|
||||
"voice.defaultVoice": "Best match",
|
||||
"voice.rate": "Speed",
|
||||
"voice.pitch": "Pitch",
|
||||
"voice.preview": "Preview",
|
||||
"voice.previews": "Preview lines (set by the admin center)",
|
||||
"voice.noVoices": "No voices in this browser.",
|
||||
"vfeat.title": "Voice previews",
|
||||
"vfeat.desc": "Default playback lines users can preview in Settings → Voice.",
|
||||
"vfeat.save": "Save",
|
||||
"vfeat.saved": "Previews saved",
|
||||
"vfeat.reset": "Reset to defaults",
|
||||
"vfeat.add": "Add line",
|
||||
"vfeat.line": "Line {n}",
|
||||
"vfeat.remove": "Remove line {n}",
|
||||
"vfeat.invalid": "1–6 non-empty lines, 500 chars max.",
|
||||
|
||||
|
||||
"msg.copy": "Copiar mensagem",
|
||||
"msg.search": "Pesquisar na web",
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Voice: speech-to-text (composer) + text-to-speech (message playback).
|
||||
*
|
||||
* STT runs on the browser SpeechRecognition API when available
|
||||
* (Chrome/Edge); otherwise the mic is disabled with an explanatory tooltip.
|
||||
* TTS runs on speechSynthesis with per-user prefs (voice, rate, pitch,
|
||||
* language incl. auto-detect). Server-side whisper/piper binaries are the
|
||||
* documented future path (see docs) — no silent stub is shipped.
|
||||
*/
|
||||
|
||||
export interface VoicePrefs {
|
||||
/** SpeechSynthesis voiceURI, or null = best match for the language. */
|
||||
voiceURI: string | null;
|
||||
rate: number;
|
||||
pitch: number;
|
||||
/** "auto" detects per message, otherwise forces a BCP-47 prefix. */
|
||||
lang: string;
|
||||
}
|
||||
|
||||
const PREFS_KEY = "ka:voice";
|
||||
|
||||
export const DEFAULT_VOICE_PREFS: VoicePrefs = { voiceURI: null, rate: 1, pitch: 1, lang: "auto" };
|
||||
|
||||
export function getVoicePrefs(): VoicePrefs {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(PREFS_KEY) ?? "null") as Partial<VoicePrefs> | null;
|
||||
if (!raw || typeof raw !== "object") return { ...DEFAULT_VOICE_PREFS };
|
||||
return {
|
||||
voiceURI: typeof raw.voiceURI === "string" ? raw.voiceURI : null,
|
||||
rate: typeof raw.rate === "number" && raw.rate >= 0.5 && raw.rate <= 2 ? raw.rate : 1,
|
||||
pitch: typeof raw.pitch === "number" && raw.pitch >= 0.5 && raw.pitch <= 2 ? raw.pitch : 1,
|
||||
lang: typeof raw.lang === "string" && raw.lang.length <= 12 ? raw.lang : "auto",
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_VOICE_PREFS };
|
||||
}
|
||||
}
|
||||
|
||||
export function setVoicePrefs(p: VoicePrefs): void {
|
||||
try {
|
||||
localStorage.setItem(PREFS_KEY, JSON.stringify(p));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/** Naive per-message language guess (accents + stop-words). */
|
||||
export function detectLang(text: string): string {
|
||||
const t = ` ${text.toLowerCase()} `;
|
||||
if (/[àâäéèêëîïôöùûüÿçœæ]/.test(text)) return "fr";
|
||||
const fr = [" le ", " la ", " les ", " un ", " une ", " des ", " est ", " sont ", " que ", " qui ", " pour ", " dans ", " avec ", " je ", " tu ", " vous ", " pas ", " plus ", " cette ", " comme "];
|
||||
const en = [" the ", " and ", " is ", " are ", " you ", " that ", " with ", " for ", " not ", " this ", " have ", " from "];
|
||||
let fs = 0;
|
||||
for (const w of fr) if (t.includes(w)) fs++;
|
||||
let es = 0;
|
||||
for (const w of en) if (t.includes(w)) es++;
|
||||
if (fs === 0 && es === 0) return "en";
|
||||
return fs >= es ? "fr" : "en";
|
||||
}
|
||||
|
||||
export function ttsSupported(): boolean {
|
||||
return typeof window !== "undefined" && "speechSynthesis" in window;
|
||||
}
|
||||
|
||||
export function listTtsVoices(): Promise<SpeechSynthesisVoice[]> {
|
||||
if (!ttsSupported()) return Promise.resolve([]);
|
||||
const synth = window.speechSynthesis;
|
||||
const snapshot = synth.getVoices();
|
||||
if (snapshot.length > 0) return Promise.resolve(snapshot);
|
||||
return new Promise((resolve) => {
|
||||
const done = () => resolve(synth.getVoices());
|
||||
synth.addEventListener("voiceschanged", done, { once: true });
|
||||
// Some engines never fire the event after getVoices polling — bail out.
|
||||
setTimeout(done, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
export interface SpeakOpts {
|
||||
onstart?: () => void;
|
||||
onend?: () => void;
|
||||
onerror?: (msg: string) => void;
|
||||
}
|
||||
|
||||
/** Speak one message; cancels any current playback first. Returns false when unsupported. */
|
||||
export function speak(text: string, prefs: VoicePrefs, voices: SpeechSynthesisVoice[], opts: SpeakOpts = {}): boolean {
|
||||
if (!ttsSupported()) return false;
|
||||
const synth = window.speechSynthesis;
|
||||
try {
|
||||
synth.cancel();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const lang = prefs.lang === "auto" ? detectLang(text) : prefs.lang;
|
||||
const utter = new SpeechSynthesisUtterance(text.slice(0, 2000));
|
||||
utter.lang = lang;
|
||||
utter.rate = prefs.rate;
|
||||
utter.pitch = prefs.pitch;
|
||||
const pick = voices.find((v) => prefs.voiceURI && v.voiceURI === prefs.voiceURI)
|
||||
?? voices.find((v) => v.lang.toLowerCase().startsWith(lang.toLowerCase()))
|
||||
?? voices.find((v) => v.default)
|
||||
?? voices[0];
|
||||
if (pick) utter.voice = pick;
|
||||
utter.onstart = () => opts.onstart?.();
|
||||
utter.onend = () => opts.onend?.();
|
||||
utter.onerror = (e) => opts.onerror?.(e.error || "error");
|
||||
try {
|
||||
synth.speak(utter);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function stopSpeak(): void {
|
||||
try {
|
||||
window.speechSynthesis?.cancel();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// --- speech recognition (STT) ---
|
||||
|
||||
export interface Recognizer {
|
||||
lang: string;
|
||||
interimResults: boolean;
|
||||
continuous: boolean;
|
||||
onresult: ((ev: { results: ArrayLike<{ 0: { transcript: string }; isFinal: boolean }> }) => void) | null;
|
||||
onerror: ((ev: { error?: string }) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
abort: () => void;
|
||||
}
|
||||
|
||||
type RecognizerCtor = new () => Recognizer;
|
||||
|
||||
export function recognizerCtor(): RecognizerCtor | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
return ((w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null) as RecognizerCtor | null);
|
||||
}
|
||||
|
||||
export function sttSupported(): boolean {
|
||||
return recognizerCtor() !== null;
|
||||
}
|
||||
|
||||
export function fmtDuration(sec: number): string {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
Reference in New Issue
Block a user