From 796a883d7060c16926614917c6293de3f0916bca Mon Sep 17 00:00:00 2001 From: Anais Saraiva Date: Thu, 24 Sep 2026 11:14:01 +0200 Subject: [PATCH] Frontend. :) --- frontend/src/components/AdminCenter.tsx | 119 +++++++- frontend/src/components/Chat.tsx | 239 ++++++++++++++- frontend/src/components/OllamaNodes.tsx | 6 + frontend/src/components/StatsSection.tsx | 355 +++++++++++++++++++++++ frontend/src/components/dialogs.tsx | 136 ++++++++- frontend/src/index.css | 48 +++ frontend/src/lib/accent.ts | 42 +++ frontend/src/lib/api.ts | 21 +- frontend/src/lib/i18n.ts | 282 ++++++++++++++++++ frontend/src/lib/voice.ts | 152 ++++++++++ src/index.ts | 79 +++++ 11 files changed, 1469 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/StatsSection.tsx create mode 100644 frontend/src/lib/voice.ts diff --git a/frontend/src/components/AdminCenter.tsx b/frontend/src/components/AdminCenter.tsx index 0357c33..4a84bf8 100644 --- a/frontend/src/components/AdminCenter.tsx +++ b/frontend/src/components/AdminCenter.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { ArrowLeft, Boxes, Eye, EyeOff, Flag, Inbox, LayoutDashboard, Mail, Send, ShieldAlert, SlidersHorizontal, Users } from "lucide-react"; +import { ArrowLeft, Boxes, Eye, EyeOff, Flag, Inbox, LayoutDashboard, Mail, Plus, Send, ShieldAlert, SlidersHorizontal, Trash2, TrendingUp, Users } from "lucide-react"; import { api, getToken, type AccessStatus, type AdminSettings, type AdminSettingsPatch, type ReportStatus } from "../lib/api.ts"; import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts"; import { navigate } from "../lib/route.ts"; @@ -7,6 +7,7 @@ import { AdminPanel } from "./AdminPanel.tsx"; import { AccessRequestsPanel } from "./AccessRequests.tsx"; import { ReportsPanel } from "./ReportsPanel.tsx"; import { ModelsPanel } from "./ModelsPanel.tsx"; +import { StatsSection } from "./StatsSection.tsx"; import { Toasts } from "./Toasts.tsx"; import { pushToast } from "../lib/toasts.ts"; import { Button, CopyButton, Field, FlowerMark, Input, LoadingScreen, Spinner, Switch } from "./ui.tsx"; @@ -14,9 +15,9 @@ import { cn } from "../lib/cn.ts"; import { msUntilNextSolarSwitch, resolveThemeDark } from "../lib/solarTheme.ts"; import { useT } from "../lib/i18n.ts"; -type Tab = "accounts" | "access" | "reports" | "models" | "features" | "mail"; +type Tab = "accounts" | "access" | "reports" | "models" | "stats" | "features" | "mail"; -const VALID_TABS: readonly string[] = ["accounts", "access", "reports", "models", "features", "mail"]; +const VALID_TABS: readonly string[] = ["accounts", "access", "reports", "models", "stats", "features", "mail"]; function validTab(v: string | null | undefined): Tab { return v !== null && v !== undefined && (VALID_TABS as readonly string[]).includes(v) ? (v as Tab) : "accounts"; @@ -60,6 +61,7 @@ export function AdminCenter({ initialTab }: { initialTab?: string | null }) { { id: "access", label: t("center.tabAccess"), icon: Inbox }, { id: "reports", label: t("center.tabReports"), icon: Flag }, { id: "models", label: t("center.tabModels"), icon: Boxes }, + { id: "stats", label: t("center.tabStats"), icon: TrendingUp }, { id: "features", label: t("center.tabFeatures"), icon: SlidersHorizontal }, { id: "mail", label: t("center.tabMail"), icon: Mail }, ]; @@ -268,6 +270,8 @@ export function AdminCenter({ initialTab }: { initialTab?: string | null }) { )} + {tab === "stats" && } + {tab === "features" && (
@@ -319,6 +323,7 @@ export function AdminCenter({ initialTab }: { initialTab?: string | null }) {
{saving &&

{t("common.saving")}

} {!settings && !error &&

{t("common.loading")}

} +
)} @@ -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([]); + 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 ( +
+

{t("vfeat.title")}

+

{t("vfeat.desc")}

+
    + {lines.map((line, i) => ( +
  • + + 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(); }} + /> + + {line} + +
  • + ))} +
