@agentprojectcontext/apx 1.37.0 → 1.38.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.
Files changed (68) hide show
  1. package/README.md +11 -0
  2. package/package.json +1 -1
  3. package/src/core/mascot.js +80 -80
  4. package/src/interfaces/web/dist/assets/index-CQc_5t8F.js +629 -0
  5. package/src/interfaces/web/dist/assets/index-CQc_5t8F.js.map +1 -0
  6. package/src/interfaces/web/dist/assets/{index-B6sYFQFa.css → index-hwxuTPcK.css} +1 -1
  7. package/src/interfaces/web/dist/index.html +2 -2
  8. package/src/interfaces/web/src/App.tsx +15 -24
  9. package/src/interfaces/web/src/components/ModelCombobox.tsx +1 -1
  10. package/src/interfaces/web/src/components/Roby.tsx +96 -0
  11. package/src/interfaces/web/src/components/TelegramChannelDialog.tsx +11 -11
  12. package/src/interfaces/web/src/components/TelegramSendDialog.tsx +5 -5
  13. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +2 -2
  14. package/src/interfaces/web/src/components/chat/ModelPicker.tsx +5 -5
  15. package/src/interfaces/web/src/components/chat/ToolCall.tsx +23 -19
  16. package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +10 -10
  17. package/src/interfaces/web/src/components/code/CodeContextTab.tsx +7 -7
  18. package/src/interfaces/web/src/components/code/CodeProjectPicker.tsx +3 -2
  19. package/src/interfaces/web/src/components/common/TabNav.tsx +3 -2
  20. package/src/interfaces/web/src/components/config/ConfigTabsEditor.tsx +3 -2
  21. package/src/interfaces/web/src/components/config/GlobalConfigEditor.tsx +2 -2
  22. package/src/interfaces/web/src/components/config/global-config-sections.ts +9 -9
  23. package/src/interfaces/web/src/components/config/project-config-sections.ts +61 -54
  24. package/src/interfaces/web/src/components/deck/DaemonCard.tsx +6 -5
  25. package/src/interfaces/web/src/components/inputs/KeyValueList.tsx +5 -4
  26. package/src/interfaces/web/src/components/inputs/VarTokenInput.tsx +3 -3
  27. package/src/interfaces/web/src/components/layout/ProjectSidebar.tsx +22 -9
  28. package/src/interfaces/web/src/components/settings/AdvancedPanel.tsx +1 -1
  29. package/src/interfaces/web/src/components/settings/AppearancePanel.tsx +1 -1
  30. package/src/interfaces/web/src/components/settings/DefaultRouterCard.tsx +14 -14
  31. package/src/interfaces/web/src/components/settings/DevicesPanel.tsx +3 -3
  32. package/src/interfaces/web/src/components/settings/EnginesPanel.tsx +7 -7
  33. package/src/interfaces/web/src/components/settings/IdentityPanel.tsx +2 -2
  34. package/src/interfaces/web/src/components/settings/MemoryPanel.tsx +37 -37
  35. package/src/interfaces/web/src/components/settings/SkillsInspectorPanel.tsx +44 -35
  36. package/src/interfaces/web/src/components/settings/SuperAgentPanel.tsx +5 -5
  37. package/src/interfaces/web/src/components/settings/TelegramChannelsPanel.tsx +3 -3
  38. package/src/interfaces/web/src/components/settings/TelegramContactsPanel.tsx +1 -1
  39. package/src/interfaces/web/src/components/settings/TelegramGlobalPanel.tsx +3 -3
  40. package/src/interfaces/web/src/components/settings/TelegramRolesPanel.tsx +1 -1
  41. package/src/interfaces/web/src/components/settings/providers/ProviderCard.tsx +6 -6
  42. package/src/interfaces/web/src/components/settings/providers/ProviderModal.tsx +36 -36
  43. package/src/interfaces/web/src/components/voice/VoiceProviderList.tsx +15 -14
  44. package/src/interfaces/web/src/components/voice/VoiceProviderModal.tsx +22 -22
  45. package/src/interfaces/web/src/components/voice/VoiceSttCard.tsx +18 -17
  46. package/src/interfaces/web/src/components/voice/VoiceTestCard.tsx +19 -18
  47. package/src/interfaces/web/src/hooks/useChat.ts +6 -5
  48. package/src/interfaces/web/src/i18n/en.ts +517 -1
  49. package/src/interfaces/web/src/i18n/es.ts +517 -1
  50. package/src/interfaces/web/src/i18n/index.ts +1 -1
  51. package/src/interfaces/web/src/lib/api/voice.ts +5 -5
  52. package/src/interfaces/web/src/screens/ProjectScreen.tsx +14 -1
  53. package/src/interfaces/web/src/screens/SettingsScreen.tsx +1 -1
  54. package/src/interfaces/web/src/screens/base/AgentDefaultsTab.tsx +8 -8
  55. package/src/interfaces/web/src/screens/base/ComingSoon.tsx +3 -2
  56. package/src/interfaces/web/src/screens/modules/CodeScreen.tsx +12 -12
  57. package/src/interfaces/web/src/screens/modules/DeckScreen.tsx +15 -15
  58. package/src/interfaces/web/src/screens/modules/DesktopScreen.tsx +37 -37
  59. package/src/interfaces/web/src/screens/modules/VoiceScreen.tsx +8 -8
  60. package/src/interfaces/web/src/screens/project/AgentBrainGraph.tsx +16 -10
  61. package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +25 -24
  62. package/src/interfaces/web/src/screens/project/ChatTab.tsx +2 -2
  63. package/src/interfaces/web/src/screens/project/ConfigTab.tsx +3 -3
  64. package/src/interfaces/web/src/screens/project/McpsTab.tsx +6 -9
  65. package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +66 -52
  66. package/src/interfaces/web/src/screens/project/TelegramTab.tsx +1 -1
  67. package/src/interfaces/web/dist/assets/index-DsADpObh.js +0 -633
  68. package/src/interfaces/web/dist/assets/index-DsADpObh.js.map +0 -1
