Frontend. :)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")}`;
|
||||
}
|
||||
@@ -223,6 +223,37 @@ async function probeOllamaHost(host: string): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default TTS voice-preview lines (admin-editable, resettable). Short,
|
||||
* varied lines that show off grave/hesitant, grave/serious, cute feminine
|
||||
* and medium feminine deliveries.
|
||||
*/
|
||||
const DEFAULT_VOICE_PREVIEWS = [
|
||||
"Salut, euhm, comment je peux t'aider aujourd'hui ?",
|
||||
"5 sur 5 je recois ! Que veux-tu ?",
|
||||
"Owww, ce chat est trop mignon, oops, pardon. Je me concentre, que puis-je faire pour toi ?",
|
||||
"Hey, que puis-je faire pour toi ?",
|
||||
];
|
||||
|
||||
async function getVoicePreviews(): Promise<string[]> {
|
||||
try {
|
||||
const raw = await getSetting("voice_previews", "");
|
||||
if (!raw) return [...DEFAULT_VOICE_PREVIEWS];
|
||||
const arr = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(arr)) return [...DEFAULT_VOICE_PREVIEWS];
|
||||
const clean = arr.filter((s): s is string => typeof s === "string").map((s) => s.trim()).filter((s) => s.length > 0 && s.length <= 500).slice(0, 6);
|
||||
return clean.length > 0 ? clean : [...DEFAULT_VOICE_PREVIEWS];
|
||||
} catch {
|
||||
return [...DEFAULT_VOICE_PREVIEWS];
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeVoicePreviews(v: unknown): string[] | null {
|
||||
if (!Array.isArray(v) || v.length === 0 || v.length > 6) return null;
|
||||
const clean = v.map((s) => (typeof s === "string" ? s.trim() : "")).filter((s) => s.length > 0 && s.length <= 500);
|
||||
return clean.length > 0 ? clean.slice(0, 6) : null;
|
||||
}
|
||||
|
||||
function clientIp(req: Request, server: { requestIP?: (r: Request) => { address: string } | null }): string {
|
||||
try {
|
||||
const info = server.requestIP?.(req);
|
||||
@@ -970,6 +1001,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
ollamaDefaultWeight: await getOllamaDefaultWeight(),
|
||||
recoveryLimitMax: await recoveryLimitMax(),
|
||||
recoveryLimitWindowMin: Math.round(await recoveryLimitWindowMs() / 60_000),
|
||||
voicePreviews: await getVoicePreviews(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1029,6 +1061,16 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
}
|
||||
await setSetting("recovery_limit_window_min", String(body.recoveryLimitWindowMin));
|
||||
}
|
||||
if (body.voicePreviews !== undefined) {
|
||||
if (Array.isArray(body.voicePreviews) && body.voicePreviews.length === 0) {
|
||||
// Empty array = reset to the built-in default lines.
|
||||
await setSetting("voice_previews", "");
|
||||
} else {
|
||||
const clean = sanitizeVoicePreviews(body.voicePreviews);
|
||||
if (!clean) return json({ error: "voicePreviews must be 1-6 non-empty strings (max 500 chars each), or [] to reset" }, 400);
|
||||
await setSetting("voice_previews", JSON.stringify(clean));
|
||||
}
|
||||
}
|
||||
return json({
|
||||
settings: {
|
||||
registrationEnabled: await isRegistrationEnabled(),
|
||||
@@ -1040,6 +1082,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
ollamaDefaultWeight: await getOllamaDefaultWeight(),
|
||||
recoveryLimitMax: await recoveryLimitMax(),
|
||||
recoveryLimitWindowMin: Math.round(await recoveryLimitWindowMs() / 60_000),
|
||||
voicePreviews: await getVoicePreviews(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1483,6 +1526,41 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
return json({ error: "not found" }, 404);
|
||||
}
|
||||
|
||||
// --- admin: platform host facts (hostname, kernel, uptime, net, ips) ---
|
||||
// This box only (Ollama hosts expose no such API — see node probes).
|
||||
if (path === "/api/admin/platform" && req.method === "GET") {
|
||||
if (!admin) return json({ error: "forbidden" }, 403);
|
||||
try {
|
||||
const os = await import("node:os");
|
||||
const nets: { name: string; address: string; family: string; internal: boolean }[] = [];
|
||||
for (const [name, list] of Object.entries(os.networkInterfaces())) {
|
||||
for (const ni of list ?? []) {
|
||||
if (ni.family === "IPv4" || ni.family === "IPv6") {
|
||||
nets.push({ name, address: ni.address, family: ni.family, internal: ni.internal });
|
||||
}
|
||||
}
|
||||
}
|
||||
return json({
|
||||
platform: {
|
||||
hostname: os.hostname(),
|
||||
kernel: `${os.type()} ${os.release()}`,
|
||||
arch: os.arch(),
|
||||
uptimeS: Math.floor(os.uptime()),
|
||||
appUptimeS: Math.floor(process.uptime()),
|
||||
loadavg: os.loadavg(),
|
||||
cpus: os.cpus().length,
|
||||
cpuModel: os.cpus()[0]?.model ?? null,
|
||||
memTotal: os.totalmem(),
|
||||
memFree: os.freemem(),
|
||||
runtime: `bun ${Bun.version}`,
|
||||
nets,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
return json({ error: (e as Error).message }, 502);
|
||||
}
|
||||
}
|
||||
|
||||
// --- admin: SMTP connectivity (viewer + tester) ---
|
||||
if (path === "/api/admin/mail" && req.method === "GET") {
|
||||
if (!admin) return json({ error: "forbidden" }, 403);
|
||||
@@ -1673,6 +1751,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
tools: await tools.list(),
|
||||
reportsEnabled: await isReportsEnabled(),
|
||||
usernameChangeEnabled: await isUsernameChangeEnabled(),
|
||||
voicePreviews: await getVoicePreviews(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user