+
+ {lines.length < 6 && ( + + )} + +
+ {msg && ( +

+ {msg.text} +

+ )} +
+ ); +} + type SmtpInfo = { enabled: boolean; host: string; diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index bb733f1..0f011ed 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { Archive, ArchiveRestore, BookOpen, Bot, Check, Copy, Cpu, FileText, Flag, Menu, PanelLeftOpen, Paperclip, RotateCcw, ScanText, Search, SendHorizontal, Share2, User as UserIcon, X } from "lucide-react"; +import { Archive, ArchiveRestore, BookOpen, Bot, Check, Copy, Cpu, FileText, Flag, Menu, Mic, PanelLeftOpen, Paperclip, RotateCcw, ScanText, Search, SendHorizontal, Share2, Square, User as UserIcon, Volume2, VolumeX, X } from "lucide-react"; import type { Attachment, ChatMessage, Conversation, DriverModel, User } from "../lib/types.ts"; import { api, ApiError, type ReportReason } from "../lib/api.ts"; import { AssistantAvatar, Avatar, Button, Field, FlowerMark, IconButton, Modal, Picker, type PickerGroup, Spinner } from "./ui.tsx"; @@ -7,11 +7,12 @@ import { Markdown } from "./Markdown.tsx"; import { UserMessageContent } from "./MessageContent.tsx"; import { cn } from "../lib/cn.ts"; import { useFeatures } from "../lib/features.ts"; +import { fmtDuration, getVoicePrefs, listTtsVoices, recognizerCtor, speak, stopSpeak, sttSupported, type Recognizer } from "../lib/voice.ts"; import { useT, type StringKey } from "../lib/i18n.ts"; const REPORT_REASONS: ReportReason[] = ["copyright", "gore", "falseinfo", "bug"]; -function MessageBubble({ msg, index, authorAvatar, authorName, onReport, reportsEnabled = true, isFailed, retryCount, maxRetries, retryDisabled, onRetry }: { msg: ChatMessage; index: number; authorAvatar?: string; authorName: string; onReport?: (index: number) => void; reportsEnabled?: boolean; isFailed?: boolean; retryCount?: number; maxRetries?: number; retryDisabled?: boolean; onRetry?: () => void }) { +function MessageBubble({ msg, index, authorAvatar, authorName, onReport, reportsEnabled = true, isFailed, retryCount, maxRetries, retryDisabled, onRetry, speaking, onToggleSpeak }: { msg: ChatMessage; index: number; authorAvatar?: string; authorName: string; onReport?: (index: number) => void; reportsEnabled?: boolean; isFailed?: boolean; retryCount?: number; maxRetries?: number; retryDisabled?: boolean; onRetry?: () => void; speaking?: boolean; onToggleSpeak?: () => void }) { const { t } = useT(); const isUser = msg.role === "user"; const [copied, setCopied] = useState(false); @@ -39,12 +40,17 @@ function MessageBubble({ msg, index, authorAvatar, authorName, onReport, reports ) : ( -
+
)} - {!isUser && (onReport || (isFailed && onRetry)) && ( + {!isUser && (onReport || onToggleSpeak || (isFailed && onRetry)) && (
+ {onToggleSpeak && ( + + {speaking ? : } + + )} {onReport && ( <> @@ -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 ( +
+ {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 ( + 0.12 ? "0 0 8px rgb(var(--ka-accent-500) / 0.55)" : "none", + }} + /> + ); + })} +
+ ); +} + function ReportDialog({ open, onClose, conversationId, messageIndex, content, prompt, model }: { open: boolean; onClose: () => void; @@ -243,6 +276,153 @@ export function Chat({ const taRef = useRef(null); const imgRef = useRef(null); const txtRef = useRef(null); + // TTS playback: which assistant bubble is speaking (null = silent). + const [speakingKey, setSpeakingKey] = useState(null); + const voicesRef = useRef([]); + // 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([]); + const [recElapsed, setRecElapsed] = useState(0); + const [recText, setRecText] = useState(""); + const recRaf = useRef(0); + const recTimer = useRef | 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) { + cancelAnimationFrame(recRaf.current); + if (recTimer.current) clearInterval(recTimer.current); + recTimer.current = null; + try { r.recog?.abort(); } catch { /* ignore */ } + try { r.audioCtx.close(); } catch { /* ignore */ } + for (const tr of r.stream.getTracks()) { + try { tr.stop(); } catch { /* ignore */ } + } + } + + function cancelRec(reset = true) { + setRec((cur) => { + if (cur) cleanupRec(cur); + return null; + }); + if (reset) { + setRecLevels([]); + setRecElapsed(0); + setRecText(""); + } + } + + async function startRec() { + if (rec || sending || readOnly || !micOn) return; + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const audioCtx = new AudioContext(); + const src = audioCtx.createMediaStreamSource(stream); + const analyser = audioCtx.createAnalyser(); + analyser.fftSize = 512; + src.connect(analyser); + const data = new Uint8Array(analyser.fftSize); + const Ctor = recognizerCtor(); + const recog: Recognizer | null = Ctor ? new Ctor() : null; + let finals = ""; + if (recog) { + recog.lang = navigator.language || "fr-FR"; + recog.interimResults = true; + recog.continuous = true; + recog.onresult = (ev) => { + let interim = ""; + for (const res of Array.from(ev.results)) { + if (res.isFinal) finals += res[0]!.transcript; + else interim += res[0]!.transcript; + } + setRecText((finals + " " + interim).trim()); + }; + recog.onerror = () => {}; + try { recog.start(); } catch { /* already started */ } + } + const startTs = Date.now(); + const session = { stream, audioCtx, analyser, recog, startTs }; + setRec(session); + setRecLevels([]); + setRecText(""); + setRecElapsed(0); + let lastPush = 0; + const loop = () => { + analyser.getByteTimeDomainData(data); + let sum = 0; + for (let i = 0; i < data.length; i++) { + const v = (data[i]! - 128) / 128; + sum += v * v; + } + const level = Math.min(1, Math.sqrt(sum / data.length) * 2.4); + const now = performance.now(); + if (now - lastPush > 90) { + lastPush = now; + setRecLevels((prev) => [...prev.slice(-47), level]); + } + recRaf.current = requestAnimationFrame(loop); + }; + recRaf.current = requestAnimationFrame(loop); + recTimer.current = setInterval(() => setRecElapsed((Date.now() - startTs) / 1000), 500); + } catch { + // Mic denied / unavailable: stay on the text composer. + cancelRec(); + } + } + + function stopRec() { + const clean = recText.trim(); + setRec((cur) => { + if (cur) cleanupRec(cur); + return null; + }); + // Transcript lands in the composer, editable before sending. + if (clean) { + setDraft((d) => (d ? `${d.trimEnd()}\n${clean}` : clean)); + requestAnimationFrame(autoGrow); + } + setRecText(""); + setRecLevels([]); + setRecElapsed(0); + } const modelGroups: PickerGroup[] = (() => { const byDriver = new Map(); @@ -263,6 +443,14 @@ export function Chat({ if (el) el.scrollTop = el.scrollHeight; }, [messages, streaming]); + // A fresh message ends any playback (the glow never sticks to a stale row). + const msgCount = messages.length; + useEffect(() => { + stopSpeak(); + setSpeakingKey(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [msgCount]); + function autoGrow() { const ta = taRef.current; if (ta) { @@ -276,6 +464,8 @@ export function Chat({ if (readOnly) return; const text = draft.trim(); if (!text || sending) return; + stopSpeak(); + setSpeakingKey(null); setDraft(""); requestAnimationFrame(autoGrow); onSend(text); @@ -329,7 +519,7 @@ export function Chat({
)} {messages.filter((m) => m.role !== "system").map((m, i) => ( - + toggleSpeak(i, m.content) : undefined} /> ))} {streaming !== "" && (
@@ -422,6 +612,35 @@ export function Chat({ {t("archived.unarchive")}
+ ) : rec ? ( +
+
+ + {fmtDuration(recElapsed)} + + {recText || t("voice.listening")} + + + +
+ +
) : (
{features.attachments && ( @@ -437,6 +656,16 @@ export function Chat({ )} { const f = e.target.files?.[0]; if (f) onPickFile("ocr", f); e.target.value = ""; }} /> { const f = e.target.files?.[0]; if (f) onPickFile("text", f); e.target.value = ""; }} /> +