@@ -144,9 +144,9 @@ export function ProviderModal({ open, initial, existingSlugs, onClose, onSave }:
144
144
  });
145
145
  if (r.error) { setModelError(r.error); return; }
146
146
  setAvailableModels(r.models);
147
- if (r.models.length === 0) setModelError("Sin modelos. ¿Key/URL correctas?");
147
+ if (r.models.length === 0) setModelError(t("providers_modal.err_no_models"));
148
148
  } catch (e) {
149
- setModelError((e as Error).message || "No se pudo listar modelos.");
149
+ setModelError((e as Error).message || t("providers_modal.err_list_models"));
150
150
  } finally { setLoadingModels(false); }
151
151
  };
152
152
 
@@ -158,8 +158,8 @@ export function ProviderModal({ open, initial, existingSlugs, onClose, onSave }:
158
158
 
159
159
  const buildProvider = (): { provider: Provider; modelLimits?: Record<string, number> } | null => {
160
160
  const slug = (f.slug || slugify(f.name)).trim();
161
- if (!slug) { setError("Slug requerido."); return null; }
162
- if (!isEdit && existingSlugs.includes(slug)) { setError(`Ya existe un provider "${slug}".`); return null; }
161
+ if (!slug) { setError(t("providers_modal.err_slug_required")); return null; }
162
+ if (!isEdit && existingSlugs.includes(slug)) { setError(t("providers_modal.err_slug_exists", { slug })); return null; }
163
163
 
164
164
  let modelLimits: Record<string, number> | undefined;
165
165
  if (f.model_context_limits_json.trim()) {
@@ -167,7 +167,7 @@ export function ProviderModal({ open, initial, existingSlugs, onClose, onSave }:
167
167
  const parsed = JSON.parse(f.model_context_limits_json);
168
168
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error();
169
169
  modelLimits = parsed;
170
- } catch { setError("Límites de contexto por modelo: JSON inválido."); return null; }
170
+ } catch { setError(t("providers_modal.err_model_limits_json")); return null; }
171
171
  }
172
172
 
173
173
  const pricingVals = [f.p_input, f.p_output, f.p_cache_read, f.p_cache_write].map((x) => x.trim());
@@ -227,16 +227,16 @@ export function ProviderModal({ open, initial, existingSlugs, onClose, onSave }:
227
227
  try {
228
228
  if (jsonMode) {
229
229
  const slug = (f.slug || slugify(f.name)).trim();
230
- if (!slug) { setError("Slug requerido (en el formulario)."); return; }
230
+ if (!slug) { setError(t("providers_modal.err_slug_required_form")); return; }
231
231
  let parsed: unknown;
232
232
  try { parsed = JSON.parse(jsonText); }
233
- catch { setError("JSON inválido: revisá la sintaxis."); return; }
233
+ catch { setError(t("providers_modal.err_json_invalid")); return; }
234
234
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
235
- setError("El JSON debe ser un objeto con la config del provider."); return;
235
+ setError(t("providers_modal.err_json_object")); return;
236
236
  }
237
237
  const raw = parsed as Record<string, unknown>;
238
238
  if (!raw.engine || typeof raw.engine !== "string") {
239
- setError('Falta "engine" (ej. "anthropic", "ollama").'); return;
239
+ setError(t("providers_modal.err_engine_missing")); return;
240
240
  }
241
241
  const provider: Provider = {
242
242
  slug,
@@ -256,13 +256,13 @@ export function ProviderModal({ open, initial, existingSlugs, onClose, onSave }:
256
256
  await onSave({ provider: built.provider, apiKeyValue: f.api_key_value.trim() || undefined, originalSlug: initial?.slug });
257
257
  onClose();
258
258
  } catch (e) {
259
- setError((e as Error).message || "Error al guardar.");
259
+ setError((e as Error).message || t("providers_modal.err_save"));
260
260
  } finally { setBusy(false); }
261
261
  };
262
262
 
263
263
  const existingKey = isEdit && isSecretMarker(initial?.api_key);
264
264
  const keySuffix = secretSuffix(initial?.api_key);
265
- const keyPlaceholder = existingKey ? `…${keySuffix ?? ""} (ya seteada)` : "sk-…";
265
+ const keyPlaceholder = existingKey ? t("providers_modal.api_key_set", { suffix: keySuffix ?? "" }) : "sk-…";
266
266
  const isOllama = f.engine === "ollama";
267
267
  const apiKeyEnv = ENGINE_PRESETS[f.engine]?.api_key_env;
268
268
 
@@ -271,12 +271,12 @@ export function ProviderModal({ open, initial, existingSlugs, onClose, onSave }:
271
271
  open={open}
272
272
  onClose={onClose}
273
273
  title={isEdit ? t("providers_modal.edit_title", { name: initial?.name || initial?.slug || "" }) : t("providers_modal.new_title")}
274
- description="Proveedor LLM. El motor (engine) define qué adapter usa (openai, ollama, …)."
274
+ description={t("providers_modal.description")}
275
275
  size="lg"
276
276
  footer={
277
277
  <>
278
- <Button variant="ghost" onClick={onClose} disabled={busy}>Cancelar</Button>
279
- <Button variant="primary" onClick={submit} loading={busy}>{isEdit ? "Guardar" : "Crear"}</Button>
278
+ <Button variant="ghost" onClick={onClose} disabled={busy}>{t("common.cancel")}</Button>
279
+ <Button variant="primary" onClick={submit} loading={busy}>{isEdit ? t("common.save") : t("common.create")}</Button>
280
280
  </>
281
281
  }
282
282
  >
@@ -286,7 +286,7 @@ export function ProviderModal({ open, initial, existingSlugs, onClose, onSave }:
286
286
  <div className="flex flex-wrap gap-1.5">
287
287
  {PRESET_PILLS.map((eng) => {
288
288
  const Icon = ENGINE_ICONS[eng];
289
- const label = eng === "custom" ? "Custom" : (ENGINE_OPTIONS.find((o) => o.value === eng)?.label || eng);
289
+ const label = eng === "custom" ? t("providers_modal.custom") : (ENGINE_OPTIONS.find((o) => o.value === eng)?.label || eng);
290
290
  const selected = f.engine === eng;
291
291
  return (
292
292
  <button
@@ -312,13 +312,13 @@ export function ProviderModal({ open, initial, existingSlugs, onClose, onSave }:
312
312
  jsonMode ? "border-sky-500/50 bg-sky-500/10 text-sky-400" : "border-border text-muted-fg hover:text-foreground"
313
313
  }`}
314
314
  >
315
- <Braces className="size-3.5" /> {jsonMode ? "Volver al formulario" : "JSON"}
315
+ <Braces className="size-3.5" /> {jsonMode ? t("providers_modal.form_mode") : t("providers_modal.json_mode")}
316
316
  </button>
317
317
  </div>
318
318
 
319
319
  {jsonMode ? (
320
320
  <div className="space-y-2">
321
- <Field label="Config del provider (JSON)" hint={`Se guarda como engines.${(f.slug || slugify(f.name)) || "<slug>"} en config.json`}>
321
+ <Field label={t("providers_modal.json_label")} hint={t("providers_modal.json_hint", { slug: (f.slug || slugify(f.name)) || "<slug>" })}>
322
322
  <Textarea
323
323
  rows={14}
324
324
  className="font-mono text-xs"
@@ -327,15 +327,15 @@ export function ProviderModal({ open, initial, existingSlugs, onClose, onSave }:
327
327
  spellCheck={false}
328
328
  />
329
329
  </Field>
330
- <p className="text-[11px] text-muted-fg">Debe ser un objeto JSON válido con al menos <code>engine</code>. El slug se toma del formulario.</p>
330
+ <p className="text-[11px] text-muted-fg">{t("providers_modal.json_help")}</p>
331
331
  </div>
332
332
  ) : (
333
333
  <>
334
334
  <div className="grid grid-cols-2 gap-3">
335
- <Field label="Nombre">
336
- <Input value={f.name} onChange={(e) => up({ name: e.target.value, slug: isEdit ? f.slug : slugify(e.target.value) })} placeholder="Mi provider" />
335
+ <Field label={t("providers_modal.name_label")}>
336
+ <Input value={f.name} onChange={(e) => up({ name: e.target.value, slug: isEdit ? f.slug : slugify(e.target.value) })} placeholder={t("providers_modal.name_ph")} />
337
337
  </Field>
338
- <Field label="Motor (engine)">
338
+ <Field label={t("providers_modal.engine_label")}>
339
339
  <UiSelect
340
340
  value={f.engine}
341
341
  onChange={(v) => changeEngine(v as EngineType)}
@@ -344,17 +344,17 @@ export function ProviderModal({ open, initial, existingSlugs, onClose, onSave }:
344
344
  </Field>
345
345
  </div>
346
346
 
347
- <Field label="URL base (base_url)" hint="Se completa sola al elegir un proveedor.">
348
- <Input value={f.base_url} onChange={(e) => up({ base_url: e.target.value })} placeholder="https://api.openai.com/v1" />
347
+ <Field label={t("providers_modal.base_url_label")} hint={t("providers_modal.base_url_hint")}>
348
+ <Input value={f.base_url} onChange={(e) => up({ base_url: e.target.value })} placeholder={t("providers_modal.base_url_ph")} />
349
349
  </Field>
350
350
 
351
351
  {!isOllama && (
352
- <Field label="API key" hint={existingKey ? "Dejá en blanco para mantener la actual." : apiKeyEnv ? `Se guarda como secreto. Env sugerida: ${apiKeyEnv}` : "Se guarda como secreto."}>
352
+ <Field label={t("providers_modal.api_key_label")} hint={existingKey ? t("providers_modal.api_key_hint_existing") : apiKeyEnv ? t("providers_modal.api_key_hint_env", { env: apiKeyEnv }) : t("providers_modal.api_key_hint")}>
353
353
  <Input type="password" autoComplete="new-password" value={f.api_key_value} onChange={(e) => up({ api_key_value: e.target.value })} placeholder={keyPlaceholder} />
354
354
  </Field>
355
355
  )}
356
356
 
357
- <Field label="Modelo por defecto">
357
+ <Field label={t("providers_modal.model_label")}>
358
358
  <div className="space-y-2">
359
359
  <div className="flex items-start gap-2">
360
360
  <ModelCombobox
@@ -365,7 +365,7 @@ export function ProviderModal({ open, initial, existingSlugs, onClose, onSave }:
365
365
  />
366
366
  <Button size="sm" variant="secondary" onClick={loadModels} disabled={loadingModels} title={t("providers_modal.list_models_hint")} aria-label={t("providers_modal.list_models_hint")}>
367
367
  {loadingModels ? <Loader2 className="size-3.5 animate-spin" /> : <RefreshCw className="size-3.5" />}
368
- Cargar modelos
368
+ {t("providers_modal.load_models")}
369
369
  </Button>
370
370
  </div>
371
371
  {modelError && <p className="text-[11px] text-amber-400">{modelError}</p>}
@@ -373,29 +373,29 @@ export function ProviderModal({ open, initial, existingSlugs, onClose, onSave }:
373
373
  </Field>
374
374
 
375
375
  <div className="grid grid-cols-2 gap-3">
376
- <Field label="Máx. tokens (max_tokens)"><Input type="number" min={256} step={256} value={f.default_max_tokens} onChange={(e) => up({ default_max_tokens: parseInt(e.target.value) || 4096 })} /></Field>
377
- <Field label={`Temperatura: ${f.default_temperature.toFixed(1)}`}>
376
+ <Field label={t("providers_modal.max_tokens_label")}><Input type="number" min={256} step={256} value={f.default_max_tokens} onChange={(e) => up({ default_max_tokens: parseInt(e.target.value) || 4096 })} /></Field>
377
+ <Field label={t("providers_modal.temperature_label", { value: f.default_temperature.toFixed(1) })}>
378
378
  <input type="range" min={0} max={2} step={0.1} value={f.default_temperature} onChange={(e) => up({ default_temperature: parseFloat(e.target.value) })} className="mt-2 w-full accent-foreground" />
379
379
  </Field>
380
380
  </div>
381
381
 
382
382
  <details className="rounded-md border border-border bg-muted/20 p-3">
383
- <summary className="cursor-pointer text-xs font-medium text-muted-fg">Análisis de tokens / pricing (opcional)</summary>
383
+ <summary className="cursor-pointer text-xs font-medium text-muted-fg">{t("providers_modal.pricing_summary")}</summary>
384
384
  <div className="mt-3 space-y-3">
385
- <Field label="Límite de contexto (tokens)"><Input type="number" min={0} step={1024} value={f.context_limit_tokens} onChange={(e) => up({ context_limit_tokens: parseInt(e.target.value) || 0 })} /></Field>
385
+ <Field label={t("providers_modal.context_limit_label")}><Input type="number" min={0} step={1024} value={f.context_limit_tokens} onChange={(e) => up({ context_limit_tokens: parseInt(e.target.value) || 0 })} /></Field>
386
386
  <div className="grid grid-cols-2 gap-3">
387
- <Field label="$ entrada / 1M"><Input type="number" min={0} step={0.0001} value={f.p_input} onChange={(e) => up({ p_input: e.target.value })} placeholder="0.15" /></Field>
388
- <Field label="$ salida / 1M"><Input type="number" min={0} step={0.0001} value={f.p_output} onChange={(e) => up({ p_output: e.target.value })} placeholder="0.60" /></Field>
389
- <Field label="$ cache read / 1M"><Input type="number" min={0} step={0.0001} value={f.p_cache_read} onChange={(e) => up({ p_cache_read: e.target.value })} placeholder="0.03" /></Field>
390
- <Field label="$ cache write / 1M"><Input type="number" min={0} step={0.0001} value={f.p_cache_write} onChange={(e) => up({ p_cache_write: e.target.value })} placeholder="0.00" /></Field>
387
+ <Field label={t("providers_modal.price_input")}><Input type="number" min={0} step={0.0001} value={f.p_input} onChange={(e) => up({ p_input: e.target.value })} placeholder="0.15" /></Field>
388
+ <Field label={t("providers_modal.price_output")}><Input type="number" min={0} step={0.0001} value={f.p_output} onChange={(e) => up({ p_output: e.target.value })} placeholder="0.60" /></Field>
389
+ <Field label={t("providers_modal.price_cache_read")}><Input type="number" min={0} step={0.0001} value={f.p_cache_read} onChange={(e) => up({ p_cache_read: e.target.value })} placeholder="0.03" /></Field>
390
+ <Field label={t("providers_modal.price_cache_write")}><Input type="number" min={0} step={0.0001} value={f.p_cache_write} onChange={(e) => up({ p_cache_write: e.target.value })} placeholder="0.00" /></Field>
391
391
  </div>
392
- <Field label="Límites de contexto por modelo (JSON)" hint='{"gpt-4o-mini":128000}'>
392
+ <Field label={t("providers_modal.model_limits_label")} hint='{"gpt-4o-mini":128000}'>
393
393
  <Textarea rows={3} className="font-mono text-xs" value={f.model_context_limits_json} onChange={(e) => up({ model_context_limits_json: e.target.value })} />
394
394
  </Field>
395
395
  </div>
396
396
  </details>
397
397
 
398
- <Switch checked={f.is_active} onChange={(v) => up({ is_active: v })} label="Activo (los agentes pueden usarlo)" />
398
+ <Switch checked={f.is_active} onChange={(v) => up({ is_active: v })} label={t("providers_modal.active_label")} />
399
399
  </>
400
400
  )}
401
401
 
@@ -7,6 +7,7 @@ import {
7
7
  type TtsEngineInfo,
8
8
  type TtsMode,
9
9
  } from "../../lib/api/voice";
10
+ import { t } from "../../i18n";
10
11
 
11
12
  // TTS engine selector. Two modes:
12
13
  // chain — ordered fallback router; toggles enable/disable engines and the
@@ -63,11 +64,11 @@ export function VoiceProviderList({
63
64
  <div className="rounded-lg border border-border p-3">
64
65
  <div className="flex flex-wrap items-center justify-between gap-2">
65
66
  <div className="min-w-0">
66
- <div className="text-sm font-medium">Modo de selección</div>
67
+ <div className="text-sm font-medium">{t("voice_ui.selection_mode")}</div>
67
68
  <div className="text-xs text-muted-fg">
68
69
  {isChain
69
- ? "Cadena con fallback: usa el primer motor disponible según el orden de abajo."
70
- : "Solo el motor por defecto: usa siempre el elegido; los demás quedan configurados para otras cosas."}
70
+ ? t("voice_ui.mode_chain_desc")
71
+ : t("voice_ui.mode_single_desc")}
71
72
  </div>
72
73
  </div>
73
74
  <div className="flex shrink-0 overflow-hidden rounded-md border border-border" role="group">
@@ -81,7 +82,7 @@ export function VoiceProviderList({
81
82
  isChain ? "bg-emerald-500/15 text-emerald-300" : "text-muted-fg hover:text-fg",
82
83
  )}
83
84
  >
84
- Cadena (router)
85
+ {t("voice_ui.mode_chain_btn")}
85
86
  </button>
86
87
  <button
87
88
  type="button"
@@ -93,7 +94,7 @@ export function VoiceProviderList({
93
94
  !isChain ? "bg-emerald-500/15 text-emerald-300" : "text-muted-fg hover:text-fg",
94
95
  )}
95
96
  >
96
- Solo el motor por defecto
97
+ {t("voice_ui.mode_single_btn")}
97
98
  </button>
98
99
  </div>
99
100
  </div>
@@ -122,7 +123,7 @@ export function VoiceProviderList({
122
123
  type="button"
123
124
  onClick={() => move(id, -1)}
124
125
  disabled={busy || idx === 0}
125
- aria-label="Subir"
126
+ aria-label={t("voice_ui.move_up")}
126
127
  data-testid={`voice-provider-${id}-up`}
127
128
  className="text-muted-fg hover:text-fg disabled:opacity-30"
128
129
  >
@@ -132,7 +133,7 @@ export function VoiceProviderList({
132
133
  type="button"
133
134
  onClick={() => move(id, 1)}
134
135
  disabled={busy || idx === ids.length - 1}
135
- aria-label="Bajar"
136
+ aria-label={t("voice_ui.move_down")}
136
137
  data-testid={`voice-provider-${id}-down`}
137
138
  className="text-muted-fg hover:text-fg disabled:opacity-30"
138
139
  >
@@ -145,15 +146,15 @@ export function VoiceProviderList({
145
146
  <div className="min-w-0 flex-1">
146
147
  <div className="flex items-center gap-2">
147
148
  <span className="text-sm font-medium">{meta.name}</span>
148
- {meta.local && <Badge tone="info">local</Badge>}
149
+ {meta.local && <Badge tone="info">{t("voice_ui.badge_local")}</Badge>}
149
150
  {e.available ? (
150
- <Badge tone="success">disponible</Badge>
151
+ <Badge tone="success">{t("voice_ui.badge_available")}</Badge>
151
152
  ) : e.configured ? (
152
- <Badge tone="warning">configurado, no disponible</Badge>
153
+ <Badge tone="warning">{t("voice_ui.badge_unavailable")}</Badge>
153
154
  ) : (
154
- <Badge tone="muted">sin configurar</Badge>
155
+ <Badge tone="muted">{t("voice_ui.badge_not_configured")}</Badge>
155
156
  )}
156
- {isDefault && <Badge tone="success">por defecto</Badge>}
157
+ {isDefault && <Badge tone="success">{t("voice_ui.badge_default")}</Badge>}
157
158
  </div>
158
159
  <div className="truncate text-xs text-muted-fg">{meta.note}</div>
159
160
  </div>
@@ -174,7 +175,7 @@ export function VoiceProviderList({
174
175
  disabled={busy}
175
176
  data-testid={`voice-provider-${id}-default`}
176
177
  >
177
- <Circle className="size-3.5" /> Usar por defecto
178
+ <Circle className="size-3.5" /> {t("voice_ui.set_as_default")}
178
179
  </Button>
179
180
  )
180
181
  )}
@@ -185,7 +186,7 @@ export function VoiceProviderList({
185
186
  onClick={() => onConfigure(id)}
186
187
  data-testid={`voice-provider-${id}-config`}
187
188
  >
188
- <Settings2 className="size-3.5" /> Configurar
189
+ <Settings2 className="size-3.5" /> {t("voice_ui.configure")}
189
190
  </Button>
190
191
  </div>
191
192
  </div>
@@ -77,7 +77,7 @@ export function VoiceProviderModal({ open, providerId, config, onClose, onSave }
77
77
 
78
78
  const hasSecret = providerId !== "piper" && providerId !== "mock";
79
79
  const existingKey = hasSecret && isSecretMarker((config as { api_key?: unknown })?.api_key);
80
- const keyPlaceholder = existingKey ? `…${secretSuffix((config as { api_key?: unknown })?.api_key) ?? ""} (ya seteada)` : "API key";
80
+ 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
81
 
82
82
  const submit = async () => {
83
83
  setBusy(true);
@@ -115,7 +115,7 @@ export function VoiceProviderModal({ open, providerId, config, onClose, onSave }
115
115
  await onSave({ set, unset });
116
116
  onClose();
117
117
  } catch (e) {
118
- setError((e as Error).message || "Error al guardar.");
118
+ setError((e as Error).message || t("voice_ui.err_save"));
119
119
  } finally {
120
120
  setBusy(false);
121
121
  }
@@ -130,21 +130,21 @@ export function VoiceProviderModal({ open, providerId, config, onClose, onSave }
130
130
  size="md"
131
131
  footer={
132
132
  <>
133
- <Button variant="ghost" onClick={onClose} disabled={busy}>Cancelar</Button>
134
- <Button variant="primary" onClick={submit} loading={busy} data-testid="voice-provider-save">Guardar</Button>
133
+ <Button variant="ghost" onClick={onClose} disabled={busy}>{t("common.cancel")}</Button>
134
+ <Button variant="primary" onClick={submit} loading={busy} data-testid="voice-provider-save">{t("common.save")}</Button>
135
135
  </>
136
136
  }
137
137
  >
138
138
  <div className="space-y-3">
139
139
  {providerId === "piper" && (
140
140
  <>
141
- <Field label="Binario (bin)" hint="Ruta o nombre del CLI piper (PATH).">
141
+ <Field label={t("voice_ui.piper_bin_label")} hint={t("voice_ui.piper_bin_hint")}>
142
142
  <Input value={f.bin} onChange={(e) => up({ bin: e.target.value })} placeholder="piper" />
143
143
  </Field>
144
- <Field label="Modelo (.onnx)" hint="Ruta absoluta al modelo de voz piper.">
145
- <Input value={f.model} onChange={(e) => up({ model: e.target.value })} placeholder="/abs/path/voz.onnx" />
144
+ <Field label={t("voice_ui.piper_model_label")} hint={t("voice_ui.piper_model_hint")}>
145
+ <Input value={f.model} onChange={(e) => up({ model: e.target.value })} placeholder="/abs/path/voice.onnx" />
146
146
  </Field>
147
- <Field label="Speaker (opcional)" hint="Id de hablante para modelos multi-voz.">
147
+ <Field label={t("voice_ui.piper_speaker_label")} hint={t("voice_ui.piper_speaker_hint")}>
148
148
  <Input value={f.speaker} onChange={(e) => up({ speaker: e.target.value })} placeholder="0" />
149
149
  </Field>
150
150
  </>
@@ -152,16 +152,16 @@ export function VoiceProviderModal({ open, providerId, config, onClose, onSave }
152
152
 
153
153
  {providerId === "elevenlabs" && (
154
154
  <>
155
- <Field label="API key" hint={existingKey ? "Dejá en blanco para mantener la actual." : "Se guarda como secreto. Env: ELEVENLABS_API_KEY"}>
155
+ <Field label={t("voice_ui.api_key_label")} hint={existingKey ? t("voice_ui.api_key_keep_hint") : t("voice_ui.api_key_secret_hint", { env: "ELEVENLABS_API_KEY" })}>
156
156
  <Input type="password" autoComplete="new-password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder={keyPlaceholder} />
157
157
  </Field>
158
- <Field label="Modelo">
158
+ <Field label={t("voice_ui.model_label")}>
159
159
  <UiSelect value={f.model || ""} onChange={(v) => up({ model: v })} options={ELEVENLABS_MODELS.map((m) => ({ value: m, label: m }))} placeholder="eleven_multilingual_v2" />
160
160
  </Field>
161
- <Field label="Voice ID" hint="Id de la voz de ElevenLabs (vacío = default).">
161
+ <Field label={t("voice_ui.voice_id_label")} hint={t("voice_ui.voice_id_hint")}>
162
162
  <Input value={f.voice_id} onChange={(e) => up({ voice_id: e.target.value })} placeholder="EXAVITQu4vr4xnSDxMaL" />
163
163
  </Field>
164
- <Field label="Formato de salida">
164
+ <Field label={t("voice_ui.output_format_label")}>
165
165
  <Input value={f.output_format} onChange={(e) => up({ output_format: e.target.value })} placeholder="mp3_44100_128" />
166
166
  </Field>
167
167
  </>
@@ -169,16 +169,16 @@ export function VoiceProviderModal({ open, providerId, config, onClose, onSave }
169
169
 
170
170
  {providerId === "openai" && (
171
171
  <>
172
- <Field label="API key" hint={existingKey ? "Dejá en blanco para mantener la actual." : "Se reusa engines.openai.api_key si la dejás en blanco. Env: OPENAI_API_KEY"}>
172
+ <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" })}>
173
173
  <Input type="password" autoComplete="new-password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder={keyPlaceholder} />
174
174
  </Field>
175
- <Field label="Modelo">
175
+ <Field label={t("voice_ui.model_label")}>
176
176
  <UiSelect value={f.model || "tts-1"} onChange={(v) => up({ model: v })} options={OPENAI_TTS_MODELS.map((m) => ({ value: m, label: m }))} />
177
177
  </Field>
178
- <Field label="Voz">
178
+ <Field label={t("voice_ui.voice_label")}>
179
179
  <UiSelect value={f.voice || "alloy"} onChange={(v) => up({ voice: v })} options={OPENAI_TTS_VOICES.map((m) => ({ value: m, label: m }))} />
180
180
  </Field>
181
- <Field label="Formato">
181
+ <Field label={t("voice_ui.format_label")}>
182
182
  <UiSelect value={f.format || "mp3"} onChange={(v) => up({ format: v })} options={["mp3", "opus", "aac", "flac", "wav"].map((m) => ({ value: m, label: m }))} />
183
183
  </Field>
184
184
  </>
@@ -186,24 +186,24 @@ export function VoiceProviderModal({ open, providerId, config, onClose, onSave }
186
186
 
187
187
  {providerId === "gemini" && (
188
188
  <>
189
- <Field label="API key" hint={existingKey ? "Dejá en blanco para mantener la actual." : "Se reusa engines.gemini.api_key si la dejás en blanco. Env: GEMINI_API_KEY"}>
189
+ <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" })}>
190
190
  <Input type="password" autoComplete="new-password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder={keyPlaceholder} />
191
191
  </Field>
192
- <Field label="Modelo" hint="TTS de Gemini sigue en preview.">
192
+ <Field label={t("voice_ui.model_label")} hint={t("voice_ui.gemini_model_hint")}>
193
193
  <Input value={f.model} onChange={(e) => up({ model: e.target.value })} placeholder="gemini-2.5-flash-preview-tts" />
194
194
  </Field>
195
- <Field label="Voz">
195
+ <Field label={t("voice_ui.voice_label")}>
196
196
  <UiSelect value={f.voice || "Kore"} onChange={(v) => up({ voice: v })} options={GEMINI_TTS_VOICES.map((m) => ({ value: m, label: m }))} />
197
197
  </Field>
198
- <Field label="Estilo (cómo querés que hable)" hint="Instrucción en lenguaje natural. Vacío = sin estilo. Ej: 'hablá en tono alegre y pausado'.">
199
- <Textarea rows={2} value={f.style || ""} onChange={(e) => up({ style: e.target.value })} placeholder="hablá en tono alegre y enérgico" />
198
+ <Field label={t("voice_ui.style_label")} hint={t("voice_ui.style_hint")}>
199
+ <Textarea rows={2} value={f.style || ""} onChange={(e) => up({ style: e.target.value })} placeholder={t("voice_ui.style_ph")} />
200
200
  </Field>
201
201
  </>
202
202
  )}
203
203
 
204
204
  {providerId === "mock" && (
205
205
  <p className="text-sm text-muted-fg">
206
- El motor <strong>mock</strong> genera un WAV silencioso de prueba. No tiene parámetros: sirve como fallback garantizado cuando no hay otro motor configurado.
206
+ {t("voice_ui.mock_desc")}
207
207
  </p>
208
208
  )}
209
209
 
@@ -1,6 +1,7 @@
1
1
  import { Field } from "../ui";
2
2
  import { UiSelect } from "../UiSelect";
3
3
  import { WHISPER_MODELS, type TranscriptionConfig } from "../../lib/api/voice";
4
+ import { t } from "../../i18n";
4
5
 
5
6
  // STT (speech-to-text) configuration. Persisted under config.transcription.
6
7
  // The actual capture happens in the deck overlay / Telegram / CLI; here the
@@ -12,20 +13,20 @@ interface Props {
12
13
  busy?: boolean;
13
14
  }
14
15
 
15
- const PROVIDER_OPTIONS = [
16
- { value: "auto", label: "Automático (local, luego OpenAI)" },
17
- { value: "local", label: "Local — faster-whisper (offline)" },
18
- { value: "openai", label: "OpenAI — Whisper-1 (cloud)" },
16
+ const providerOptions = () => [
17
+ { value: "auto", label: t("voice_ui.stt_provider_auto") },
18
+ { value: "local", label: t("voice_ui.stt_provider_local") },
19
+ { value: "openai", label: t("voice_ui.stt_provider_openai") },
19
20
  ];
20
21
 
21
- const LANG_OPTIONS = [
22
- { value: "auto", label: "Auto-detectar" },
23
- { value: "es", label: "Español" },
24
- { value: "en", label: "Inglés" },
25
- { value: "pt", label: "Portugués" },
26
- { value: "fr", label: "Francés" },
27
- { value: "it", label: "Italiano" },
28
- { value: "de", label: "Alemán" },
22
+ const langOptions = () => [
23
+ { value: "auto", label: t("voice_ui.lang_auto") },
24
+ { value: "es", label: t("voice_ui.lang_es") },
25
+ { value: "en", label: t("voice_ui.lang_en") },
26
+ { value: "pt", label: t("voice_ui.lang_pt") },
27
+ { value: "fr", label: t("voice_ui.lang_fr") },
28
+ { value: "it", label: t("voice_ui.lang_it") },
29
+ { value: "de", label: t("voice_ui.lang_de") },
29
30
  ];
30
31
 
31
32
  export function VoiceSttCard({ config, onPatch, busy }: Props) {
@@ -37,11 +38,11 @@ export function VoiceSttCard({ config, onPatch, busy }: Props) {
37
38
 
38
39
  return (
39
40
  <div className="space-y-3">
40
- <Field label="Motor de transcripción" hint="Local usa faster-whisper (requiere python3 + faster-whisper). OpenAI usa la key de engines.openai.">
41
+ <Field label={t("voice_ui.stt_engine_label")} hint={t("voice_ui.stt_engine_hint")}>
41
42
  <UiSelect
42
43
  value={provider}
43
44
  onChange={(v) => onPatch({ "transcription.provider": v })}
44
- options={PROVIDER_OPTIONS}
45
+ options={providerOptions()}
45
46
  disabled={busy}
46
47
  className="max-w-md"
47
48
  />
@@ -49,7 +50,7 @@ export function VoiceSttCard({ config, onPatch, busy }: Props) {
49
50
 
50
51
  {usesLocal && (
51
52
  <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
52
- <Field label="Modelo local (whisper)" hint="Más grande = más preciso y más lento.">
53
+ <Field label={t("voice_ui.stt_model_label")} hint={t("voice_ui.stt_model_hint")}>
53
54
  <UiSelect
54
55
  value={model}
55
56
  onChange={(v) => onPatch({ "transcription.local.model": v })}
@@ -57,11 +58,11 @@ export function VoiceSttCard({ config, onPatch, busy }: Props) {
57
58
  disabled={busy}
58
59
  />
59
60
  </Field>
60
- <Field label="Idioma" hint='Para español, fijá "Español" mejora la precisión.'>
61
+ <Field label={t("voice_ui.stt_language_label")} hint={t("voice_ui.stt_language_hint")}>
61
62
  <UiSelect
62
63
  value={language}
63
64
  onChange={(v) => onPatch({ "transcription.local.language": v })}
64
- options={LANG_OPTIONS}
65
+ options={langOptions()}
65
66
  disabled={busy}
66
67
  />
67
68
  </Field>
@@ -5,6 +5,7 @@ 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
+ import { t } from "../../i18n";
8
9
 
9
10
  // "Decir esto" tester. Lets you pick which engine to synthesize with (overriding
10
11
  // the saved default) and add a free-text speaking-style instruction, then plays
@@ -21,7 +22,7 @@ interface Props {
21
22
  export function VoiceTestCard({ engines, defaultProvider, mode }: Props) {
22
23
  const toast = useToast();
23
24
  const { play, stop, playing, loading: playLoading } = useTtsPlayer();
24
- const [text, setText] = useState("Hola, soy APX. Esto es una prueba de voz.");
25
+ const [text, setText] = useState(t("voice_ui.test_default_text"));
25
26
  // "" = use the saved default; otherwise force a specific engine.
26
27
  const [engine, setEngine] = useState("");
27
28
  const [style, setStyle] = useState("");
@@ -30,34 +31,34 @@ export function VoiceTestCard({ engines, defaultProvider, mode }: Props) {
30
31
 
31
32
  const defaultLabel =
32
33
  mode === "single" && defaultProvider && defaultProvider !== "auto"
33
- ? `Por defecto (${TTS_PROVIDER_META[defaultProvider]?.name || defaultProvider})`
34
- : "Por defecto (cadena)";
34
+ ? t("voice_ui.test_default_engine", { name: TTS_PROVIDER_META[defaultProvider]?.name || defaultProvider })
35
+ : t("voice_ui.test_default_chain");
35
36
 
36
37
  const options = [
37
38
  { value: "", label: defaultLabel },
38
39
  ...engines.map((e) => ({
39
40
  value: e.id,
40
- label: `${TTS_PROVIDER_META[e.id]?.name || e.id}${e.available ? "" : " · no disponible"}`,
41
+ label: `${TTS_PROVIDER_META[e.id]?.name || e.id}${e.available ? "" : t("voice_ui.test_unavailable_suffix")}`,
41
42
  })),
42
43
  ];
43
44
 
44
45
  const say = async () => {
45
- const t = text.trim();
46
- if (!t) {
47
- toast.error("Escribí algo para decir.");
46
+ const txt = text.trim();
47
+ if (!txt) {
48
+ toast.error(t("voice_ui.test_empty_error"));
48
49
  return;
49
50
  }
50
51
  setBusy(true);
51
52
  try {
52
53
  const res = await Voice.say({
53
- text: t,
54
+ text: txt,
54
55
  provider: engine || undefined,
55
56
  style: style.trim() || undefined,
56
57
  });
57
58
  setLast(res);
58
59
  await play(res.audio_path);
59
60
  } catch (e) {
60
- toast.error((e as Error).message || "No se pudo sintetizar.");
61
+ toast.error((e as Error).message || t("voice_ui.test_synth_error"));
61
62
  } finally {
62
63
  setBusy(false);
63
64
  }
@@ -66,43 +67,43 @@ export function VoiceTestCard({ engines, defaultProvider, mode }: Props) {
66
67
  return (
67
68
  <div className="space-y-3">
68
69
  <div className="grid gap-3 sm:grid-cols-2">
69
- <Field label="Motor" hint="Override del por defecto para probar.">
70
+ <Field label={t("voice_ui.test_engine_label")} hint={t("voice_ui.test_engine_hint")}>
70
71
  <UiSelect value={engine} onChange={setEngine} options={options} />
71
72
  </Field>
72
- <Field label="Estilo (solo Gemini)" hint="Cómo querés que hable. Vacío = sin estilo.">
73
+ <Field label={t("voice_ui.test_style_label")} hint={t("voice_ui.test_style_hint")}>
73
74
  <Input
74
75
  value={style}
75
76
  onChange={(e) => setStyle(e.target.value)}
76
- placeholder="hablá en tono alegre y enérgico"
77
+ placeholder={t("voice_ui.style_ph")}
77
78
  data-testid="voice-test-style"
78
79
  />
79
80
  </Field>
80
81
  </div>
81
- <Field label="Texto a decir">
82
+ <Field label={t("voice_ui.test_text_label")}>
82
83
  <Textarea
83
84
  rows={2}
84
85
  value={text}
85
86
  onChange={(e) => setText(e.target.value)}
86
- placeholder="Escribí lo que querés que diga…"
87
+ placeholder={t("voice_ui.test_text_ph")}
87
88
  data-testid="voice-test-input"
88
89
  />
89
90
  </Field>
90
91
  <div className="flex items-center gap-2">
91
92
  <Button variant="primary" onClick={say} loading={busy} disabled={playLoading} data-testid="voice-test-say">
92
- <Volume2 className="size-4" /> Decir esto
93
+ <Volume2 className="size-4" /> {t("voice_ui.say_this")}
93
94
  </Button>
94
95
  {playing ? (
95
96
  <Button variant="secondary" onClick={stop} data-testid="voice-test-stop">
96
- <Square className="size-4" /> Parar
97
+ <Square className="size-4" /> {t("voice_ui.stop")}
97
98
  </Button>
98
99
  ) : last ? (
99
100
  <Button variant="secondary" onClick={() => play(last.audio_path)} loading={playLoading} data-testid="voice-test-replay">
100
- <Play className="size-4" /> Repetir
101
+ <Play className="size-4" /> {t("voice_ui.replay")}
101
102
  </Button>
102
103
  ) : null}
103
104
  {last && (
104
105
  <span className="text-xs text-muted-fg">
105
- Motor: <strong>{last.provider}</strong>
106
+ {t("voice_ui.engine_result")}: <strong>{last.provider}</strong>
106
107
  {last.duration_s ? ` · ${last.duration_s.toFixed(1)}s` : ""}
107
108
  </span>
108
109
  )}