new aii local model
This commit is contained in:
@@ -67,6 +67,7 @@ from the **Accounts** panel (each gets a one-time access key to hand over).
|
|||||||
| PATCH/DELETE | `/api/admin/ollama/nodes/:id` | admin | rename / reweight / enable / delete a node |
|
| PATCH/DELETE | `/api/admin/ollama/nodes/:id` | admin | rename / reweight / enable / delete a node |
|
||||||
| GET | `/api/admin/ollama/nodes/:id/status`, `…/stats?window=` | admin | live probe (version, running, VRAM) / load charts 1h–30d |
|
| GET | `/api/admin/ollama/nodes/:id/status`, `…/stats?window=` | admin | live probe (version, running, VRAM) / load charts 1h–30d |
|
||||||
| GET | `/api/admin/ollama/probe?host=` | admin | test a host before registering it |
|
| GET | `/api/admin/ollama/probe?host=` | admin | test a host before registering it |
|
||||||
|
| GET | `/api/admin/platform` | admin | this host facts: hostname, kernel, uptime, load, memory, IPs |
|
||||||
| GET | `/api/admin/ollama/status` | admin | Ollama connectivity probe: reachability, version, on-disk models |
|
| GET | `/api/admin/ollama/status` | admin | Ollama connectivity probe: reachability, version, on-disk models |
|
||||||
| POST | `/api/access/request` | public | reserve username+email with a motivation → ticket (`/review/:id`) |
|
| POST | `/api/access/request` | public | reserve username+email with a motivation → ticket (`/review/:id`) |
|
||||||
| GET/POST | `/api/access/ticket/:id` (+`/message`) | ticket bearer | follow + reply on an access request |
|
| GET/POST | `/api/access/ticket/:id` (+`/message`) | ticket bearer | follow + reply on an access request |
|
||||||
|
|||||||
+9
-1
@@ -82,10 +82,18 @@ No router library — a tiny history wrapper (`lib/route.ts`: `navigate()`,
|
|||||||
copy button and dependency-free syntax highlighting (`lib/highlight.tsx` —
|
copy button and dependency-free syntax highlighting (`lib/highlight.tsx` —
|
||||||
regex tokenizer, no highlight.js, so the Nix offline lockfile is untouched).
|
regex tokenizer, no highlight.js, so the Nix offline lockfile is untouched).
|
||||||
- Admin Center tabs are deep-linkable (`/admin`, `/admin/models`,
|
- Admin Center tabs are deep-linkable (`/admin`, `/admin/models`,
|
||||||
`/admin/access`, … — `lib/route.ts` `adminTabFromPath`, SPA fallback serves
|
`/admin/stats`, `/admin/access`, … — `lib/route.ts` `adminTabFromPath`, SPA fallback serves
|
||||||
them all). Models tab: `OllamaNodes.tsx` (pool with weights, per-node live
|
them all). Models tab: `OllamaNodes.tsx` (pool with weights, per-node live
|
||||||
status + 1h–30d load charts, generation timeout) and `OllamaCatalog.tsx`
|
status + 1h–30d load charts, generation timeout) and `OllamaCatalog.tsx`
|
||||||
(live Hugging Face GGUF search + one-click `hf.co/…` pull to a node).
|
(live Hugging Face GGUF search + one-click `hf.co/…` pull to a node).
|
||||||
|
Stats tab: `StatsSection.tsx` (node/window pickers, bar/area/line dock, SVG
|
||||||
|
tooltips, platform host cards).
|
||||||
|
- Accent color (`lib/accent.ts`) derives the 50–950 ramp from one hex and
|
||||||
|
also repaints the SVG favicon (data URL) so the tab icon matches the dress.
|
||||||
|
- Voice (`lib/voice.ts`, no dependency): `Chat.tsx` composer mic with live
|
||||||
|
waveform + speech-recognition transcript (editable before send), per-bubble
|
||||||
|
TTS playback with glow, `Settings → Voice` prefs (voice/lang/speed/pitch,
|
||||||
|
localStorage), admin-editable preview lines.
|
||||||
|
|
||||||
## Theming
|
## Theming
|
||||||
|
|
||||||
|
|||||||
+38
-8
@@ -84,14 +84,16 @@ Notes:
|
|||||||
## Admin Center (`/admin`) & self-registration
|
## Admin Center (`/admin`) & self-registration
|
||||||
|
|
||||||
The sidebar **Admin Center** button (admin only) opens the `/admin` page with
|
The sidebar **Admin Center** button (admin only) opens the `/admin` page with
|
||||||
six deep-linkable tabs (`/admin` = accounts, `/admin/models`, `/admin/access`,
|
seven deep-linkable tabs (`/admin` = accounts, `/admin/models`,
|
||||||
`/admin/reports`, `/admin/features`, `/admin/mail`): **Accounts** (same
|
`/admin/stats`, `/admin/access`, `/admin/reports`, `/admin/features`,
|
||||||
manager as before), **Access** (access-request wishlist triage, see below),
|
`/admin/mail`): **Accounts** (same manager as before), **Access**
|
||||||
**Reports**, **Models** (node pool, per-model policy, Ollama library + HF
|
(access-request wishlist triage, see below), **Reports**, **Models** (node
|
||||||
catalog), **Features** (server-side switches), **Mail** (SMTP credentials
|
pool, per-model policy, Ollama library + HF catalog), **Stats** (pool load
|
||||||
viewer + connectivity tester: `GET /api/admin/mail`,
|
charts + platform host facts, see below), **Features** (server-side
|
||||||
`POST /api/admin/mail/verify`, `POST /api/admin/mail/test`, admin Bearer
|
switches + voice-preview lines), **Mail** (SMTP credentials viewer +
|
||||||
only — plus the password-recovery rate limits below).
|
connectivity tester: `GET /api/admin/mail`, `POST /api/admin/mail/verify`,
|
||||||
|
`POST /api/admin/mail/test`, admin Bearer only — plus the password-recovery
|
||||||
|
rate limits below).
|
||||||
|
|
||||||
- `registration_enabled` (default off): when on, the login page shows a
|
- `registration_enabled` (default off): when on, the login page shows a
|
||||||
working **Register** button — anyone can create an account (key shown once)
|
working **Register** button — anyone can create an account (key shown once)
|
||||||
@@ -173,6 +175,34 @@ invalidate the model list cache; `listModels` merges tags across nodes.
|
|||||||
`GET …/nodes/:id/stats?window=1h|6h|24h|7d|30d`,
|
`GET …/nodes/:id/stats?window=1h|6h|24h|7d|30d`,
|
||||||
`GET /api/admin/ollama/probe?host=…`.
|
`GET /api/admin/ollama/probe?host=…`.
|
||||||
|
|
||||||
|
## Telemetry sub-page (`/admin/stats`) + platform facts
|
||||||
|
|
||||||
|
The **Stats** tab is the pool observatory: node picker, window picker
|
||||||
|
(1h / 6h / 24h / 7d / 30d), chart-type dock (bar / area / line SVG charts
|
||||||
|
with hover tooltips: requests, errors, avg latency, tokens), totals tiles,
|
||||||
|
and a latency underlay. Below, **platform host facts** for this server —
|
||||||
|
hostname, kernel, arch/CPU, app + system uptime, load average, memory bar,
|
||||||
|
runtime, network interfaces/IPs (`GET /api/admin/platform`, admin only).
|
||||||
|
Disk meters are intentionally absent (models live on the Ollama hosts).
|
||||||
|
|
||||||
|
## Voice: dictation + playback
|
||||||
|
|
||||||
|
- **Composer mic** (browser speech recognition — Chrome/Edge): records with
|
||||||
|
a live gradient waveform, timer and live transcript; **Stop** drops the
|
||||||
|
transcript into the composer, editable before sending; **Discard** throws
|
||||||
|
it away. Unsupported browsers get a disabled mic with an explanatory
|
||||||
|
tooltip. No audio leaves the browser (no server STT endpoint yet —
|
||||||
|
whisper.cpp via `WHISPER_BIN` is the documented next step).
|
||||||
|
- **Message playback**: every assistant bubble has a Listen button
|
||||||
|
(speechSynthesis, Siri-style breathing glow while speaking, click again to
|
||||||
|
stop; playback stops on send / new message / conversation switch).
|
||||||
|
- **Settings → Voice** (per browser, localStorage): language (auto-detect
|
||||||
|
per message via `detectLang`, or forced), voice picker grouped by locale,
|
||||||
|
speed, pitch, and one-tap previews of the admin-set lines.
|
||||||
|
- **Admin Center → Features → Voice previews**: the default playback lines
|
||||||
|
(`voice_previews` setting, 1–6 lines; empty list resets to the built-ins),
|
||||||
|
also served to users via `GET /api/tools`.
|
||||||
|
|
||||||
## Personal providers (BYOK)
|
## Personal providers (BYOK)
|
||||||
|
|
||||||
Users can connect their own `OpenAI` / `Anthropic` / `DeepSeek` / `Gemini`
|
Users can connect their own `OpenAI` / `Anthropic` / `DeepSeek` / `Gemini`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
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 { 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 { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
|
||||||
import { navigate } from "../lib/route.ts";
|
import { navigate } from "../lib/route.ts";
|
||||||
import { AdminPanel } from "./AdminPanel.tsx";
|
import { AdminPanel } from "./AdminPanel.tsx";
|
||||||
@@ -10,10 +10,10 @@ import { ModelsPanel } from "./ModelsPanel.tsx";
|
|||||||
import { StatsSection } from "./StatsSection.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, Picker, Spinner, Switch } from "./ui.tsx";
|
||||||
import { cn } from "../lib/cn.ts";
|
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, type StringKey } from "../lib/i18n.ts";
|
||||||
|
|
||||||
type Tab = "accounts" | "access" | "reports" | "models" | "stats" | "features" | "mail";
|
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. */
|
/** Admin voice-preview lines (Settings → Voice for users). Empty = reset. */
|
||||||
function VoicePreviewsCard() {
|
function VoicePreviewsCard() {
|
||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
const [lines, setLines] = useState<string[]>([]);
|
const [lines, setLines] = useState<VoicePreview[]>([]);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||||
@@ -351,7 +351,7 @@ function VoicePreviewsCard() {
|
|||||||
.catch(() => setLoaded(true));
|
.catch(() => setLoaded(true));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function save(next: string[]) {
|
async function save(next: VoicePreview[]) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
try {
|
try {
|
||||||
@@ -365,10 +365,12 @@ function VoicePreviewsCard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function commit(i: number, v: string) {
|
/** Commit one line after editing (empty text drops the row). */
|
||||||
const trimmed = v.trim();
|
function commit(i: number) {
|
||||||
if (trimmed === "") {
|
const line = lines[i];
|
||||||
// Emptied line: drop the row locally (server needs 1+ lines).
|
if (!line) return;
|
||||||
|
const text = line.text.trim();
|
||||||
|
if (text === "") {
|
||||||
const next = lines.filter((_, j) => j !== i);
|
const next = lines.filter((_, j) => j !== i);
|
||||||
if (next.length === 0) {
|
if (next.length === 0) {
|
||||||
setMsg({ ok: false, text: t("vfeat.invalid") });
|
setMsg({ ok: false, text: t("vfeat.invalid") });
|
||||||
@@ -378,7 +380,11 @@ function VoicePreviewsCard() {
|
|||||||
void save(next);
|
void save(next);
|
||||||
return;
|
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() {
|
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">
|
<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="text-sm font-medium">{t("vfeat.title")}</p>
|
||||||
<p className="mb-3 mt-0.5 text-sm opacity-60">{t("vfeat.desc")}</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) => (
|
{lines.map((line, i) => (
|
||||||
<li key={i} className="flex items-center gap-2">
|
<li key={i} className="rounded-2xl border border-stone-200/70 p-3 dark:border-zinc-800">
|
||||||
<span className="w-40 shrink-0">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<span className="min-w-0 flex-1">
|
||||||
value={line}
|
<Input
|
||||||
disabled={busy}
|
value={line.text}
|
||||||
maxLength={500}
|
disabled={busy}
|
||||||
aria-label={t("vfeat.line", { n: i + 1 })}
|
maxLength={500}
|
||||||
onChange={(e) => setLines((prev) => prev.map((l, j) => (j === i ? e.target.value : l)))}
|
aria-label={t("vfeat.line", { n: i + 1 })}
|
||||||
onBlur={(e) => { if (e.target.value !== line) commit(i, e.target.value); }}
|
onChange={(e) => patch(i, { text: e.target.value })}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
|
onBlur={() => commit(i)}
|
||||||
/>
|
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>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busy || lines.length <= 1}
|
disabled={busy || lines.length <= 1}
|
||||||
onClick={() => void save(lines.filter((_, j) => j !== i))}
|
onClick={() => void save(lines.filter((_, j) => j !== i))}
|
||||||
aria-label={t("vfeat.remove", { n: i + 1 })}
|
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"
|
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} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
{lines.length < 6 && (
|
{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")}
|
<Plus size={14} /> {t("vfeat.add")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -440,6 +494,12 @@ function VoicePreviewsCard() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 = {
|
type SmtpInfo = {
|
||||||
|
|||||||
@@ -1,10 +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, Mic, Palette, Pencil, Play, Plug, ScanText, Search, Settings2, ShieldCheck, Sparkles, Square, Tag, Trash2, Unplug, User as UserIcon, Volume2, 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, type VoicePreview } 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 { detectLang, getVoicePrefs, listTtsVoices, pickVoice, setVoicePrefs, speakPreview, 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";
|
||||||
@@ -875,7 +875,7 @@ function VoiceSection() {
|
|||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
const [prefs, setPrefs] = useState<VoicePrefs>(() => getVoicePrefs());
|
const [prefs, setPrefs] = useState<VoicePrefs>(() => getVoicePrefs());
|
||||||
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([]);
|
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([]);
|
||||||
const [previews, setPreviews] = useState<string[]>([]);
|
const [previews, setPreviews] = useState<VoicePreview[]>([]);
|
||||||
const [playing, setPlaying] = useState<number | null>(null);
|
const [playing, setPlaying] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -891,17 +891,34 @@ function VoiceSection() {
|
|||||||
setVoicePrefs(next);
|
setVoicePrefs(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
function preview(i: number, text: string) {
|
function preview(i: number, line: VoicePreview) {
|
||||||
if (playing === i) {
|
if (playing === i) {
|
||||||
stopSpeak();
|
stopSpeak();
|
||||||
setPlaying(null);
|
setPlaying(null);
|
||||||
return;
|
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),
|
onend: () => setPlaying(null),
|
||||||
onerror: () => 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();
|
const supported = ttsSupported();
|
||||||
@@ -980,10 +997,22 @@ function VoiceSection() {
|
|||||||
>
|
>
|
||||||
{playing === i ? <Square size={13} /> : <Play size={13} className="translate-x-[1px]" />}
|
{playing === i ? <Square size={13} /> : <Play size={13} className="translate-x-[1px]" />}
|
||||||
</button>
|
</button>
|
||||||
<span className="min-w-0 flex-1 truncate text-sm">{line}</span>
|
<span className="min-w-0 flex-1">
|
||||||
<span className="hidden shrink-0 text-xs opacity-40 sm:block">
|
<span className="block truncate text-sm">{line.text}</span>
|
||||||
{prefs.lang === "auto" ? detectLang(line) : prefs.lang} · {prefs.rate.toFixed(2)}×
|
<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>
|
</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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
+11
-3
@@ -68,6 +68,14 @@ export interface OllamaHostModel {
|
|||||||
modifiedAt: string | null;
|
modifiedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VoicePreview {
|
||||||
|
text: string;
|
||||||
|
lang: string;
|
||||||
|
rate: number;
|
||||||
|
pitch: number;
|
||||||
|
timbre: "masculine" | "feminine" | "any";
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlatformInfo {
|
export interface PlatformInfo {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
kernel: string;
|
kernel: string;
|
||||||
@@ -93,7 +101,7 @@ export interface AdminSettings {
|
|||||||
ollamaDefaultWeight: number;
|
ollamaDefaultWeight: number;
|
||||||
recoveryLimitMax: number;
|
recoveryLimitMax: number;
|
||||||
recoveryLimitWindowMin: number;
|
recoveryLimitWindowMin: number;
|
||||||
voicePreviews: string[];
|
voicePreviews: VoicePreview[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminSettingsPatch {
|
export interface AdminSettingsPatch {
|
||||||
@@ -106,7 +114,7 @@ export interface AdminSettingsPatch {
|
|||||||
ollamaDefaultWeight?: number;
|
ollamaDefaultWeight?: number;
|
||||||
recoveryLimitMax?: number;
|
recoveryLimitMax?: number;
|
||||||
recoveryLimitWindowMin?: number;
|
recoveryLimitWindowMin?: number;
|
||||||
voicePreviews?: string[];
|
voicePreviews?: VoicePreview[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OllamaNode {
|
export interface OllamaNode {
|
||||||
@@ -432,7 +440,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; 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). */
|
/** 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 }> => {
|
||||||
|
|||||||
@@ -597,6 +597,14 @@ const en = {
|
|||||||
"vfeat.line": "Line {n}",
|
"vfeat.line": "Line {n}",
|
||||||
"vfeat.remove": "Remove line {n}",
|
"vfeat.remove": "Remove line {n}",
|
||||||
"vfeat.invalid": "1–6 non-empty lines, 500 chars max.",
|
"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",
|
"msg.copy": "Copy message",
|
||||||
@@ -1281,6 +1289,14 @@ const es: Dict = {
|
|||||||
"vfeat.line": "Line {n}",
|
"vfeat.line": "Line {n}",
|
||||||
"vfeat.remove": "Remove line {n}",
|
"vfeat.remove": "Remove line {n}",
|
||||||
"vfeat.invalid": "1–6 non-empty lines, 500 chars max.",
|
"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",
|
"msg.copy": "Copiar mensaje",
|
||||||
@@ -1960,6 +1976,14 @@ const fr: Dict = {
|
|||||||
"vfeat.line": "Line {n}",
|
"vfeat.line": "Line {n}",
|
||||||
"vfeat.remove": "Remove line {n}",
|
"vfeat.remove": "Remove line {n}",
|
||||||
"vfeat.invalid": "1–6 non-empty lines, 500 chars max.",
|
"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",
|
"msg.copy": "Copier le message",
|
||||||
@@ -2627,6 +2651,14 @@ const ru: Dict = {
|
|||||||
"vfeat.line": "Line {n}",
|
"vfeat.line": "Line {n}",
|
||||||
"vfeat.remove": "Remove line {n}",
|
"vfeat.remove": "Remove line {n}",
|
||||||
"vfeat.invalid": "1–6 non-empty lines, 500 chars max.",
|
"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": "Скопировать сообщение",
|
"msg.copy": "Скопировать сообщение",
|
||||||
@@ -3299,6 +3331,14 @@ const it: Dict = {
|
|||||||
"vfeat.line": "Line {n}",
|
"vfeat.line": "Line {n}",
|
||||||
"vfeat.remove": "Remove line {n}",
|
"vfeat.remove": "Remove line {n}",
|
||||||
"vfeat.invalid": "1–6 non-empty lines, 500 chars max.",
|
"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",
|
"msg.copy": "Copia messaggio",
|
||||||
@@ -3962,6 +4002,14 @@ const pt: Dict = {
|
|||||||
"vfeat.line": "Line {n}",
|
"vfeat.line": "Line {n}",
|
||||||
"vfeat.remove": "Remove line {n}",
|
"vfeat.remove": "Remove line {n}",
|
||||||
"vfeat.invalid": "1–6 non-empty lines, 500 chars max.",
|
"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",
|
"msg.copy": "Copiar mensagem",
|
||||||
|
|||||||
@@ -81,6 +81,48 @@ export interface SpeakOpts {
|
|||||||
onerror?: (msg: string) => void;
|
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. */
|
/** Speak one message; cancels any current playback first. Returns false when unsupported. */
|
||||||
export function speak(text: string, prefs: VoicePrefs, voices: SpeechSynthesisVoice[], opts: SpeakOpts = {}): boolean {
|
export function speak(text: string, prefs: VoicePrefs, voices: SpeechSynthesisVoice[], opts: SpeakOpts = {}): boolean {
|
||||||
if (!ttsSupported()) return false;
|
if (!ttsSupported()) return false;
|
||||||
@@ -95,10 +137,7 @@ export function speak(text: string, prefs: VoicePrefs, voices: SpeechSynthesisVo
|
|||||||
utter.lang = lang;
|
utter.lang = lang;
|
||||||
utter.rate = prefs.rate;
|
utter.rate = prefs.rate;
|
||||||
utter.pitch = prefs.pitch;
|
utter.pitch = prefs.pitch;
|
||||||
const pick = voices.find((v) => prefs.voiceURI && v.voiceURI === prefs.voiceURI)
|
const pick = pickVoice(voices, { lang, voiceURI: prefs.voiceURI });
|
||||||
?? voices.find((v) => v.lang.toLowerCase().startsWith(lang.toLowerCase()))
|
|
||||||
?? voices.find((v) => v.default)
|
|
||||||
?? voices[0];
|
|
||||||
if (pick) utter.voice = pick;
|
if (pick) utter.voice = pick;
|
||||||
utter.onstart = () => opts.onstart?.();
|
utter.onstart = () => opts.onstart?.();
|
||||||
utter.onend = () => opts.onend?.();
|
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) ---
|
// --- speech recognition (STT) ---
|
||||||
|
|
||||||
export interface Recognizer {
|
export interface Recognizer {
|
||||||
|
|||||||
+46
-17
@@ -224,33 +224,62 @@ async function probeOllamaHost(host: string): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default TTS voice-preview lines (admin-editable, resettable). Short,
|
* Voice-preview lines (admin-editable, resettable). Each line carries its
|
||||||
* varied lines that show off grave/hesitant, grave/serious, cute feminine
|
* OWN voice character — timbre/rate/pitch/lang — so previews sound
|
||||||
* and medium feminine deliveries.
|
* different from each other instead of sharing the user's playback voice:
|
||||||
|
* deep-hesitant masculine, deep-serious masculine, cute feminine,
|
||||||
|
* medium feminine.
|
||||||
*/
|
*/
|
||||||
const DEFAULT_VOICE_PREVIEWS = [
|
export interface VoicePreview {
|
||||||
"Salut, euhm, comment je peux t'aider aujourd'hui ?",
|
text: string;
|
||||||
"5 sur 5 je recois ! Que veux-tu ?",
|
lang: string;
|
||||||
"Owww, ce chat est trop mignon, oops, pardon. Je me concentre, que puis-je faire pour toi ?",
|
rate: number;
|
||||||
"Hey, que puis-je faire pour toi ?",
|
pitch: number;
|
||||||
|
timbre: "masculine" | "feminine" | "any";
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_VOICE_PREVIEWS: VoicePreview[] = [
|
||||||
|
{ text: "Salut, euhm, comment je peux t'aider aujourd'hui ?", lang: "fr", rate: 0.85, pitch: 0.75, timbre: "masculine" },
|
||||||
|
{ text: "5 sur 5 je recois ! Que veux-tu ?", lang: "fr", rate: 1, pitch: 0.8, timbre: "masculine" },
|
||||||
|
{ text: "Owww, ce chat est trop mignon, oops, pardon. Je me concentre, que puis-je faire pour toi ?", lang: "fr", rate: 1.05, pitch: 1.35, timbre: "feminine" },
|
||||||
|
{ text: "Hey, que puis-je faire pour toi ?", lang: "fr", rate: 1, pitch: 1.1, timbre: "feminine" },
|
||||||
];
|
];
|
||||||
|
|
||||||
async function getVoicePreviews(): Promise<string[]> {
|
function sanitizePreviewLine(v: unknown): VoicePreview | null {
|
||||||
|
// Legacy plain-string lines (first version) still play, neutrally.
|
||||||
|
if (typeof v === "string") {
|
||||||
|
const text = v.trim();
|
||||||
|
if (text.length === 0 || text.length > 500) return null;
|
||||||
|
return { text, lang: "auto", rate: 1, pitch: 1, timbre: "any" };
|
||||||
|
}
|
||||||
|
if (!v || typeof v !== "object") return null;
|
||||||
|
const o = v as Record<string, unknown>;
|
||||||
|
if (typeof o.text !== "string") return null;
|
||||||
|
const text = o.text.trim();
|
||||||
|
if (text.length === 0 || text.length > 500) return null;
|
||||||
|
const lang = typeof o.lang === "string" && o.lang.length > 0 && o.lang.length <= 12 ? o.lang : "auto";
|
||||||
|
const rate = typeof o.rate === "number" && o.rate >= 0.5 && o.rate <= 2 ? o.rate : 1;
|
||||||
|
const pitch = typeof o.pitch === "number" && o.pitch >= 0.5 && o.pitch <= 2 ? o.pitch : 1;
|
||||||
|
const timbre = o.timbre === "masculine" || o.timbre === "feminine" ? o.timbre : "any";
|
||||||
|
return { text, lang, rate, pitch, timbre };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getVoicePreviews(): Promise<VoicePreview[]> {
|
||||||
try {
|
try {
|
||||||
const raw = await getSetting("voice_previews", "");
|
const raw = await getSetting("voice_previews", "");
|
||||||
if (!raw) return [...DEFAULT_VOICE_PREVIEWS];
|
if (!raw) return DEFAULT_VOICE_PREVIEWS.map((p) => ({ ...p }));
|
||||||
const arr = JSON.parse(raw) as unknown;
|
const arr = JSON.parse(raw) as unknown;
|
||||||
if (!Array.isArray(arr)) return [...DEFAULT_VOICE_PREVIEWS];
|
if (!Array.isArray(arr)) return DEFAULT_VOICE_PREVIEWS.map((p) => ({ ...p }));
|
||||||
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);
|
const clean = arr.map(sanitizePreviewLine).filter((p): p is VoicePreview => p !== null).slice(0, 6);
|
||||||
return clean.length > 0 ? clean : [...DEFAULT_VOICE_PREVIEWS];
|
return clean.length > 0 ? clean : DEFAULT_VOICE_PREVIEWS.map((p) => ({ ...p }));
|
||||||
} catch {
|
} catch {
|
||||||
return [...DEFAULT_VOICE_PREVIEWS];
|
return DEFAULT_VOICE_PREVIEWS.map((p) => ({ ...p }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeVoicePreviews(v: unknown): string[] | null {
|
function sanitizeVoicePreviews(v: unknown): VoicePreview[] | null {
|
||||||
if (!Array.isArray(v) || v.length === 0 || v.length > 6) return 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);
|
const clean = v.map(sanitizePreviewLine).filter((p): p is VoicePreview => p !== null);
|
||||||
return clean.length > 0 ? clean.slice(0, 6) : null;
|
return clean.length > 0 ? clean.slice(0, 6) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1067,7 +1096,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
await setSetting("voice_previews", "");
|
await setSetting("voice_previews", "");
|
||||||
} else {
|
} else {
|
||||||
const clean = sanitizeVoicePreviews(body.voicePreviews);
|
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);
|
if (!clean) return json({ error: "voicePreviews must be 1-6 lines {text, lang?, rate?, pitch?, timbre?}, or [] to reset" }, 400);
|
||||||
await setSetting("voice_previews", JSON.stringify(clean));
|
await setSetting("voice_previews", JSON.stringify(clean));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user