Frontend. :)

This commit is contained in:
k
2026-09-24 11:14:01 +02:00
parent 6e65d84a1c
commit 796a883d70
11 changed files with 1469 additions and 10 deletions
+42
View File
@@ -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
View File
@@ -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 }> => {
+282
View File
@@ -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",
+152
View File
@@ -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")}`;
}