@agentprojectcontext/apx 1.50.0 → 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.
@@ -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
- if (providerId === "piper") {
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 (providerId === "piper") {
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: meta?.name || providerId || "" })}
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, Input, Textarea } from "../ui";
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
- // "Decir esto" tester. Lets you pick which engine to synthesize with (overriding
11
- // the saved default) and add a free-text speaking-style instruction, then plays
12
- // the resulting audio in-browser via /tts/say. Style only affects engines that
13
- // support it (today: Gemini); other engines ignore it.
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.map((e) => ({
40
- value: e.id,
41
- label: `${TTS_PROVIDER_META[e.id]?.name || e.id}${e.available ? "" : t("voice_ui.test_unavailable_suffix")}`,
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
- <div className="grid gap-3 sm:grid-cols-2">
70
- <Field label={t("voice_ui.test_engine_label")} hint={t("voice_ui.test_engine_hint")}>
71
- <UiSelect value={engine} onChange={setEngine} options={options} />
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}
@@ -354,7 +354,7 @@ export function useChat(pid: string, onError?: (msg: string) => void): UseChatRe
354
354
  if (streaming) return;
355
355
  try {
356
356
  const detail = await Conversations.get(pid, agentSlug, conversationId);
357
- const loaded: ChatMsg[] = detail.messages
357
+ const loaded: ChatMsg[] = (detail.messages ?? [])
358
358
  .filter((m) => m.role === "user" || m.role === "assistant")
359
359
  .map((m) => ({
360
360
  role: m.role as "user" | "assistant",
@@ -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: "e.g. America/New_York",
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. Pick which one to use by default.",
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: "ej. America/Argentina/Buenos_Aires",
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. Elegí cuál usar por defecto.",
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
- model?: string; // tts-1 | tts-1-hd
73
- voice?: string; // alloy | echo | fable | onyx | nova | shimmer
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). Uses your OpenAI key." },
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
  };