Compare commits
9
Commits
562ab5191f
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b010762a0b | ||
|
|
2dab720e55 | ||
|
|
67f13dbc93 | ||
|
|
a7f0097a11 | ||
|
|
644aa21426 | ||
|
|
6ebd26b60e | ||
|
|
ac04c674a6 | ||
|
|
30918f33e7 | ||
|
|
8d72defb82 |
@@ -25,6 +25,21 @@ CDN_DIR=./cdn
|
||||
OCR_LANG=eng
|
||||
OCR_MAX_CHARS=100000
|
||||
|
||||
# --- platform tools: neural TTS (first configured wins, TTS_PROVIDER forces one) ---
|
||||
# ElevenLabs (most natural): https://elevenlabs.io/app/settings/api-keys
|
||||
#ELEVENLABS_API_KEY=
|
||||
#ELEVENLABS_VOICE=
|
||||
# OpenAI (reuses the LLM key when set, tts-1 + alloy/echo/fable/onyx/nova/shimmer)
|
||||
#OPENAI_API_KEY=
|
||||
#TTS_VOICE=alloy
|
||||
# Local piper (offline, like tesseract): PIPER_BIN + onnx model
|
||||
#PIPER_BIN=piper
|
||||
#PIPER_MODEL=/var/lib/kisassistant/piper/en_US-lessac-medium.onnx
|
||||
# Test/development overrides (base URLs, default = api.elevenlabs.io / api.openai.com)
|
||||
#ELEVENLABS_BASE_URL=
|
||||
#OPENAI_BASE_URL=
|
||||
#TTS_PROVIDER=auto
|
||||
|
||||
# Per-driver debug logging to stdout ([driver:name] lines).
|
||||
DRIVER_DEBUG=true
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ from the **Accounts** panel (each gets a one-time access key to hand over).
|
||||
| GET/POST | `/api/admin/users` | admin | create returns `{user, key}` (key shown once) |
|
||||
| PATCH/DELETE | `/api/admin/users/:id` | admin | edit profile / delete + cascade |
|
||||
| POST | `/api/admin/users/:id/regenerate` | admin | new key, old sessions revoked |
|
||||
| POST | `/api/admin/users/:id/reset-link` | admin | email a one-time password-reset link (needs user email + SMTP) |
|
||||
| GET/PATCH | `/api/admin/settings` | admin | feature flags: registrationEnabled, ocrEnabled |
|
||||
| GET/POST | `/api/conversations` | Bearer | server-persisted chats (title, topic, model) |
|
||||
| GET | `/api/conversations/search?q=` | Bearer | search titles, topics + old prompts (with snippet) |
|
||||
@@ -68,6 +69,8 @@ from the **Accounts** panel (each gets a one-time access key to hand over).
|
||||
| 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/platform` | admin | this host facts: hostname, kernel, uptime, load, memory, IPs |
|
||||
| GET | `/api/tools/tts/info` | Bearer | neural TTS provider + voices (null = browser fallback) |
|
||||
| POST | `/api/tools/tts` | Bearer | synthesize `{text, voice?}` → audio bytes (cached, 30/hour) |
|
||||
| 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`) |
|
||||
| GET/POST | `/api/access/ticket/:id` (+`/message`) | ticket bearer | follow + reply on an access request |
|
||||
|
||||
+10
-3
@@ -60,11 +60,13 @@ No router library — a tiny history wrapper (`lib/route.ts`: `navigate()`,
|
||||
share button), message list (`Markdown` for assistant, emerald bubble for
|
||||
user), rounded composer with paperclip attach menu, attachment chips
|
||||
(Enter to send, Shift+Enter for newline). With `readOnly` (archived chat)
|
||||
the composer and share button are replaced by an unarchive banner.
|
||||
the composer is replaced by an unarchive banner with a share button
|
||||
(archived chats stay shareable, but an archived public chat cannot go
|
||||
private again — `DELETE …/share` answers 403 until unarchived).
|
||||
- `dialogs.tsx` — `ConvEditDialog` (title + topic), `ShareModal` (public link,
|
||||
copy, unshare), `SettingsModal` (display name, avatar upload box with
|
||||
drag & drop, avatar URL, theme, plus an "Archived chats" category to
|
||||
view / unarchive / delete archived conversations).
|
||||
view / unarchive / delete / share archived conversations).
|
||||
- `AdminPanel.tsx` — No-KYC account management: create (key shown once),
|
||||
regenerate (revokes sessions), edit, delete with confirm, plus server-side
|
||||
search / sort (newest, oldest, A–Z, Z–A) / email filter / pagination.
|
||||
@@ -81,6 +83,9 @@ No router library — a tiny history wrapper (`lib/route.ts`: `navigate()`,
|
||||
- `Markdown.tsx` — `react-markdown` + GFM; code blocks get a language label, a
|
||||
copy button and dependency-free syntax highlighting (`lib/highlight.tsx` —
|
||||
regex tokenizer, no highlight.js, so the Nix offline lockfile is untouched).
|
||||
- Assistant bubbles show the producing model (`<model>` chip in the footer,
|
||||
stored per message in `messages.model` — survives model switches; same
|
||||
chip on the streaming bubble and shared pages).
|
||||
- Admin Center tabs are deep-linkable (`/admin`, `/admin/models`,
|
||||
`/admin/stats`, `/admin/access`, … — `lib/route.ts` `adminTabFromPath`, SPA fallback serves
|
||||
them all). Models tab: `OllamaNodes.tsx` (pool with weights, per-node live
|
||||
@@ -92,7 +97,9 @@ No router library — a tiny history wrapper (`lib/route.ts`: `navigate()`,
|
||||
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,
|
||||
playback with glow through a unified player (`playText`: neural server
|
||||
audio via `POST /api/tools/tts` with browser-synthesis fallback),
|
||||
`Settings → Voice` prefs (engine/lang/voice/speed/pitch/neural voice,
|
||||
localStorage), admin-editable preview lines.
|
||||
|
||||
## Theming
|
||||
|
||||
+29
-9
@@ -126,7 +126,10 @@ dedicated per-IP envelope (separate from login brute-force protection):
|
||||
every hit counts, successes included (recovery answers are always-ok by
|
||||
design, so enumeration is impossible). Over-limit answers 429 with
|
||||
`retryAfterSec` + `Retry-After`. The limits are admin-configurable live in
|
||||
Admin Center → Mail → Password recovery limits, no restart.
|
||||
Admin Center → Mail → Password recovery limits, no restart. Admins can also
|
||||
email a one-time reset link to any user with an address from the Accounts
|
||||
tab (`POST /api/admin/users/:id/reset-link` — 409 without email, 501
|
||||
without SMTP).
|
||||
|
||||
## Model access policy (kill-switch + rate limits)
|
||||
|
||||
@@ -193,15 +196,32 @@ Disk meters are intentionally absent (models live on the Ollama hosts).
|
||||
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.
|
||||
- **Message playback**: every assistant bubble has a Listen button with a
|
||||
Siri-style breathing glow while speaking (click again to stop; playback
|
||||
stops on send / new message / conversation switch). Engine per user
|
||||
(Settings → Voice): **auto** (neural server voice when available, browser
|
||||
fallback), **browser**, or **server**.
|
||||
- **Neural server TTS** (`POST /api/tools/tts` `{text, voice?}` → audio
|
||||
bytes, `GET /api/tools/tts/info` → provider + voices): ElevenLabs
|
||||
(any voice_id, most natural) → OpenAI `tts-1` (6 stock voices, reuses the
|
||||
LLM key) → local piper (`PIPER_BIN` + `PIPER_MODEL`, offline WAV).
|
||||
`TTS_PROVIDER` forces one; `TTS_VOICE` / `ELEVENLABS_VOICE` set defaults.
|
||||
Output is cached on disk (sha of provider|voice|text, 200 files cap),
|
||||
rate-limited to 30 jobs/hour/account (429), kill-switched via
|
||||
`tools_tts_enabled` (Admin Center → Features). Preview lines are
|
||||
pre-generated once at boot and right after each preview save, so serving
|
||||
users always hits the cache (`X-TTS-Cache: HIT/MISS` proves it). Test
|
||||
overrides: `ELEVENLABS_BASE_URL` / `OPENAI_BASE_URL`.
|
||||
- **Settings → Voice** (per browser, localStorage): engine, language
|
||||
(auto-detect per message or forced), browser voice/rate/pitch, neural
|
||||
voice id, 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`.
|
||||
(`voice_previews` setting, 1–6 lines `{text, lang, rate, pitch, timbre,
|
||||
voice}`, each with its OWN voice character — masculine/feminine/any
|
||||
timbre hint, speed, pitch, optional server voice id picked from a select
|
||||
menu fed by the provider catalog), also served to users via
|
||||
`GET /api/tools`. Users can adopt any preview's character as their own
|
||||
playback voice from Settings → Voice.
|
||||
|
||||
## Personal providers (BYOK)
|
||||
|
||||
|
||||
@@ -432,7 +432,7 @@ export function App() {
|
||||
} else {
|
||||
setFlight(convId);
|
||||
}
|
||||
const userMsg: ChatMessage = { role: "user", content: text };
|
||||
const userMsg: ChatMessage = { role: "user", content: text, model };
|
||||
// Attachments travel as reviewed text blocks appended to the message.
|
||||
const blocks = attachments.map(
|
||||
(a) =>
|
||||
@@ -460,7 +460,7 @@ export function App() {
|
||||
// switched chats mid-stream, the reply is already persisted
|
||||
// server-side and the other view reloads it on selection.
|
||||
if (activeIdRef.current === convId) {
|
||||
setMessages([...history, { role: "assistant", content: full }]);
|
||||
setMessages([...history, { role: "assistant", content: full, model }]);
|
||||
}
|
||||
setStreaming("");
|
||||
clearFailedEntry(convId!);
|
||||
@@ -505,7 +505,7 @@ export function App() {
|
||||
try {
|
||||
const full = await streamReply(history, convId);
|
||||
if (activeIdRef.current === convId) {
|
||||
setMessages([...history, { role: "assistant", content: full }]);
|
||||
setMessages([...history, { role: "assistant", content: full, model }]);
|
||||
}
|
||||
setRetryCount(0);
|
||||
clearFailedEntry(convId);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Button, CopyButton, Field, FlowerMark, Input, LoadingScreen, Picker, Sp
|
||||
import { cn } from "../lib/cn.ts";
|
||||
import { msUntilNextSolarSwitch, resolveThemeDark } from "../lib/solarTheme.ts";
|
||||
import { useT, type StringKey } from "../lib/i18n.ts";
|
||||
import { OPENAI_STATIC_VOICES } from "../lib/voice.ts";
|
||||
|
||||
type Tab = "accounts" | "access" | "reports" | "models" | "stats" | "features" | "mail";
|
||||
|
||||
@@ -206,33 +207,39 @@ export function AdminCenter({ initialTab }: { initialTab?: string | null }) {
|
||||
</div>
|
||||
<div className="mx-auto w-full max-w-4xl px-4 pb-3">
|
||||
<div className="flex gap-1 overflow-x-auto rounded-full border border-stone-200/70 bg-stone-100/70 p-1 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
{TABS.map((t) => (
|
||||
{TABS.map((tb) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => selectTab(t.id)}
|
||||
key={tb.id}
|
||||
onClick={() => selectTab(tb.id)}
|
||||
className={cn(
|
||||
"relative flex flex-1 items-center justify-center gap-2 whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition",
|
||||
tab === t.id
|
||||
tab === tb.id
|
||||
? "bg-white text-stone-900 shadow dark:bg-zinc-800 dark:text-zinc-100"
|
||||
: "opacity-60 hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
<t.icon size={15} />
|
||||
{t.label}
|
||||
{t.id === "access" && openAccessCount > 0 && (
|
||||
<tb.icon size={15} />
|
||||
{tb.label}
|
||||
{tb.id === "access" && (
|
||||
<span
|
||||
role="status"
|
||||
aria-label={`${t.label}: ${openAccessCount}`}
|
||||
className="absolute -right-0.5 -top-0.5 z-10 grid min-h-4 min-w-4 place-items-center rounded-full bg-red-500 px-1 text-[10px] font-bold leading-4 text-white shadow"
|
||||
aria-label={`${tb.label}: ${openAccessCount}`}
|
||||
className={cn(
|
||||
"absolute -right-0.5 -top-0.5 z-10 grid min-h-4 min-w-4 place-items-center rounded-full px-1 text-[10px] font-bold leading-4 text-white shadow",
|
||||
openAccessCount > 0 ? "bg-red-500" : "bg-stone-400 dark:bg-zinc-600",
|
||||
)}
|
||||
>
|
||||
{openAccessCount > 99 ? "99+" : openAccessCount}
|
||||
</span>
|
||||
)}
|
||||
{t.id === "reports" && openReportCount > 0 && (
|
||||
{tb.id === "reports" && (
|
||||
<span
|
||||
role="status"
|
||||
aria-label={`${t.label}: ${openReportCount}`}
|
||||
className="absolute -right-0.5 -top-0.5 z-10 grid min-h-4 min-w-4 place-items-center rounded-full bg-red-500 px-1 text-[10px] font-bold leading-4 text-white shadow"
|
||||
aria-label={`${tb.label}: ${openReportCount}`}
|
||||
className={cn(
|
||||
"absolute -right-0.5 -top-0.5 z-10 grid min-h-4 min-w-4 place-items-center rounded-full px-1 text-[10px] font-bold leading-4 text-white shadow",
|
||||
openReportCount > 0 ? "bg-red-500" : "bg-stone-400 dark:bg-zinc-600",
|
||||
)}
|
||||
>
|
||||
{openReportCount > 99 ? "99+" : openReportCount}
|
||||
</span>
|
||||
@@ -321,6 +328,13 @@ export function AdminCenter({ initialTab }: { initialTab?: string | null }) {
|
||||
</div>
|
||||
<Switch label={t("center.usernameChangeTitle")} checked={settings?.usernameChangeEnabled ?? false} onChange={(v) => toggle({ usernameChangeEnabled: v })} />
|
||||
</div>
|
||||
<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="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{t("center.ttsTitle")}</p>
|
||||
<p className="text-sm opacity-60">{t("center.ttsDesc")}</p>
|
||||
</div>
|
||||
<Switch label={t("center.ttsTitle")} checked={settings?.ttsEnabled ?? false} onChange={(v) => toggle({ ttsEnabled: v })} />
|
||||
</div>
|
||||
{saving && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.saving")}</p>}
|
||||
{!settings && !error && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
|
||||
<VoicePreviewsCard />
|
||||
@@ -341,6 +355,9 @@ function VoicePreviewsCard() {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [serverVoices, setServerVoices] = useState<{ id: string; name: string }[]>([]);
|
||||
const [ttsProvider, setTtsProvider] = useState<string | null>(null);
|
||||
const [ttsAvailable, setTtsAvailable] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.adminGetSettings()
|
||||
@@ -349,6 +366,13 @@ function VoicePreviewsCard() {
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(() => setLoaded(true));
|
||||
api.ttsInfo()
|
||||
.then((r) => {
|
||||
setServerVoices(r.voices);
|
||||
setTtsProvider(r.provider);
|
||||
setTtsAvailable(r.available);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function save(next: VoicePreview[]) {
|
||||
@@ -444,6 +468,39 @@ function VoicePreviewsCard() {
|
||||
/>
|
||||
</Field>
|
||||
</span>
|
||||
<span className="w-40">
|
||||
<Field label={t("vfeat.voice")} hint={ttsAvailable ? (ttsProvider ? `${ttsProvider} · ${t("vfeat.voiceHint")}` : t("vfeat.voiceHint")) : t("vfeat.noProvider")}>
|
||||
{serverVoices.length > 0 || ttsProvider !== "elevenlabs" ? (
|
||||
<Picker
|
||||
ariaLabel={t("vfeat.voice")}
|
||||
value={line.voice}
|
||||
onChange={(v) => {
|
||||
patch(i, { voice: v });
|
||||
const next = lines.map((l, j) => (j === i ? { ...l, voice: v } : l));
|
||||
if (next[i]!.text.trim() !== "") void save(next);
|
||||
}}
|
||||
align="left"
|
||||
options={[
|
||||
{ value: "", label: t("vfeat.voicePh") },
|
||||
...(serverVoices.length > 0
|
||||
? serverVoices.map((v) => ({ value: v.id, label: v.name }))
|
||||
: OPENAI_STATIC_VOICES.map((v) => ({ value: v, label: v }))),
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={line.voice}
|
||||
disabled={busy}
|
||||
maxLength={64}
|
||||
placeholder={t("vfeat.voicePh")}
|
||||
aria-label={t("vfeat.voice")}
|
||||
onChange={(e) => patch(i, { voice: e.target.value })}
|
||||
onBlur={() => commit(i)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
|
||||
/>
|
||||
)}
|
||||
</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
|
||||
@@ -478,7 +535,7 @@ function VoicePreviewsCard() {
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => setLines((prev) => [...prev, { text: "", lang: "auto", rate: 1, pitch: 1, timbre: "any" as const }])}
|
||||
onClick={() => setLines((prev) => [...prev, { text: "", lang: "auto", rate: 1, pitch: 1, timbre: "any" as const, voice: "" }])}
|
||||
>
|
||||
<Plus size={14} /> {t("vfeat.add")}
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, type FC, type ReactNode } from "react";
|
||||
import { ArrowUpDown, Bot, ChevronLeft, ChevronRight, Download, Filter, HardDrive, KeyRound, Pencil, RefreshCw, Search, Trash2, UserPlus, Users } from "lucide-react";
|
||||
import { ArrowUpDown, Bot, ChevronLeft, ChevronRight, Download, Filter, HardDrive, KeyRound, Mail, Pencil, RefreshCw, Search, Trash2, UserPlus, Users } from "lucide-react";
|
||||
import { api } from "../lib/api.ts";
|
||||
import type { DriverModel, User } from "../lib/types.ts";
|
||||
import { useT, type StringKey } from "../lib/i18n.ts";
|
||||
@@ -190,6 +190,9 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
||||
const [freshKey, setFreshKey] = useState<{ username: string; key: string } | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
|
||||
const [editTarget, setEditTarget] = useState<User | null>(null);
|
||||
const [resetTarget, setResetTarget] = useState<User | null>(null);
|
||||
const [resetSent, setResetSent] = useState<{ username: string; email: string } | null>(null);
|
||||
const [regenTarget, setRegenTarget] = useState<User | null>(null);
|
||||
// Listing: search + sort + filter + pagination (server-side).
|
||||
const [q, setQ] = useState("");
|
||||
const [debouncedQ, setDebouncedQ] = useState("");
|
||||
@@ -262,6 +265,7 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
||||
|
||||
async function regenerate(u: User) {
|
||||
setError("");
|
||||
setFreshKey(null);
|
||||
try {
|
||||
const res = await api.adminRegenerate(u.id);
|
||||
setFreshKey({ username: u.username, key: res.key });
|
||||
@@ -270,6 +274,17 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
||||
}
|
||||
}
|
||||
|
||||
async function sendResetLink(u: User) {
|
||||
setError("");
|
||||
setResetSent(null);
|
||||
try {
|
||||
const res = await api.adminResetLink(u.id);
|
||||
setResetSent({ username: u.username, email: res.email });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("admin.resetFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(u: User) {
|
||||
try {
|
||||
await api.adminDelete(u.id);
|
||||
@@ -303,6 +318,12 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resetSent && (
|
||||
<div className="mb-4 rounded-2xl border border-accent-500/40 bg-accent-50 p-4 dark:bg-accent-950/30">
|
||||
<p className="flex items-center gap-2 text-sm font-medium"><Mail size={15} /> {t("admin.resetSent", { user: resetSent.username, email: resetSent.email })}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showCreate ? (
|
||||
<div className="mb-5 flex flex-wrap gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowCreate(true)}>
|
||||
@@ -371,7 +392,8 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
||||
{!u.isAdmin && (
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<button title={t("admin.editProfile")} onClick={() => setEditTarget(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><Pencil size={15} /></button>
|
||||
<button title={t("admin.regenKey")} onClick={() => regenerate(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><RefreshCw size={15} /></button>
|
||||
<button title={t("admin.regenKey")} onClick={() => setRegenTarget(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><RefreshCw size={15} /></button>
|
||||
<button title={t("admin.resetLink")} onClick={() => setResetTarget(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><Mail size={15} /></button>
|
||||
<button title={t("admin.deleteAccount")} onClick={() => setDeleteTarget(u)} className="rounded-full p-2 text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"><Trash2 size={15} /></button>
|
||||
</div>
|
||||
)}
|
||||
@@ -410,6 +432,32 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
||||
onConfirm={() => deleteTarget && remove(deleteTarget)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={resetTarget !== null}
|
||||
onClose={() => setResetTarget(null)}
|
||||
title={t("admin.resetTitle", { user: resetTarget?.username ?? "" })}
|
||||
message={t("admin.resetMsg", { email: resetTarget?.email || t("admin.noEmail") })}
|
||||
confirmLabel={t("admin.resetConfirm")}
|
||||
onConfirm={() => {
|
||||
const u = resetTarget;
|
||||
setResetTarget(null);
|
||||
if (u) void sendResetLink(u);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={regenTarget !== null}
|
||||
onClose={() => setRegenTarget(null)}
|
||||
title={t("admin.regenTitle", { user: regenTarget?.username ?? "" })}
|
||||
message={t("admin.regenMsg")}
|
||||
confirmLabel={t("admin.regenConfirm")}
|
||||
onConfirm={() => {
|
||||
const u = regenTarget;
|
||||
setRegenTarget(null);
|
||||
if (u) void regenerate(u);
|
||||
}}
|
||||
/>
|
||||
|
||||
<EditUserDialog user={editTarget} onClose={() => setEditTarget(null)} onSaved={refresh} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,12 +2,12 @@ import { useEffect, useRef, useState } from "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";
|
||||
import { AssistantAvatar, Avatar, Button, copyText, Field, FlowerMark, IconButton, Modal, Picker, type PickerGroup, Spinner } from "./ui.tsx";
|
||||
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 { fmtDuration, getVoicePrefs, listTtsVoices, playText, recognizerCtor, stopPlayback, sttSupported, type Recognizer } from "../lib/voice.ts";
|
||||
import { useT, type StringKey } from "../lib/i18n.ts";
|
||||
|
||||
const REPORT_REASONS: ReportReason[] = ["copyright", "gore", "falseinfo", "bug"];
|
||||
@@ -17,10 +17,11 @@ function MessageBubble({ msg, index, authorAvatar, authorName, onReport, reports
|
||||
const isUser = msg.role === "user";
|
||||
const [copied, setCopied] = useState(false);
|
||||
function copy() {
|
||||
navigator.clipboard.writeText(msg.content).then(() => {
|
||||
copyText(msg.content).then((ok) => {
|
||||
if (!ok) return;
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
function searchWeb() {
|
||||
window.open(`https://duckduckgo.com/?q=${encodeURIComponent(msg.content.slice(0, 400))}`, "_blank", "noopener,noreferrer");
|
||||
@@ -44,8 +45,16 @@ function MessageBubble({ msg, index, authorAvatar, authorName, onReport, reports
|
||||
<Markdown text={msg.content} />
|
||||
</div>
|
||||
)}
|
||||
{!isUser && (onReport || onToggleSpeak || (isFailed && onRetry)) && (
|
||||
{!isUser && (msg.model || onReport || onToggleSpeak || (isFailed && onRetry)) && (
|
||||
<div className="mt-1.5 flex items-center gap-0.5 opacity-70 transition hover:opacity-100">
|
||||
{msg.model ? (
|
||||
<span
|
||||
title={msg.model}
|
||||
className="mr-1 max-w-40 truncate rounded-full bg-stone-200/70 px-2.5 py-1 font-mono text-[11px] opacity-80 dark:bg-zinc-800"
|
||||
>
|
||||
{msg.model}
|
||||
</span>
|
||||
) : null}
|
||||
{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} />}
|
||||
@@ -279,6 +288,7 @@ export function Chat({
|
||||
// TTS playback: which assistant bubble is speaking (null = silent).
|
||||
const [speakingKey, setSpeakingKey] = useState<number | null>(null);
|
||||
const voicesRef = useRef<SpeechSynthesisVoice[]>([]);
|
||||
const ttsServerRef = useRef(false);
|
||||
// Voice input: live recording session (null = composer idle).
|
||||
const [rec, setRec] = useState<{
|
||||
stream: MediaStream;
|
||||
@@ -296,12 +306,13 @@ export function Chat({
|
||||
|
||||
useEffect(() => {
|
||||
listTtsVoices().then((v) => { voicesRef.current = v; });
|
||||
return () => stopSpeak();
|
||||
api.ttsInfo().then((r) => { ttsServerRef.current = r.available; }).catch(() => {});
|
||||
return () => stopPlayback();
|
||||
}, []);
|
||||
|
||||
// New message / conversation: stop any playback (never leak speech).
|
||||
useEffect(() => {
|
||||
stopSpeak();
|
||||
stopPlayback();
|
||||
setSpeakingKey(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [conv?.id]);
|
||||
@@ -309,22 +320,29 @@ export function Chat({
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelRec(false);
|
||||
stopSpeak();
|
||||
stopPlayback();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function toggleSpeak(index: number, text: string) {
|
||||
if (speakingKey === index) {
|
||||
stopSpeak();
|
||||
stopPlayback();
|
||||
setSpeakingKey(null);
|
||||
return;
|
||||
}
|
||||
const ok = speak(text, getVoicePrefs(), voicesRef.current, {
|
||||
const prefs = getVoicePrefs();
|
||||
playText({
|
||||
text,
|
||||
engine: prefs.engine,
|
||||
browser: { voices: voicesRef.current, lang: prefs.lang, rate: prefs.rate, pitch: prefs.pitch, voiceURI: prefs.voiceURI },
|
||||
serverVoice: prefs.serverVoice ?? undefined,
|
||||
serverAvailable: ttsServerRef.current,
|
||||
fetchAudio: (t, v) => api.ttsSpeak(t, v),
|
||||
onend: () => setSpeakingKey(null),
|
||||
onerror: () => setSpeakingKey(null),
|
||||
});
|
||||
if (ok) setSpeakingKey(index);
|
||||
setSpeakingKey(index);
|
||||
}
|
||||
|
||||
function cleanupRec(r: NonNullable<typeof rec>) {
|
||||
@@ -446,7 +464,7 @@ export function Chat({
|
||||
// A fresh message ends any playback (the glow never sticks to a stale row).
|
||||
const msgCount = messages.length;
|
||||
useEffect(() => {
|
||||
stopSpeak();
|
||||
stopPlayback();
|
||||
setSpeakingKey(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [msgCount]);
|
||||
@@ -464,7 +482,7 @@ export function Chat({
|
||||
if (readOnly) return;
|
||||
const text = draft.trim();
|
||||
if (!text || sending) return;
|
||||
stopSpeak();
|
||||
stopPlayback();
|
||||
setSpeakingKey(null);
|
||||
setDraft("");
|
||||
requestAnimationFrame(autoGrow);
|
||||
@@ -525,7 +543,12 @@ export function Chat({
|
||||
<div className="flex gap-2 sm:gap-3">
|
||||
<AssistantAvatar size={32} dynamic />
|
||||
<div className="min-w-0 max-w-[88%] sm:max-w-[85%]">
|
||||
<div className="mb-1 text-xs opacity-60">KisAssistant</div>
|
||||
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
|
||||
<span>KisAssistant</span>
|
||||
{model ? (
|
||||
<span title={model} className="max-w-40 truncate rounded-full bg-stone-200/70 px-2 py-0.5 font-mono text-[11px] dark:bg-zinc-800">{model}</span>
|
||||
) : null}
|
||||
</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="typing-cursor"><Markdown text={streaming} /></div>
|
||||
</div>
|
||||
@@ -603,6 +626,15 @@ export function Chat({
|
||||
<Archive size={17} />
|
||||
</span>
|
||||
<p className="min-w-0 flex-1 text-sm opacity-70">{t("archived.readonly")}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShare}
|
||||
aria-label={t("sidebar.share")}
|
||||
title={t("sidebar.share")}
|
||||
className="grid h-10 w-10 shrink-0 place-items-center rounded-full transition hover:bg-stone-200/70 active:scale-95 dark:hover:bg-zinc-700"
|
||||
>
|
||||
<Share2 size={16} className="opacity-70" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUnarchive}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { AccessRequestModal } from "./ReviewPage.tsx";
|
||||
|
||||
export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
|
||||
const { t } = useT();
|
||||
const [username, setUsername] = useState("admin");
|
||||
const [username, setUsername] = useState("");
|
||||
const [key, setKey] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
@@ -4,6 +4,16 @@ import remarkGfm from "remark-gfm";
|
||||
import { CopyButton } from "./ui.tsx";
|
||||
import { highlightCode } from "../lib/highlight.tsx";
|
||||
|
||||
/** Raw source text of a react-markdown <code> element (children may be split). */
|
||||
export function extractCodeText(children: unknown): string {
|
||||
if (children === null || children === undefined) return "";
|
||||
if (typeof children === "string" || typeof children === "number") return String(children);
|
||||
if (Array.isArray(children)) return children.map(extractCodeText).join("");
|
||||
const props = (children as { props?: { children?: unknown } })?.props;
|
||||
if (props && typeof props === "object" && "children" in props) return extractCodeText(props.children);
|
||||
return "";
|
||||
}
|
||||
|
||||
function CodeBlock({ code, lang }: { code: string; lang: string }) {
|
||||
const nodes = useMemo(() => highlightCode(code, lang), [code, lang]);
|
||||
return (
|
||||
@@ -31,9 +41,10 @@ export function Markdown({ text }: { text: string }) {
|
||||
// react-markdown nests <code> inside <pre>; render our block instead.
|
||||
const child = Array.isArray(children) ? children[0] : children;
|
||||
const props = (child as { props?: { className?: string; children?: unknown } })?.props;
|
||||
const className = props?.className || "";
|
||||
if (!props || typeof props !== "object") return <pre>{children}</pre>;
|
||||
const className = typeof props.className === "string" ? props.className : "";
|
||||
const lang = className.replace("language-", "");
|
||||
const code = String(props?.children ?? "").replace(/\n$/, "");
|
||||
const code = extractCodeText(props.children).replace(/\n$/, "");
|
||||
return <CodeBlock code={code} lang={lang} />;
|
||||
},
|
||||
code({ children }) {
|
||||
|
||||
@@ -76,27 +76,29 @@ export function RegisterPage() {
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} className="mt-4 space-y-2.5">
|
||||
<form onSubmit={submit} autoComplete="off" className="mt-4 space-y-2.5">
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<Field label={t("common.username")}>
|
||||
<Input
|
||||
variant="soft"
|
||||
name="ka-register-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="alice"
|
||||
maxLength={32}
|
||||
autoComplete="username"
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("common.displayName")}>
|
||||
<Input
|
||||
variant="soft"
|
||||
name="ka-register-displayname"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Alice"
|
||||
maxLength={60}
|
||||
autoComplete="nickname"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@@ -79,6 +79,9 @@ export function SharePage({ publicId }: { publicId: string }) {
|
||||
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
|
||||
{m.role === "user" ? <UserIcon size={12} /> : <Bot size={12} />}
|
||||
{m.role === "user" ? chat.authorName : "KisAssistant"}
|
||||
{m.role !== "user" && m.model ? (
|
||||
<span title={m.model} className="max-w-40 truncate rounded-full bg-stone-200/70 px-2 py-0.5 font-mono text-[11px] dark:bg-zinc-800">{m.model}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{m.role === "user" ? (
|
||||
<div className="break-words whitespace-pre-wrap rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-5 py-3 text-[15px] dark:border-zinc-800 dark:bg-zinc-900"><UserMessageContent content={m.content} tone="plain" /></div>
|
||||
|
||||
@@ -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 { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Archive, ArchiveRestore, Eye, Fingerprint, Globe, FileText, ImagePlus, KeyRound, Link2, Mail, Mic, Palette, Pencil, Pipette, Play, Plug, ScanText, Search, Settings2, Share2, ShieldCheck, Sparkles, Square, Tag, Trash2, Unplug, User as UserIcon, Volume2, X } from "lucide-react";
|
||||
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, pickVoice, setVoicePrefs, speakPreview, stopSpeak, ttsSupported, type VoicePrefs } from "../lib/voice.ts";
|
||||
import { ACCENT_PRESETS, applyAccent, currentAccent, hexToHsv, hsvToHex, previewAccent, type AccentState } from "../lib/accent.ts";
|
||||
import { detectLang, getVoicePrefs, listTtsVoices, OPENAI_STATIC_VOICES, pickVoice, playText, setVoicePrefs, stopPlayback, 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";
|
||||
@@ -229,10 +229,17 @@ export function ShareModal({ conv, onClose }: { conv: Conversation | null; onClo
|
||||
|
||||
async function unshare() {
|
||||
if (!conv) return;
|
||||
setError("");
|
||||
try {
|
||||
await api.unshare(conv.id);
|
||||
pushToast(t("toast.unshared"), { icon: "unshare" });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("dlg.reqFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
const archived = (conv?.archived_at ?? 0) !== 0;
|
||||
|
||||
return (
|
||||
<Modal open={conv !== null} onClose={onClose} title={t("dlg.shareTitle")} icon={Globe}>
|
||||
@@ -246,8 +253,13 @@ export function ShareModal({ conv, onClose }: { conv: Conversation | null; onClo
|
||||
<span className="min-w-0 flex-1 truncate">{url}</span>
|
||||
<CopyButton text={url} />
|
||||
</div>
|
||||
{archived && <p className="text-xs opacity-60">{t("dlg.unshareBlocked")}</p>}
|
||||
<div className="flex justify-between">
|
||||
{!archived ? (
|
||||
<Button variant="ghost" size="sm" onClick={unshare}><Unplug size={15} /> {t("dlg.unshare")}</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button size="sm" onClick={onClose}>{t("common.done")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -651,6 +663,7 @@ function ArchivedSection({ onView, onChanged }: { onView: (c: Conversation) => v
|
||||
const [error, setError] = useState("");
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Conversation | null>(null);
|
||||
const [shareTarget, setShareTarget] = useState<Conversation | null>(null);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
@@ -738,6 +751,16 @@ function ArchivedSection({ onView, onChanged }: { onView: (c: Conversation) => v
|
||||
>
|
||||
<ArchiveRestore size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t("sidebar.share")}
|
||||
aria-label={`${t("sidebar.share")}: ${c.title}`}
|
||||
disabled={busyId === c.id}
|
||||
onClick={() => setShareTarget(c)}
|
||||
className="rounded-full p-2 transition hover:bg-stone-100 disabled:opacity-50 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<Share2 size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t("common.delete")}
|
||||
@@ -753,6 +776,7 @@ function ArchivedSection({ onView, onChanged }: { onView: (c: Conversation) => v
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<ShareModal conv={shareTarget} onClose={() => setShareTarget(null)} />
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
@@ -770,6 +794,7 @@ function ArchivedSection({ onView, onChanged }: { onView: (c: Conversation) => v
|
||||
function AccentSection() {
|
||||
const { t } = useT();
|
||||
const [state, setState] = useState<AccentState>(() => currentAccent());
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
function pick(presetId: string) {
|
||||
const next = { ...state, presetId };
|
||||
@@ -778,13 +803,6 @@ function AccentSection() {
|
||||
pushToast(t("toast.accentChanged"), { icon: "accent", tag: "accent" });
|
||||
}
|
||||
|
||||
function pickCustom(hex: string) {
|
||||
const next = { presetId: "custom", customHex: hex };
|
||||
setState(next);
|
||||
previewAccent(hex);
|
||||
pushToast(t("toast.accentChanged"), { icon: "accent", tag: "accent" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="mb-1.5 block text-sm font-medium opacity-80">{t("settings.accent")}</span>
|
||||
@@ -806,26 +824,213 @@ function AccentSection() {
|
||||
style={{ backgroundColor: p.base }}
|
||||
/>
|
||||
))}
|
||||
<label
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen((o) => !o)}
|
||||
aria-expanded={pickerOpen}
|
||||
aria-label={t("settings.customAria")}
|
||||
title={t("settings.customColor")}
|
||||
className={cn(
|
||||
"relative grid h-8 w-8 cursor-pointer place-items-center overflow-hidden rounded-full border-2 transition active:scale-90",
|
||||
state.presetId === "custom"
|
||||
"grid h-8 w-8 place-items-center rounded-full border-2 transition active:scale-90",
|
||||
state.presetId === "custom" || pickerOpen
|
||||
? "border-stone-900 dark:border-white"
|
||||
: "border-transparent hover:scale-110",
|
||||
)}
|
||||
style={{ background: state.presetId === "custom" ? state.customHex : undefined }}
|
||||
title={t("settings.customColor")}
|
||||
style={{ background: "conic-gradient(#f43f5e,#f59e0b,#84cc16,#14b8a6,#3b82f6,#8b5cf6,#f43f5e)" }}
|
||||
>
|
||||
<Sparkles size={14} className={cn("text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.6)]", state.presetId === "custom" && "opacity-0")} />
|
||||
<input
|
||||
type="color"
|
||||
aria-label={t("settings.customAria")}
|
||||
value={state.presetId === "custom" ? state.customHex : "#10b981"}
|
||||
onChange={(e) => pickCustom(e.target.value)}
|
||||
onBlur={() => applyAccent(state)} // persist the last previewed value
|
||||
className="absolute inset-0 cursor-pointer opacity-0"
|
||||
<Pipette size={14} className="text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.6)]" />
|
||||
</button>
|
||||
</div>
|
||||
{pickerOpen && (
|
||||
<AccentCustomPanel
|
||||
initialHex={state.presetId === "custom" ? state.customHex : (ACCENT_PRESETS.find((p) => p.id === state.presetId)?.base ?? "#10b981")}
|
||||
onPreview={(hex) => {
|
||||
setState({ presetId: "custom", customHex: hex });
|
||||
previewAccent(hex);
|
||||
}}
|
||||
onCommit={(hex) => {
|
||||
const next = { presetId: "custom", customHex: hex };
|
||||
setState(next);
|
||||
applyAccent(next);
|
||||
pushToast(t("toast.accentChanged"), { icon: "accent", tag: "accent" });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Custom accent picker: SV square + hue slider + hex field ---------- */
|
||||
|
||||
function AccentCustomPanel({ initialHex, onPreview, onCommit }: {
|
||||
initialHex: string;
|
||||
onPreview: (hex: string) => void;
|
||||
onCommit: (hex: string) => void;
|
||||
}) {
|
||||
const { t } = useT();
|
||||
const start = hexToHsv(initialHex) ?? { h: 160, s: 84, v: 73 };
|
||||
const [h, setH] = useState(start.h);
|
||||
const [s, setS] = useState(start.s);
|
||||
const [v, setV] = useState(start.v);
|
||||
const [hexInput, setHexInput] = useState(() => hsvToHex(start.h, start.s, start.v));
|
||||
const [hexError, setHexError] = useState(false);
|
||||
const [drag, setDrag] = useState<null | "sv" | "hue">(null);
|
||||
const svRef = useRef<HTMLDivElement>(null);
|
||||
const hueRef = useRef<HTMLDivElement>(null);
|
||||
// Synchronous mirror (pointerup fires before setState flushes).
|
||||
const hsvRef = useRef({ h: start.h, s: start.s, v: start.v });
|
||||
|
||||
const currentHex = useMemo(() => hsvToHex(h, s, v), [h, s, v]);
|
||||
|
||||
function applyHsv(nh: number, ns: number, nv: number) {
|
||||
const c = {
|
||||
h: Math.min(359.99, Math.max(0, nh)),
|
||||
s: Math.min(100, Math.max(0, ns)),
|
||||
v: Math.min(100, Math.max(0, nv)),
|
||||
};
|
||||
hsvRef.current = c;
|
||||
setH(c.h);
|
||||
setS(c.s);
|
||||
setV(c.v);
|
||||
const hex = hsvToHex(c.h, c.s, c.v);
|
||||
setHexInput(hex);
|
||||
setHexError(false);
|
||||
onPreview(hex);
|
||||
}
|
||||
|
||||
function posToSv(e: React.PointerEvent): void {
|
||||
const el = svRef.current;
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
applyHsv(hsvRef.current.h, ((e.clientX - r.left) / r.width) * 100, (1 - (e.clientY - r.top) / r.height) * 100);
|
||||
}
|
||||
|
||||
function posToHue(e: React.PointerEvent): void {
|
||||
const el = hueRef.current;
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
const nh = ((e.clientX - r.left) / r.width) * 360;
|
||||
const c = hsvRef.current;
|
||||
applyHsv(nh, c.s, c.v);
|
||||
}
|
||||
|
||||
function begin(kind: "sv" | "hue", e: React.PointerEvent) {
|
||||
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
|
||||
setDrag(kind);
|
||||
if (kind === "sv") posToSv(e);
|
||||
else posToHue(e);
|
||||
}
|
||||
|
||||
function commitHex(raw: string) {
|
||||
const m = /^#?([0-9a-fA-F]{6})$/.exec(raw.trim());
|
||||
if (!m) {
|
||||
setHexError(true);
|
||||
setHexInput(currentHex);
|
||||
return;
|
||||
}
|
||||
const hex = `#${m[1]!.toLowerCase()}`;
|
||||
const c = hexToHsv(hex);
|
||||
if (c) {
|
||||
hsvRef.current = c;
|
||||
setH(c.h);
|
||||
setS(c.s);
|
||||
setV(c.v);
|
||||
setHexInput(hex);
|
||||
setHexError(false);
|
||||
onPreview(hex);
|
||||
onCommit(hex);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-3 rounded-2xl border border-stone-200/70 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div
|
||||
ref={svRef}
|
||||
role="slider"
|
||||
aria-label={t("settings.customAria")}
|
||||
aria-valuetext={currentHex}
|
||||
tabIndex={0}
|
||||
onPointerDown={(e) => begin("sv", e)}
|
||||
onPointerMove={(e) => { if (drag === "sv") posToSv(e); }}
|
||||
onPointerUp={() => { if (drag === "sv") { setDrag(null); onCommit(hsvToHex(hsvRef.current.h, hsvRef.current.s, hsvRef.current.v)); } }}
|
||||
onPointerCancel={() => setDrag(null)}
|
||||
onKeyDown={(e) => {
|
||||
const step = e.shiftKey ? 10 : 2;
|
||||
const c = hsvRef.current;
|
||||
if (e.key === "ArrowLeft") { applyHsv(c.h, c.s - step, c.v); onCommit(hsvToHex(Math.min(359.99, Math.max(0, c.h)), Math.min(100, Math.max(0, c.s - step)), c.v)); }
|
||||
else if (e.key === "ArrowRight") { applyHsv(c.h, c.s + step, c.v); onCommit(hsvToHex(Math.min(359.99, Math.max(0, c.h)), Math.min(100, Math.max(0, c.s + step)), c.v)); }
|
||||
else if (e.key === "ArrowUp") { applyHsv(c.h, c.s, c.v + step); onCommit(hsvToHex(Math.min(359.99, Math.max(0, c.h)), c.s, Math.min(100, Math.max(0, c.v + step)))); }
|
||||
else if (e.key === "ArrowDown") { applyHsv(c.h, c.s, c.v - step); onCommit(hsvToHex(Math.min(359.99, Math.max(0, c.h)), c.s, Math.min(100, Math.max(0, c.v - step)))); }
|
||||
else return;
|
||||
e.preventDefault();
|
||||
}}
|
||||
className="relative h-40 w-full cursor-crosshair touch-none select-none overflow-hidden rounded-2xl"
|
||||
style={{ background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${h}, 100%, 50%))` }}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute h-4 w-4 rounded-full border-2 border-white shadow-md"
|
||||
style={{ left: `${s}%`, top: `${100 - v}%`, transform: "translate(-50%, -50%)", backgroundColor: currentHex }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={hueRef}
|
||||
role="slider"
|
||||
aria-label={t("settings.accent")}
|
||||
aria-valuetext={String(Math.round(h))}
|
||||
tabIndex={0}
|
||||
onPointerDown={(e) => begin("hue", e)}
|
||||
onPointerMove={(e) => { if (drag === "hue") posToHue(e); }}
|
||||
onPointerUp={() => { if (drag === "hue") { setDrag(null); onCommit(hsvToHex(hsvRef.current.h, hsvRef.current.s, hsvRef.current.v)); } }}
|
||||
onPointerCancel={() => setDrag(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
||||
const nh = hsvRef.current.h + (e.key === "ArrowRight" ? 4 : -4);
|
||||
const c = hsvRef.current;
|
||||
applyHsv(nh, c.s, c.v);
|
||||
onCommit(hsvToHex(nh, c.s, c.v));
|
||||
e.preventDefault();
|
||||
}}
|
||||
className="relative h-3 w-full cursor-pointer touch-none select-none rounded-full"
|
||||
style={{ background: "linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)" }}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute top-1/2 h-5 w-5 rounded-full border-2 border-white bg-transparent shadow-md"
|
||||
style={{ left: `calc(${(h / 360) * 100}% - 10px)`, transform: "translateY(-50%)", backgroundColor: currentHex }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-9 w-9 shrink-0 rounded-full border border-stone-200 dark:border-zinc-700" style={{ backgroundColor: currentHex }} aria-hidden />
|
||||
<Field label={t("accent.hexLabel")}>
|
||||
<Input
|
||||
value={hexInput}
|
||||
onChange={(e) => {
|
||||
setHexInput(e.target.value);
|
||||
const m = /^#?([0-9a-fA-F]{6})$/.exec(e.target.value.trim());
|
||||
if (m) {
|
||||
const hex = `#${m[1]!.toLowerCase()}`;
|
||||
const c = hexToHsv(hex);
|
||||
if (c) {
|
||||
hsvRef.current = c;
|
||||
setH(c.h);
|
||||
setS(c.s);
|
||||
setV(c.v);
|
||||
setHexError(false);
|
||||
onPreview(hex);
|
||||
}
|
||||
} else {
|
||||
setHexError(e.target.value.trim().length > 0);
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => commitHex(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") commitHex((e.target as HTMLInputElement).value); }}
|
||||
maxLength={7}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className={cn("font-mono uppercase", hexError && "!border-red-500")}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -877,12 +1082,14 @@ function VoiceSection() {
|
||||
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([]);
|
||||
const [previews, setPreviews] = useState<VoicePreview[]>([]);
|
||||
const [playing, setPlaying] = useState<number | null>(null);
|
||||
const [ttsServer, setTtsServer] = useState<{ available: boolean; provider: string | null; voices: { id: string; name: string }[] } | 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(); };
|
||||
api.ttsInfo().then((r) => { if (alive) setTtsServer({ available: r.available, provider: r.provider, voices: r.voices }); }).catch(() => {});
|
||||
return () => { alive = false; stopPlayback(); };
|
||||
}, []);
|
||||
|
||||
function update(p: Partial<VoicePrefs>) {
|
||||
@@ -893,17 +1100,23 @@ function VoiceSection() {
|
||||
|
||||
function preview(i: number, line: VoicePreview) {
|
||||
if (playing === i) {
|
||||
stopSpeak();
|
||||
stopPlayback();
|
||||
setPlaying(null);
|
||||
return;
|
||||
}
|
||||
// Each line plays with ITS OWN character (timbre/rate/pitch/lang) —
|
||||
// never the global playback prefs.
|
||||
const pick = speakPreview(line.text, line, voices, {
|
||||
// Each line plays with ITS OWN character; the neural server engine is
|
||||
// used when selected and available (line.server voice or user default).
|
||||
playText({
|
||||
text: line.text,
|
||||
engine: prefs.engine,
|
||||
browser: { voices, lang: line.lang, rate: line.rate, pitch: line.pitch, timbre: line.timbre },
|
||||
serverVoice: line.voice || prefs.serverVoice || undefined,
|
||||
serverAvailable: ttsServer?.available ?? false,
|
||||
fetchAudio: (text, voice) => api.ttsSpeak(text, voice),
|
||||
onend: () => setPlaying(null),
|
||||
onerror: () => setPlaying(null),
|
||||
});
|
||||
if (pick || ttsSupported()) setPlaying(i);
|
||||
setPlaying(i);
|
||||
}
|
||||
|
||||
/** Adopt a preview's character as the global playback voice. */
|
||||
@@ -911,6 +1124,7 @@ function VoiceSection() {
|
||||
const lang = line.lang === "auto" ? detectLang(line.text) : line.lang;
|
||||
const pick = pickVoice(voices, { lang, timbre: line.timbre });
|
||||
const next: VoicePrefs = {
|
||||
...prefs,
|
||||
voiceURI: pick?.voiceURI ?? prefs.voiceURI,
|
||||
rate: line.rate,
|
||||
pitch: line.pitch,
|
||||
@@ -954,6 +1168,61 @@ function VoiceSection() {
|
||||
options={VOICE_LANGS.map((l) => ({ value: l, label: l === "auto" ? t("voice.auto") : l }))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("voice.engine")} hint={ttsServer?.available ? t("voice.engineHintOn", { provider: ttsServer.provider ?? "?" }) : t("voice.engineHintOff")}>
|
||||
<Picker
|
||||
ariaLabel={t("voice.engine")}
|
||||
icon={Volume2}
|
||||
value={prefs.engine}
|
||||
onChange={(v) => update({ engine: v as VoicePrefs["engine"] })}
|
||||
align="left"
|
||||
options={[
|
||||
{ value: "auto", label: t("voice.engineAuto") },
|
||||
{ value: "browser", label: t("voice.engineBrowser") },
|
||||
{ value: "server", label: t("voice.engineServer") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
{(prefs.engine !== "browser") && (
|
||||
<>
|
||||
{!ttsServer?.available && (
|
||||
<p className="rounded-2xl bg-amber-500/10 px-4 py-2.5 text-sm text-amber-700 dark:text-amber-400">{t("voice.noServer")}</p>
|
||||
)}
|
||||
{ttsServer && ttsServer.voices.length > 0 ? (
|
||||
<Field label={t("voice.serverVoice")}>
|
||||
<Picker
|
||||
ariaLabel={t("voice.serverVoice")}
|
||||
icon={Volume2}
|
||||
value={prefs.serverVoice ?? ""}
|
||||
onChange={(v) => update({ serverVoice: v || null })}
|
||||
align="left"
|
||||
options={[{ value: "", label: t("voice.defaultVoice") }, ...ttsServer.voices.map((v) => ({ value: v.id, label: v.name }))]}
|
||||
/>
|
||||
</Field>
|
||||
) : ttsServer?.provider === "elevenlabs" ? (
|
||||
<Field label={t("voice.serverVoice")} hint={t("voice.serverVoiceHint")}>
|
||||
<Input
|
||||
value={prefs.serverVoice ?? ""}
|
||||
onChange={(e) => update({ serverVoice: e.target.value.trim() || null })}
|
||||
placeholder="pNInz6obpgDQGcFmaJgB"
|
||||
maxLength={64}
|
||||
/>
|
||||
</Field>
|
||||
) : ttsServer?.provider === "piper" ? (
|
||||
<p className="rounded-2xl bg-stone-100 px-4 py-2.5 text-sm opacity-70 dark:bg-zinc-800">{t("voice.piperNote")}</p>
|
||||
) : (
|
||||
<Field label={t("voice.serverVoice")} hint={t("vfeat.noProvider")}>
|
||||
<Picker
|
||||
ariaLabel={t("voice.serverVoice")}
|
||||
icon={Volume2}
|
||||
value={prefs.serverVoice ?? ""}
|
||||
onChange={(v) => update({ serverVoice: v || null })}
|
||||
align="left"
|
||||
options={[{ value: "", label: t("voice.defaultVoice") }, ...OPENAI_STATIC_VOICES.map((v) => ({ value: v, label: v }))]}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Field label={t("voice.voice")}>
|
||||
<Picker
|
||||
ariaLabel={t("voice.voice")}
|
||||
|
||||
@@ -633,6 +633,28 @@ export function Toggle({
|
||||
);
|
||||
}
|
||||
|
||||
/** Clipboard write with legacy fallback (non-secure contexts lack the async API). */
|
||||
export async function copyText(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function CopyButton({ text, label }: { text: string; label?: string }) {
|
||||
const { t } = useT();
|
||||
const [done, setDone] = useState(false);
|
||||
@@ -640,7 +662,8 @@ export function CopyButton({ text, label }: { text: string; label?: string }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
copyText(text).then((ok) => {
|
||||
if (!ok) return;
|
||||
setDone(true);
|
||||
setTimeout(() => setDone(false), 1500);
|
||||
});
|
||||
|
||||
@@ -66,8 +66,42 @@ function hslToRgbTriplet(h: number, s: number, l: number): string {
|
||||
return `${to(r)} ${to(g)} ${to(b)}`;
|
||||
}
|
||||
|
||||
/** Build the 50–950 ramp from a base hex (HSL ramp, emerald-style). */
|
||||
export function shadesFor(baseHex: string): AccentShades {
|
||||
/** HSV helpers for the custom color picker (no native input involved). */
|
||||
export function hexToHsv(hex: string): { h: number; s: number; v: number } | null {
|
||||
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
||||
if (!m) return null;
|
||||
const n = parseInt(m[1]!, 16);
|
||||
const r = ((n >> 16) & 255) / 255, g = ((n >> 8) & 255) / 255, b = (n & 255) / 255;
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
const d = max - min;
|
||||
let h = 0;
|
||||
if (d !== 0) {
|
||||
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
|
||||
else if (max === g) h = ((b - r) / d + 2) * 60;
|
||||
else h = ((r - g) / d + 4) * 60;
|
||||
}
|
||||
return { h, s: max === 0 ? 0 : (d / max) * 100, v: max * 100 };
|
||||
}
|
||||
|
||||
export function hsvToHex(h: number, s: number, v: number): string {
|
||||
const sn = Math.min(100, Math.max(0, s)) / 100;
|
||||
const vn = Math.min(100, Math.max(0, v)) / 100;
|
||||
const hh = ((h % 360) + 360) % 360;
|
||||
const c = vn * sn;
|
||||
const x = c * (1 - Math.abs(((hh / 60) % 2) - 1));
|
||||
const m = vn - c;
|
||||
let r = 0, g = 0, b = 0;
|
||||
if (hh < 60) [r, g, b] = [c, x, 0];
|
||||
else if (hh < 120) [r, g, b] = [x, c, 0];
|
||||
else if (hh < 180) [r, g, b] = [0, c, x];
|
||||
else if (hh < 240) [r, g, b] = [0, x, c];
|
||||
else if (hh < 300) [r, g, b] = [x, 0, c];
|
||||
else [r, g, b] = [c, 0, x];
|
||||
const to = (zv: number) => Math.round((zv + m) * 255).toString(16).padStart(2, "0");
|
||||
return `#${to(r)}${to(g)}${to(b)}`;
|
||||
}
|
||||
|
||||
/** Build the 50–950 ramp from a base hex (HSL ramp, emerald-style). */export function shadesFor(baseHex: string): AccentShades {
|
||||
const { h, s } = hexToHsl(baseHex);
|
||||
const out = {} as AccentShades;
|
||||
for (const k of SHADE_KEYS) {
|
||||
@@ -123,21 +157,23 @@ function mixHex(hex: string, target: number, amount: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Favicon follows the accent AND the theme: the tile background is
|
||||
* theme-owned (white by day, near-black by night) while the flower itself
|
||||
* wears the accent ramp — never an accent tile.
|
||||
*/
|
||||
export function paintFavicon(baseHex: string): void {
|
||||
export function paintFavicon(baseHex: string, dark?: boolean): 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 isDark = dark ?? document.documentElement.classList.contains("dark");
|
||||
const tile = isDark ? "#09090b" : "#ffffff";
|
||||
const ring = isDark ? "#27272a" : "#e7e5e4";
|
||||
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}` +
|
||||
`<rect width="100" height="100" rx="22" fill="${tile}" stroke="${ring}" stroke-width="3"/>${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)}`;
|
||||
@@ -153,6 +189,24 @@ export function paintFavicon(baseHex: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Last accent base, so theme flips can repaint without knowing the color. */
|
||||
let lastBase = "#10b981";
|
||||
|
||||
function repaint(): void {
|
||||
paintFavicon(lastBase);
|
||||
}
|
||||
|
||||
if (typeof document !== "undefined" && typeof MutationObserver !== "undefined") {
|
||||
new MutationObserver((muts) => {
|
||||
for (const m of muts) {
|
||||
if (m.attributeName === "class") {
|
||||
repaint();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}).observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
}
|
||||
|
||||
// Restore the saved accent on load.
|
||||
if (typeof document !== "undefined") {
|
||||
applyAccent(currentAccent());
|
||||
|
||||
@@ -74,6 +74,7 @@ export interface VoicePreview {
|
||||
rate: number;
|
||||
pitch: number;
|
||||
timbre: "masculine" | "feminine" | "any";
|
||||
voice: string;
|
||||
}
|
||||
|
||||
export interface PlatformInfo {
|
||||
@@ -102,6 +103,7 @@ export interface AdminSettings {
|
||||
recoveryLimitMax: number;
|
||||
recoveryLimitWindowMin: number;
|
||||
voicePreviews: VoicePreview[];
|
||||
ttsEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface AdminSettingsPatch {
|
||||
@@ -115,6 +117,7 @@ export interface AdminSettingsPatch {
|
||||
recoveryLimitMax?: number;
|
||||
recoveryLimitWindowMin?: number;
|
||||
voicePreviews?: VoicePreview[];
|
||||
ttsEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface OllamaNode {
|
||||
@@ -264,6 +267,7 @@ export const api = {
|
||||
req<{ user: User; key: string }>("/api/admin/users", { method: "POST", body: JSON.stringify(u) }),
|
||||
adminDelete: (id: string) => req<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }),
|
||||
adminRegenerate: (id: string) => req<{ key: string }>(`/api/admin/users/${id}/regenerate`, { method: "POST" }),
|
||||
adminResetLink: (id: string) => req<{ ok: boolean; email: string }>(`/api/admin/users/${id}/reset-link`, { method: "POST" }),
|
||||
adminShadowban: (id: string, shadowbanned: boolean) =>
|
||||
req<{ shadowbanned: boolean }>(`/api/admin/users/${id}/shadowban`, {
|
||||
method: "POST",
|
||||
@@ -442,6 +446,27 @@ export const api = {
|
||||
|
||||
tools: () => req<{ tools: { name: string; description: string; available: boolean; reason: string | null }[]; reportsEnabled: boolean; usernameChangeEnabled: boolean; voicePreviews: VoicePreview[] }>("/api/tools"),
|
||||
|
||||
/** Neural TTS provider status (null = browser speech fallback). */
|
||||
ttsInfo: () => req<{ provider: "elevenlabs" | "openai" | "piper" | null; voices: { id: string; name: string }[]; available: boolean; reason: string | null }>("/api/tools/tts/info"),
|
||||
|
||||
/** Synthesize text server-side (neural voice). Returns audio bytes. */
|
||||
ttsSpeak: async (text: string, voice?: string): Promise<Blob> => {
|
||||
const res = await fetch("/api/tools/tts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` },
|
||||
body: JSON.stringify({ text, ...(voice ? { voice } : {}) }),
|
||||
});
|
||||
if (res.status === 401) {
|
||||
clearToken();
|
||||
window.location.reload();
|
||||
throw new ApiError(401, "Session expired.");
|
||||
}
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new ApiError(res.status, data.error || `TTS failed (${res.status})`);
|
||||
}
|
||||
return await res.blob();
|
||||
},
|
||||
/** 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 }> => {
|
||||
const res = await fetch("/api/tools/ocr", {
|
||||
|
||||
@@ -160,6 +160,7 @@ const en = {
|
||||
"dlg.shareIntro":
|
||||
"Anyone with this link can read {title}. No login required.",
|
||||
"dlg.unshare": "Unshare",
|
||||
"dlg.unshareBlocked": "Archived chats stay public — unarchive first to remove the link.",
|
||||
"dlg.reviewOcr": "Review transcription",
|
||||
"dlg.reviewText": "Review text attachment",
|
||||
"dlg.transcribing": "Transcribing the image on the server (OCR)…",
|
||||
@@ -224,6 +225,7 @@ const en = {
|
||||
"accent.preset.orange": "Orange",
|
||||
"accent.preset.teal": "Teal",
|
||||
"accent.preset.pink": "Pink",
|
||||
"accent.hexLabel": "Hex color",
|
||||
|
||||
"feat.thinking.label": "Thinking",
|
||||
"feat.thinking.hint":
|
||||
@@ -244,6 +246,15 @@ const en = {
|
||||
"admin.email": "Email (recovery)",
|
||||
"admin.createFailed": "Create failed",
|
||||
"admin.regenFailed": "Regenerate failed",
|
||||
"admin.resetLink": "Email a password reset link",
|
||||
"admin.regenTitle": "Regenerate the key for @{user}?",
|
||||
"admin.regenMsg": "Their sessions will be revoked immediately and the new key is shown only once — make sure you can reach them.",
|
||||
"admin.regenConfirm": "Regenerate",
|
||||
"admin.resetTitle": "Email a reset link to @{user}?",
|
||||
"admin.resetMsg": "A one-time key-recovery link will be sent to {email}.",
|
||||
"admin.resetConfirm": "Send link",
|
||||
"admin.resetSent": "Reset link sent to @{user} ({email}).",
|
||||
"admin.resetFailed": "Could not send the reset link",
|
||||
"admin.deleteFailed": "Delete failed",
|
||||
"admin.loadFailed": "Load failed",
|
||||
"admin.searchPh": "Search accounts…",
|
||||
@@ -605,6 +616,24 @@ const en = {
|
||||
"vfeat.timbre": "Timbre",
|
||||
"vfeat.rate": "Speed",
|
||||
"vfeat.pitch": "Pitch",
|
||||
"vfeat.voice": "Server voice",
|
||||
"vfeat.voiceHint": "ElevenLabs id / OpenAI name, empty = default",
|
||||
"vfeat.voicePh": "auto",
|
||||
"vfeat.noProvider": "No neural provider yet — add ELEVENLABS_API_KEY or OPENAI_API_KEY to activate.",
|
||||
|
||||
"center.ttsTitle": "Neural voice playback",
|
||||
"center.ttsDesc": "Server text-to-speech for message playback (ElevenLabs / OpenAI / piper). Off = browser speech only.",
|
||||
"voice.engine": "Playback engine",
|
||||
"voice.engineAuto": "Auto (neural if available)",
|
||||
"voice.engineBrowser": "Browser speech",
|
||||
"voice.engineServer": "Neural server voice",
|
||||
"voice.engineHintOn": "Neural TTS via {provider}",
|
||||
"voice.engineHintOff": "No neural provider configured — browser speech only",
|
||||
"voice.serverVoice": "Neural voice",
|
||||
"voice.serverVoiceHint": "ElevenLabs voice id",
|
||||
"voice.piperNote": "Local piper voice (single configured model).",
|
||||
"voice.noServer": "Configure ELEVENLABS_API_KEY, OPENAI_API_KEY or piper to unlock neural voices.",
|
||||
|
||||
|
||||
|
||||
"msg.copy": "Copy message",
|
||||
@@ -854,6 +883,7 @@ const es: Dict = {
|
||||
"dlg.shareCreating": "Creando enlace público…",
|
||||
"dlg.shareIntro": "Cualquiera con este enlace puede leer {title}. Sin login.",
|
||||
"dlg.unshare": "Dejar de compartir",
|
||||
"dlg.unshareBlocked": "Archived chats stay public — unarchive first to remove the link.",
|
||||
"dlg.reviewOcr": "Revisar transcripción",
|
||||
"dlg.reviewText": "Revisar adjunto de texto",
|
||||
"dlg.transcribing": "Transcribiendo la imagen en el servidor (OCR)…",
|
||||
@@ -919,6 +949,7 @@ const es: Dict = {
|
||||
"accent.preset.orange": "Naranja",
|
||||
"accent.preset.teal": "Turquesa",
|
||||
"accent.preset.pink": "Rosa fuerte",
|
||||
"accent.hexLabel": "Hex color",
|
||||
|
||||
"feat.thinking.label": "Razonamiento",
|
||||
"feat.thinking.hint":
|
||||
@@ -939,6 +970,15 @@ const es: Dict = {
|
||||
"admin.email": "Email (recuperación)",
|
||||
"admin.createFailed": "No se pudo crear",
|
||||
"admin.regenFailed": "No se pudo regenerar",
|
||||
"admin.resetLink": "Email a password reset link",
|
||||
"admin.regenTitle": "Regenerate the key for @{user}?",
|
||||
"admin.regenMsg": "Their sessions will be revoked immediately and the new key is shown only once — make sure you can reach them.",
|
||||
"admin.regenConfirm": "Regenerate",
|
||||
"admin.resetTitle": "Email a reset link to @{user}?",
|
||||
"admin.resetMsg": "A one-time key-recovery link will be sent to {email}.",
|
||||
"admin.resetConfirm": "Send link",
|
||||
"admin.resetSent": "Reset link sent to @{user} ({email}).",
|
||||
"admin.resetFailed": "Could not send the reset link",
|
||||
"admin.deleteFailed": "No se pudo eliminar",
|
||||
"admin.loadFailed": "No se pudo cargar",
|
||||
"admin.searchPh": "Buscar cuentas…",
|
||||
@@ -1297,6 +1337,24 @@ const es: Dict = {
|
||||
"vfeat.timbre": "Timbre",
|
||||
"vfeat.rate": "Speed",
|
||||
"vfeat.pitch": "Pitch",
|
||||
"vfeat.voice": "Server voice",
|
||||
"vfeat.voiceHint": "ElevenLabs id / OpenAI name, empty = default",
|
||||
"vfeat.voicePh": "auto",
|
||||
"vfeat.noProvider": "No neural provider yet — add ELEVENLABS_API_KEY or OPENAI_API_KEY to activate.",
|
||||
|
||||
"center.ttsTitle": "Neural voice playback",
|
||||
"center.ttsDesc": "Server text-to-speech for message playback (ElevenLabs / OpenAI / piper). Off = browser speech only.",
|
||||
"voice.engine": "Playback engine",
|
||||
"voice.engineAuto": "Auto (neural if available)",
|
||||
"voice.engineBrowser": "Browser speech",
|
||||
"voice.engineServer": "Neural server voice",
|
||||
"voice.engineHintOn": "Neural TTS via {provider}",
|
||||
"voice.engineHintOff": "No neural provider configured — browser speech only",
|
||||
"voice.serverVoice": "Neural voice",
|
||||
"voice.serverVoiceHint": "ElevenLabs voice id",
|
||||
"voice.piperNote": "Local piper voice (single configured model).",
|
||||
"voice.noServer": "Configure ELEVENLABS_API_KEY, OPENAI_API_KEY or piper to unlock neural voices.",
|
||||
|
||||
|
||||
|
||||
"msg.copy": "Copiar mensaje",
|
||||
@@ -1544,6 +1602,7 @@ const fr: Dict = {
|
||||
"dlg.shareCreating": "Création du lien public…",
|
||||
"dlg.shareIntro": "Quiconque a ce lien peut lire {title}. Sans connexion.",
|
||||
"dlg.unshare": "Ne plus partager",
|
||||
"dlg.unshareBlocked": "Archived chats stay public — unarchive first to remove the link.",
|
||||
"dlg.reviewOcr": "Relire la transcription",
|
||||
"dlg.reviewText": "Relire la pièce jointe",
|
||||
"dlg.transcribing": "Transcription de l'image sur le serveur (OCR)…",
|
||||
@@ -1607,6 +1666,7 @@ const fr: Dict = {
|
||||
"accent.preset.orange": "Orange",
|
||||
"accent.preset.teal": "Sarcelle",
|
||||
"accent.preset.pink": "Rose vif",
|
||||
"accent.hexLabel": "Hex color",
|
||||
|
||||
"feat.thinking.label": "Réflexion",
|
||||
"feat.thinking.hint": "Demande au modèle de raisonner étape par étape.",
|
||||
@@ -1626,6 +1686,15 @@ const fr: Dict = {
|
||||
"admin.email": "Email (récupération)",
|
||||
"admin.createFailed": "Création impossible",
|
||||
"admin.regenFailed": "Régénération impossible",
|
||||
"admin.resetLink": "Email a password reset link",
|
||||
"admin.regenTitle": "Regenerate the key for @{user}?",
|
||||
"admin.regenMsg": "Their sessions will be revoked immediately and the new key is shown only once — make sure you can reach them.",
|
||||
"admin.regenConfirm": "Regenerate",
|
||||
"admin.resetTitle": "Email a reset link to @{user}?",
|
||||
"admin.resetMsg": "A one-time key-recovery link will be sent to {email}.",
|
||||
"admin.resetConfirm": "Send link",
|
||||
"admin.resetSent": "Reset link sent to @{user} ({email}).",
|
||||
"admin.resetFailed": "Could not send the reset link",
|
||||
"admin.deleteFailed": "Suppression impossible",
|
||||
"admin.loadFailed": "Chargement impossible",
|
||||
"admin.searchPh": "Rechercher des comptes…",
|
||||
@@ -1984,6 +2053,24 @@ const fr: Dict = {
|
||||
"vfeat.timbre": "Timbre",
|
||||
"vfeat.rate": "Speed",
|
||||
"vfeat.pitch": "Pitch",
|
||||
"vfeat.voice": "Server voice",
|
||||
"vfeat.voiceHint": "ElevenLabs id / OpenAI name, empty = default",
|
||||
"vfeat.voicePh": "auto",
|
||||
"vfeat.noProvider": "No neural provider yet — add ELEVENLABS_API_KEY or OPENAI_API_KEY to activate.",
|
||||
|
||||
"center.ttsTitle": "Neural voice playback",
|
||||
"center.ttsDesc": "Server text-to-speech for message playback (ElevenLabs / OpenAI / piper). Off = browser speech only.",
|
||||
"voice.engine": "Playback engine",
|
||||
"voice.engineAuto": "Auto (neural if available)",
|
||||
"voice.engineBrowser": "Browser speech",
|
||||
"voice.engineServer": "Neural server voice",
|
||||
"voice.engineHintOn": "Neural TTS via {provider}",
|
||||
"voice.engineHintOff": "No neural provider configured — browser speech only",
|
||||
"voice.serverVoice": "Neural voice",
|
||||
"voice.serverVoiceHint": "ElevenLabs voice id",
|
||||
"voice.piperNote": "Local piper voice (single configured model).",
|
||||
"voice.noServer": "Configure ELEVENLABS_API_KEY, OPENAI_API_KEY or piper to unlock neural voices.",
|
||||
|
||||
|
||||
|
||||
"msg.copy": "Copier le message",
|
||||
@@ -2228,6 +2315,7 @@ const ru: Dict = {
|
||||
"dlg.shareCreating": "Создаём публичную ссылку…",
|
||||
"dlg.shareIntro": "Каждый с этой ссылкой прочитает {title}. Вход не нужен.",
|
||||
"dlg.unshare": "Закрыть доступ",
|
||||
"dlg.unshareBlocked": "Archived chats stay public — unarchive first to remove the link.",
|
||||
"dlg.reviewOcr": "Проверить распознавание",
|
||||
"dlg.reviewText": "Проверить вложение",
|
||||
"dlg.transcribing": "Распознаём изображение на сервере (OCR)…",
|
||||
@@ -2290,6 +2378,7 @@ const ru: Dict = {
|
||||
"accent.preset.orange": "Оранжевый",
|
||||
"accent.preset.teal": "Бирюза",
|
||||
"accent.preset.pink": "Розовый",
|
||||
"accent.hexLabel": "Hex color",
|
||||
|
||||
"feat.thinking.label": "Размышление",
|
||||
"feat.thinking.hint": "Просить модель рассуждать шаг за шагом.",
|
||||
@@ -2307,6 +2396,15 @@ const ru: Dict = {
|
||||
"admin.email": "Email (восстановление)",
|
||||
"admin.createFailed": "Не создалось",
|
||||
"admin.regenFailed": "Не сменилось",
|
||||
"admin.resetLink": "Email a password reset link",
|
||||
"admin.regenTitle": "Regenerate the key for @{user}?",
|
||||
"admin.regenMsg": "Their sessions will be revoked immediately and the new key is shown only once — make sure you can reach them.",
|
||||
"admin.regenConfirm": "Regenerate",
|
||||
"admin.resetTitle": "Email a reset link to @{user}?",
|
||||
"admin.resetMsg": "A one-time key-recovery link will be sent to {email}.",
|
||||
"admin.resetConfirm": "Send link",
|
||||
"admin.resetSent": "Reset link sent to @{user} ({email}).",
|
||||
"admin.resetFailed": "Could not send the reset link",
|
||||
"admin.deleteFailed": "Не удалилось",
|
||||
"admin.loadFailed": "Не загрузилось",
|
||||
"admin.searchPh": "Поиск аккаунтов…",
|
||||
@@ -2659,6 +2757,24 @@ const ru: Dict = {
|
||||
"vfeat.timbre": "Timbre",
|
||||
"vfeat.rate": "Speed",
|
||||
"vfeat.pitch": "Pitch",
|
||||
"vfeat.voice": "Server voice",
|
||||
"vfeat.voiceHint": "ElevenLabs id / OpenAI name, empty = default",
|
||||
"vfeat.voicePh": "auto",
|
||||
"vfeat.noProvider": "No neural provider yet — add ELEVENLABS_API_KEY or OPENAI_API_KEY to activate.",
|
||||
|
||||
"center.ttsTitle": "Neural voice playback",
|
||||
"center.ttsDesc": "Server text-to-speech for message playback (ElevenLabs / OpenAI / piper). Off = browser speech only.",
|
||||
"voice.engine": "Playback engine",
|
||||
"voice.engineAuto": "Auto (neural if available)",
|
||||
"voice.engineBrowser": "Browser speech",
|
||||
"voice.engineServer": "Neural server voice",
|
||||
"voice.engineHintOn": "Neural TTS via {provider}",
|
||||
"voice.engineHintOff": "No neural provider configured — browser speech only",
|
||||
"voice.serverVoice": "Neural voice",
|
||||
"voice.serverVoiceHint": "ElevenLabs voice id",
|
||||
"voice.piperNote": "Local piper voice (single configured model).",
|
||||
"voice.noServer": "Configure ELEVENLABS_API_KEY, OPENAI_API_KEY or piper to unlock neural voices.",
|
||||
|
||||
|
||||
|
||||
"msg.copy": "Скопировать сообщение",
|
||||
@@ -2903,6 +3019,7 @@ const it: Dict = {
|
||||
"dlg.shareIntro":
|
||||
"Chiunque abbia questo link può leggere {title}. Senza login.",
|
||||
"dlg.unshare": "Rimuovi condivisione",
|
||||
"dlg.unshareBlocked": "Archived chats stay public — unarchive first to remove the link.",
|
||||
"dlg.reviewOcr": "Rivedi trascrizione",
|
||||
"dlg.reviewText": "Rivedi allegato",
|
||||
"dlg.transcribing": "Trascrizione immagine sul server (OCR)…",
|
||||
@@ -2965,6 +3082,7 @@ const it: Dict = {
|
||||
"accent.preset.orange": "Arancione",
|
||||
"accent.preset.teal": "Teal",
|
||||
"accent.preset.pink": "Rosa shocking",
|
||||
"accent.hexLabel": "Hex color",
|
||||
|
||||
"feat.thinking.label": "Ragionamento",
|
||||
"feat.thinking.hint": "Chiedi al modello di ragionare passo passo.",
|
||||
@@ -2983,6 +3101,15 @@ const it: Dict = {
|
||||
"admin.email": "Email (recupero)",
|
||||
"admin.createFailed": "Creazione fallita",
|
||||
"admin.regenFailed": "Rigenerazione fallita",
|
||||
"admin.resetLink": "Email a password reset link",
|
||||
"admin.regenTitle": "Regenerate the key for @{user}?",
|
||||
"admin.regenMsg": "Their sessions will be revoked immediately and the new key is shown only once — make sure you can reach them.",
|
||||
"admin.regenConfirm": "Regenerate",
|
||||
"admin.resetTitle": "Email a reset link to @{user}?",
|
||||
"admin.resetMsg": "A one-time key-recovery link will be sent to {email}.",
|
||||
"admin.resetConfirm": "Send link",
|
||||
"admin.resetSent": "Reset link sent to @{user} ({email}).",
|
||||
"admin.resetFailed": "Could not send the reset link",
|
||||
"admin.deleteFailed": "Eliminazione fallita",
|
||||
"admin.loadFailed": "Caricamento fallito",
|
||||
"admin.searchPh": "Cerca account…",
|
||||
@@ -3339,6 +3466,24 @@ const it: Dict = {
|
||||
"vfeat.timbre": "Timbre",
|
||||
"vfeat.rate": "Speed",
|
||||
"vfeat.pitch": "Pitch",
|
||||
"vfeat.voice": "Server voice",
|
||||
"vfeat.voiceHint": "ElevenLabs id / OpenAI name, empty = default",
|
||||
"vfeat.voicePh": "auto",
|
||||
"vfeat.noProvider": "No neural provider yet — add ELEVENLABS_API_KEY or OPENAI_API_KEY to activate.",
|
||||
|
||||
"center.ttsTitle": "Neural voice playback",
|
||||
"center.ttsDesc": "Server text-to-speech for message playback (ElevenLabs / OpenAI / piper). Off = browser speech only.",
|
||||
"voice.engine": "Playback engine",
|
||||
"voice.engineAuto": "Auto (neural if available)",
|
||||
"voice.engineBrowser": "Browser speech",
|
||||
"voice.engineServer": "Neural server voice",
|
||||
"voice.engineHintOn": "Neural TTS via {provider}",
|
||||
"voice.engineHintOff": "No neural provider configured — browser speech only",
|
||||
"voice.serverVoice": "Neural voice",
|
||||
"voice.serverVoiceHint": "ElevenLabs voice id",
|
||||
"voice.piperNote": "Local piper voice (single configured model).",
|
||||
"voice.noServer": "Configure ELEVENLABS_API_KEY, OPENAI_API_KEY or piper to unlock neural voices.",
|
||||
|
||||
|
||||
|
||||
"msg.copy": "Copia messaggio",
|
||||
@@ -3582,6 +3727,7 @@ const pt: Dict = {
|
||||
"dlg.shareCreating": "A criar link público…",
|
||||
"dlg.shareIntro": "Quem tiver este link pode ler {title}. Sem login.",
|
||||
"dlg.unshare": "Remover partilha",
|
||||
"dlg.unshareBlocked": "Archived chats stay public — unarchive first to remove the link.",
|
||||
"dlg.reviewOcr": "Rever transcrição",
|
||||
"dlg.reviewText": "Rever anexo",
|
||||
"dlg.transcribing": "A transcrever imagem no servidor (OCR)…",
|
||||
@@ -3643,6 +3789,7 @@ const pt: Dict = {
|
||||
"accent.preset.orange": "Laranja",
|
||||
"accent.preset.teal": "Turquesa",
|
||||
"accent.preset.pink": "Rosa choque",
|
||||
"accent.hexLabel": "Hex color",
|
||||
|
||||
"feat.thinking.label": "Raciocínio",
|
||||
"feat.thinking.hint": "Pede ao modelo para raciocinar passo a passo.",
|
||||
@@ -3660,6 +3807,15 @@ const pt: Dict = {
|
||||
"admin.email": "Email (recuperação)",
|
||||
"admin.createFailed": "Falha ao criar",
|
||||
"admin.regenFailed": "Falha ao regenerar",
|
||||
"admin.resetLink": "Email a password reset link",
|
||||
"admin.regenTitle": "Regenerate the key for @{user}?",
|
||||
"admin.regenMsg": "Their sessions will be revoked immediately and the new key is shown only once — make sure you can reach them.",
|
||||
"admin.regenConfirm": "Regenerate",
|
||||
"admin.resetTitle": "Email a reset link to @{user}?",
|
||||
"admin.resetMsg": "A one-time key-recovery link will be sent to {email}.",
|
||||
"admin.resetConfirm": "Send link",
|
||||
"admin.resetSent": "Reset link sent to @{user} ({email}).",
|
||||
"admin.resetFailed": "Could not send the reset link",
|
||||
"admin.deleteFailed": "Falha ao eliminar",
|
||||
"admin.loadFailed": "Falha ao carregar",
|
||||
"admin.searchPh": "Pesquisar contas…",
|
||||
@@ -4010,6 +4166,24 @@ const pt: Dict = {
|
||||
"vfeat.timbre": "Timbre",
|
||||
"vfeat.rate": "Speed",
|
||||
"vfeat.pitch": "Pitch",
|
||||
"vfeat.voice": "Server voice",
|
||||
"vfeat.voiceHint": "ElevenLabs id / OpenAI name, empty = default",
|
||||
"vfeat.voicePh": "auto",
|
||||
"vfeat.noProvider": "No neural provider yet — add ELEVENLABS_API_KEY or OPENAI_API_KEY to activate.",
|
||||
|
||||
"center.ttsTitle": "Neural voice playback",
|
||||
"center.ttsDesc": "Server text-to-speech for message playback (ElevenLabs / OpenAI / piper). Off = browser speech only.",
|
||||
"voice.engine": "Playback engine",
|
||||
"voice.engineAuto": "Auto (neural if available)",
|
||||
"voice.engineBrowser": "Browser speech",
|
||||
"voice.engineServer": "Neural server voice",
|
||||
"voice.engineHintOn": "Neural TTS via {provider}",
|
||||
"voice.engineHintOff": "No neural provider configured — browser speech only",
|
||||
"voice.serverVoice": "Neural voice",
|
||||
"voice.serverVoiceHint": "ElevenLabs voice id",
|
||||
"voice.piperNote": "Local piper voice (single configured model).",
|
||||
"voice.noServer": "Configure ELEVENLABS_API_KEY, OPENAI_API_KEY or piper to unlock neural voices.",
|
||||
|
||||
|
||||
|
||||
"msg.copy": "Copiar mensagem",
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface Conversation {
|
||||
export interface ChatMessage {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string;
|
||||
/** Fully-qualified model id that produced this message (assistant only, "" = unknown). */
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface DriverModel {
|
||||
|
||||
+198
-37
@@ -15,21 +15,28 @@ export interface VoicePrefs {
|
||||
pitch: number;
|
||||
/** "auto" detects per message, otherwise forces a BCP-47 prefix. */
|
||||
lang: string;
|
||||
/** Playback engine: neural server TTS when available, else browser. */
|
||||
engine: "auto" | "browser" | "server";
|
||||
/** Server neural voice id (ElevenLabs voice_id / OpenAI voice name). */
|
||||
serverVoice: string | null;
|
||||
}
|
||||
|
||||
const PREFS_KEY = "ka:voice";
|
||||
|
||||
export const DEFAULT_VOICE_PREFS: VoicePrefs = { voiceURI: null, rate: 1, pitch: 1, lang: "auto" };
|
||||
export const DEFAULT_VOICE_PREFS: VoicePrefs = { voiceURI: null, rate: 1, pitch: 1, lang: "auto", engine: "auto", serverVoice: null };
|
||||
|
||||
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 };
|
||||
const engine = raw.engine === "browser" || raw.engine === "server" ? raw.engine : "auto";
|
||||
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",
|
||||
engine,
|
||||
serverVoice: typeof raw.serverVoice === "string" && raw.serverVoice.length > 0 ? raw.serverVoice : null,
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_VOICE_PREFS };
|
||||
@@ -44,8 +51,42 @@ export function setVoicePrefs(p: VoicePrefs): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Naive per-message language guess (accents + stop-words). */
|
||||
export function detectLang(text: string): string {
|
||||
/** Max chars sent to any TTS engine (server enforces the same cap). */
|
||||
export const SPEECH_MAX_CHARS = 2000;
|
||||
|
||||
/**
|
||||
* Markdown → clean speakable text. Code blocks become a pause (reading
|
||||
* code aloud is noise), formatting/links/URLs are stripped to their
|
||||
* readable part, paragraphs become pauses. Over-long texts are cut at a
|
||||
* sentence boundary so playback never stops mid-word.
|
||||
*/
|
||||
export function toSpeechText(md: string): string {
|
||||
let s = md;
|
||||
s = s.replace(/```[\s\S]*?```/g, ". ");
|
||||
s = s.replace(/`([^`]+)`/g, "$1");
|
||||
s = s.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1");
|
||||
s = s.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
|
||||
s = s.replace(/^#{1,6}\s+/gm, "");
|
||||
s = s.replace(/(\*\*|__)(.*?)\1/g, "$2");
|
||||
s = s.replace(/(^|[\s(])[*_]([^*_\n]+)[*_]/g, "$1$2");
|
||||
s = s.replace(/^>\s?/gm, "");
|
||||
s = s.replace(/^\s*(?:[-*+]|\d+[.)])\s+/gm, "");
|
||||
s = s.replace(/^(\*\*\*|---|___)\s*$/gm, "");
|
||||
s = s.replace(/<[^>]+>/g, " ");
|
||||
s = s.replace(/https?:\/\/[^\s)]+/g, " ");
|
||||
s = s.replace(/[ \t]+/g, " ");
|
||||
s = s.replace(/\n{2,}/g, ". ");
|
||||
s = s.replace(/\n/g, " ");
|
||||
s = s.replace(/\s{2,}/g, " ").trim();
|
||||
if (s.length > SPEECH_MAX_CHARS) {
|
||||
const cut = s.slice(0, SPEECH_MAX_CHARS);
|
||||
const m = cut.match(/.*[.!?…]["»\s]/);
|
||||
s = (m ? m[0] : cut).trim();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 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 "];
|
||||
@@ -83,12 +124,8 @@ export interface SpeakOpts {
|
||||
|
||||
export type VoiceTimbre = "masculine" | "feminine" | "any";
|
||||
|
||||
export interface PreviewVoice {
|
||||
lang: string;
|
||||
rate: number;
|
||||
pitch: number;
|
||||
timbre: VoiceTimbre;
|
||||
}
|
||||
/** Stock OpenAI voices (static catalog — selectable even before any key). */
|
||||
export const OPENAI_STATIC_VOICES = ["alloy", "echo", "fable", "onyx", "nova", "shimmer"];
|
||||
|
||||
// Name fragments hinting a masculine / feminine voice (Chrome, Edge,
|
||||
// Safari and Android voices carry such markers; absence falls back to
|
||||
@@ -158,43 +195,167 @@ 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;
|
||||
// --- unified playback (neural server audio with browser fallback) ---
|
||||
|
||||
export interface PlayHandle {
|
||||
/** Engine that ended up playing (may differ from the request in auto). */
|
||||
engine: "server" | "browser";
|
||||
stop: () => void;
|
||||
done: Promise<void>;
|
||||
}
|
||||
|
||||
let currentAudio: HTMLAudioElement | null = null;
|
||||
|
||||
function stopCurrentAudio(): void {
|
||||
if (currentAudio) {
|
||||
try {
|
||||
synth.cancel();
|
||||
currentAudio.pause();
|
||||
const url = currentAudio.src;
|
||||
currentAudio.removeAttribute("src");
|
||||
currentAudio.load();
|
||||
if (url.startsWith("blob:")) URL.revokeObjectURL(url);
|
||||
} 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");
|
||||
currentAudio = null;
|
||||
}
|
||||
try {
|
||||
synth.speak(utter);
|
||||
window.speechSynthesis?.cancel();
|
||||
} catch {
|
||||
return null;
|
||||
// ignore
|
||||
}
|
||||
return pick;
|
||||
}
|
||||
|
||||
// --- speech recognition (STT) ---
|
||||
/** Stop whatever is playing (server audio or browser speech). */
|
||||
export function stopPlayback(): void {
|
||||
stopCurrentAudio();
|
||||
}
|
||||
|
||||
export interface PlayRequest {
|
||||
text: string;
|
||||
/** "server" forces neural, "browser" forces synthesis, "auto" tries neural first. */
|
||||
engine: "auto" | "browser" | "server";
|
||||
browser: {
|
||||
voices: SpeechSynthesisVoice[];
|
||||
/** "auto" detects per text, otherwise a BCP-47 prefix. */
|
||||
lang: string;
|
||||
rate: number;
|
||||
pitch: number;
|
||||
/** Explicit voice URI (user pref); otherwise timbre-matched. */
|
||||
voiceURI?: string | null;
|
||||
/** Preview character timbre (ignored when voiceURI is set). */
|
||||
timbre?: VoiceTimbre;
|
||||
};
|
||||
/** Server neural voice id (preview line voice or user serverVoice). */
|
||||
serverVoice?: string | null;
|
||||
/** True when the platform TTS tool is available. */
|
||||
serverAvailable: boolean;
|
||||
fetchAudio: (text: string, voice?: string) => Promise<Blob>;
|
||||
onstart?: () => void;
|
||||
onend?: () => void;
|
||||
onerror?: (msg: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Play one text with the best engine: neural server audio when requested
|
||||
* and available (shared <audio> element, user speed), otherwise the
|
||||
* browser character voice. Single global playback — a new play stops the
|
||||
* previous one. `done` resolves when playback fully ends (fallback
|
||||
* included); it never rejects (errors go to onend/onerror like speech).
|
||||
*/
|
||||
export function playText(req: PlayRequest): PlayHandle {
|
||||
stopPlayback();
|
||||
// Never read markdown cruft aloud; fall back to raw text when cleaning
|
||||
// leaves nothing (e.g. a code-only message).
|
||||
const clean = toSpeechText(req.text) || req.text.slice(0, SPEECH_MAX_CHARS);
|
||||
const cleanReq: PlayRequest = { ...req, text: clean };
|
||||
const wantServer = cleanReq.serverAvailable && cleanReq.engine !== "browser";
|
||||
if (wantServer) return playServer(cleanReq, cleanReq.engine === "auto");
|
||||
return playBrowser(cleanReq);
|
||||
}
|
||||
|
||||
function playServer(req: PlayRequest, allowFallback: boolean): PlayHandle {
|
||||
let settled = false;
|
||||
const stop = () => {
|
||||
settled = true;
|
||||
stopCurrentAudio();
|
||||
};
|
||||
const done = (async () => {
|
||||
try {
|
||||
const blob = await req.fetchAudio(req.text.slice(0, 2000), req.serverVoice ?? undefined);
|
||||
if (settled) return;
|
||||
await playAudioBlob(blob, req, () => settled);
|
||||
} catch (e) {
|
||||
if (settled) return;
|
||||
if (allowFallback) {
|
||||
await playBrowserAudio(req, () => settled);
|
||||
return;
|
||||
}
|
||||
req.onerror?.(e instanceof Error ? e.message : "playback failed");
|
||||
}
|
||||
})();
|
||||
return { engine: "server", stop, done };
|
||||
}
|
||||
|
||||
function playAudioBlob(blob: Blob, req: PlayRequest, cancelled: () => boolean): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const audio = new Audio(url);
|
||||
audio.playbackRate = req.browser.rate;
|
||||
currentAudio = audio;
|
||||
const finish = (ok: boolean) => {
|
||||
if (currentAudio === audio) stopCurrentAudio();
|
||||
else {
|
||||
try {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (!cancelled() && ok) req.onend?.();
|
||||
resolve();
|
||||
};
|
||||
audio.onended = () => finish(true);
|
||||
audio.onerror = () => finish(false);
|
||||
req.onstart?.();
|
||||
audio.play().catch(() => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
function playBrowser(req: PlayRequest): PlayHandle {
|
||||
let settled = false;
|
||||
const stop = () => {
|
||||
settled = true;
|
||||
stopSpeak();
|
||||
};
|
||||
const done = playBrowserAudio(req, () => settled);
|
||||
return { engine: "browser", stop, done };
|
||||
}
|
||||
|
||||
function playBrowserAudio(req: PlayRequest, cancelled: () => boolean): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const b = req.browser;
|
||||
const lang = b.lang === "auto" ? detectLang(req.text) : b.lang;
|
||||
const explicit = b.voiceURI ? b.voices.find((v) => v.voiceURI === b.voiceURI) : undefined;
|
||||
const pick = explicit ?? pickVoice(b.voices, { lang, timbre: b.timbre ?? "any" });
|
||||
const finish = (ok: boolean, msg?: string) => {
|
||||
if (cancelled()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (ok) req.onend?.();
|
||||
else req.onerror?.(msg ?? "error");
|
||||
resolve();
|
||||
};
|
||||
const ok = speak(
|
||||
req.text,
|
||||
{ voiceURI: pick?.voiceURI ?? null, rate: b.rate, pitch: b.pitch, lang, engine: "browser", serverVoice: null },
|
||||
b.voices,
|
||||
{ onstart: () => req.onstart?.(), onend: () => finish(true), onerror: (m) => finish(false, m) },
|
||||
);
|
||||
if (!ok) finish(false, "unsupported");
|
||||
});
|
||||
}
|
||||
|
||||
export interface Recognizer {
|
||||
lang: string;
|
||||
|
||||
@@ -40,3 +40,24 @@ Le modèle doit détecter la langue par le texte/etc. l'user peut customiser l'a
|
||||
|
||||
Ces exemples sont les default voix preview . dans l'admin center , l'utilisateur peut les modifier /revenir à défaut
|
||||
L'utilisateur doit pouvoir configurée ces ai,vocaux,voix,langue dans une sous-section dédier dans ces paramètres.
|
||||
|
||||
--- actually the favicon have an background svg as the same color of the accenthique theme. should be indepandant .
|
||||
white = day theme
|
||||
black / night theme
|
||||
witht the flower itself following thr accentic color
|
||||
|
||||
|
||||
-- in specify
|
||||
specify the model used in each footer of assistant message
|
||||
like <model> <button>
|
||||
|
||||
|
||||
---- pouvoir dans admin centers, sur users, envoyez un lien mdp reset à un user
|
||||
|
||||
|
||||
--- <input type="color" aria-label="Custom accent color" class="absolute inset-0 cursor-pointer opacity-0" value="#10b981">
|
||||
|
||||
Ajouter un vrai composant pour pouvoir chosisir la couleur accentique sans dépendre du système.
|
||||
Utiliser un vrai copiosant come shadcdn.
|
||||
|
||||
Avec un vrai accent color picket etpouvoir mettre sontpropr e code hex pour la couleur.
|
||||
|
||||
@@ -80,6 +80,14 @@ export const config = {
|
||||
ocrLang: env("OCR_LANG", "eng"),
|
||||
ocrMaxChars: envInt("OCR_MAX_CHARS", 100_000),
|
||||
|
||||
// Neural TTS tool (ElevenLabs / OpenAI / local piper, first configured
|
||||
// wins unless TTS_PROVIDER forces one). Without any of these the tool
|
||||
// reports unavailable and clients fall back to browser speech.
|
||||
elevenlabsApiKey: env("ELEVENLABS_API_KEY", ""),
|
||||
elevenlabsVoice: env("ELEVENLABS_VOICE", ""),
|
||||
piperModel: env("PIPER_MODEL", ""),
|
||||
ttsVoice: env("TTS_VOICE", "alloy"),
|
||||
|
||||
// Key recovery via email (optional — empty SMTP_HOST disables it).
|
||||
smtpHost: env("SMTP_HOST", ""),
|
||||
smtpPort: envInt("SMTP_PORT", 587),
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface MessageRow {
|
||||
conv_id: string;
|
||||
role: string;
|
||||
content: string;
|
||||
/** Fully-qualified model id that produced this message ("" = unknown/pre-migration). */
|
||||
model: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
@@ -236,10 +238,10 @@ export async function deleteConversation(id: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function addMessage(convId: string, role: string, content: string): Promise<void> {
|
||||
export async function addMessage(convId: string, role: string, content: string, model = ""): Promise<void> {
|
||||
await db().run(
|
||||
"INSERT INTO messages (conv_id, role, content, created_at) VALUES (?, ?, ?, ?)",
|
||||
convId, role, content, Date.now(),
|
||||
"INSERT INTO messages (conv_id, role, content, model, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
convId, role, content, model.slice(0, 160), Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -311,6 +313,11 @@ export async function isOcrToolEnabled(): Promise<boolean> {
|
||||
return (await getSetting("tools_ocr_enabled", "1")) === "1";
|
||||
}
|
||||
|
||||
/** Admin kill-switch for the neural TTS platform tool (default on). */
|
||||
export async function isTtsToolEnabled(): Promise<boolean> {
|
||||
return (await getSetting("tools_tts_enabled", "1")) === "1";
|
||||
}
|
||||
|
||||
/** Admin kill-switch for message reports / flag button (default on). */
|
||||
export async function isReportsEnabled(): Promise<boolean> {
|
||||
return (await getSetting("reports_enabled", "1")) === "1";
|
||||
|
||||
@@ -397,6 +397,7 @@ const LATER_COLUMNS = [
|
||||
"ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE users ADD COLUMN report_shadowbanned INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE conversations ADD COLUMN archived_at INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE messages ADD COLUMN model TEXT NOT NULL DEFAULT ''",
|
||||
];
|
||||
|
||||
/** Split a multi-statement DDL string into single statements. */
|
||||
@@ -430,6 +431,10 @@ async function initSqliteSchema(c: DbClient): Promise<void> {
|
||||
if (!convCols.some((col) => col.name === "archived_at")) {
|
||||
await c.exec("ALTER TABLE conversations ADD COLUMN archived_at INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
const msgCols = await c.all<{ name: string }>("PRAGMA table_info(messages)");
|
||||
if (!msgCols.some((col) => col.name === "model")) {
|
||||
await c.exec("ALTER TABLE messages ADD COLUMN model TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
await ensureAdminRow(c);
|
||||
}
|
||||
|
||||
|
||||
+95
-12
@@ -143,6 +143,7 @@ import {
|
||||
writeCdnFile,
|
||||
} from "./cdn.ts";
|
||||
import { OCR_MAX_BYTES, ocrAllowed, recordOcrJob } from "./tools/ocr.ts";
|
||||
import { TTS_MAX_CHARS, recordTtsJob, ttsAllowed } from "./tools/tts.ts";
|
||||
import { tools } from "./tools/registry.ts";
|
||||
import { initOllamaNodes } from "./ollamaNodes.ts";
|
||||
|
||||
@@ -236,13 +237,15 @@ export interface VoicePreview {
|
||||
rate: number;
|
||||
pitch: number;
|
||||
timbre: "masculine" | "feminine" | "any";
|
||||
/** Server neural voice id (ElevenLabs voice_id / OpenAI voice name). Empty = provider default. */
|
||||
voice: string;
|
||||
}
|
||||
|
||||
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" },
|
||||
{ text: "Salut, euhm, comment je peux t'aider aujourd'hui ?", lang: "fr", rate: 0.85, pitch: 0.75, timbre: "masculine", voice: "" },
|
||||
{ text: "5 sur 5 je recois ! Que veux-tu ?", lang: "fr", rate: 1, pitch: 0.8, timbre: "masculine", voice: "" },
|
||||
{ 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", voice: "" },
|
||||
{ text: "Hey, que puis-je faire pour toi ?", lang: "fr", rate: 1, pitch: 1.1, timbre: "feminine", voice: "" },
|
||||
];
|
||||
|
||||
function sanitizePreviewLine(v: unknown): VoicePreview | null {
|
||||
@@ -250,7 +253,7 @@ function sanitizePreviewLine(v: unknown): VoicePreview | null {
|
||||
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" };
|
||||
return { text, lang: "auto", rate: 1, pitch: 1, timbre: "any", voice: "" };
|
||||
}
|
||||
if (!v || typeof v !== "object") return null;
|
||||
const o = v as Record<string, unknown>;
|
||||
@@ -261,7 +264,8 @@ function sanitizePreviewLine(v: unknown): VoicePreview | null {
|
||||
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 };
|
||||
const voice = typeof o.voice === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(o.voice.trim()) ? o.voice.trim() : "";
|
||||
return { text, lang, rate, pitch, timbre, voice };
|
||||
}
|
||||
|
||||
async function getVoicePreviews(): Promise<VoicePreview[]> {
|
||||
@@ -283,6 +287,28 @@ function sanitizeVoicePreviews(v: unknown): VoicePreview[] | null {
|
||||
return clean.length > 0 ? clean.slice(0, 6) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate every preview line's audio ONCE (background, best-effort) so
|
||||
* serving users always hits the disk cache — no synthesis latency on
|
||||
* playback, no repeated provider cost. Runs at boot and after each
|
||||
* preview save; failures are swallowed (first playback synthesizes).
|
||||
*/
|
||||
async function pregenerateVoicePreviews(): Promise<void> {
|
||||
try {
|
||||
const tts = tools.tts;
|
||||
if (!await tts.isAvailable()) return;
|
||||
for (const line of await getVoicePreviews()) {
|
||||
try {
|
||||
await tts.synthesize(line.text, line.voice || undefined);
|
||||
} catch {
|
||||
// one line must not block the others
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// pregen must never break boot or saves
|
||||
}
|
||||
}
|
||||
|
||||
function clientIp(req: Request, server: { requestIP?: (r: Request) => { address: string } | null }): string {
|
||||
try {
|
||||
const info = server.requestIP?.(req);
|
||||
@@ -427,7 +453,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
authorAvatarUrl: s.authorAvatarUrl,
|
||||
archived: s.archived,
|
||||
sharedAt: s.sharedAt,
|
||||
messages: s.messages.map((m) => ({ role: m.role, content: m.content })),
|
||||
messages: s.messages.map((m) => ({ role: m.role, content: m.content, model: m.model || undefined })),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -954,7 +980,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
return json({ user: toPublicUser(created), key }, 201);
|
||||
}
|
||||
|
||||
const adminUserMatch = path.match(/^\/api\/admin\/users\/([0-9a-f-]{10,50})(\/regenerate|\/shadowban)?$/);
|
||||
const adminUserMatch = path.match(/^\/api\/admin\/users\/([0-9a-f-]{10,50})(\/regenerate|\/shadowban|\/reset-link)?$/);
|
||||
if (adminUserMatch) {
|
||||
if (!admin) return json({ error: "forbidden" }, 403);
|
||||
const target = await getUserById(adminUserMatch[1]!);
|
||||
@@ -976,6 +1002,16 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
}
|
||||
return json({ shadowbanned: (await setReportShadowbanned(target.id, body.shadowbanned)) === 1 });
|
||||
}
|
||||
// Admin-initiated key recovery: email a one-time reset link to the
|
||||
// user (same flow as self-service /api/auth/recover, but targeted).
|
||||
if (req.method === "POST" && adminUserMatch[2] === "/reset-link") {
|
||||
if (!target.email) return json({ error: "user has no email address — key recovery needs one" }, 409);
|
||||
if (!mailEnabled()) return json({ error: "email is not configured on this platform" }, 501);
|
||||
const token = "rt_" + randomBytes(24).toString("base64url");
|
||||
await createReset(createHash("sha256").update(token).digest("hex"), target.id, Date.now() + config.resetTtlMs);
|
||||
sendRecoveryEmail(target.email, target.username, `${config.appUrl}/reset/${token}`).catch(() => {});
|
||||
return json({ ok: true, email: target.email });
|
||||
}
|
||||
if (req.method === "PATCH" && !adminUserMatch[2]) {
|
||||
const { ok, body } = await readJson(req);
|
||||
if (!ok) return json({ error: "invalid JSON" }, 400);
|
||||
@@ -1031,6 +1067,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
recoveryLimitMax: await recoveryLimitMax(),
|
||||
recoveryLimitWindowMin: Math.round(await recoveryLimitWindowMs() / 60_000),
|
||||
voicePreviews: await getVoicePreviews(),
|
||||
ttsEnabled: (await getSetting("tools_tts_enabled", "1")) === "1",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1096,9 +1133,15 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
await setSetting("voice_previews", "");
|
||||
} else {
|
||||
const clean = sanitizeVoicePreviews(body.voicePreviews);
|
||||
if (!clean) return json({ error: "voicePreviews must be 1-6 lines {text, lang?, rate?, pitch?, timbre?}, or [] to reset" }, 400);
|
||||
if (!clean) return json({ error: "voicePreviews must be 1-6 lines {text, lang?, rate?, pitch?, timbre?, voice?}, or [] to reset" }, 400);
|
||||
await setSetting("voice_previews", JSON.stringify(clean));
|
||||
}
|
||||
// New/changed lines get their audio generated once, right away.
|
||||
void pregenerateVoicePreviews();
|
||||
}
|
||||
if (body.ttsEnabled !== undefined) {
|
||||
if (typeof body.ttsEnabled !== "boolean") return json({ error: "ttsEnabled must be boolean" }, 400);
|
||||
await setSetting("tools_tts_enabled", body.ttsEnabled ? "1" : "0");
|
||||
}
|
||||
return json({
|
||||
settings: {
|
||||
@@ -1112,6 +1155,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
recoveryLimitMax: await recoveryLimitMax(),
|
||||
recoveryLimitWindowMin: Math.round(await recoveryLimitWindowMs() / 60_000),
|
||||
voicePreviews: await getVoicePreviews(),
|
||||
ttsEnabled: (await getSetting("tools_tts_enabled", "1")) === "1",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1707,6 +1751,8 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
return publicId ? json({ publicId, url: `/share/${publicId}` }) : json({ shared: false });
|
||||
}
|
||||
if (req.method === "DELETE") {
|
||||
// Archived public chats stay public: unarchive first to go private.
|
||||
if (conv.archived_at) return json({ error: "conversation is archived (read-only) — unarchive it to remove the public link" }, 403);
|
||||
await deleteShare(conv.id);
|
||||
return json({ ok: true });
|
||||
}
|
||||
@@ -1815,6 +1861,39 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
}
|
||||
}
|
||||
|
||||
// --- platform tools: neural TTS (natural voices, cached) ---
|
||||
if (path === "/api/tools/tts/info" && req.method === "GET") {
|
||||
const tts = tools.tts;
|
||||
return json({ ...(await tts.providerInfo()), available: await tts.isAvailable(), reason: await tts.unavailableReason() });
|
||||
}
|
||||
|
||||
if (path === "/api/tools/tts" && req.method === "POST") {
|
||||
const tts = tools.tts;
|
||||
if (!await tts.isAvailable()) return json({ error: (await tts.unavailableReason()) ?? "tts unavailable" }, 501);
|
||||
const { ok, body } = await readJson(req);
|
||||
if (!ok || typeof body.text !== "string") return json({ error: "expected { text, voice? }" }, 400);
|
||||
const text = body.text.trim();
|
||||
if (text.length === 0 || text.length > TTS_MAX_CHARS) {
|
||||
return json({ error: `text: 1-${TTS_MAX_CHARS} chars` }, 400);
|
||||
}
|
||||
const rl = ttsAllowed(user.id);
|
||||
if (!rl.ok) {
|
||||
return json({ error: "tts rate limited: 30 jobs per hour", retryAfterSec: rl.retryAfterSec }, 429);
|
||||
}
|
||||
recordTtsJob(user.id);
|
||||
try {
|
||||
const { audio, contentType, fromCache } = await tts.synthesize(text, typeof body.voice === "string" ? body.voice : undefined);
|
||||
const bytes = audio.buffer.slice(audio.byteOffset, audio.byteOffset + audio.byteLength) as ArrayBuffer;
|
||||
return new Response(bytes, {
|
||||
headers: { "Content-Type": contentType, "Cache-Control": "private, max-age=86400", "X-TTS-Cache": fromCache ? "HIT" : "MISS" },
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message;
|
||||
if (/not available|disabled|failed to start/i.test(msg)) return json({ error: msg }, 501);
|
||||
return json({ error: msg }, 502);
|
||||
}
|
||||
}
|
||||
|
||||
if (path === "/api/chat" && req.method === "POST") {
|
||||
const { ok, body } = await readJson(req);
|
||||
if (!ok) return json({ error: "invalid JSON" }, 400);
|
||||
@@ -1864,7 +1943,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
const stored = await getMessages(conv.id);
|
||||
const lastStored = stored[stored.length - 1];
|
||||
if (lastUser && (!lastStored || lastStored.content !== lastUser.content || lastStored.role !== "user")) {
|
||||
await addMessage(conv.id, "user", lastUser.content);
|
||||
await addMessage(conv.id, "user", lastUser.content, body.model as string);
|
||||
if (conv.title === "New chat") {
|
||||
await updateConversation(conv.id, { title: lastUser.content.slice(0, 50) || "New chat" });
|
||||
}
|
||||
@@ -1874,7 +1953,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
|
||||
const persistReply = async (text: string): Promise<void> => {
|
||||
if (conv && text) {
|
||||
await addMessage(conv.id, "assistant", text);
|
||||
await addMessage(conv.id, "assistant", text, body.model as string);
|
||||
await touchConversation(conv.id, body.model as string);
|
||||
}
|
||||
};
|
||||
@@ -1883,7 +1962,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
try {
|
||||
const text = await driver.chat(messages, { model, signal: req.signal });
|
||||
await persistReply(text);
|
||||
return json({ model: body.model, message: { role: "assistant", content: text } });
|
||||
return json({ model: body.model, message: { role: "assistant", content: text, model: body.model } });
|
||||
} catch (e) {
|
||||
return json({ error: (e as Error).message }, 502);
|
||||
}
|
||||
@@ -1967,3 +2046,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
||||
console.log(`KisAssistant listening on http://${config.host}:${config.port}`);
|
||||
console.log(`Drivers (priority order): ${registry.ordered().map((d) => `${d.name}${d.enabled ? "" : " [disabled]"}`).join(" -> ")}`);
|
||||
void server;
|
||||
|
||||
// Preview voice lines get their audio generated once at boot so playback
|
||||
// always serves the disk cache (background, best-effort).
|
||||
void pregenerateVoicePreviews();
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { OcrTool } from "./ocr.ts";
|
||||
import { TtsTool } from "./tts.ts";
|
||||
import type { PlatformTool } from "./types.ts";
|
||||
|
||||
/** Platform tools registry (upload flows always go through tools). */
|
||||
class ToolRegistry {
|
||||
readonly ocr = new OcrTool();
|
||||
private tools: PlatformTool[] = [this.ocr];
|
||||
readonly tts = new TtsTool();
|
||||
private tools: PlatformTool[] = [this.ocr, this.tts];
|
||||
|
||||
async init(): Promise<void> {
|
||||
await this.ocr.init();
|
||||
await this.tts.init();
|
||||
}
|
||||
|
||||
async list(): Promise<{ name: string; description: string; available: boolean; reason: string | null }[]> {
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { config } from "../config.ts";
|
||||
import { isTtsToolEnabled } from "../db.ts";
|
||||
import { driverLog, fmtBytes, fmtMs } from "../drivers/log.ts";
|
||||
import type { PlatformTool } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Neural TTS tool: turns assistant replies / preview lines into natural
|
||||
* speech (far beyond the browser's robotic speechSynthesis).
|
||||
*
|
||||
* Providers, tried in order (first configured wins; `TTS_PROVIDER` forces
|
||||
* one explicitly):
|
||||
* - elevenlabs (ELEVENLABS_API_KEY [+ ELEVENLABS_VOICE]) — most natural,
|
||||
* any voice_id (20-char ids, clones included).
|
||||
* - openai (OPENAI_API_KEY, reuses the LLM provider key) — tts-1 with the
|
||||
* 6 stock voices (alloy/echo/fable/onyx/nova/shimmer).
|
||||
* - piper (PIPER_BIN + PIPER_MODEL, local binary like tesseract) — offline,
|
||||
* single configured voice, WAV output.
|
||||
*
|
||||
* Output is cached on disk (DATA_DIR/tts-cache, sha of provider|voice|text,
|
||||
* 200 files cap) so repeated lines cost nothing. Without any provider the
|
||||
* tool reports unavailable and the frontend falls back to browser speech.
|
||||
*/
|
||||
|
||||
export type TtsProvider = "elevenlabs" | "openai" | "piper";
|
||||
|
||||
export const OPENAI_VOICES = ["alloy", "echo", "fable", "onyx", "nova", "shimmer"] as const;
|
||||
|
||||
const TTS_MAX_CHARS = 2000;
|
||||
const TTS_TIMEOUT_MS = 60_000;
|
||||
const TTS_CACHE_CAP = 200;
|
||||
|
||||
// Abuse protection: neural TTS costs money per byte.
|
||||
const TTS_WINDOW_MS = 3600_000;
|
||||
const TTS_MAX_JOBS = 30;
|
||||
const ttsJobs = new Map<string, number[]>();
|
||||
|
||||
export function ttsAllowed(userId: string): { ok: boolean; retryAfterSec: number } {
|
||||
const now = Date.now();
|
||||
const list = (ttsJobs.get(userId) ?? []).filter((t) => t > now - TTS_WINDOW_MS);
|
||||
ttsJobs.set(userId, list);
|
||||
if (list.length >= TTS_MAX_JOBS) {
|
||||
return { ok: false, retryAfterSec: Math.max(1, Math.ceil((list[0]! + TTS_WINDOW_MS - now) / 1000)) };
|
||||
}
|
||||
return { ok: true, retryAfterSec: 0 };
|
||||
}
|
||||
|
||||
export function recordTtsJob(userId: string): void {
|
||||
const list = ttsJobs.get(userId) ?? [];
|
||||
list.push(Date.now());
|
||||
ttsJobs.set(userId, list);
|
||||
}
|
||||
|
||||
function elevenlabsBase(): string {
|
||||
return (process.env["ELEVENLABS_BASE_URL"] || "https://api.elevenlabs.io").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function openaiBase(): string {
|
||||
return (process.env["OPENAI_BASE_URL"] || "https://api.openai.com").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function piperBin(): string {
|
||||
return process.env["PIPER_BIN"] || "piper";
|
||||
}
|
||||
|
||||
export interface TtsVoice {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
async function piperAvailable(): Promise<boolean> {
|
||||
if (!config.piperModel) return false;
|
||||
try {
|
||||
const proc = Bun.spawn([piperBin(), "--version"], { stdout: "pipe", stderr: "pipe" });
|
||||
const code = await proc.exited;
|
||||
return code === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Which provider would serve right now (null = none configured). */
|
||||
export async function activeTtsProvider(): Promise<TtsProvider | null> {
|
||||
const forced = (process.env["TTS_PROVIDER"] || "auto").toLowerCase();
|
||||
const eleven = config.elevenlabsApiKey.length > 0;
|
||||
const openai = config.openaiApiKey.length > 0;
|
||||
const piper = await piperAvailable();
|
||||
const pick = (p: TtsProvider): TtsProvider | null =>
|
||||
p === "elevenlabs" ? (eleven ? p : null) : p === "openai" ? (openai ? p : null) : piper ? p : null;
|
||||
if (forced === "elevenlabs" || forced === "openai" || forced === "piper") return pick(forced);
|
||||
return pick("elevenlabs") ?? pick("openai") ?? pick("piper");
|
||||
}
|
||||
|
||||
export function sanitizeTtsVoice(provider: TtsProvider, raw: unknown): string | null {
|
||||
if (raw === undefined || raw === null || raw === "") return null;
|
||||
if (typeof raw !== "string") return null;
|
||||
const v = raw.trim();
|
||||
if (provider === "openai") {
|
||||
return (OPENAI_VOICES as readonly string[]).includes(v.toLowerCase()) ? v.toLowerCase() : null;
|
||||
}
|
||||
if (provider === "elevenlabs") {
|
||||
return /^[A-Za-z0-9_-]{1,64}$/.test(v) ? v : null;
|
||||
}
|
||||
return null; // piper: single configured model voice
|
||||
}
|
||||
|
||||
function cacheDir(): string {
|
||||
const dir = join(config.dataDir, "tts-cache");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function cacheKey(provider: TtsProvider, voice: string, text: string): string {
|
||||
return createHash("sha256").update(`${provider}|${voice}|${text}`).digest("hex");
|
||||
}
|
||||
|
||||
function cachedPath(key: string, ext: string): string {
|
||||
return join(cacheDir(), `${key}.${ext}`);
|
||||
}
|
||||
|
||||
function pruneCache(): void {
|
||||
try {
|
||||
const dir = cacheDir();
|
||||
const files = readdirSync(dir)
|
||||
.map((f) => {
|
||||
try {
|
||||
return { f, mtime: statSync(join(dir, f)).mtimeMs };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((x): x is { f: string; mtime: number } => x !== null)
|
||||
.sort((a, b) => a.mtime - b.mtime);
|
||||
for (const old of files.slice(0, Math.max(0, files.length - TTS_CACHE_CAP))) {
|
||||
try {
|
||||
rmSync(join(dir, old.f));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// cache must never break synthesis
|
||||
}
|
||||
}
|
||||
|
||||
// ElevenLabs voice catalog (cached 5 min, best-effort — drives the admin
|
||||
// voice select menus; never blocks synthesis when unreachable).
|
||||
let elVoicesCache: { at: number; voices: TtsVoice[] } | null = null;
|
||||
|
||||
export async function elevenlabsVoices(): Promise<TtsVoice[]> {
|
||||
const now = Date.now();
|
||||
if (elVoicesCache && now - elVoicesCache.at < 300_000) return elVoicesCache.voices;
|
||||
const fallback = config.elevenlabsVoice ? [{ id: config.elevenlabsVoice, name: config.elevenlabsVoice }] : [];
|
||||
try {
|
||||
const res = await fetch(`${elevenlabsBase()}/v1/voices`, {
|
||||
headers: { "xi-api-key": config.elevenlabsApiKey },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
const data = (await res.json()) as { voices?: { voice_id?: unknown; name?: unknown }[] };
|
||||
const voices = (data.voices ?? [])
|
||||
.filter((v) => typeof v.voice_id === "string" && typeof v.name === "string")
|
||||
.map((v) => ({ id: v.voice_id as string, name: v.name as string }));
|
||||
if (voices.length === 0) return fallback;
|
||||
elVoicesCache = { at: now, voices };
|
||||
return [...voices, ...fallback.filter((f) => !voices.some((v) => v.id === f.id))];
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function synthElevenlabs(text: string, voiceId: string): Promise<{ audio: Uint8Array; contentType: string }> { const res = await fetch(`${elevenlabsBase()}/v1/text-to-speech/${encodeURIComponent(voiceId)}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "audio/mpeg",
|
||||
"xi-api-key": config.elevenlabsApiKey,
|
||||
},
|
||||
body: JSON.stringify({ text, model_id: "eleven_multilingual_v2" }),
|
||||
signal: AbortSignal.timeout(TTS_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text().catch(() => "");
|
||||
throw new Error(`elevenlabs tts failed (${res.status}): ${err.slice(0, 200)}`);
|
||||
}
|
||||
return { audio: new Uint8Array(await res.arrayBuffer()), contentType: "audio/mpeg" };
|
||||
}
|
||||
|
||||
async function synthOpenai(text: string, voice: string): Promise<{ audio: Uint8Array; contentType: string }> {
|
||||
const res = await fetch(`${openaiBase()}/v1/audio/speech`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.openaiApiKey}` },
|
||||
body: JSON.stringify({ model: "tts-1", input: text, voice, response_format: "mp3" }),
|
||||
signal: AbortSignal.timeout(TTS_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text().catch(() => "");
|
||||
throw new Error(`openai tts failed (${res.status}): ${err.slice(0, 200)}`);
|
||||
}
|
||||
return { audio: new Uint8Array(await res.arrayBuffer()), contentType: "audio/mpeg" };
|
||||
}
|
||||
|
||||
async function synthPiper(text: string): Promise<{ audio: Uint8Array; contentType: string }> {
|
||||
const out = join(cacheDir(), `piper-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}.wav`);
|
||||
let proc;
|
||||
try {
|
||||
proc = Bun.spawn([piperBin(), "--model", config.piperModel, "--output_file", out], {
|
||||
stdin: new TextEncoder().encode(text),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`tts engine failed to start: ${(e as Error).message}`);
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
// already exited
|
||||
}
|
||||
}, TTS_TIMEOUT_MS);
|
||||
try {
|
||||
const [code, errText] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
|
||||
if (code !== 0) throw new Error(`piper failed (exit ${code}): ${errText.trim().slice(0, 200)}`);
|
||||
const file = Bun.file(out);
|
||||
if (!(await file.exists())) throw new Error("piper produced no output");
|
||||
return { audio: new Uint8Array(await file.arrayBuffer()), contentType: "audio/wav" };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
rmSync(out);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class TtsTool implements PlatformTool {
|
||||
readonly name = "tts";
|
||||
readonly description = "Neural text-to-speech (ElevenLabs / OpenAI / local piper) for natural voice playback.";
|
||||
|
||||
async init(): Promise<void> {
|
||||
const p = await activeTtsProvider();
|
||||
driverLog("tools", `tts engine: ${p ?? "none configured — browser speech fallback only"}`);
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return (await activeTtsProvider()) !== null && (await isTtsToolEnabled());
|
||||
}
|
||||
|
||||
async unavailableReason(): Promise<string | null> {
|
||||
if ((await activeTtsProvider()) === null) {
|
||||
return "no TTS provider configured (ELEVENLABS_API_KEY, OPENAI_API_KEY or PIPER_BIN+PIPER_MODEL). Browser speech is used instead.";
|
||||
}
|
||||
if (!(await isTtsToolEnabled())) return "TTS is disabled by the administrator (Admin Center → Features).";
|
||||
return null;
|
||||
}
|
||||
|
||||
async providerInfo(): Promise<{ provider: TtsProvider | null; voices: TtsVoice[] }> {
|
||||
const p = await activeTtsProvider();
|
||||
if (!p) return { provider: null, voices: [] };
|
||||
if (p === "openai") return { provider: p, voices: OPENAI_VOICES.map((v) => ({ id: v, name: v })) };
|
||||
if (p === "elevenlabs") return { provider: p, voices: await elevenlabsVoices() };
|
||||
return { provider: p, voices: [] };
|
||||
}
|
||||
|
||||
defaultVoice(provider: TtsProvider): string {
|
||||
if (provider === "openai") {
|
||||
const v = sanitizeTtsVoice("openai", config.ttsVoice);
|
||||
return v ?? "alloy";
|
||||
}
|
||||
if (provider === "elevenlabs") return config.elevenlabsVoice || "pNInz6obpgDQGcFmaJgB";
|
||||
return "";
|
||||
}
|
||||
|
||||
async synthesize(text: string, voice?: string): Promise<{ audio: Uint8Array; contentType: string; provider: TtsProvider; fromCache: boolean }> {
|
||||
const provider = await activeTtsProvider();
|
||||
if (!provider) throw new Error((await this.unavailableReason()) ?? "tts unavailable");
|
||||
if (!(await isTtsToolEnabled())) throw new Error("tts disabled");
|
||||
const clean = text.trim().slice(0, TTS_MAX_CHARS);
|
||||
if (!clean) throw new Error("empty text");
|
||||
const resolved = sanitizeTtsVoice(provider, voice) ?? this.defaultVoice(provider);
|
||||
const ext = provider === "piper" ? "wav" : "mp3";
|
||||
const key = cacheKey(provider, resolved, clean);
|
||||
const hit = Bun.file(cachedPath(key, ext));
|
||||
if (await hit.exists()) {
|
||||
return {
|
||||
audio: new Uint8Array(await hit.arrayBuffer()),
|
||||
contentType: provider === "piper" ? "audio/wav" : "audio/mpeg",
|
||||
provider,
|
||||
fromCache: true,
|
||||
};
|
||||
}
|
||||
const t0 = Date.now();
|
||||
const out = provider === "elevenlabs"
|
||||
? await synthElevenlabs(clean, resolved)
|
||||
: provider === "openai"
|
||||
? await synthOpenai(clean, resolved)
|
||||
: await synthPiper(clean);
|
||||
await Bun.write(cachedPath(key, ext), out.audio);
|
||||
pruneCache();
|
||||
driverLog("tools", `tts ${provider} voice=${resolved || "default"}: ${fmtBytes(out.audio.length)} in ${fmtMs(Date.now() - t0)}`);
|
||||
return { ...out, provider, fromCache: false };
|
||||
}
|
||||
}
|
||||
|
||||
export { TTS_MAX_CHARS };
|
||||
Reference in New Issue
Block a user