@agentprojectcontext/apx 1.50.1 → 1.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/agent/prompt-builder.js +15 -4
- package/src/core/voice/emotions.js +106 -0
- package/src/core/voice/engines/index.js +39 -9
- package/src/core/voice/engines/openai.js +55 -19
- package/src/core/voice/tts.js +9 -2
- package/src/host/daemon/api/voice.js +1 -0
- package/src/interfaces/web/dist/assets/index-C1tm8nvc.js +651 -0
- package/src/interfaces/web/dist/assets/index-C1tm8nvc.js.map +1 -0
- package/src/interfaces/web/dist/assets/{index-C53eJujd.css → index-L1pXYFUg.css} +1 -1
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/package-lock.json +77 -77
- package/src/interfaces/web/src/components/UiSelect.tsx +2 -1
- package/src/interfaces/web/src/components/voice/VoiceProviderList.tsx +101 -147
- package/src/interfaces/web/src/components/voice/VoiceProviderModal.tsx +137 -6
- package/src/interfaces/web/src/components/voice/VoiceTestCard.tsx +17 -24
- package/src/interfaces/web/src/i18n/en.ts +34 -2
- package/src/interfaces/web/src/i18n/es.ts +34 -2
- package/src/interfaces/web/src/lib/api/voice.ts +36 -3
- package/src/interfaces/web/src/screens/modules/VoiceScreen.tsx +29 -37
- package/src/interfaces/web/dist/assets/index-rx9XIp5S.js +0 -656
- package/src/interfaces/web/dist/assets/index-rx9XIp5S.js.map +0 -1
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
|
-
import { Button, Dialog, Field, Input, Textarea } from "../ui";
|
|
2
|
+
import { Button, Dialog, Field, Input, Switch, Textarea } from "../ui";
|
|
3
3
|
import { UiSelect } from "../UiSelect";
|
|
4
4
|
import { isSecretMarker, secretSuffix } from "../../lib/secrets";
|
|
5
5
|
import {
|
|
6
|
+
DEFAULT_EMOTION_TAGS,
|
|
6
7
|
ELEVENLABS_MODELS,
|
|
7
8
|
GEMINI_TTS_VOICES,
|
|
8
9
|
OPENAI_TTS_MODELS,
|
|
9
10
|
OPENAI_TTS_VOICES,
|
|
10
11
|
TTS_PROVIDER_META,
|
|
12
|
+
type CustomTtsConfig,
|
|
11
13
|
type ElevenLabsConfig,
|
|
14
|
+
type EmotionsConfig,
|
|
12
15
|
type GeminiTtsConfig,
|
|
13
16
|
type OpenAiTtsConfig,
|
|
14
17
|
type PiperConfig,
|
|
@@ -39,6 +42,35 @@ function str(v: unknown): string {
|
|
|
39
42
|
return typeof v === "string" ? v : v == null ? "" : String(v);
|
|
40
43
|
}
|
|
41
44
|
|
|
45
|
+
// Stable config slug from a display label (e.g. "My QVox 🎙" → "my-qvox").
|
|
46
|
+
function slugify(label: string): string {
|
|
47
|
+
return label
|
|
48
|
+
.toLowerCase()
|
|
49
|
+
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
|
|
50
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
51
|
+
.replace(/^-+|-+$/g, "")
|
|
52
|
+
.slice(0, 40);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Generic inline-emotion-tags control. Enabling it makes the agent emit [tag]
|
|
56
|
+
// markers in voice mode; the daemon teaches the syntax only when this engine
|
|
57
|
+
// will speak, and strips stray tags when it won't (so they're never read aloud).
|
|
58
|
+
function EmotionsField({ on, setOn, tags, setTags }: {
|
|
59
|
+
on: boolean; setOn: (v: boolean) => void; tags: string; setTags: (v: string) => void;
|
|
60
|
+
}) {
|
|
61
|
+
return (
|
|
62
|
+
<div className="rounded-md border border-border/60 p-3 space-y-2">
|
|
63
|
+
<Switch checked={on} onChange={setOn} label={t("voice_ui.emotions_label")} />
|
|
64
|
+
<p className="text-xs text-muted-fg">{t("voice_ui.emotions_hint")}</p>
|
|
65
|
+
{on && (
|
|
66
|
+
<Field label={t("voice_ui.emotions_tags_label")} hint={t("voice_ui.emotions_tags_hint")}>
|
|
67
|
+
<Input value={tags} onChange={(e) => setTags(e.target.value)} placeholder={DEFAULT_EMOTION_TAGS.join(", ")} />
|
|
68
|
+
</Field>
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
42
74
|
export function VoiceProviderModal({ open, providerId, config, onClose, onSave }: Props) {
|
|
43
75
|
const [busy, setBusy] = useState(false);
|
|
44
76
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -46,13 +78,34 @@ export function VoiceProviderModal({ open, providerId, config, onClose, onSave }
|
|
|
46
78
|
// Field state (the api_key field is always blank on open — typing replaces).
|
|
47
79
|
const [apiKey, setApiKey] = useState("");
|
|
48
80
|
const [f, setF] = useState<Record<string, string>>({});
|
|
81
|
+
// Generic emotion-tags capability (openai/gemini). Separate state because it
|
|
82
|
+
// saves as nested keys, not flat strings.
|
|
83
|
+
const [emoOn, setEmoOn] = useState(false);
|
|
84
|
+
const [emoTags, setEmoTags] = useState("");
|
|
85
|
+
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
49
86
|
|
|
50
87
|
useEffect(() => {
|
|
51
88
|
if (!open || !providerId) return;
|
|
52
89
|
setError(null);
|
|
53
90
|
setApiKey("");
|
|
54
91
|
const c = config || {};
|
|
55
|
-
|
|
92
|
+
const emo = (c as { emotions?: EmotionsConfig }).emotions;
|
|
93
|
+
setEmoOn(!!emo?.enabled);
|
|
94
|
+
setEmoTags(Array.isArray(emo?.tags) ? emo!.tags!.join(", ") : "");
|
|
95
|
+
setShowAdvanced(false);
|
|
96
|
+
const customMode = providerId === "__new__" || providerId.startsWith("custom:");
|
|
97
|
+
if (customMode) {
|
|
98
|
+
const p = c as unknown as CustomTtsConfig;
|
|
99
|
+
setF({
|
|
100
|
+
label: str(p.label),
|
|
101
|
+
base_url: str(p.base_url),
|
|
102
|
+
model: str(p.model),
|
|
103
|
+
voice: str(p.voice),
|
|
104
|
+
format: str(p.format),
|
|
105
|
+
style: str(p.style),
|
|
106
|
+
temperature: str(p.temperature),
|
|
107
|
+
});
|
|
108
|
+
} else if (providerId === "piper") {
|
|
56
109
|
const p = c as PiperConfig;
|
|
57
110
|
setF({ bin: str(p.bin), model: str(p.model), speaker: str(p.speaker) });
|
|
58
111
|
} else if (providerId === "elevenlabs") {
|
|
@@ -71,18 +124,35 @@ export function VoiceProviderModal({ open, providerId, config, onClose, onSave }
|
|
|
71
124
|
|
|
72
125
|
if (!providerId) return null;
|
|
73
126
|
|
|
127
|
+
const isCreate = providerId === "__new__";
|
|
128
|
+
const isCustom = isCreate || providerId.startsWith("custom:");
|
|
74
129
|
const meta = TTS_PROVIDER_META[providerId];
|
|
75
|
-
const base = `voice.tts.${providerId}`;
|
|
76
130
|
const up = (patch: Record<string, string>) => setF((s) => ({ ...s, ...patch }));
|
|
77
131
|
|
|
78
132
|
const hasSecret = providerId !== "piper" && providerId !== "mock";
|
|
79
133
|
const existingKey = hasSecret && isSecretMarker((config as { api_key?: unknown })?.api_key);
|
|
80
134
|
const keyPlaceholder = existingKey ? t("voice_ui.api_key_set", { suffix: secretSuffix((config as { api_key?: unknown })?.api_key) ?? "" }) : t("voice_ui.api_key_label");
|
|
81
135
|
|
|
136
|
+
const title = isCreate
|
|
137
|
+
? t("voice_ui.new_provider")
|
|
138
|
+
: isCustom
|
|
139
|
+
? (f.label || providerId.slice(7))
|
|
140
|
+
: meta?.name || providerId;
|
|
141
|
+
|
|
82
142
|
const submit = async () => {
|
|
83
143
|
setBusy(true);
|
|
84
144
|
setError(null);
|
|
85
145
|
try {
|
|
146
|
+
// Resolve the config base. Custom providers live under
|
|
147
|
+
// voice.tts.custom.<slug>; built-ins are flat voice.tts.<id>.
|
|
148
|
+
const slug = isCreate ? slugify(f.label) : isCustom ? providerId.slice(7) : "";
|
|
149
|
+
if (isCustom) {
|
|
150
|
+
if (!f.label.trim()) throw new Error(t("voice_ui.err_label_required"));
|
|
151
|
+
if (!f.base_url.trim()) throw new Error(t("voice_ui.err_base_url_required"));
|
|
152
|
+
if (!slug) throw new Error(t("voice_ui.err_label_required"));
|
|
153
|
+
}
|
|
154
|
+
const base = isCustom ? `voice.tts.custom.${slug}` : `voice.tts.${providerId}`;
|
|
155
|
+
|
|
86
156
|
const set: Record<string, unknown> = {};
|
|
87
157
|
const unset: string[] = [];
|
|
88
158
|
const opt = (key: string, val: string) => {
|
|
@@ -90,7 +160,17 @@ export function VoiceProviderModal({ open, providerId, config, onClose, onSave }
|
|
|
90
160
|
else unset.push(`${base}.${key}`);
|
|
91
161
|
};
|
|
92
162
|
|
|
93
|
-
if (
|
|
163
|
+
if (isCustom) {
|
|
164
|
+
set[`${base}.label`] = f.label.trim();
|
|
165
|
+
set[`${base}.base_url`] = f.base_url.trim();
|
|
166
|
+
opt("style", f.style);
|
|
167
|
+
if (f.temperature.trim() && !Number.isNaN(Number(f.temperature)))
|
|
168
|
+
set[`${base}.temperature`] = Number(f.temperature);
|
|
169
|
+
else unset.push(`${base}.temperature`);
|
|
170
|
+
// Advanced (optional): model/voice for non-QVox OpenAI-compatible servers.
|
|
171
|
+
opt("model", f.model);
|
|
172
|
+
opt("voice", f.voice);
|
|
173
|
+
} else if (providerId === "piper") {
|
|
94
174
|
opt("bin", f.bin);
|
|
95
175
|
opt("model", f.model);
|
|
96
176
|
if (f.speaker.trim()) set[`${base}.speaker`] = f.speaker.trim();
|
|
@@ -109,6 +189,14 @@ export function VoiceProviderModal({ open, providerId, config, onClose, onSave }
|
|
|
109
189
|
opt("style", f.style);
|
|
110
190
|
}
|
|
111
191
|
|
|
192
|
+
// Generic emotion-tags capability (engines that parse inline [tags]).
|
|
193
|
+
if (isCustom || providerId === "gemini") {
|
|
194
|
+
set[`${base}.emotions.enabled`] = emoOn;
|
|
195
|
+
const tags = emoTags.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
|
|
196
|
+
if (tags.length) set[`${base}.emotions.tags`] = tags;
|
|
197
|
+
else unset.push(`${base}.emotions.tags`);
|
|
198
|
+
}
|
|
199
|
+
|
|
112
200
|
// Only push a key the user actually typed (blank keeps the stored one).
|
|
113
201
|
if (hasSecret && apiKey.trim()) set[`${base}.api_key`] = apiKey.trim();
|
|
114
202
|
|
|
@@ -125,8 +213,8 @@ export function VoiceProviderModal({ open, providerId, config, onClose, onSave }
|
|
|
125
213
|
<Dialog
|
|
126
214
|
open={open}
|
|
127
215
|
onClose={onClose}
|
|
128
|
-
title={t("voice_screen.configure_provider", { name:
|
|
129
|
-
description={meta?.note}
|
|
216
|
+
title={t("voice_screen.configure_provider", { name: title })}
|
|
217
|
+
description={isCustom ? t("voice_ui.custom_desc") : meta?.note}
|
|
130
218
|
size="md"
|
|
131
219
|
footer={
|
|
132
220
|
<>
|
|
@@ -167,6 +255,7 @@ export function VoiceProviderModal({ open, providerId, config, onClose, onSave }
|
|
|
167
255
|
</>
|
|
168
256
|
)}
|
|
169
257
|
|
|
258
|
+
{/* Built-in OpenAI (cloud only). */}
|
|
170
259
|
{providerId === "openai" && (
|
|
171
260
|
<>
|
|
172
261
|
<Field label={t("voice_ui.api_key_label")} hint={existingKey ? t("voice_ui.api_key_keep_hint") : t("voice_ui.api_key_reuse_hint", { engine: "engines.openai.api_key", env: "OPENAI_API_KEY" })}>
|
|
@@ -184,6 +273,47 @@ export function VoiceProviderModal({ open, providerId, config, onClose, onSave }
|
|
|
184
273
|
</>
|
|
185
274
|
)}
|
|
186
275
|
|
|
276
|
+
{/* Custom OpenAI-compatible provider (e.g. a local QVox server). */}
|
|
277
|
+
{isCustom && (
|
|
278
|
+
<>
|
|
279
|
+
<Field label={t("voice_ui.label_label")} hint={t("voice_ui.label_hint")}>
|
|
280
|
+
<Input value={f.label} onChange={(e) => up({ label: e.target.value })} placeholder="QVox" />
|
|
281
|
+
</Field>
|
|
282
|
+
<Field label={t("voice_ui.base_url_req_label")} hint={t("voice_ui.base_url_req_hint")}>
|
|
283
|
+
<Input value={f.base_url} onChange={(e) => up({ base_url: e.target.value })} placeholder="http://127.0.0.1:5111/v1" />
|
|
284
|
+
</Field>
|
|
285
|
+
<Field label={t("voice_ui.api_key_label")} hint={existingKey ? t("voice_ui.api_key_keep_hint") : t("voice_ui.api_key_optional_hint")}>
|
|
286
|
+
<Input type="password" autoComplete="new-password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder={keyPlaceholder} />
|
|
287
|
+
</Field>
|
|
288
|
+
<Field label={t("voice_ui.style_label")} hint={t("voice_ui.openai_style_hint")}>
|
|
289
|
+
<Textarea rows={2} value={f.style || ""} onChange={(e) => up({ style: e.target.value })} placeholder={t("voice_ui.style_ph")} />
|
|
290
|
+
</Field>
|
|
291
|
+
<Field label={t("voice_ui.temperature_label")} hint={t("voice_ui.temperature_hint")}>
|
|
292
|
+
<Input value={f.temperature} onChange={(e) => up({ temperature: e.target.value })} inputMode="decimal" placeholder="0.7" />
|
|
293
|
+
</Field>
|
|
294
|
+
<EmotionsField on={emoOn} setOn={setEmoOn} tags={emoTags} setTags={setEmoTags} />
|
|
295
|
+
<div>
|
|
296
|
+
<button
|
|
297
|
+
type="button"
|
|
298
|
+
onClick={() => setShowAdvanced((s) => !s)}
|
|
299
|
+
className="text-xs text-muted-fg hover:text-fg"
|
|
300
|
+
>
|
|
301
|
+
{showAdvanced ? "▾ " : "▸ "}{t("voice_ui.advanced")}
|
|
302
|
+
</button>
|
|
303
|
+
{showAdvanced && (
|
|
304
|
+
<div className="mt-2 space-y-3">
|
|
305
|
+
<Field label={t("voice_ui.model_label")} hint={t("voice_ui.custom_model_hint")}>
|
|
306
|
+
<Input value={f.model} onChange={(e) => up({ model: e.target.value })} placeholder={t("voice_ui.custom_optional_ph")} />
|
|
307
|
+
</Field>
|
|
308
|
+
<Field label={t("voice_ui.voice_label")} hint={t("voice_ui.custom_voice_hint")}>
|
|
309
|
+
<Input value={f.voice} onChange={(e) => up({ voice: e.target.value })} placeholder={t("voice_ui.custom_optional_ph")} />
|
|
310
|
+
</Field>
|
|
311
|
+
</div>
|
|
312
|
+
)}
|
|
313
|
+
</div>
|
|
314
|
+
</>
|
|
315
|
+
)}
|
|
316
|
+
|
|
187
317
|
{providerId === "gemini" && (
|
|
188
318
|
<>
|
|
189
319
|
<Field label={t("voice_ui.api_key_label")} hint={existingKey ? t("voice_ui.api_key_keep_hint") : t("voice_ui.api_key_reuse_hint", { engine: "engines.gemini.api_key", env: "GEMINI_API_KEY" })}>
|
|
@@ -198,6 +328,7 @@ export function VoiceProviderModal({ open, providerId, config, onClose, onSave }
|
|
|
198
328
|
<Field label={t("voice_ui.style_label")} hint={t("voice_ui.style_hint")}>
|
|
199
329
|
<Textarea rows={2} value={f.style || ""} onChange={(e) => up({ style: e.target.value })} placeholder={t("voice_ui.style_ph")} />
|
|
200
330
|
</Field>
|
|
331
|
+
<EmotionsField on={emoOn} setOn={setEmoOn} tags={emoTags} setTags={setEmoTags} />
|
|
201
332
|
</>
|
|
202
333
|
)}
|
|
203
334
|
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { Play, Square, Volume2 } from "lucide-react";
|
|
3
|
-
import { Button, Field,
|
|
3
|
+
import { Button, Field, Textarea } from "../ui";
|
|
4
4
|
import { UiSelect } from "../UiSelect";
|
|
5
5
|
import { useToast } from "../Toast";
|
|
6
6
|
import { useTtsPlayer } from "./useTtsPlayer";
|
|
7
7
|
import { Voice, TTS_PROVIDER_META, type TtsEngineInfo, type TtsMode, type TtsSayResult } from "../../lib/api/voice";
|
|
8
8
|
import { t } from "../../i18n";
|
|
9
9
|
|
|
10
|
-
// "
|
|
11
|
-
// the saved default)
|
|
12
|
-
//
|
|
13
|
-
//
|
|
10
|
+
// "Say this" tester. Lets you pick which engine to synthesize with (overriding
|
|
11
|
+
// the saved default), then plays the resulting audio in-browser via /tts/say.
|
|
12
|
+
// The base voice / emotions are configured per-engine now, so there's no
|
|
13
|
+
// free-text style field here — pick the engine and hear it.
|
|
14
14
|
|
|
15
15
|
interface Props {
|
|
16
16
|
engines: TtsEngineInfo[];
|
|
@@ -25,7 +25,6 @@ export function VoiceTestCard({ engines, defaultProvider, mode }: Props) {
|
|
|
25
25
|
const [text, setText] = useState(t("voice_ui.test_default_text"));
|
|
26
26
|
// "" = use the saved default; otherwise force a specific engine.
|
|
27
27
|
const [engine, setEngine] = useState("");
|
|
28
|
-
const [style, setStyle] = useState("");
|
|
29
28
|
const [busy, setBusy] = useState(false);
|
|
30
29
|
const [last, setLast] = useState<TtsSayResult | null>(null);
|
|
31
30
|
|
|
@@ -36,10 +35,15 @@ export function VoiceTestCard({ engines, defaultProvider, mode }: Props) {
|
|
|
36
35
|
|
|
37
36
|
const options = [
|
|
38
37
|
{ value: "", label: defaultLabel },
|
|
39
|
-
...engines
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
...engines
|
|
39
|
+
.filter((e) => e.id !== "mock")
|
|
40
|
+
.map((e) => ({
|
|
41
|
+
value: e.id,
|
|
42
|
+
label: e.custom ? e.label || e.id : TTS_PROVIDER_META[e.id]?.name || e.id,
|
|
43
|
+
// Unavailable engines stay listed (so the user sees they exist) but
|
|
44
|
+
// can't be picked.
|
|
45
|
+
disabled: !e.available,
|
|
46
|
+
})),
|
|
43
47
|
];
|
|
44
48
|
|
|
45
49
|
const say = async () => {
|
|
@@ -53,7 +57,6 @@ export function VoiceTestCard({ engines, defaultProvider, mode }: Props) {
|
|
|
53
57
|
const res = await Voice.say({
|
|
54
58
|
text: txt,
|
|
55
59
|
provider: engine || undefined,
|
|
56
|
-
style: style.trim() || undefined,
|
|
57
60
|
});
|
|
58
61
|
setLast(res);
|
|
59
62
|
await play(res.audio_path);
|
|
@@ -66,19 +69,9 @@ export function VoiceTestCard({ engines, defaultProvider, mode }: Props) {
|
|
|
66
69
|
|
|
67
70
|
return (
|
|
68
71
|
<div className="space-y-3">
|
|
69
|
-
<
|
|
70
|
-
<
|
|
71
|
-
|
|
72
|
-
</Field>
|
|
73
|
-
<Field label={t("voice_ui.test_style_label")} hint={t("voice_ui.test_style_hint")}>
|
|
74
|
-
<Input
|
|
75
|
-
value={style}
|
|
76
|
-
onChange={(e) => setStyle(e.target.value)}
|
|
77
|
-
placeholder={t("voice_ui.style_ph")}
|
|
78
|
-
data-testid="voice-test-style"
|
|
79
|
-
/>
|
|
80
|
-
</Field>
|
|
81
|
-
</div>
|
|
72
|
+
<Field label={t("voice_ui.test_engine_label")} hint={t("voice_ui.test_engine_hint")}>
|
|
73
|
+
<UiSelect value={engine} onChange={setEngine} options={options} />
|
|
74
|
+
</Field>
|
|
82
75
|
<Field label={t("voice_ui.test_text_label")}>
|
|
83
76
|
<Textarea
|
|
84
77
|
rows={2}
|
|
@@ -85,6 +85,7 @@ export const en = {
|
|
|
85
85
|
desktop: "Desktop",
|
|
86
86
|
deck: "Deck",
|
|
87
87
|
code: "Code",
|
|
88
|
+
web: "Web",
|
|
88
89
|
},
|
|
89
90
|
},
|
|
90
91
|
topbar: {
|
|
@@ -143,6 +144,7 @@ export const en = {
|
|
|
143
144
|
appearance: "Appearance",
|
|
144
145
|
light_mode: "Light",
|
|
145
146
|
dark_mode: "Dark",
|
|
147
|
+
system_mode: "System",
|
|
146
148
|
language: "Language",
|
|
147
149
|
daemon: "Daemon",
|
|
148
150
|
daemon_sub: "Status of the local process that serves this web and orchestrates agents.",
|
|
@@ -202,7 +204,7 @@ export const en = {
|
|
|
202
204
|
owner_context_hint: "Who you are, what you work on, what the agent should know about you.",
|
|
203
205
|
language: "Preferred language",
|
|
204
206
|
timezone: "Timezone (IANA)",
|
|
205
|
-
timezone_hint: "
|
|
207
|
+
timezone_hint: "Auto-detected — search to change.",
|
|
206
208
|
saved: "Identity saved.",
|
|
207
209
|
},
|
|
208
210
|
|
|
@@ -1146,9 +1148,20 @@ export const en = {
|
|
|
1146
1148
|
voice_id_label: "Voice ID",
|
|
1147
1149
|
voice_id_hint: "ElevenLabs voice id (empty = default).",
|
|
1148
1150
|
gemini_model_hint: "Gemini TTS is still in preview.",
|
|
1151
|
+
base_url_label: "Base URL (optional)",
|
|
1152
|
+
base_url_hint: "OpenAI-compatible endpoint. Empty = OpenAI. Point it at a local server (e.g. a QVox / Qwen3-TTS daemon) to use that instead.",
|
|
1153
|
+
openai_model_hint: "tts-1 / tts-1-hd for OpenAI. Leave blank to let a custom server pick.",
|
|
1154
|
+
openai_voice_hint: "OpenAI preset (alloy…) or a custom server's preset (e.g. custom). Empty = server default.",
|
|
1155
|
+
openai_style_hint: "Base voice / instruct, used by custom endpoints (the persona kept across the audio). Ignored by stock OpenAI tts-1.",
|
|
1149
1156
|
style_label: "Style (how it should speak)",
|
|
1150
1157
|
style_hint: "Natural-language instruction. Empty = no style. E.g.: 'speak in a cheerful, unhurried tone'.",
|
|
1151
1158
|
style_ph: "speak in a cheerful, energetic tone",
|
|
1159
|
+
temperature_label: "Temperature (optional)",
|
|
1160
|
+
temperature_hint: "Sampling temperature for custom endpoints. Empty = server default.",
|
|
1161
|
+
emotions_label: "Inline emotion tags",
|
|
1162
|
+
emotions_hint: "When this engine speaks, let the agent drop [happy]/[whisper]-style tags into voice replies to color the delivery. Only enable it if this engine understands the tags (e.g. a QVox/Qwen3-TTS endpoint) — otherwise they're stripped before synthesis.",
|
|
1163
|
+
emotions_tags_label: "Allowed tags",
|
|
1164
|
+
emotions_tags_hint: "Comma-separated. Empty = the default set.",
|
|
1152
1165
|
piper_bin_label: "Binary (bin)",
|
|
1153
1166
|
piper_bin_hint: "Path or name of the piper CLI (PATH).",
|
|
1154
1167
|
piper_model_label: "Model (.onnx)",
|
|
@@ -1168,8 +1181,24 @@ export const en = {
|
|
|
1168
1181
|
badge_unavailable: "configured, unavailable",
|
|
1169
1182
|
badge_not_configured: "not configured",
|
|
1170
1183
|
badge_default: "default",
|
|
1184
|
+
badge_custom: "custom",
|
|
1171
1185
|
set_as_default: "Set as default",
|
|
1172
1186
|
configure: "Configure",
|
|
1187
|
+
remove: "Remove",
|
|
1188
|
+
remove_confirm: "Remove this custom provider?",
|
|
1189
|
+
add_provider: "Add provider",
|
|
1190
|
+
new_provider: "New provider",
|
|
1191
|
+
custom_note: "Custom OpenAI-compatible endpoint.",
|
|
1192
|
+
custom_desc: "Any OpenAI-compatible speech endpoint (e.g. a local QVox / Qwen3-TTS server).",
|
|
1193
|
+
label_label: "Name",
|
|
1194
|
+
label_hint: "Display name for this provider.",
|
|
1195
|
+
base_url_req_label: "Base URL",
|
|
1196
|
+
base_url_req_hint: "Required. The OpenAI-compatible endpoint, e.g. http://127.0.0.1:5111/v1",
|
|
1197
|
+
api_key_optional_hint:"Optional — only if your server requires a key.",
|
|
1198
|
+
advanced: "Advanced",
|
|
1199
|
+
custom_model_hint: "Optional. Most local servers ignore it (e.g. QVox).",
|
|
1200
|
+
custom_voice_hint: "Optional. A preset your server understands (e.g. custom). Empty = server default.",
|
|
1201
|
+
custom_optional_ph: "(optional)",
|
|
1173
1202
|
stt_engine_label: "Transcription engine",
|
|
1174
1203
|
stt_engine_hint: "Local uses faster-whisper (requires python3 + faster-whisper). OpenAI uses the engines.openai key.",
|
|
1175
1204
|
stt_model_label: "Local model (whisper)",
|
|
@@ -1217,7 +1246,7 @@ export const en = {
|
|
|
1217
1246
|
stop: "Stop",
|
|
1218
1247
|
replay: "Replay",
|
|
1219
1248
|
engine_result: "Engine",
|
|
1220
|
-
providers_desc: "Synthesis engines. Status is reported live by the daemon.
|
|
1249
|
+
providers_desc: "Synthesis engines, in fallback order. Status is reported live by the daemon. Add your own OpenAI-compatible endpoints.",
|
|
1221
1250
|
providers_load_error: "Could not load providers: {msg}",
|
|
1222
1251
|
test_desc: "Pick which engine to synthesize with and, if applicable, how it should speak.",
|
|
1223
1252
|
stt_desc: "Speech-to-text engine used by the deck, Telegram, and the CLI when listening.",
|
|
@@ -1225,6 +1254,9 @@ export const en = {
|
|
|
1225
1254
|
toast_mode_chain: "Mode: chain with fallback.",
|
|
1226
1255
|
toast_mode_single: "Mode: default engine only.",
|
|
1227
1256
|
toast_config_saved: "Voice configuration saved.",
|
|
1257
|
+
toast_provider_removed: "Provider removed.",
|
|
1258
|
+
err_label_required: "A name is required.",
|
|
1259
|
+
err_base_url_required:"A base URL is required.",
|
|
1228
1260
|
toast_transcription_updated: "Transcription updated.",
|
|
1229
1261
|
},
|
|
1230
1262
|
|
|
@@ -86,6 +86,7 @@ export const es = {
|
|
|
86
86
|
desktop: "Escritorio",
|
|
87
87
|
deck: "Deck",
|
|
88
88
|
code: "Code",
|
|
89
|
+
web: "Web",
|
|
89
90
|
},
|
|
90
91
|
},
|
|
91
92
|
topbar: {
|
|
@@ -144,6 +145,7 @@ export const es = {
|
|
|
144
145
|
appearance: "Apariencia",
|
|
145
146
|
light_mode: "Claro",
|
|
146
147
|
dark_mode: "Oscuro",
|
|
148
|
+
system_mode: "Sistema",
|
|
147
149
|
language: "Idioma",
|
|
148
150
|
daemon: "Daemon",
|
|
149
151
|
daemon_sub: "Estado del proceso local que sirve esta web y orquesta los agentes.",
|
|
@@ -203,7 +205,7 @@ export const es = {
|
|
|
203
205
|
owner_context_hint: "Quién sos, en qué trabajás, qué le interesa al agente saber de vos.",
|
|
204
206
|
language: "Idioma preferido",
|
|
205
207
|
timezone: "Timezone (IANA)",
|
|
206
|
-
timezone_hint: "
|
|
208
|
+
timezone_hint: "Detectado automáticamente — buscá para cambiar.",
|
|
207
209
|
saved: "Identidad guardada.",
|
|
208
210
|
},
|
|
209
211
|
|
|
@@ -1144,9 +1146,20 @@ export const es = {
|
|
|
1144
1146
|
voice_id_label: "Voice ID",
|
|
1145
1147
|
voice_id_hint: "Voice id de ElevenLabs (vacío = default).",
|
|
1146
1148
|
gemini_model_hint: "El TTS de Gemini todavía está en preview.",
|
|
1149
|
+
base_url_label: "Base URL (opcional)",
|
|
1150
|
+
base_url_hint: "Endpoint compatible con OpenAI. Vacío = OpenAI. Apuntalo a un servidor local (ej. un daemon QVox / Qwen3-TTS) para usar ese en su lugar.",
|
|
1151
|
+
openai_model_hint: "tts-1 / tts-1-hd para OpenAI. Dejalo vacío para que un servidor custom elija.",
|
|
1152
|
+
openai_voice_hint: "Preset de OpenAI (alloy…) o preset de tu servidor custom (ej. custom). Vacío = default del servidor.",
|
|
1153
|
+
openai_style_hint: "Voz base / instruct, usada por endpoints custom (la persona que se mantiene en todo el audio). El tts-1 de OpenAI la ignora.",
|
|
1147
1154
|
style_label: "Estilo (cómo debería hablar)",
|
|
1148
1155
|
style_hint: "Instrucción en lenguaje natural. Vacío = sin estilo. Ej.: 'hablá en un tono alegre y pausado'.",
|
|
1149
1156
|
style_ph: "hablá en un tono alegre y enérgico",
|
|
1157
|
+
temperature_label: "Temperatura (opcional)",
|
|
1158
|
+
temperature_hint: "Temperatura de sampleo para endpoints custom. Vacío = default del servidor.",
|
|
1159
|
+
emotions_label: "Tags de emoción inline",
|
|
1160
|
+
emotions_hint: "Cuando hable este motor, deja que el agente meta tags tipo [happy]/[whisper] en las respuestas de voz para darles color. Activalo solo si este motor entiende los tags (ej. un endpoint QVox/Qwen3-TTS) — si no, se quitan antes de sintetizar.",
|
|
1161
|
+
emotions_tags_label: "Tags permitidos",
|
|
1162
|
+
emotions_tags_hint: "Separados por coma. Vacío = el set por defecto.",
|
|
1150
1163
|
piper_bin_label: "Binario (bin)",
|
|
1151
1164
|
piper_bin_hint: "Ruta o nombre del CLI de piper (PATH).",
|
|
1152
1165
|
piper_model_label: "Modelo (.onnx)",
|
|
@@ -1166,8 +1179,24 @@ export const es = {
|
|
|
1166
1179
|
badge_unavailable: "configurado, no disponible",
|
|
1167
1180
|
badge_not_configured: "sin configurar",
|
|
1168
1181
|
badge_default: "default",
|
|
1182
|
+
badge_custom: "custom",
|
|
1169
1183
|
set_as_default: "Usar como default",
|
|
1170
1184
|
configure: "Configurar",
|
|
1185
|
+
remove: "Quitar",
|
|
1186
|
+
remove_confirm: "¿Quitar este proveedor custom?",
|
|
1187
|
+
add_provider: "Agregar proveedor",
|
|
1188
|
+
new_provider: "Nuevo proveedor",
|
|
1189
|
+
custom_note: "Endpoint custom compatible con OpenAI.",
|
|
1190
|
+
custom_desc: "Cualquier endpoint de voz compatible con OpenAI (ej. un servidor local QVox / Qwen3-TTS).",
|
|
1191
|
+
label_label: "Nombre",
|
|
1192
|
+
label_hint: "Nombre para mostrar de este proveedor.",
|
|
1193
|
+
base_url_req_label: "Base URL",
|
|
1194
|
+
base_url_req_hint: "Requerido. El endpoint compatible con OpenAI, ej. http://127.0.0.1:5111/v1",
|
|
1195
|
+
api_key_optional_hint:"Opcional — solo si tu servidor pide key.",
|
|
1196
|
+
advanced: "Avanzado",
|
|
1197
|
+
custom_model_hint: "Opcional. La mayoría de los servidores locales lo ignoran (ej. QVox).",
|
|
1198
|
+
custom_voice_hint: "Opcional. Un preset que entienda tu servidor (ej. custom). Vacío = default del servidor.",
|
|
1199
|
+
custom_optional_ph: "(opcional)",
|
|
1171
1200
|
stt_engine_label: "Engine de transcripción",
|
|
1172
1201
|
stt_engine_hint: "Local usa faster-whisper (requiere python3 + faster-whisper). OpenAI usa la key de engines.openai.",
|
|
1173
1202
|
stt_model_label: "Modelo local (whisper)",
|
|
@@ -1215,7 +1244,7 @@ export const es = {
|
|
|
1215
1244
|
stop: "Detener",
|
|
1216
1245
|
replay: "Repetir",
|
|
1217
1246
|
engine_result: "Engine",
|
|
1218
|
-
providers_desc: "Engines de síntesis. El estado lo reporta el daemon en vivo.
|
|
1247
|
+
providers_desc: "Engines de síntesis, en orden de fallback. El estado lo reporta el daemon en vivo. Agregá tus propios endpoints compatibles con OpenAI.",
|
|
1219
1248
|
providers_load_error: "No pude cargar los proveedores: {msg}",
|
|
1220
1249
|
test_desc: "Elegí con qué engine sintetizar y, si aplica, cómo debería hablar.",
|
|
1221
1250
|
stt_desc: "Engine de speech-to-text que usan el deck, Telegram y la CLI al escuchar.",
|
|
@@ -1223,6 +1252,9 @@ export const es = {
|
|
|
1223
1252
|
toast_mode_chain: "Modo: cadena con fallback.",
|
|
1224
1253
|
toast_mode_single: "Modo: solo engine default.",
|
|
1225
1254
|
toast_config_saved: "Configuración de voz guardada.",
|
|
1255
|
+
toast_provider_removed: "Proveedor eliminado.",
|
|
1256
|
+
err_label_required: "Falta el nombre.",
|
|
1257
|
+
err_base_url_required:"Falta la base URL.",
|
|
1226
1258
|
toast_transcription_updated: "Transcripción actualizada.",
|
|
1227
1259
|
},
|
|
1228
1260
|
|
|
@@ -22,6 +22,9 @@ export interface TtsEngineInfo {
|
|
|
22
22
|
available: boolean; // probe says it can synthesize right now
|
|
23
23
|
configured: boolean; // has a non-empty voice.tts.<id> config block
|
|
24
24
|
enabled: boolean; // included in the fallback chain (voice.tts.<id>.enabled)
|
|
25
|
+
custom?: boolean; // user-added OpenAI-compatible provider ("custom:<slug>")
|
|
26
|
+
label?: string; // display name for custom providers
|
|
27
|
+
note?: string; // e.g. the custom base_url
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
export interface TtsProvidersResponse {
|
|
@@ -67,11 +70,20 @@ export interface ElevenLabsConfig {
|
|
|
67
70
|
voice_id?: string;
|
|
68
71
|
output_format?: string;
|
|
69
72
|
}
|
|
73
|
+
/** Inline emotion-tags capability (generic; add it to any tag-aware engine). */
|
|
74
|
+
export interface EmotionsConfig {
|
|
75
|
+
enabled?: boolean;
|
|
76
|
+
tags?: string[]; // empty → canonical default set
|
|
77
|
+
}
|
|
70
78
|
export interface OpenAiTtsConfig {
|
|
71
79
|
api_key?: string;
|
|
72
|
-
|
|
73
|
-
|
|
80
|
+
base_url?: string; // custom OpenAI-compatible endpoint (e.g. a local QVox)
|
|
81
|
+
model?: string; // tts-1 | tts-1-hd (optional for custom endpoints)
|
|
82
|
+
voice?: string; // alloy | echo | fable | onyx | nova | shimmer | preset
|
|
74
83
|
format?: string; // mp3 | opus | aac | flac | wav
|
|
84
|
+
style?: string; // base voice / "instruct" (custom endpoints)
|
|
85
|
+
temperature?: number; // custom endpoints
|
|
86
|
+
emotions?: EmotionsConfig;
|
|
75
87
|
}
|
|
76
88
|
export interface GeminiTtsConfig {
|
|
77
89
|
api_key?: string;
|
|
@@ -79,6 +91,21 @@ export interface GeminiTtsConfig {
|
|
|
79
91
|
voice?: string; // e.g. Kore
|
|
80
92
|
style?: string; // natural-language speaking-style instruction
|
|
81
93
|
enabled?: boolean;
|
|
94
|
+
emotions?: EmotionsConfig;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A user-added OpenAI-compatible provider (voice.tts.custom.<slug>). */
|
|
98
|
+
export interface CustomTtsConfig {
|
|
99
|
+
label?: string; // display name
|
|
100
|
+
base_url: string; // OpenAI-compatible endpoint (required)
|
|
101
|
+
api_key?: string;
|
|
102
|
+
model?: string;
|
|
103
|
+
voice?: string;
|
|
104
|
+
format?: string;
|
|
105
|
+
style?: string; // base voice / instruct
|
|
106
|
+
temperature?: number;
|
|
107
|
+
emotions?: EmotionsConfig;
|
|
108
|
+
enabled?: boolean;
|
|
82
109
|
}
|
|
83
110
|
|
|
84
111
|
export interface VoiceTtsConfig {
|
|
@@ -89,6 +116,7 @@ export interface VoiceTtsConfig {
|
|
|
89
116
|
elevenlabs?: ElevenLabsConfig;
|
|
90
117
|
openai?: OpenAiTtsConfig;
|
|
91
118
|
gemini?: GeminiTtsConfig;
|
|
119
|
+
custom?: Record<string, CustomTtsConfig>; // user-added providers, by slug
|
|
92
120
|
}
|
|
93
121
|
|
|
94
122
|
export interface TranscriptionLocalConfig {
|
|
@@ -163,6 +191,11 @@ export const OPENAI_TTS_VOICES = ["alloy", "echo", "fable", "onyx", "nova", "shi
|
|
|
163
191
|
export const GEMINI_TTS_VOICES = ["Kore", "Puck", "Charon", "Fenrir", "Aoede"];
|
|
164
192
|
export const ELEVENLABS_MODELS = ["eleven_multilingual_v2", "eleven_turbo_v2_5", "eleven_flash_v2_5"];
|
|
165
193
|
export const OPENAI_TTS_MODELS = ["tts-1", "tts-1-hd"];
|
|
194
|
+
// Canonical inline emotion-tag set (mirrors the daemon's DEFAULT_EMOTION_TAGS).
|
|
195
|
+
export const DEFAULT_EMOTION_TAGS = [
|
|
196
|
+
"happy", "sad", "excited", "angry", "calm",
|
|
197
|
+
"whisper", "shout", "laugh", "cry", "narrator", "neutral",
|
|
198
|
+
];
|
|
166
199
|
export const WHISPER_MODELS = ["tiny", "base", "small", "medium", "large-v2", "large-v3", "large-v3-turbo"];
|
|
167
200
|
|
|
168
201
|
// Friendly labels + ordering for the provider list. The daemon is the source
|
|
@@ -170,7 +203,7 @@ export const WHISPER_MODELS = ["tiny", "base", "small", "medium", "large-v2", "l
|
|
|
170
203
|
export const TTS_PROVIDER_META: Record<string, { name: string; note: string; local?: boolean }> = {
|
|
171
204
|
piper: { name: "Piper", note: "Local, offline (CLI + .onnx model). No API key.", local: true },
|
|
172
205
|
elevenlabs: { name: "ElevenLabs", note: "Cloud, multilingual. Requires an API key." },
|
|
173
|
-
openai: { name: "OpenAI", note: "Cloud (tts-1 / tts-1-hd)
|
|
206
|
+
openai: { name: "OpenAI", note: "Cloud (tts-1 / tts-1-hd) or any OpenAI-compatible endpoint (set a base URL for a local server, e.g. QVox)." },
|
|
174
207
|
gemini: { name: "Gemini", note: "Cloud (preview). Uses your Gemini key." },
|
|
175
208
|
mock: { name: "Mock", note: "Silent test engine. Always available as a fallback.", local: true },
|
|
176
209
|
};
|