new aii local model

This commit is contained in:
k
2026-09-24 11:19:26 +02:00
parent 796a883d70
commit 562ab5191f
9 changed files with 364 additions and 76 deletions
+94 -34
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "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, type VoicePreview } from "../lib/api.ts";
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
import { navigate } from "../lib/route.ts";
import { AdminPanel } from "./AdminPanel.tsx";
@@ -10,10 +10,10 @@ 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";
import { Button, CopyButton, Field, FlowerMark, Input, LoadingScreen, Picker, Spinner, Switch } from "./ui.tsx";
import { cn } from "../lib/cn.ts";
import { msUntilNextSolarSwitch, resolveThemeDark } from "../lib/solarTheme.ts";
import { useT } from "../lib/i18n.ts";
import { useT, type StringKey } from "../lib/i18n.ts";
type Tab = "accounts" | "access" | "reports" | "models" | "stats" | "features" | "mail";
@@ -337,7 +337,7 @@ 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 [lines, setLines] = useState<VoicePreview[]>([]);
const [loaded, setLoaded] = useState(false);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
@@ -351,7 +351,7 @@ function VoicePreviewsCard() {
.catch(() => setLoaded(true));
}, []);
async function save(next: string[]) {
async function save(next: VoicePreview[]) {
setBusy(true);
setMsg(null);
try {
@@ -365,10 +365,12 @@ function VoicePreviewsCard() {
}
}
function commit(i: number, v: string) {
const trimmed = v.trim();
if (trimmed === "") {
// Emptied line: drop the row locally (server needs 1+ lines).
/** Commit one line after editing (empty text drops the row). */
function commit(i: number) {
const line = lines[i];
if (!line) return;
const text = line.text.trim();
if (text === "") {
const next = lines.filter((_, j) => j !== i);
if (next.length === 0) {
setMsg({ ok: false, text: t("vfeat.invalid") });
@@ -378,7 +380,11 @@ function VoicePreviewsCard() {
void save(next);
return;
}
void save(lines.map((l, j) => (j === i ? trimmed : l)));
void save(lines.map((l, j) => (j === i ? { ...l, text } : l)));
}
function patch(i: number, p: Partial<VoicePreview>) {
setLines((prev) => prev.map((l, j) => (j === i ? { ...l, ...p } : l)));
}
async function refreshLines() {
@@ -396,36 +402,84 @@ function VoicePreviewsCard() {
<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">
<ul className="space-y-3">
{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 key={i} className="rounded-2xl border border-stone-200/70 p-3 dark:border-zinc-800">
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1">
<Input
value={line.text}
disabled={busy}
maxLength={500}
aria-label={t("vfeat.line", { n: i + 1 })}
onChange={(e) => patch(i, { text: e.target.value })}
onBlur={() => commit(i)}
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
/>
</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>
</div>
<div className="mt-2 flex flex-wrap items-end gap-x-4 gap-y-2">
<span className="w-36">
<Field label={t("vfeat.timbre")}>
<Picker
ariaLabel={t("vfeat.timbre")}
value={line.timbre}
onChange={(v) => {
patch(i, { timbre: v as VoicePreview["timbre"] });
// Persist timbre/rate/pitch tweaks live (text commits on blur).
const next = lines.map((l, j) => (j === i ? { ...l, timbre: v as VoicePreview["timbre"] } : l));
if (next[i]!.text.trim() !== "") void save(next);
}}
align="left"
options={(["masculine", "feminine", "any"] as const).map((v) => ({ value: v, label: t(`voice.timbre.${v}` as StringKey) }))}
/>
</Field>
</span>
<label className="min-w-32 flex-1 text-xs opacity-80">
<span className="mb-1 block font-medium">{t("voice.rate")} · {line.rate.toFixed(2)}×</span>
<input
type="range" min={0.5} max={1.5} step={0.05} value={line.rate}
disabled={busy}
onChange={(e) => patch(i, { rate: Number(e.target.value) })}
onMouseUp={() => commitSliders(i)}
onTouchEnd={() => commitSliders(i)}
className="w-full accent-[rgb(var(--ka-accent-600))]"
aria-label={t("voice.rate")}
/>
</label>
<label className="min-w-32 flex-1 text-xs opacity-80">
<span className="mb-1 block font-medium">{t("voice.pitch")} · {line.pitch.toFixed(2)}×</span>
<input
type="range" min={0.5} max={2} step={0.05} value={line.pitch}
disabled={busy}
onChange={(e) => patch(i, { pitch: Number(e.target.value) })}
onMouseUp={() => commitSliders(i)}
onTouchEnd={() => commitSliders(i)}
className="w-full accent-[rgb(var(--ka-accent-600))]"
aria-label={t("voice.pitch")}
/>
</label>
</div>
</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, ""])}>
<Button
variant="secondary"
size="sm"
disabled={busy}
onClick={() => setLines((prev) => [...prev, { text: "", lang: "auto", rate: 1, pitch: 1, timbre: "any" as const }])}
>
<Plus size={14} /> {t("vfeat.add")}
</Button>
)}
@@ -440,6 +494,12 @@ function VoicePreviewsCard() {
)}
</div>
);
function commitSliders(i: number) {
const line = lines[i];
if (!line || line.text.trim() === "") return;
void save(lines.map((l, j) => (j === i ? { ...l } : l)));
}
}
type SmtpInfo = {
+38 -9
View File
@@ -1,10 +1,10 @@
import { useEffect, useRef, useState } from "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, type VoicePreview } from "../lib/api.ts";
import { pushToast } from "../lib/toasts.ts";
import { cn } from "../lib/cn.ts";
import { ACCENT_PRESETS, applyAccent, currentAccent, previewAccent, type AccentState } from "../lib/accent.ts";
import { detectLang, getVoicePrefs, listTtsVoices, setVoicePrefs, speak, stopSpeak, ttsSupported, type VoicePrefs } from "../lib/voice.ts";
import { detectLang, getVoicePrefs, listTtsVoices, pickVoice, setVoicePrefs, speakPreview, stopSpeak, ttsSupported, type VoicePrefs } from "../lib/voice.ts";
import { FEATURES, setFeature, useFeatures } from "../lib/features.ts";
import type { Conversation, FilePreview, User } from "../lib/types.ts";
import { useT, type StringKey } from "../lib/i18n.ts";
@@ -875,7 +875,7 @@ function VoiceSection() {
const { t } = useT();
const [prefs, setPrefs] = useState<VoicePrefs>(() => getVoicePrefs());
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([]);
const [previews, setPreviews] = useState<string[]>([]);
const [previews, setPreviews] = useState<VoicePreview[]>([]);
const [playing, setPlaying] = useState<number | null>(null);
useEffect(() => {
@@ -891,17 +891,34 @@ function VoiceSection() {
setVoicePrefs(next);
}
function preview(i: number, text: string) {
function preview(i: number, line: VoicePreview) {
if (playing === i) {
stopSpeak();
setPlaying(null);
return;
}
const ok = speak(text, prefs, voices, {
// Each line plays with ITS OWN character (timbre/rate/pitch/lang) —
// never the global playback prefs.
const pick = speakPreview(line.text, line, voices, {
onend: () => setPlaying(null),
onerror: () => setPlaying(null),
});
if (ok) setPlaying(i);
if (pick || ttsSupported()) setPlaying(i);
}
/** Adopt a preview's character as the global playback voice. */
function adoptVoice(line: VoicePreview) {
const lang = line.lang === "auto" ? detectLang(line.text) : line.lang;
const pick = pickVoice(voices, { lang, timbre: line.timbre });
const next: VoicePrefs = {
voiceURI: pick?.voiceURI ?? prefs.voiceURI,
rate: line.rate,
pitch: line.pitch,
lang,
};
setPrefs(next);
setVoicePrefs(next);
pushToast(t("voice.adopted"), { icon: "check" });
}
const supported = ttsSupported();
@@ -980,10 +997,22 @@ function VoiceSection() {
>
{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 className="min-w-0 flex-1">
<span className="block truncate text-sm">{line.text}</span>
<span className="block text-xs opacity-50">
{t(`voice.timbre.${line.timbre}` as StringKey)} · {(line.lang === "auto" ? detectLang(line.text) : line.lang).toUpperCase()} · {line.rate.toFixed(2)}× / {line.pitch.toFixed(2)}×
</span>
</span>
<button
type="button"
onClick={() => adoptVoice(line)}
disabled={!supported}
title={t("voice.adopt")}
aria-label={`${t("voice.adopt")} ${i + 1}`}
className="shrink-0 rounded-full px-2.5 py-1.5 text-xs font-medium text-accent-700 transition hover:bg-accent-600/10 disabled:opacity-40 dark:text-accent-400"
>
{t("voice.adopt")}
</button>
</li>
))}
</ul>
+11 -3
View File
@@ -68,6 +68,14 @@ export interface OllamaHostModel {
modifiedAt: string | null;
}
export interface VoicePreview {
text: string;
lang: string;
rate: number;
pitch: number;
timbre: "masculine" | "feminine" | "any";
}
export interface PlatformInfo {
hostname: string;
kernel: string;
@@ -93,7 +101,7 @@ export interface AdminSettings {
ollamaDefaultWeight: number;
recoveryLimitMax: number;
recoveryLimitWindowMin: number;
voicePreviews: string[];
voicePreviews: VoicePreview[];
}
export interface AdminSettingsPatch {
@@ -106,7 +114,7 @@ export interface AdminSettingsPatch {
ollamaDefaultWeight?: number;
recoveryLimitMax?: number;
recoveryLimitWindowMin?: number;
voicePreviews?: string[];
voicePreviews?: VoicePreview[];
}
export interface OllamaNode {
@@ -432,7 +440,7 @@ export const api = {
deleteProvider: (provider: string) =>
req<{ providers: UserProvider[] }>(`/api/me/providers/${provider}`, { method: "DELETE" }),
tools: () => req<{ tools: { name: string; description: string; available: boolean; reason: string | null }[]; reportsEnabled: boolean; usernameChangeEnabled: boolean; voicePreviews: string[] }>("/api/tools"),
tools: () => req<{ tools: { name: string; description: string; available: boolean; reason: string | null }[]; reportsEnabled: boolean; usernameChangeEnabled: boolean; voicePreviews: VoicePreview[] }>("/api/tools"),
/** Send an image to the platform OCR tool. Returns editable text (never the raw image). */
ocrImage: async (file: File): Promise<{ text: string; truncated: boolean; chars: number }> => {
+48
View File
@@ -597,6 +597,14 @@ const en = {
"vfeat.line": "Line {n}",
"vfeat.remove": "Remove line {n}",
"vfeat.invalid": "1–6 non-empty lines, 500 chars max.",
"voice.timbre.masculine": "Masculine",
"voice.timbre.feminine": "Feminine",
"voice.timbre.any": "Any",
"voice.adopt": "Use this voice",
"voice.adopted": "Playback voice updated",
"vfeat.timbre": "Timbre",
"vfeat.rate": "Speed",
"vfeat.pitch": "Pitch",
"msg.copy": "Copy message",
@@ -1281,6 +1289,14 @@ const es: Dict = {
"vfeat.line": "Line {n}",
"vfeat.remove": "Remove line {n}",
"vfeat.invalid": "1–6 non-empty lines, 500 chars max.",
"voice.timbre.masculine": "Masculine",
"voice.timbre.feminine": "Feminine",
"voice.timbre.any": "Any",
"voice.adopt": "Use this voice",
"voice.adopted": "Playback voice updated",
"vfeat.timbre": "Timbre",
"vfeat.rate": "Speed",
"vfeat.pitch": "Pitch",
"msg.copy": "Copiar mensaje",
@@ -1960,6 +1976,14 @@ const fr: Dict = {
"vfeat.line": "Line {n}",
"vfeat.remove": "Remove line {n}",
"vfeat.invalid": "1–6 non-empty lines, 500 chars max.",
"voice.timbre.masculine": "Masculine",
"voice.timbre.feminine": "Feminine",
"voice.timbre.any": "Any",
"voice.adopt": "Use this voice",
"voice.adopted": "Playback voice updated",
"vfeat.timbre": "Timbre",
"vfeat.rate": "Speed",
"vfeat.pitch": "Pitch",
"msg.copy": "Copier le message",
@@ -2627,6 +2651,14 @@ const ru: Dict = {
"vfeat.line": "Line {n}",
"vfeat.remove": "Remove line {n}",
"vfeat.invalid": "1–6 non-empty lines, 500 chars max.",
"voice.timbre.masculine": "Masculine",
"voice.timbre.feminine": "Feminine",
"voice.timbre.any": "Any",
"voice.adopt": "Use this voice",
"voice.adopted": "Playback voice updated",
"vfeat.timbre": "Timbre",
"vfeat.rate": "Speed",
"vfeat.pitch": "Pitch",
"msg.copy": "Скопировать сообщение",
@@ -3299,6 +3331,14 @@ const it: Dict = {
"vfeat.line": "Line {n}",
"vfeat.remove": "Remove line {n}",
"vfeat.invalid": "1–6 non-empty lines, 500 chars max.",
"voice.timbre.masculine": "Masculine",
"voice.timbre.feminine": "Feminine",
"voice.timbre.any": "Any",
"voice.adopt": "Use this voice",
"voice.adopted": "Playback voice updated",
"vfeat.timbre": "Timbre",
"vfeat.rate": "Speed",
"vfeat.pitch": "Pitch",
"msg.copy": "Copia messaggio",
@@ -3962,6 +4002,14 @@ const pt: Dict = {
"vfeat.line": "Line {n}",
"vfeat.remove": "Remove line {n}",
"vfeat.invalid": "1–6 non-empty lines, 500 chars max.",
"voice.timbre.masculine": "Masculine",
"voice.timbre.feminine": "Feminine",
"voice.timbre.any": "Any",
"voice.adopt": "Use this voice",
"voice.adopted": "Playback voice updated",
"vfeat.timbre": "Timbre",
"vfeat.rate": "Speed",
"vfeat.pitch": "Pitch",
"msg.copy": "Copiar mensagem",
+79 -4
View File
@@ -81,6 +81,48 @@ export interface SpeakOpts {
onerror?: (msg: string) => void;
}
export type VoiceTimbre = "masculine" | "feminine" | "any";
export interface PreviewVoice {
lang: string;
rate: number;
pitch: number;
timbre: VoiceTimbre;
}
// Name fragments hinting a masculine / feminine voice (Chrome, Edge,
// Safari and Android voices carry such markers; absence falls back to
// any same-language voice — never an error).
const MASCULINE_HINTS = ["male", "homme", "masculin", "man", "garcon", "garçon", "david", "daniel", "thomas", "paul", "pierre", "jean", "marco", "jorge", "diego", "alex "];
const FEMININE_HINTS = ["female", "femme", "féminin", "feminin", "woman", "girl", "fille", "samantha", "marie", "sophie", "anna", "alice", "amelie", "amélie", "camille", "léa", "lea", "chloe", "chloé", "zira", "eva"];
/**
* Pick a voice for a language + timbre character. Explicit URI wins, then
* same-language voices matching the timbre hints, then any same-language
* voice, then the engine default.
*/
export function pickVoice(
voices: SpeechSynthesisVoice[],
opts: { lang: string; timbre?: VoiceTimbre; voiceURI?: string | null },
): SpeechSynthesisVoice | null {
if (opts.voiceURI) {
const exact = voices.find((v) => v.voiceURI === opts.voiceURI);
if (exact) return exact;
}
const lang = opts.lang.toLowerCase();
const sameLang = voices.filter((v) => v.lang.toLowerCase().startsWith(lang));
const pool = sameLang.length > 0 ? sameLang : voices;
if (opts.timbre === "masculine" || opts.timbre === "feminine") {
const hints = opts.timbre === "masculine" ? MASCULINE_HINTS : FEMININE_HINTS;
const match = pool.find((v) => {
const n = `${v.name} ${v.voiceURI}`.toLowerCase();
return hints.some((h) => n.includes(h));
});
if (match) return match;
}
return pool.find((v) => v.default) ?? pool[0] ?? null;
}
/** 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;
@@ -95,10 +137,7 @@ export function speak(text: string, prefs: VoicePrefs, voices: SpeechSynthesisVo
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];
const pick = pickVoice(voices, { lang, voiceURI: prefs.voiceURI });
if (pick) utter.voice = pick;
utter.onstart = () => opts.onstart?.();
utter.onend = () => opts.onend?.();
@@ -119,6 +158,42 @@ export function stopSpeak(): void {
}
}
/**
* Play a preview line with ITS OWN voice character (timbre + rate + pitch),
* never the user's global playback prefs — previews must sound different
* from each other. Returns the resolved voice (null when unsupported) so
* callers can offer "use this voice" adoption.
*/
export function speakPreview(
text: string,
preview: PreviewVoice,
voices: SpeechSynthesisVoice[],
opts: SpeakOpts = {},
): SpeechSynthesisVoice | null {
if (!ttsSupported()) return null;
const synth = window.speechSynthesis;
try {
synth.cancel();
} catch {
// ignore
}
const lang = preview.lang === "auto" ? detectLang(text) : preview.lang;
const utter = new SpeechSynthesisUtterance(text.slice(0, 2000));
utter.lang = lang;
utter.rate = preview.rate;
utter.pitch = preview.pitch;
const pick = pickVoice(voices, { lang, timbre: preview.timbre });
if (pick) utter.voice = pick;
utter.onend = () => opts.onend?.();
utter.onerror = () => opts.onerror?.("error");
try {
synth.speak(utter);
} catch {
return null;
}
return pick;
}
// --- speech recognition (STT) ---
export interface Recognizer {