Compare commits

...
9 Commits
Author SHA1 Message Date
k b010762a0b fixes admin autofill in fields 2026-09-24 15:55:23 +02:00
k 2dab720e55 new components 2026-09-24 13:06:10 +02:00
k 67f13dbc93 achived chats 2026-09-24 12:00:32 +02:00
k a7f0097a11 fix 2026-09-24 11:55:41 +02:00
k 644aa21426 updates 2026-09-24 11:51:02 +02:00
k 6ebd26b60e updates 2026-09-24 11:48:46 +02:00
k ac04c674a6 . 2026-09-24 11:42:53 +02:00
k 30918f33e7 . 2026-09-24 11:37:02 +02:00
k 8d72defb82 natural voice 2026-09-24 11:31:43 +02:00
26 changed files with 1492 additions and 150 deletions
+15
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+3 -3
View File
@@ -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);
+70 -13
View File
@@ -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>
+50 -2
View File
@@ -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} />
</>
);
+46 -14
View File
@@ -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}
+1 -1
View File
@@ -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);
+13 -2
View File
@@ -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 }) {
+5 -3
View File
@@ -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>
+3
View File
@@ -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>
+304 -35
View File
@@ -1,10 +1,10 @@
import { useEffect, useRef, useState } from "react";
import { Archive, ArchiveRestore, Eye, Fingerprint, Globe, FileText, ImagePlus, KeyRound, Link2, Mail, Mic, Palette, Pencil, Play, Plug, ScanText, Search, Settings2, ShieldCheck, Sparkles, Square, Tag, Trash2, Unplug, User as UserIcon, Volume2, X } from "lucide-react";
import { 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,11 +229,18 @@ export function ShareModal({ conv, onClose }: { conv: Conversation | null; onClo
async function unshare() {
if (!conv) return;
await api.unshare(conv.id);
pushToast(t("toast.unshared"), { icon: "unshare" });
onClose();
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}>
{busy && <p className="flex items-center gap-2 text-sm opacity-70"><Spinner size={15} /> {t("dlg.shareCreating")}</p>}
@@ -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">
<Button variant="ghost" size="sm" onClick={unshare}><Unplug size={15} /> {t("dlg.unshare")}</Button>
{!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" });
}}
/>
)}
</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")}
/>
</label>
</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")}
+24 -1
View File
@@ -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);
});
+62 -8
View File
@@ -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());
+25
View File
@@ -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", {
+174
View File
@@ -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",
+2
View File
@@ -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 {
+201 -40
View File
@@ -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 {
currentAudio.pause();
const url = currentAudio.src;
currentAudio.removeAttribute("src");
currentAudio.load();
if (url.startsWith("blob:")) URL.revokeObjectURL(url);
} catch {
// ignore
}
currentAudio = null;
}
try {
synth.cancel();
window.speechSynthesis?.cancel();
} catch {
// ignore
}
const lang = preview.lang === "auto" ? detectLang(text) : preview.lang;
const utter = new SpeechSynthesisUtterance(text.slice(0, 2000));
utter.lang = lang;
utter.rate = preview.rate;
utter.pitch = preview.pitch;
const pick = pickVoice(voices, { lang, timbre: preview.timbre });
if (pick) utter.voice = pick;
utter.onend = () => opts.onend?.();
utter.onerror = () => opts.onerror?.("error");
try {
synth.speak(utter);
} catch {
return null;
}
return pick;
}
// --- speech recognition (STT) ---
/** 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;
+21
View File
@@ -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.
+8
View File
@@ -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),
+10 -3
View File
@@ -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";
+5
View File
@@ -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
View File
@@ -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();
+4 -1
View File
@@ -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 }[]> {
+309
View File
@@ -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 };