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
+116 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { ArrowLeft, Boxes, Eye, EyeOff, Flag, Inbox, LayoutDashboard, Mail, Send, ShieldAlert, SlidersHorizontal, Users } from "lucide-react";
import { ArrowLeft, Boxes, Eye, EyeOff, Flag, Inbox, LayoutDashboard, Mail, Plus, Send, ShieldAlert, SlidersHorizontal, Trash2, TrendingUp, Users } from "lucide-react";
import { api, getToken, type AccessStatus, type AdminSettings, type AdminSettingsPatch, type ReportStatus } from "../lib/api.ts";
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
import { navigate } from "../lib/route.ts";
@@ -7,6 +7,7 @@ import { AdminPanel } from "./AdminPanel.tsx";
import { AccessRequestsPanel } from "./AccessRequests.tsx";
import { ReportsPanel } from "./ReportsPanel.tsx";
import { ModelsPanel } from "./ModelsPanel.tsx";
import { StatsSection } from "./StatsSection.tsx";
import { Toasts } from "./Toasts.tsx";
import { pushToast } from "../lib/toasts.ts";
import { Button, CopyButton, Field, FlowerMark, Input, LoadingScreen, Spinner, Switch } from "./ui.tsx";
@@ -14,9 +15,9 @@ import { cn } from "../lib/cn.ts";
import { msUntilNextSolarSwitch, resolveThemeDark } from "../lib/solarTheme.ts";
import { useT } from "../lib/i18n.ts";
type Tab = "accounts" | "access" | "reports" | "models" | "features" | "mail";
type Tab = "accounts" | "access" | "reports" | "models" | "stats" | "features" | "mail";
const VALID_TABS: readonly string[] = ["accounts", "access", "reports", "models", "features", "mail"];
const VALID_TABS: readonly string[] = ["accounts", "access", "reports", "models", "stats", "features", "mail"];
function validTab(v: string | null | undefined): Tab {
return v !== null && v !== undefined && (VALID_TABS as readonly string[]).includes(v) ? (v as Tab) : "accounts";
@@ -60,6 +61,7 @@ export function AdminCenter({ initialTab }: { initialTab?: string | null }) {
{ id: "access", label: t("center.tabAccess"), icon: Inbox },
{ id: "reports", label: t("center.tabReports"), icon: Flag },
{ id: "models", label: t("center.tabModels"), icon: Boxes },
{ id: "stats", label: t("center.tabStats"), icon: TrendingUp },
{ id: "features", label: t("center.tabFeatures"), icon: SlidersHorizontal },
{ id: "mail", label: t("center.tabMail"), icon: Mail },
];
@@ -268,6 +270,8 @@ export function AdminCenter({ initialTab }: { initialTab?: string | null }) {
</section>
)}
{tab === "stats" && <StatsSection />}
{tab === "features" && (
<section className="space-y-3">
<div className="flex items-center gap-4 rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
@@ -319,6 +323,7 @@ export function AdminCenter({ initialTab }: { initialTab?: string | null }) {
</div>
{saving && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.saving")}</p>}
{!settings && !error && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
<VoicePreviewsCard />
</section>
)}
@@ -329,6 +334,114 @@ export function AdminCenter({ initialTab }: { initialTab?: string | null }) {
);
}
/** Admin voice-preview lines (Settings → Voice for users). Empty = reset. */
function VoicePreviewsCard() {
const { t } = useT();
const [lines, setLines] = useState<string[]>([]);
const [loaded, setLoaded] = useState(false);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
useEffect(() => {
api.adminGetSettings()
.then((r) => {
setLines(r.settings.voicePreviews);
setLoaded(true);
})
.catch(() => setLoaded(true));
}, []);
async function save(next: string[]) {
setBusy(true);
setMsg(null);
try {
const res = await api.adminPatchSettings({ voicePreviews: next });
setLines(res.settings.voicePreviews);
setMsg({ ok: true, text: t("vfeat.saved") });
} catch (e) {
setMsg({ ok: false, text: e instanceof Error ? e.message : t("vfeat.invalid") });
} finally {
setBusy(false);
}
}
function commit(i: number, v: string) {
const trimmed = v.trim();
if (trimmed === "") {
// Emptied line: drop the row locally (server needs 1+ lines).
const next = lines.filter((_, j) => j !== i);
if (next.length === 0) {
setMsg({ ok: false, text: t("vfeat.invalid") });
void refreshLines();
return;
}
void save(next);
return;
}
void save(lines.map((l, j) => (j === i ? trimmed : l)));
}
async function refreshLines() {
try {
const r = await api.adminGetSettings();
setLines(r.settings.voicePreviews);
} catch {
// keep current
}
}
if (!loaded) return null;
return (
<div className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="text-sm font-medium">{t("vfeat.title")}</p>
<p className="mb-3 mt-0.5 text-sm opacity-60">{t("vfeat.desc")}</p>
<ul className="space-y-2">
{lines.map((line, i) => (
<li key={i} className="flex items-center gap-2">
<span className="w-40 shrink-0">
<Input
value={line}
disabled={busy}
maxLength={500}
aria-label={t("vfeat.line", { n: i + 1 })}
onChange={(e) => setLines((prev) => prev.map((l, j) => (j === i ? e.target.value : l)))}
onBlur={(e) => { if (e.target.value !== line) commit(i, e.target.value); }}
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
/>
</span>
<span className="min-w-0 flex-1 truncate text-sm opacity-60">{line}</span>
<button
type="button"
disabled={busy || lines.length <= 1}
onClick={() => void save(lines.filter((_, j) => j !== i))}
aria-label={t("vfeat.remove", { n: i + 1 })}
className="shrink-0 rounded-full p-2 text-red-600 transition hover:bg-red-50 disabled:opacity-30 dark:text-red-400 dark:hover:bg-red-950/40"
>
<Trash2 size={14} />
</button>
</li>
))}
</ul>
<div className="mt-3 flex flex-wrap gap-2">
{lines.length < 6 && (
<Button variant="secondary" size="sm" disabled={busy} onClick={() => setLines((prev) => [...prev, ""])}>
<Plus size={14} /> {t("vfeat.add")}
</Button>
)}
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void save([])}>
{t("vfeat.reset")}
</Button>
</div>
{msg && (
<p role={msg.ok ? "status" : "alert"} className={cn("mt-3 rounded-2xl px-4 py-2.5 text-sm", msg.ok ? "bg-emerald-600/10 text-emerald-700 dark:text-emerald-400" : "bg-red-500/10 text-red-600 dark:text-red-400")}>
{msg.text}
</p>
)}
</div>
);
}
type SmtpInfo = {
enabled: boolean;
host: string;
+234 -5
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { Archive, ArchiveRestore, BookOpen, Bot, Check, Copy, Cpu, FileText, Flag, Menu, PanelLeftOpen, Paperclip, RotateCcw, ScanText, Search, SendHorizontal, Share2, User as UserIcon, X } from "lucide-react";
import { Archive, ArchiveRestore, BookOpen, Bot, Check, Copy, Cpu, FileText, Flag, Menu, Mic, PanelLeftOpen, Paperclip, RotateCcw, ScanText, Search, SendHorizontal, Share2, Square, User as UserIcon, Volume2, VolumeX, X } from "lucide-react";
import type { Attachment, ChatMessage, Conversation, DriverModel, User } from "../lib/types.ts";
import { api, ApiError, type ReportReason } from "../lib/api.ts";
import { AssistantAvatar, Avatar, Button, Field, FlowerMark, IconButton, Modal, Picker, type PickerGroup, Spinner } from "./ui.tsx";
@@ -7,11 +7,12 @@ import { Markdown } from "./Markdown.tsx";
import { UserMessageContent } from "./MessageContent.tsx";
import { cn } from "../lib/cn.ts";
import { useFeatures } from "../lib/features.ts";
import { fmtDuration, getVoicePrefs, listTtsVoices, recognizerCtor, speak, stopSpeak, sttSupported, type Recognizer } from "../lib/voice.ts";
import { useT, type StringKey } from "../lib/i18n.ts";
const REPORT_REASONS: ReportReason[] = ["copyright", "gore", "falseinfo", "bug"];
function MessageBubble({ msg, index, authorAvatar, authorName, onReport, reportsEnabled = true, isFailed, retryCount, maxRetries, retryDisabled, onRetry }: { msg: ChatMessage; index: number; authorAvatar?: string; authorName: string; onReport?: (index: number) => void; reportsEnabled?: boolean; isFailed?: boolean; retryCount?: number; maxRetries?: number; retryDisabled?: boolean; onRetry?: () => void }) {
function MessageBubble({ msg, index, authorAvatar, authorName, onReport, reportsEnabled = true, isFailed, retryCount, maxRetries, retryDisabled, onRetry, speaking, onToggleSpeak }: { msg: ChatMessage; index: number; authorAvatar?: string; authorName: string; onReport?: (index: number) => void; reportsEnabled?: boolean; isFailed?: boolean; retryCount?: number; maxRetries?: number; retryDisabled?: boolean; onRetry?: () => void; speaking?: boolean; onToggleSpeak?: () => void }) {
const { t } = useT();
const isUser = msg.role === "user";
const [copied, setCopied] = useState(false);
@@ -39,12 +40,17 @@ function MessageBubble({ msg, index, authorAvatar, authorName, onReport, reports
<UserMessageContent content={msg.content} />
</div>
) : (
<div className="rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-4 py-3 shadow-sm sm:px-5 sm:py-3.5 dark:border-zinc-800 dark:bg-zinc-900">
<div className={cn("rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-4 py-3 shadow-sm sm:px-5 sm:py-3.5 dark:border-zinc-800 dark:bg-zinc-900", speaking && "speaking-glow")}>
<Markdown text={msg.content} />
</div>
)}
{!isUser && (onReport || (isFailed && onRetry)) && (
{!isUser && (onReport || onToggleSpeak || (isFailed && onRetry)) && (
<div className="mt-1.5 flex items-center gap-0.5 opacity-70 transition hover:opacity-100">
{onToggleSpeak && (
<IconButton title={speaking ? t("voice.stopSpeak") : t("voice.speak")} onClick={onToggleSpeak}>
{speaking ? <VolumeX size={14} className="speaking-orb text-accent-500" /> : <Volume2 size={14} />}
</IconButton>
)}
{onReport && (
<>
<IconButton title={copied ? t("common.copied") : t("msg.copy")} onClick={copy}>
@@ -78,6 +84,33 @@ function MessageBubble({ msg, index, authorAvatar, authorName, onReport, reports
);
}
/** Live voice waveform: mirrored gradient bars glowing with the input level. */
function Waveform({ levels }: { levels: number[] }) {
const BARS = 48;
const padded = [...Array(Math.max(0, BARS - levels.length)).fill(0), ...levels].slice(-BARS) as number[];
return (
<div className="flex h-12 items-center gap-[3px] px-2 pt-1" aria-hidden>
{padded.map((lv, i) => {
const mid = (BARS - 1) / 2;
const edge = 1 - Math.abs(i - mid) / mid; // taller in the middle
const h = Math.max(6, Math.min(100, (8 + lv * 92) * (0.35 + edge * 0.65)));
return (
<span
key={i}
className="wave-bar w-full rounded-full"
style={{
height: `${h}%`,
opacity: 0.35 + lv * 0.65,
background: "linear-gradient(to top, rgb(var(--ka-accent-600)), rgb(var(--ka-accent-300)))",
boxShadow: lv > 0.12 ? "0 0 8px rgb(var(--ka-accent-500) / 0.55)" : "none",
}}
/>
);
})}
</div>
);
}
function ReportDialog({ open, onClose, conversationId, messageIndex, content, prompt, model }: {
open: boolean;
onClose: () => void;
@@ -243,6 +276,153 @@ export function Chat({
const taRef = useRef<HTMLTextAreaElement>(null);
const imgRef = useRef<HTMLInputElement>(null);
const txtRef = useRef<HTMLInputElement>(null);
// TTS playback: which assistant bubble is speaking (null = silent).
const [speakingKey, setSpeakingKey] = useState<number | null>(null);
const voicesRef = useRef<SpeechSynthesisVoice[]>([]);
// Voice input: live recording session (null = composer idle).
const [rec, setRec] = useState<{
stream: MediaStream;
audioCtx: AudioContext;
analyser: AnalyserNode;
recog: Recognizer | null;
startTs: number;
} | null>(null);
const [recLevels, setRecLevels] = useState<number[]>([]);
const [recElapsed, setRecElapsed] = useState(0);
const [recText, setRecText] = useState("");
const recRaf = useRef(0);
const recTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const micOn = sttSupported();
useEffect(() => {
listTtsVoices().then((v) => { voicesRef.current = v; });
return () => stopSpeak();
}, []);
// New message / conversation: stop any playback (never leak speech).
useEffect(() => {
stopSpeak();
setSpeakingKey(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conv?.id]);
useEffect(() => {
return () => {
cancelRec(false);
stopSpeak();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function toggleSpeak(index: number, text: string) {
if (speakingKey === index) {
stopSpeak();
setSpeakingKey(null);
return;
}
const ok = speak(text, getVoicePrefs(), voicesRef.current, {
onend: () => setSpeakingKey(null),
onerror: () => setSpeakingKey(null),
});
if (ok) setSpeakingKey(index);
}
function cleanupRec(r: NonNullable<typeof rec>) {
cancelAnimationFrame(recRaf.current);
if (recTimer.current) clearInterval(recTimer.current);
recTimer.current = null;
try { r.recog?.abort(); } catch { /* ignore */ }
try { r.audioCtx.close(); } catch { /* ignore */ }
for (const tr of r.stream.getTracks()) {
try { tr.stop(); } catch { /* ignore */ }
}
}
function cancelRec(reset = true) {
setRec((cur) => {
if (cur) cleanupRec(cur);
return null;
});
if (reset) {
setRecLevels([]);
setRecElapsed(0);
setRecText("");
}
}
async function startRec() {
if (rec || sending || readOnly || !micOn) return;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const audioCtx = new AudioContext();
const src = audioCtx.createMediaStreamSource(stream);
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 512;
src.connect(analyser);
const data = new Uint8Array(analyser.fftSize);
const Ctor = recognizerCtor();
const recog: Recognizer | null = Ctor ? new Ctor() : null;
let finals = "";
if (recog) {
recog.lang = navigator.language || "fr-FR";
recog.interimResults = true;
recog.continuous = true;
recog.onresult = (ev) => {
let interim = "";
for (const res of Array.from(ev.results)) {
if (res.isFinal) finals += res[0]!.transcript;
else interim += res[0]!.transcript;
}
setRecText((finals + " " + interim).trim());
};
recog.onerror = () => {};
try { recog.start(); } catch { /* already started */ }
}
const startTs = Date.now();
const session = { stream, audioCtx, analyser, recog, startTs };
setRec(session);
setRecLevels([]);
setRecText("");
setRecElapsed(0);
let lastPush = 0;
const loop = () => {
analyser.getByteTimeDomainData(data);
let sum = 0;
for (let i = 0; i < data.length; i++) {
const v = (data[i]! - 128) / 128;
sum += v * v;
}
const level = Math.min(1, Math.sqrt(sum / data.length) * 2.4);
const now = performance.now();
if (now - lastPush > 90) {
lastPush = now;
setRecLevels((prev) => [...prev.slice(-47), level]);
}
recRaf.current = requestAnimationFrame(loop);
};
recRaf.current = requestAnimationFrame(loop);
recTimer.current = setInterval(() => setRecElapsed((Date.now() - startTs) / 1000), 500);
} catch {
// Mic denied / unavailable: stay on the text composer.
cancelRec();
}
}
function stopRec() {
const clean = recText.trim();
setRec((cur) => {
if (cur) cleanupRec(cur);
return null;
});
// Transcript lands in the composer, editable before sending.
if (clean) {
setDraft((d) => (d ? `${d.trimEnd()}\n${clean}` : clean));
requestAnimationFrame(autoGrow);
}
setRecText("");
setRecLevels([]);
setRecElapsed(0);
}
const modelGroups: PickerGroup[] = (() => {
const byDriver = new Map<string, { value: string; label: string; hint: string }[]>();
@@ -263,6 +443,14 @@ export function Chat({
if (el) el.scrollTop = el.scrollHeight;
}, [messages, streaming]);
// A fresh message ends any playback (the glow never sticks to a stale row).
const msgCount = messages.length;
useEffect(() => {
stopSpeak();
setSpeakingKey(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [msgCount]);
function autoGrow() {
const ta = taRef.current;
if (ta) {
@@ -276,6 +464,8 @@ export function Chat({
if (readOnly) return;
const text = draft.trim();
if (!text || sending) return;
stopSpeak();
setSpeakingKey(null);
setDraft("");
requestAnimationFrame(autoGrow);
onSend(text);
@@ -329,7 +519,7 @@ export function Chat({
</div>
)}
{messages.filter((m) => m.role !== "system").map((m, i) => (
<MessageBubble key={i} msg={m} index={i} authorAvatar={user.avatarUrl} authorName={user.displayName} onReport={readOnly ? undefined : setReportIndex} reportsEnabled={reportsEnabled} isFailed={failedIndex === i} retryCount={retryCount} maxRetries={maxRetries} retryDisabled={sending} onRetry={failedIndex === i && retryCount < maxRetries ? onRetry : undefined} />
<MessageBubble key={i} msg={m} index={i} authorAvatar={user.avatarUrl} authorName={user.displayName} onReport={readOnly ? undefined : setReportIndex} reportsEnabled={reportsEnabled} isFailed={failedIndex === i} retryCount={retryCount} maxRetries={maxRetries} retryDisabled={sending} onRetry={failedIndex === i && retryCount < maxRetries ? onRetry : undefined} speaking={speakingKey === i} onToggleSpeak={m.role === "assistant" ? () => toggleSpeak(i, m.content) : undefined} />
))}
{streaming !== "" && (
<div className="flex gap-2 sm:gap-3">
@@ -422,6 +612,35 @@ export function Chat({
{t("archived.unarchive")}
</button>
</div>
) : rec ? (
<div className="voice-recorder mx-auto w-full max-w-3xl rounded-[1.75rem] border border-accent-500/40 bg-white p-3 shadow-lg dark:border-accent-500/30 dark:bg-zinc-900">
<div className="flex items-center gap-2 px-1">
<span className="rec-dot" aria-hidden />
<span className="font-mono text-xs opacity-70">{fmtDuration(recElapsed)}</span>
<span className="min-w-0 flex-1 truncate text-sm opacity-80" aria-live="polite">
{recText || t("voice.listening")}
</span>
<button
type="button"
onClick={() => cancelRec()}
aria-label={t("voice.cancel")}
title={t("voice.cancel")}
className="grid h-9 w-9 shrink-0 place-items-center rounded-full transition hover:bg-stone-100 active:scale-95 dark:hover:bg-zinc-800"
>
<X size={16} className="opacity-70" />
</button>
<button
type="button"
onClick={stopRec}
aria-label={t("voice.stop")}
title={t("voice.stop")}
className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-accent-600 text-white shadow transition hover:bg-accent-500 active:scale-95 dark:bg-accent-500 dark:text-zinc-950 dark:hover:bg-accent-400"
>
<Square size={15} />
</button>
</div>
<Waveform levels={recLevels} />
</div>
) : (
<form onSubmit={submit} className={`mx-auto flex w-full max-w-3xl items-end gap-2 rounded-[1.75rem] border border-stone-200 bg-white p-2 shadow-lg dark:border-zinc-700 dark:bg-zinc-900 ${features.attachments ? "pl-2" : "pl-5"}`}>
{features.attachments && (
@@ -437,6 +656,16 @@ export function Chat({
)}
<input ref={imgRef} type="file" accept="image/jpeg,image/png,image/webp" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) onPickFile("ocr", f); e.target.value = ""; }} />
<input ref={txtRef} type="file" accept=".txt,.md,text/plain,text/markdown" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) onPickFile("text", f); e.target.value = ""; }} />
<button
type="button"
onClick={startRec}
disabled={!micOn || sending}
aria-label={t("voice.mic")}
title={micOn ? t("voice.mic") : t("voice.micOff")}
className="grid h-10 w-10 shrink-0 -translate-y-[2px] place-items-center rounded-full transition hover:bg-stone-100 active:scale-95 disabled:opacity-40 dark:hover:bg-zinc-800"
>
<Mic size={17} className="opacity-70" />
</button>
<textarea
ref={taRef}
rows={1}
+6
View File
@@ -8,6 +8,7 @@ import {
type OllamaNodeStatus,
} from "../lib/api.ts";
import { Button, ConfirmDialog, Field, Input, Modal, Picker, Spinner, Switch } from "./ui.tsx";
import { navigate } from "../lib/route.ts";
import { useT } from "../lib/i18n.ts";
import { cn } from "../lib/cn.ts";
@@ -413,6 +414,11 @@ function NodeDetailModal({ nodeId, onClose }: { nodeId: string; onClose: () => v
return (
<Modal open onClose={onClose} title={t("nodes.detail")} icon={Activity} wide>
<div className="mb-3 flex justify-end">
<Button variant="secondary" size="sm" onClick={() => { onClose(); navigate("/admin/stats"); }}>
<Activity size={14} /> {t("stats.openFull")}
</Button>
</div>
{!status ? (
<p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>
) : (
+355
View File
@@ -0,0 +1,355 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { BarChart3, Cpu, Globe, HardDrive, Network, Server, Spline } from "lucide-react";
import {
api,
type EffectiveOllamaNode,
type OllamaNodeStats,
type PlatformInfo,
} from "../lib/api.ts";
import { fmtSize } from "./OllamaNodes.tsx";
import { Picker, Spinner } from "./ui.tsx";
import { useT } from "../lib/i18n.ts";
import { cn } from "../lib/cn.ts";
type StatsWindowId = "1h" | "6h" | "24h" | "7d" | "30d";
const WINDOWS: StatsWindowId[] = ["1h", "6h", "24h", "7d", "30d"];
type ChartKind = "bar" | "area" | "line";
const CHARTS: { id: ChartKind; label: string; icon: typeof BarChart3 }[] = [
{ id: "bar", label: "Bar", icon: BarChart3 },
{ id: "area", label: "Area", icon: Spline },
{ id: "line", label: "Line", icon: Spline },
];
function fmtUptime(totalS: number): string {
const d = Math.floor(totalS / 86400);
const h = Math.floor((totalS % 86400) / 3600);
const m = Math.floor((totalS % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function fmtClock(t: number): string {
const d = new Date(t);
const hh = d.getHours().toString().padStart(2, "0");
const mm = d.getMinutes().toString().padStart(2, "0");
return `${d.getDate()}/${d.getMonth() + 1} ${hh}:${mm}`;
}
/** Admin telemetry sub-page: pool load charts + platform host facts. */
export function StatsSection() {
const { t } = useT();
const [nodes, setNodes] = useState<EffectiveOllamaNode[]>([{ id: "default", name: "default", host: "", weight: 0 }]);
const [nodeId, setNodeId] = useState("default");
const [window, setWindow] = useState<StatsWindowId>("24h");
const [kind, setKind] = useState<ChartKind>("bar");
const [stats, setStats] = useState<OllamaNodeStats | null>(null);
const [loading, setLoading] = useState(true);
const [platform, setPlatform] = useState<PlatformInfo | null>(null);
useEffect(() => {
api.ollamaNodes()
.then((r) => setNodes([{ id: "default", name: "default", host: "", weight: 0 }, ...r.effective.filter((n) => n.id !== "default")]))
.catch(() => {});
api.platform().then((r) => setPlatform(r.platform)).catch(() => {});
}, []);
useEffect(() => {
setLoading(true);
api.ollamaNodeStats(nodeId, window)
.then((r) => setStats(r.stats))
.catch(() => setStats(null))
.finally(() => setLoading(false));
}, [nodeId, window]);
const totals = stats?.totals;
return (
<div className="space-y-4">
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="mb-1 flex flex-wrap items-center gap-2">
<p className="flex items-center gap-1.5 text-sm font-medium">
<Server size={14} className="opacity-60" /> {t("stats.poolTitle")}
</p>
<span className="ml-auto flex flex-wrap items-center gap-2">
<span className="w-40">
<Picker
ariaLabel={t("stats.node")}
value={nodeId}
onChange={setNodeId}
align="right"
options={nodes.map((n) => ({ value: n.id, label: n.id === "default" ? "default" : n.name }))}
/>
</span>
<span className="w-24">
<Picker
ariaLabel={t("stats.window")}
value={window}
onChange={(v) => setWindow(v as StatsWindowId)}
align="right"
options={WINDOWS.map((w) => ({ value: w, label: w }))}
/>
</span>
</span>
</div>
<p className="mb-3 text-sm opacity-60">{t("stats.poolDesc")}</p>
{/* Chart-type dock */}
<div className="mb-3 inline-flex gap-1 rounded-full border border-stone-200/70 bg-stone-100/70 p-1 dark:border-zinc-800 dark:bg-zinc-900" role="tablist" aria-label={t("stats.chart")}>
{CHARTS.map((c) => (
<button
key={c.id}
role="tab"
aria-selected={kind === c.id}
onClick={() => setKind(c.id)}
className={cn(
"flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-medium transition",
kind === c.id
? "bg-white text-stone-900 shadow dark:bg-zinc-800 dark:text-zinc-100"
: "opacity-60 hover:opacity-100",
)}
>
<c.icon size={13} />
{c.label}
</button>
))}
</div>
{loading && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
{totals && (
<div className="mb-3 grid grid-cols-2 gap-2 sm:grid-cols-5">
<StatTile label={t("nodes.req")} value={String(totals.req)} />
<StatTile label={t("nodes.err")} value={String(totals.err)} alert={totals.err > 0} />
<StatTile label={t("nodes.avg")} value={totals.avgMs === null ? "—" : `${Math.round(totals.avgMs)} ms`} />
<StatTile label={t("nodes.tps")} value={totals.tokPerSec === null ? "—" : totals.tokPerSec.toFixed(1)} />
<StatTile label={t("nodes.tok")} value={String(totals.promptTok + totals.evalTok)} />
</div>
)}
{stats && (stats.totals.req === 0 ? (
<p className="rounded-2xl border border-dashed border-stone-300 px-4 py-8 text-center text-sm opacity-50 dark:border-zinc-700">
{t("nodes.emptyStats")}
</p>
) : (
<LoadChart stats={stats} kind={kind} />
))}
<p className="mt-2 text-xs opacity-50">{t("nodes.cpuNote")}</p>
</section>
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="mb-1 flex items-center gap-1.5 text-sm font-medium">
<Cpu size={14} className="opacity-60" /> {t("stats.hostTitle")}
</p>
<p className="mb-3 text-sm opacity-60">{t("stats.hostDesc")}</p>
{!platform ? (
<p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>
) : (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<StatTile label={t("stats.hostname")} value={platform.hostname} mono />
<StatTile label={t("stats.kernel")} value={platform.kernel} mono />
<StatTile label={t("stats.cpu")} value={`${platform.cpus} × ${platform.cpuModel?.split("@")[0]?.trim() ?? "?"}`} mono />
<StatTile label={t("stats.runtime")} value={platform.runtime} mono />
<StatTile label={t("stats.uptime")} value={fmtUptime(platform.uptimeS)} />
<StatTile label={t("stats.appUptime")} value={fmtUptime(platform.appUptimeS)} />
<StatTile label={t("stats.load")} value={platform.loadavg.map((v) => v.toFixed(2)).join(" ")} mono />
<StatTile
label={t("stats.memory")}
value={`${fmtSize(platform.memTotal - platform.memFree)} / ${fmtSize(platform.memTotal)}`}
/>
</div>
<div>
<div className="h-2 overflow-hidden rounded-full bg-stone-200 dark:bg-zinc-800" title={t("stats.memory")}>
<div
className="h-full rounded-full bg-accent-600 dark:bg-accent-500"
style={{ width: `${Math.min(100, Math.round(((platform.memTotal - platform.memFree) / Math.max(1, platform.memTotal)) * 100))}%` }}
/>
</div>
</div>
<div>
<p className="mb-1.5 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wider opacity-50">
<Network size={12} /> {t("stats.network")}
</p>
<ul className="space-y-1.5">
{platform.nets
.filter((n) => !n.address.startsWith("fe80"))
.sort((a, b) => Number(a.internal) - Number(b.internal))
.map((n, i) => (
<li key={`${n.name}-${n.address}-${i}`} className="flex items-center gap-3 rounded-2xl border border-stone-200/70 px-3.5 py-2 text-sm dark:border-zinc-800">
<Globe size={13} className="shrink-0 opacity-50" />
<span className="min-w-0 flex-1 truncate font-mono">{n.address}</span>
<span className="shrink-0 text-xs opacity-60">{n.name} · {n.family}{n.internal ? " · lo" : ""}</span>
</li>
))}
</ul>
<p className="mt-1.5 flex items-center gap-1.5 text-xs opacity-50">
<HardDrive size={12} /> {t("stats.diskNote")}
</p>
</div>
</div>
)}
</section>
</div>
);
}
function StatTile({ label, value, mono, alert }: { label: string; value: string; mono?: boolean; alert?: boolean }) {
return (
<div className="min-w-0 rounded-2xl bg-stone-100/70 px-3 py-2 dark:bg-zinc-800/60">
<p className="truncate text-[11px] uppercase tracking-wider opacity-50">{label}</p>
<p className={cn("truncate text-sm font-medium", mono && "font-mono", alert && "text-red-500")} title={value}>{value}</p>
</div>
);
}
/* ---------- SVG load chart with tooltip ---------- */
const W = 480;
const H = 180;
const PAD_L = 8;
const PAD_R = 8;
const PAD_T = 12;
const PAD_B = 22;
function LoadChart({ stats, kind }: { stats: OllamaNodeStats; kind: ChartKind }) {
const { t } = useT();
const wrapRef = useRef<HTMLDivElement>(null);
const [hover, setHover] = useState<number | null>(null);
const buckets = stats.buckets;
const n = buckets.length;
const maxReq = Math.max(1, ...buckets.map((b) => b.req));
const maxAvg = Math.max(1, ...buckets.map((b) => b.avgMs ?? 0));
const iw = W - PAD_L - PAD_R;
const ih = H - PAD_T - PAD_B;
const step = iw / n;
const bw = Math.max(2, step - 2);
const { points, areaPath, errPoints } = useMemo(() => {
const pts = buckets.map((b, i) => ({
x: PAD_L + i * step + step / 2,
y: PAD_T + ih - (b.req / maxReq) * ih,
}));
const area = `M${PAD_L},${PAD_T + ih} ` + pts.map((p) => `L${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ") + ` L${(PAD_L + iw).toFixed(1)},${PAD_T + ih} Z`;
const err = buckets.map((b, i) => ({
x: PAD_L + i * step + step / 2,
y: PAD_T + ih - (b.err / maxReq) * ih,
}));
return { points: pts, areaPath: area, errPoints: err };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stats, kind]);
function onMove(e: React.MouseEvent<SVGSVGElement>) {
const rect = wrapRef.current?.getBoundingClientRect();
if (!rect) return;
const sx = W / rect.width;
const x = (e.clientX - rect.left) * sx - PAD_L;
const i = Math.round(x / step - 0.5);
setHover(Math.max(0, Math.min(n - 1, i)));
}
const hb = hover !== null ? buckets[hover]! : null;
// Latency sparkline (normalized, bottom-anchored, subtle).
const latPath = useMemo(() => {
const pts = buckets.map((b, i) => {
const x = PAD_L + i * step + step / 2;
const v = b.avgMs ?? 0;
return `L${x.toFixed(1)},${(PAD_T + ih - (v / maxAvg) * ih * 0.5).toFixed(1)}`;
});
return `M${PAD_L},${PAD_T + ih} ` + pts.join(" ");
}, [buckets, maxAvg, step, ih]);
return (
<div ref={wrapRef} className="relative">
<svg
viewBox={`0 0 ${W} ${H}`}
className="block w-full select-none"
onMouseMove={onMove}
onMouseLeave={() => setHover(null)}
role="img"
aria-label={`${stats.totals.req} ${t("nodes.req")}`}
>
{/* gridlines */}
{[0.25, 0.5, 0.75].map((f) => (
<line
key={f}
x1={PAD_L}
x2={W - PAD_R}
y1={PAD_T + ih * f}
y2={PAD_T + ih * f}
className="stroke-stone-200 dark:stroke-zinc-800"
strokeWidth={1}
/>
))}
{kind === "bar" && buckets.map((b, i) => {
const h = Math.max(1.5, (b.req / maxReq) * ih);
const x = PAD_L + i * step + (step - bw) / 2;
const y = PAD_T + ih - h;
const errH = b.req > 0 ? h * (b.err / b.req) : 0;
return (
<g key={i}>
<rect x={x} y={y} width={bw} height={h} rx={1.5} style={{ fill: "rgb(var(--ka-accent-500) / 0.75)" }} opacity={hover === i ? 1 : 0.85} />
{errH > 0 && <rect x={x} y={y} width={bw} height={errH} rx={1.5} className="fill-red-500" />}
</g>
);
})}
{kind === "area" && (
<>
<path d={areaPath} style={{ fill: "rgb(var(--ka-accent-500) / 0.25)" }} />
<polyline
points={points.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ")}
fill="none"
style={{ stroke: "rgb(var(--ka-accent-500))" }}
strokeWidth={2}
strokeLinejoin="round"
/>
<polyline points={errPoints.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ")} fill="none" className="stroke-red-500" strokeWidth={1.5} strokeDasharray="3 2" />
</>
)}
{kind === "line" && (
<>
<polyline points={points.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ")} fill="none" style={{ stroke: "rgb(var(--ka-accent-500))" }} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
<polyline points={errPoints.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ")} fill="none" className="stroke-red-500" strokeWidth={1.5} strokeDasharray="3 2" />
{points.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={hover === i ? 3.5 : 2} style={{ fill: "rgb(var(--ka-accent-500))" }} />
))}
</>
)}
{/* latency underlay */}
<path d={latPath} fill="none" className="stroke-sky-400" strokeWidth={1} opacity={0.5} />
{/* hover cursor */}
{hover !== null && (
<line x1={points[hover]!.x} x2={points[hover]!.x} y1={PAD_T} y2={PAD_T + ih} className="stroke-stone-400 dark:stroke-zinc-500" strokeWidth={1} strokeDasharray="2 2" />
)}
{/* x labels */}
{[0, Math.floor(n / 2), n - 1].map((i) => (
<text key={i} x={PAD_L + i * step + step / 2} y={H - 6} textAnchor="middle" fontSize={9} className="fill-stone-400 dark:fill-zinc-500">
{fmtClock(buckets[i]!.t)}
</text>
))}
</svg>
{hb && hover !== null && (
<div
className="pointer-events-none absolute z-10 min-w-36 rounded-2xl border border-stone-200 bg-white/95 px-3 py-2 text-xs shadow-xl backdrop-blur dark:border-zinc-700 dark:bg-zinc-900/95"
style={{
left: `clamp(4px, ${(points[hover]!.x / W) * 100}%, calc(100% - 150px))`,
top: 0,
transform: "translateX(-50%)",
}}
>
<p className="mb-1 font-medium opacity-70">{fmtClock(hb.t)}</p>
<p>{hb.req} {t("nodes.req")} · <span className="text-red-500">{hb.err} {t("nodes.err")}</span></p>
<p className="opacity-70">{t("nodes.avg")}: {hb.avgMs === null ? "—" : `${Math.round(hb.avgMs)} ms`}</p>
<p className="opacity-70">{hb.promptTok + hb.evalTok} {t("nodes.tok")}</p>
</div>
)}
<div className="mt-1 flex items-center gap-4 text-[11px] opacity-60">
<span className="flex items-center gap-1.5"><span className="h-2 w-2 rounded-full" style={{ background: "rgb(var(--ka-accent-500))" }} /> {t("nodes.req")}</span>
<span className="flex items-center gap-1.5"><span className="h-2 w-2 rounded-full bg-red-500" /> {t("nodes.err")}</span>
<span className="flex items-center gap-1.5"><span className="h-2 w-2 rounded-full bg-sky-400" /> {t("nodes.avg")}</span>
</div>
</div>
);
}
+135 -1
View File
@@ -1,9 +1,10 @@
import { useEffect, useRef, useState } from "react";
import { Archive, ArchiveRestore, Eye, Fingerprint, Globe, FileText, ImagePlus, KeyRound, Link2, Mail, Palette, Pencil, Plug, ScanText, Search, Settings2, ShieldCheck, Sparkles, Tag, Trash2, Unplug, User as UserIcon, X } from "lucide-react";
import { Archive, ArchiveRestore, Eye, Fingerprint, Globe, FileText, ImagePlus, KeyRound, Link2, Mail, Mic, Palette, Pencil, Play, Plug, ScanText, Search, Settings2, ShieldCheck, Sparkles, Square, Tag, Trash2, Unplug, User as UserIcon, Volume2, X } from "lucide-react";
import { api, type UserProvider } from "../lib/api.ts";
import { pushToast } from "../lib/toasts.ts";
import { cn } from "../lib/cn.ts";
import { ACCENT_PRESETS, applyAccent, currentAccent, previewAccent, type AccentState } from "../lib/accent.ts";
import { detectLang, getVoicePrefs, listTtsVoices, setVoicePrefs, speak, stopSpeak, ttsSupported, type VoicePrefs } from "../lib/voice.ts";
import { FEATURES, setFeature, useFeatures } from "../lib/features.ts";
import type { Conversation, FilePreview, User } from "../lib/types.ts";
import { useT, type StringKey } from "../lib/i18n.ts";
@@ -307,6 +308,7 @@ export function SettingsModal({
{ id: "profile", title: t("settings.profile"), icon: UserIcon, keywords: ["profile", "avatar", "picture", "photo", "name", "username", "login", "display", "email", "account"] },
{ id: "appearance", title: t("settings.appearance"), icon: Palette, keywords: ["appearance", "theme", "dark", "light", "color", "colour", "accent", "palette", "language", "langue", "idioma", "lingua", "язык"] },
{ id: "features", title: t("settings.features"), icon: Sparkles, keywords: ["features", "thinking", "attachments", "search", "deep", "upload", "ocr", "capabilities", "enable", "disable"] },
{ id: "voice", title: t("voice.title"), icon: Mic, keywords: ["voice", "voix", "voz", "speech", "tts", "audio", "speak", "listen", "language", "langue", "accent", "parler"] },
{ id: "security", title: t("settings.security"), icon: ShieldCheck, keywords: ["security", "key", "rotate", "password", "totp", "2fa", "two", "factor", "authenticator", "passkey", "webauthn"] },
{ id: "providers", title: t("settings.providers"), icon: Plug, keywords: ["providers", "provider", "api", "keys", "openai", "anthropic", "deepseek", "gemini", "byok", "model", "fournisseur", "proveedor", "провайдер"] },
{ id: "archived", title: t("archived.title"), icon: Archive, keywords: ["archived", "archive", "old", "hidden", "read-only", "readonly", "archiv"] },
@@ -591,6 +593,12 @@ export function SettingsModal({
</SettingsPane>
)}
{activeCat === "voice" && (
<SettingsPane>
<VoiceSection />
</SettingsPane>
)}
{activeCat === "security" && (
<SettingsPane>
<SecuritySection onKeyRotated={onKeyRotated} />
@@ -859,6 +867,132 @@ function FeaturesSection() {
);
}
/* ---------- Voice (settings): playback voice, language, previews ---------- */
const VOICE_LANGS = ["auto", "fr", "en", "es", "de", "it", "pt", "ru"];
function VoiceSection() {
const { t } = useT();
const [prefs, setPrefs] = useState<VoicePrefs>(() => getVoicePrefs());
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([]);
const [previews, setPreviews] = useState<string[]>([]);
const [playing, setPlaying] = useState<number | null>(null);
useEffect(() => {
let alive = true;
listTtsVoices().then((v) => { if (alive) setVoices(v); });
api.tools().then((r) => { if (alive) setPreviews(r.voicePreviews); }).catch(() => {});
return () => { alive = false; stopSpeak(); };
}, []);
function update(p: Partial<VoicePrefs>) {
const next = { ...prefs, ...p };
setPrefs(next);
setVoicePrefs(next);
}
function preview(i: number, text: string) {
if (playing === i) {
stopSpeak();
setPlaying(null);
return;
}
const ok = speak(text, prefs, voices, {
onend: () => setPlaying(null),
onerror: () => setPlaying(null),
});
if (ok) setPlaying(i);
}
const supported = ttsSupported();
const groups = (() => {
const byLang = new Map<string, SpeechSynthesisVoice[]>();
for (const v of voices) {
const lang = (v.lang || "?").split("-")[0]!.toLowerCase();
const list = byLang.get(lang) ?? [];
list.push(v);
byLang.set(lang, list);
}
return [...byLang.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([group, options]) => ({
group,
options: options.map((v) => ({ value: v.voiceURI, label: v.name + (v.default ? " ★" : "") })),
}));
})();
return (
<div className="space-y-4">
<p className="-mb-2 text-xs opacity-60">{t("voice.desc")}</p>
{!supported && (
<p className="rounded-2xl bg-amber-500/10 px-4 py-2.5 text-sm text-amber-700 dark:text-amber-400" role="alert">
{t("voice.noVoices")}
</p>
)}
<Field label={t("voice.language")}>
<Picker
ariaLabel={t("voice.language")}
icon={Globe}
value={prefs.lang}
onChange={(v) => update({ lang: v, voiceURI: null })}
align="left"
options={VOICE_LANGS.map((l) => ({ value: l, label: l === "auto" ? t("voice.auto") : l }))}
/>
</Field>
<Field label={t("voice.voice")}>
<Picker
ariaLabel={t("voice.voice")}
icon={Volume2}
value={prefs.voiceURI ?? ""}
onChange={(v) => update({ voiceURI: v || null })}
align="left"
groups={[{ group: "—", options: [{ value: "", label: t("voice.defaultVoice") }] }, ...groups]}
/>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label={`${t("voice.rate")} · ${prefs.rate.toFixed(2)}×`}>
<input
type="range" min={0.5} max={1.5} step={0.05} value={prefs.rate}
onChange={(e) => update({ rate: Number(e.target.value) })}
className="w-full accent-[rgb(var(--ka-accent-600))]"
aria-label={t("voice.rate")}
/>
</Field>
<Field label={`${t("voice.pitch")} · ${prefs.pitch.toFixed(2)}×`}>
<input
type="range" min={0.5} max={1.5} step={0.05} value={prefs.pitch}
onChange={(e) => update({ pitch: Number(e.target.value) })}
className="w-full accent-[rgb(var(--ka-accent-600))]"
aria-label={t("voice.pitch")}
/>
</Field>
</div>
{previews.length > 0 && (
<div>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider opacity-50">{t("voice.previews")}</p>
<ul className="space-y-1.5">
{previews.map((line, i) => (
<li key={i} className="flex items-center gap-2 rounded-2xl border border-stone-200/70 bg-white px-3 py-2 dark:border-zinc-700 dark:bg-zinc-900">
<button
type="button"
onClick={() => preview(i, line)}
disabled={!supported}
aria-label={`${t("voice.preview")} ${i + 1}`}
className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-accent-600 text-white transition hover:bg-accent-500 disabled:opacity-40 dark:bg-accent-500 dark:text-zinc-950"
>
{playing === i ? <Square size={13} /> : <Play size={13} className="translate-x-[1px]" />}
</button>
<span className="min-w-0 flex-1 truncate text-sm">{line}</span>
<span className="hidden shrink-0 text-xs opacity-40 sm:block">
{prefs.lang === "auto" ? detectLang(line) : prefs.lang} · {prefs.rate.toFixed(2)}×
</span>
</li>
))}
</ul>
</div>
)}
</div>
);
}
/* ---------- Security (settings): key rotation, TOTP, passkeys ---------- */
function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
+48
View File
@@ -180,6 +180,54 @@ body {
}
}
/* Voice: Siri-style glow on the bubble being read aloud. */
.speaking-glow {
position: relative;
border-color: rgb(var(--ka-accent-500) / 0.55) !important;
box-shadow:
0 0 0 1px rgb(var(--ka-accent-500) / 0.25),
0 0 22px rgb(var(--ka-accent-500) / 0.28),
0 8px 30px rgb(var(--ka-accent-500) / 0.12);
animation: speak-breathe 2.2s ease-in-out infinite;
}
@keyframes speak-breathe {
0%, 100% { box-shadow: 0 0 0 1px rgb(var(--ka-accent-500) / 0.25), 0 0 14px rgb(var(--ka-accent-500) / 0.2), 0 8px 30px rgb(var(--ka-accent-500) / 0.1); }
50% { box-shadow: 0 0 0 1px rgb(var(--ka-accent-500) / 0.4), 0 0 30px rgb(var(--ka-accent-500) / 0.38), 0 8px 30px rgb(var(--ka-accent-500) / 0.16); }
}
.speaking-orb {
animation: orb-pulse 1.1s ease-in-out infinite;
}
@keyframes orb-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.55; transform: scale(0.86); }
}
/* Voice recorder: pulsing REC dot, buttery bars, soft entrance. */
.rec-dot {
width: 10px;
height: 10px;
border-radius: 999px;
background: #ef4444;
box-shadow: 0 0 10px rgb(239 68 68 / 0.8);
animation: rec-blink 1.2s ease-in-out infinite;
flex-shrink: 0;
}
@keyframes rec-blink {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.45; transform: scale(0.8); }
}
.wave-bar {
transition: height 90ms linear, opacity 90ms linear;
min-width: 2px;
}
.voice-recorder {
animation: recorder-in 0.25s cubic-bezier(0.21, 1.02, 0.73, 1) both;
}
@keyframes recorder-in {
from { opacity: 0; transform: translateY(10px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* Kill Chrome's yellow autofill background (login / password fields) */
input:-webkit-autofill,
input:-webkit-autofill:hover,
+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")}`;
}