Frontend. :)
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
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 { api, getToken, type AccessStatus, type AdminSettings, type AdminSettingsPatch, type ReportStatus } from "../lib/api.ts";
|
||||||
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
|
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
|
||||||
import { navigate } from "../lib/route.ts";
|
import { navigate } from "../lib/route.ts";
|
||||||
@@ -7,6 +7,7 @@ import { AdminPanel } from "./AdminPanel.tsx";
|
|||||||
import { AccessRequestsPanel } from "./AccessRequests.tsx";
|
import { AccessRequestsPanel } from "./AccessRequests.tsx";
|
||||||
import { ReportsPanel } from "./ReportsPanel.tsx";
|
import { ReportsPanel } from "./ReportsPanel.tsx";
|
||||||
import { ModelsPanel } from "./ModelsPanel.tsx";
|
import { ModelsPanel } from "./ModelsPanel.tsx";
|
||||||
|
import { StatsSection } from "./StatsSection.tsx";
|
||||||
import { Toasts } from "./Toasts.tsx";
|
import { Toasts } from "./Toasts.tsx";
|
||||||
import { pushToast } from "../lib/toasts.ts";
|
import { pushToast } from "../lib/toasts.ts";
|
||||||
import { Button, CopyButton, Field, FlowerMark, Input, LoadingScreen, Spinner, Switch } from "./ui.tsx";
|
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 { msUntilNextSolarSwitch, resolveThemeDark } from "../lib/solarTheme.ts";
|
||||||
import { useT } from "../lib/i18n.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 {
|
function validTab(v: string | null | undefined): Tab {
|
||||||
return v !== null && v !== undefined && (VALID_TABS as readonly string[]).includes(v) ? (v as Tab) : "accounts";
|
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: "access", label: t("center.tabAccess"), icon: Inbox },
|
||||||
{ id: "reports", label: t("center.tabReports"), icon: Flag },
|
{ id: "reports", label: t("center.tabReports"), icon: Flag },
|
||||||
{ id: "models", label: t("center.tabModels"), icon: Boxes },
|
{ 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: "features", label: t("center.tabFeatures"), icon: SlidersHorizontal },
|
||||||
{ id: "mail", label: t("center.tabMail"), icon: Mail },
|
{ id: "mail", label: t("center.tabMail"), icon: Mail },
|
||||||
];
|
];
|
||||||
@@ -268,6 +270,8 @@ export function AdminCenter({ initialTab }: { initialTab?: string | null }) {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === "stats" && <StatsSection />}
|
||||||
|
|
||||||
{tab === "features" && (
|
{tab === "features" && (
|
||||||
<section className="space-y-3">
|
<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">
|
<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>
|
</div>
|
||||||
{saving && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.saving")}</p>}
|
{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>}
|
{!settings && !error && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
|
||||||
|
<VoicePreviewsCard />
|
||||||
</section>
|
</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 = {
|
type SmtpInfo = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
host: string;
|
host: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
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 type { Attachment, ChatMessage, Conversation, DriverModel, User } from "../lib/types.ts";
|
||||||
import { api, ApiError, type ReportReason } from "../lib/api.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";
|
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 { UserMessageContent } from "./MessageContent.tsx";
|
||||||
import { cn } from "../lib/cn.ts";
|
import { cn } from "../lib/cn.ts";
|
||||||
import { useFeatures } from "../lib/features.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";
|
import { useT, type StringKey } from "../lib/i18n.ts";
|
||||||
|
|
||||||
const REPORT_REASONS: ReportReason[] = ["copyright", "gore", "falseinfo", "bug"];
|
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 { t } = useT();
|
||||||
const isUser = msg.role === "user";
|
const isUser = msg.role === "user";
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
@@ -39,12 +40,17 @@ function MessageBubble({ msg, index, authorAvatar, authorName, onReport, reports
|
|||||||
<UserMessageContent content={msg.content} />
|
<UserMessageContent content={msg.content} />
|
||||||
</div>
|
</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} />
|
<Markdown text={msg.content} />
|
||||||
</div>
|
</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">
|
<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 && (
|
{onReport && (
|
||||||
<>
|
<>
|
||||||
<IconButton title={copied ? t("common.copied") : t("msg.copy")} onClick={copy}>
|
<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 }: {
|
function ReportDialog({ open, onClose, conversationId, messageIndex, content, prompt, model }: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -243,6 +276,153 @@ export function Chat({
|
|||||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const imgRef = useRef<HTMLInputElement>(null);
|
const imgRef = useRef<HTMLInputElement>(null);
|
||||||
const txtRef = 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 modelGroups: PickerGroup[] = (() => {
|
||||||
const byDriver = new Map<string, { value: string; label: string; hint: string }[]>();
|
const byDriver = new Map<string, { value: string; label: string; hint: string }[]>();
|
||||||
@@ -263,6 +443,14 @@ export function Chat({
|
|||||||
if (el) el.scrollTop = el.scrollHeight;
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
}, [messages, streaming]);
|
}, [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() {
|
function autoGrow() {
|
||||||
const ta = taRef.current;
|
const ta = taRef.current;
|
||||||
if (ta) {
|
if (ta) {
|
||||||
@@ -276,6 +464,8 @@ export function Chat({
|
|||||||
if (readOnly) return;
|
if (readOnly) return;
|
||||||
const text = draft.trim();
|
const text = draft.trim();
|
||||||
if (!text || sending) return;
|
if (!text || sending) return;
|
||||||
|
stopSpeak();
|
||||||
|
setSpeakingKey(null);
|
||||||
setDraft("");
|
setDraft("");
|
||||||
requestAnimationFrame(autoGrow);
|
requestAnimationFrame(autoGrow);
|
||||||
onSend(text);
|
onSend(text);
|
||||||
@@ -329,7 +519,7 @@ export function Chat({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{messages.filter((m) => m.role !== "system").map((m, i) => (
|
{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 !== "" && (
|
{streaming !== "" && (
|
||||||
<div className="flex gap-2 sm:gap-3">
|
<div className="flex gap-2 sm:gap-3">
|
||||||
@@ -422,6 +612,35 @@ export function Chat({
|
|||||||
{t("archived.unarchive")}
|
{t("archived.unarchive")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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"}`}>
|
<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 && (
|
{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={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 = ""; }} />
|
<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
|
<textarea
|
||||||
ref={taRef}
|
ref={taRef}
|
||||||
rows={1}
|
rows={1}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type OllamaNodeStatus,
|
type OllamaNodeStatus,
|
||||||
} from "../lib/api.ts";
|
} from "../lib/api.ts";
|
||||||
import { Button, ConfirmDialog, Field, Input, Modal, Picker, Spinner, Switch } from "./ui.tsx";
|
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 { useT } from "../lib/i18n.ts";
|
||||||
import { cn } from "../lib/cn.ts";
|
import { cn } from "../lib/cn.ts";
|
||||||
|
|
||||||
@@ -413,6 +414,11 @@ function NodeDetailModal({ nodeId, onClose }: { nodeId: string; onClose: () => v
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open onClose={onClose} title={t("nodes.detail")} icon={Activity} wide>
|
<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 ? (
|
{!status ? (
|
||||||
<p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>
|
<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 { 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 { api, type UserProvider } from "../lib/api.ts";
|
||||||
import { pushToast } from "../lib/toasts.ts";
|
import { pushToast } from "../lib/toasts.ts";
|
||||||
import { cn } from "../lib/cn.ts";
|
import { cn } from "../lib/cn.ts";
|
||||||
import { ACCENT_PRESETS, applyAccent, currentAccent, previewAccent, type AccentState } from "../lib/accent.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 { FEATURES, setFeature, useFeatures } from "../lib/features.ts";
|
||||||
import type { Conversation, FilePreview, User } from "../lib/types.ts";
|
import type { Conversation, FilePreview, User } from "../lib/types.ts";
|
||||||
import { useT, type StringKey } from "../lib/i18n.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: "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: "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: "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: "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: "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"] },
|
{ 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>
|
</SettingsPane>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeCat === "voice" && (
|
||||||
|
<SettingsPane>
|
||||||
|
<VoiceSection />
|
||||||
|
</SettingsPane>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeCat === "security" && (
|
{activeCat === "security" && (
|
||||||
<SettingsPane>
|
<SettingsPane>
|
||||||
<SecuritySection onKeyRotated={onKeyRotated} />
|
<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 ---------- */
|
/* ---------- Security (settings): key rotation, TOTP, passkeys ---------- */
|
||||||
|
|
||||||
function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
|
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) */
|
/* Kill Chrome's yellow autofill background (login / password fields) */
|
||||||
input:-webkit-autofill,
|
input:-webkit-autofill,
|
||||||
input:-webkit-autofill:hover,
|
input:-webkit-autofill:hover,
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export function applyAccent(state: AccentState): void {
|
|||||||
const shades = shadesFor(base);
|
const shades = shadesFor(base);
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
for (const k of SHADE_KEYS) root.style.setProperty(`--ka-accent-${k}`, shades[k]);
|
for (const k of SHADE_KEYS) root.style.setProperty(`--ka-accent-${k}`, shades[k]);
|
||||||
|
paintFavicon(base);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
@@ -109,6 +110,47 @@ export function previewAccent(baseHex: string): void {
|
|||||||
const shades = shadesFor(baseHex);
|
const shades = shadesFor(baseHex);
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
for (const k of SHADE_KEYS) root.style.setProperty(`--ka-accent-${k}`, shades[k]);
|
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.
|
// Restore the saved accent on load.
|
||||||
|
|||||||
+20
-1
@@ -68,6 +68,21 @@ export interface OllamaHostModel {
|
|||||||
modifiedAt: string | null;
|
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 {
|
export interface AdminSettings {
|
||||||
registrationEnabled: boolean;
|
registrationEnabled: boolean;
|
||||||
accessRequestEnabled: boolean;
|
accessRequestEnabled: boolean;
|
||||||
@@ -78,6 +93,7 @@ export interface AdminSettings {
|
|||||||
ollamaDefaultWeight: number;
|
ollamaDefaultWeight: number;
|
||||||
recoveryLimitMax: number;
|
recoveryLimitMax: number;
|
||||||
recoveryLimitWindowMin: number;
|
recoveryLimitWindowMin: number;
|
||||||
|
voicePreviews: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminSettingsPatch {
|
export interface AdminSettingsPatch {
|
||||||
@@ -90,6 +106,7 @@ export interface AdminSettingsPatch {
|
|||||||
ollamaDefaultWeight?: number;
|
ollamaDefaultWeight?: number;
|
||||||
recoveryLimitMax?: number;
|
recoveryLimitMax?: number;
|
||||||
recoveryLimitWindowMin?: number;
|
recoveryLimitWindowMin?: number;
|
||||||
|
voicePreviews?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OllamaNode {
|
export interface OllamaNode {
|
||||||
@@ -397,6 +414,8 @@ export const api = {
|
|||||||
req<OllamaNodeStatus>(`/api/admin/ollama/nodes/${encodeURIComponent(id)}/status`),
|
req<OllamaNodeStatus>(`/api/admin/ollama/nodes/${encodeURIComponent(id)}/status`),
|
||||||
ollamaNodeProbe: (host: string) =>
|
ollamaNodeProbe: (host: string) =>
|
||||||
req<OllamaNodeStatus>(`/api/admin/ollama/probe?host=${encodeURIComponent(host)}`),
|
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) =>
|
ollamaNodeStats: (id: string, window: string) =>
|
||||||
req<{ stats: OllamaNodeStats }>(`/api/admin/ollama/nodes/${encodeURIComponent(id)}/stats?window=${encodeURIComponent(window)}`),
|
req<{ stats: OllamaNodeStats }>(`/api/admin/ollama/nodes/${encodeURIComponent(id)}/stats?window=${encodeURIComponent(window)}`),
|
||||||
|
|
||||||
@@ -413,7 +432,7 @@ export const api = {
|
|||||||
deleteProvider: (provider: string) =>
|
deleteProvider: (provider: string) =>
|
||||||
req<{ providers: UserProvider[] }>(`/api/me/providers/${provider}`, { method: "DELETE" }),
|
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). */
|
/** 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 }> => {
|
ocrImage: async (file: File): Promise<{ text: string; truncated: boolean; chars: number }> => {
|
||||||
|
|||||||
@@ -551,6 +551,53 @@ const en = {
|
|||||||
"rec.save": "Save limits",
|
"rec.save": "Save limits",
|
||||||
"rec.saved": "Limits saved",
|
"rec.saved": "Limits saved",
|
||||||
"rec.invalid": "Check the values (attempts 1–100, window 1–1440 min).",
|
"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.copy": "Copy message",
|
||||||
"msg.search": "Search the web",
|
"msg.search": "Search the web",
|
||||||
@@ -1188,6 +1235,53 @@ const es: Dict = {
|
|||||||
"rec.save": "Save limits",
|
"rec.save": "Save limits",
|
||||||
"rec.saved": "Limits saved",
|
"rec.saved": "Limits saved",
|
||||||
"rec.invalid": "Check the values (attempts 1–100, window 1–1440 min).",
|
"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.copy": "Copiar mensaje",
|
||||||
"msg.search": "Buscar en la web",
|
"msg.search": "Buscar en la web",
|
||||||
@@ -1820,6 +1914,53 @@ const fr: Dict = {
|
|||||||
"rec.save": "Save limits",
|
"rec.save": "Save limits",
|
||||||
"rec.saved": "Limits saved",
|
"rec.saved": "Limits saved",
|
||||||
"rec.invalid": "Check the values (attempts 1–100, window 1–1440 min).",
|
"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.copy": "Copier le message",
|
||||||
"msg.search": "Rechercher sur le web",
|
"msg.search": "Rechercher sur le web",
|
||||||
@@ -2440,6 +2581,53 @@ const ru: Dict = {
|
|||||||
"rec.save": "Save limits",
|
"rec.save": "Save limits",
|
||||||
"rec.saved": "Limits saved",
|
"rec.saved": "Limits saved",
|
||||||
"rec.invalid": "Check the values (attempts 1–100, window 1–1440 min).",
|
"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.copy": "Скопировать сообщение",
|
||||||
"msg.search": "Искать в интернете",
|
"msg.search": "Искать в интернете",
|
||||||
@@ -3065,6 +3253,53 @@ const it: Dict = {
|
|||||||
"rec.save": "Save limits",
|
"rec.save": "Save limits",
|
||||||
"rec.saved": "Limits saved",
|
"rec.saved": "Limits saved",
|
||||||
"rec.invalid": "Check the values (attempts 1–100, window 1–1440 min).",
|
"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.copy": "Copia messaggio",
|
||||||
"msg.search": "Cerca nel web",
|
"msg.search": "Cerca nel web",
|
||||||
@@ -3681,6 +3916,53 @@ const pt: Dict = {
|
|||||||
"rec.save": "Save limits",
|
"rec.save": "Save limits",
|
||||||
"rec.saved": "Limits saved",
|
"rec.saved": "Limits saved",
|
||||||
"rec.invalid": "Check the values (attempts 1–100, window 1–1440 min).",
|
"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.copy": "Copiar mensagem",
|
||||||
"msg.search": "Pesquisar na web",
|
"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 {
|
function clientIp(req: Request, server: { requestIP?: (r: Request) => { address: string } | null }): string {
|
||||||
try {
|
try {
|
||||||
const info = server.requestIP?.(req);
|
const info = server.requestIP?.(req);
|
||||||
@@ -970,6 +1001,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
ollamaDefaultWeight: await getOllamaDefaultWeight(),
|
ollamaDefaultWeight: await getOllamaDefaultWeight(),
|
||||||
recoveryLimitMax: await recoveryLimitMax(),
|
recoveryLimitMax: await recoveryLimitMax(),
|
||||||
recoveryLimitWindowMin: Math.round(await recoveryLimitWindowMs() / 60_000),
|
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));
|
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({
|
return json({
|
||||||
settings: {
|
settings: {
|
||||||
registrationEnabled: await isRegistrationEnabled(),
|
registrationEnabled: await isRegistrationEnabled(),
|
||||||
@@ -1040,6 +1082,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
ollamaDefaultWeight: await getOllamaDefaultWeight(),
|
ollamaDefaultWeight: await getOllamaDefaultWeight(),
|
||||||
recoveryLimitMax: await recoveryLimitMax(),
|
recoveryLimitMax: await recoveryLimitMax(),
|
||||||
recoveryLimitWindowMin: Math.round(await recoveryLimitWindowMs() / 60_000),
|
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);
|
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) ---
|
// --- admin: SMTP connectivity (viewer + tester) ---
|
||||||
if (path === "/api/admin/mail" && req.method === "GET") {
|
if (path === "/api/admin/mail" && req.method === "GET") {
|
||||||
if (!admin) return json({ error: "forbidden" }, 403);
|
if (!admin) return json({ error: "forbidden" }, 403);
|
||||||
@@ -1673,6 +1751,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
tools: await tools.list(),
|
tools: await tools.list(),
|
||||||
reportsEnabled: await isReportsEnabled(),
|
reportsEnabled: await isReportsEnabled(),
|
||||||
usernameChangeEnabled: await isUsernameChangeEnabled(),
|
usernameChangeEnabled: await isUsernameChangeEnabled(),
|
||||||
|
voicePreviews: await getVoicePreviews(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user