@agentprojectcontext/apx 1.36.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 (79) hide show
  1. package/README.md +81 -3
  2. package/package.json +1 -1
  3. package/src/core/mascot.js +80 -80
  4. package/src/host/daemon/api/agents.js +6 -0
  5. package/src/host/daemon/api/conversations.js +9 -2
  6. package/src/host/daemon/api/web.js +20 -1
  7. package/src/host/daemon/desktop-ws.js +31 -0
  8. package/src/host/daemon/index.js +12 -2
  9. package/src/interfaces/cli/commands/agent.js +20 -0
  10. package/src/interfaces/cli/commands/chat.js +15 -6
  11. package/src/interfaces/cli/commands/identity.js +20 -1
  12. package/src/interfaces/cli/commands/update.js +2 -0
  13. package/src/interfaces/cli/index.js +14 -0
  14. package/src/interfaces/web/dist/assets/index-CQc_5t8F.js +629 -0
  15. package/src/interfaces/web/dist/assets/index-CQc_5t8F.js.map +1 -0
  16. package/src/interfaces/web/dist/assets/index-hwxuTPcK.css +1 -0
  17. package/src/interfaces/web/dist/index.html +2 -2
  18. package/src/interfaces/web/src/App.tsx +20 -9
  19. package/src/interfaces/web/src/components/ModelCombobox.tsx +1 -1
  20. package/src/interfaces/web/src/components/Roby.tsx +96 -0
  21. package/src/interfaces/web/src/components/TelegramChannelDialog.tsx +11 -11
  22. package/src/interfaces/web/src/components/TelegramSendDialog.tsx +5 -5
  23. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +2 -2
  24. package/src/interfaces/web/src/components/chat/ModelPicker.tsx +5 -5
  25. package/src/interfaces/web/src/components/chat/ToolCall.tsx +23 -19
  26. package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +10 -10
  27. package/src/interfaces/web/src/components/code/CodeContextTab.tsx +7 -7
  28. package/src/interfaces/web/src/components/code/CodeProjectPicker.tsx +3 -2
  29. package/src/interfaces/web/src/components/common/TabNav.tsx +3 -2
  30. package/src/interfaces/web/src/components/config/ConfigTabsEditor.tsx +3 -2
  31. package/src/interfaces/web/src/components/config/GlobalConfigEditor.tsx +2 -2
  32. package/src/interfaces/web/src/components/config/global-config-sections.ts +9 -9
  33. package/src/interfaces/web/src/components/config/project-config-sections.ts +61 -54
  34. package/src/interfaces/web/src/components/deck/DaemonCard.tsx +6 -5
  35. package/src/interfaces/web/src/components/inputs/KeyValueList.tsx +5 -4
  36. package/src/interfaces/web/src/components/inputs/VarTokenInput.tsx +3 -3
  37. package/src/interfaces/web/src/components/layout/ProjectSidebar.tsx +22 -9
  38. package/src/interfaces/web/src/components/settings/AdvancedPanel.tsx +1 -1
  39. package/src/interfaces/web/src/components/settings/AppearancePanel.tsx +1 -1
  40. package/src/interfaces/web/src/components/settings/DefaultRouterCard.tsx +14 -14
  41. package/src/interfaces/web/src/components/settings/DevicesPanel.tsx +3 -3
  42. package/src/interfaces/web/src/components/settings/EnginesPanel.tsx +7 -7
  43. package/src/interfaces/web/src/components/settings/IdentityPanel.tsx +2 -2
  44. package/src/interfaces/web/src/components/settings/MemoryPanel.tsx +37 -37
  45. package/src/interfaces/web/src/components/settings/SkillsInspectorPanel.tsx +44 -35
  46. package/src/interfaces/web/src/components/settings/SuperAgentPanel.tsx +5 -5
  47. package/src/interfaces/web/src/components/settings/TelegramChannelsPanel.tsx +3 -3
  48. package/src/interfaces/web/src/components/settings/TelegramContactsPanel.tsx +1 -1
  49. package/src/interfaces/web/src/components/settings/TelegramGlobalPanel.tsx +3 -3
  50. package/src/interfaces/web/src/components/settings/TelegramRolesPanel.tsx +1 -1
  51. package/src/interfaces/web/src/components/settings/providers/ProviderCard.tsx +6 -6
  52. package/src/interfaces/web/src/components/settings/providers/ProviderModal.tsx +36 -36
  53. package/src/interfaces/web/src/components/voice/VoiceProviderList.tsx +15 -14
  54. package/src/interfaces/web/src/components/voice/VoiceProviderModal.tsx +22 -22
  55. package/src/interfaces/web/src/components/voice/VoiceSttCard.tsx +18 -17
  56. package/src/interfaces/web/src/components/voice/VoiceTestCard.tsx +19 -18
  57. package/src/interfaces/web/src/hooks/useChat.ts +6 -5
  58. package/src/interfaces/web/src/i18n/en.ts +519 -2
  59. package/src/interfaces/web/src/i18n/es.ts +519 -2
  60. package/src/interfaces/web/src/i18n/index.ts +1 -1
  61. package/src/interfaces/web/src/lib/api/voice.ts +5 -5
  62. package/src/interfaces/web/src/screens/ProjectScreen.tsx +14 -1
  63. package/src/interfaces/web/src/screens/SettingsScreen.tsx +1 -1
  64. package/src/interfaces/web/src/screens/base/AgentDefaultsTab.tsx +8 -8
  65. package/src/interfaces/web/src/screens/base/ComingSoon.tsx +3 -2
  66. package/src/interfaces/web/src/screens/modules/CodeScreen.tsx +12 -12
  67. package/src/interfaces/web/src/screens/modules/DeckScreen.tsx +15 -15
  68. package/src/interfaces/web/src/screens/modules/DesktopScreen.tsx +37 -37
  69. package/src/interfaces/web/src/screens/modules/VoiceScreen.tsx +8 -8
  70. package/src/interfaces/web/src/screens/project/AgentBrainGraph.tsx +16 -10
  71. package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +25 -24
  72. package/src/interfaces/web/src/screens/project/ChatTab.tsx +2 -2
  73. package/src/interfaces/web/src/screens/project/ConfigTab.tsx +3 -3
  74. package/src/interfaces/web/src/screens/project/McpsTab.tsx +6 -9
  75. package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +66 -52
  76. package/src/interfaces/web/src/screens/project/TelegramTab.tsx +1 -1
  77. package/src/interfaces/web/dist/assets/index-Cm0KyPoZ.css +0 -1
  78. package/src/interfaces/web/dist/assets/index-DJKA763h.js +0 -628
  79. package/src/interfaces/web/dist/assets/index-DJKA763h.js.map +0 -1
@@ -5,6 +5,7 @@ import { Section } from "../Section";
5
5
  import { Button, Field, Input, Loading, Badge, Switch } from "../ui";
6
6
  import { useToast } from "../Toast";
7
7
  import { Skills, type InspectTrace } from "../../lib/api/skills";
8
+ import { t } from "../../i18n";
8
9
 
9
10
  // Skill Inspector — per-turn skill RAG middleware. When ON, the static
10
11
  // "available skills" slug-dump is removed from the agent's system prompt and a
@@ -19,15 +20,17 @@ import { Skills, type InspectTrace } from "../../lib/api/skills";
19
20
  // Numeric knobs with human labels + sane ranges. We keep them as plain number
20
21
  // inputs (same idiom as the embeddings model fields) rather than sliders so the
21
22
  // values are explicit and copy-pasteable.
22
- const KNOBS: { key: keyof NumericKnobs; label: string; hint: string; step: number; min: number; max: number }[] = [
23
- { key: "load_threshold", label: "Umbral de carga", hint: "Similitud mínima para inyectar el CUERPO de la skill (alto = más estricto).", step: 0.01, min: 0, max: 1 },
24
- { key: "hint_threshold", label: "Umbral de sugerencia", hint: "Similitud mínima para solo SUGERIR la skill (que el agente la cargue si quiere).", step: 0.01, min: 0, max: 1 },
25
- { key: "margin", label: "Margen sobre el 2º", hint: "El top debe superar al segundo por este margen para cargar su cuerpo (evita empates flojos).", step: 0.01, min: 0, max: 1 },
26
- { key: "max_loaded", label: "Máx. cuerpos cargados", hint: "Cuántas skills se inyectan completas por turno.", step: 1, min: 0, max: 5 },
27
- { key: "max_hints", label: "Máx. sugerencias", hint: "Cuántas skills extra se nombran como sugerencia.", step: 1, min: 0, max: 8 },
28
- { key: "prompt_floor", label: "Largo mínimo del prompt", hint: "Mensajes más cortos que esto se ignoran (evita 'ok', 'hola').", step: 1, min: 0, max: 40 },
29
- { key: "body_char_cap", label: "Tope de chars del cuerpo", hint: "Recorta cuerpos de skill largos para no inflar el contexto.", step: 500, min: 500, max: 20000 },
30
- ];
23
+ function knobs(): { key: keyof NumericKnobs; label: string; hint: string; step: number; min: number; max: number }[] {
24
+ return [
25
+ { key: "load_threshold", label: t("settings_ui.knob_load_threshold"), hint: t("settings_ui.knob_load_threshold_hint"), step: 0.01, min: 0, max: 1 },
26
+ { key: "hint_threshold", label: t("settings_ui.knob_hint_threshold"), hint: t("settings_ui.knob_hint_threshold_hint"), step: 0.01, min: 0, max: 1 },
27
+ { key: "margin", label: t("settings_ui.knob_margin"), hint: t("settings_ui.knob_margin_hint"), step: 0.01, min: 0, max: 1 },
28
+ { key: "max_loaded", label: t("settings_ui.knob_max_loaded"), hint: t("settings_ui.knob_max_loaded_hint"), step: 1, min: 0, max: 5 },
29
+ { key: "max_hints", label: t("settings_ui.knob_max_hints"), hint: t("settings_ui.knob_max_hints_hint"), step: 1, min: 0, max: 8 },
30
+ { key: "prompt_floor", label: t("settings_ui.knob_prompt_floor"), hint: t("settings_ui.knob_prompt_floor_hint"), step: 1, min: 0, max: 40 },
31
+ { key: "body_char_cap", label: t("settings_ui.knob_body_char_cap"), hint: t("settings_ui.knob_body_char_cap_hint"), step: 500, min: 500, max: 20000 },
32
+ ];
33
+ }
31
34
 
32
35
  type NumericKnobs = {
33
36
  load_threshold: number; hint_threshold: number; margin: number;
@@ -52,7 +55,7 @@ export function SkillsInspectorPanel() {
52
55
  await Skills.updateInspector(patch);
53
56
  await mutate();
54
57
  } catch (e) {
55
- toast.error(`No se pudo guardar: ${(e as Error).message}`);
58
+ toast.error(t("settings_ui.could_not_save", { msg: (e as Error).message }));
56
59
  } finally {
57
60
  setBusy(false);
58
61
  }
@@ -63,11 +66,17 @@ export function SkillsInspectorPanel() {
63
66
  try {
64
67
  const r = await Skills.index({ force });
65
68
  toast.success(
66
- `Indexado con ${r.embedder} (dim ${r.dim}): +${r.changed.added} ~${r.changed.refreshed} -${r.changed.removed}.`,
69
+ t("settings_ui.indexed_with", {
70
+ embedder: r.embedder,
71
+ dim: r.dim,
72
+ added: r.changed.added,
73
+ refreshed: r.changed.refreshed,
74
+ removed: r.changed.removed,
75
+ }),
67
76
  );
68
77
  await mutate();
69
78
  } catch (e) {
70
- toast.error(`Falló el index: ${(e as Error).message}`);
79
+ toast.error(t("settings_ui.index_failed", { msg: (e as Error).message }));
71
80
  } finally {
72
81
  setBusy(false);
73
82
  }
@@ -81,7 +90,7 @@ export function SkillsInspectorPanel() {
81
90
  const r = await Skills.inspect(probe.trim());
82
91
  setProbeResult(r.trace);
83
92
  } catch (e) {
84
- toast.error(`Falló el dry-run: ${(e as Error).message}`);
93
+ toast.error(t("settings_ui.dry_run_failed", { msg: (e as Error).message }));
85
94
  } finally {
86
95
  setBusy(false);
87
96
  }
@@ -90,55 +99,55 @@ export function SkillsInspectorPanel() {
90
99
  return (
91
100
  <div className="grid gap-6 xl:grid-cols-2 xl:items-start">
92
101
  <Section
93
- title="Skill Inspector (RAG por turno)"
94
- description="Función experimental. Cuando está activa, el agente NO recibe la lista completa de skills en su prompt; en cada mensaje un RAG local decide qué skill(s) cargar — el cuerpo completo si hay match fuerte, una sugerencia si hay match medio, nada si no aplica. Se reevalúa cada turno: una skill que dejó de ser relevante desaparece del contexto."
102
+ title={t("settings_ui.inspector_title")}
103
+ description={t("settings_ui.inspector_desc")}
95
104
  >
96
105
  <div className="space-y-4">
97
106
  <Field
98
- label="Activar inspector"
99
- hint="Apagado = comportamiento clásico (lista de slugs + sugerencia pasiva). Encendido = el RAG decide por turno."
107
+ label={t("settings_ui.enable_inspector")}
108
+ hint={t("settings_ui.enable_inspector_hint")}
100
109
  >
101
110
  <Switch
102
111
  checked={cfg.enabled}
103
112
  disabled={busy}
104
113
  onChange={(v) => apply({ enabled: v })}
105
- label={cfg.enabled ? "Encendido" : "Apagado"}
114
+ label={cfg.enabled ? t("settings_ui.on") : t("settings_ui.off")}
106
115
  />
107
116
  </Field>
108
117
 
109
118
  <div className="flex flex-wrap items-center gap-2 pt-1">
110
119
  <Badge tone={idx.count > 0 ? "success" : "warning"}>
111
- Índice: {idx.count} skills
120
+ {t("settings_ui.index_count", { n: idx.count })}
112
121
  </Badge>
113
- <Badge tone="muted">{idx.embedder || "sin indexar"}</Badge>
114
- {idx.dim ? <Badge tone="muted">dim {idx.dim}</Badge> : null}
122
+ <Badge tone="muted">{idx.embedder || t("settings_ui.not_indexed")}</Badge>
123
+ {idx.dim ? <Badge tone="muted">{t("settings_ui.dim", { dim: idx.dim })}</Badge> : null}
115
124
  {idx.updated_at ? (
116
125
  <span className="text-xs text-muted-foreground">
117
- actualizado {new Date(idx.updated_at).toLocaleString()}
126
+ {t("settings_ui.updated_at", { date: new Date(idx.updated_at).toLocaleString() })}
118
127
  </span>
119
128
  ) : null}
120
129
  </div>
121
130
 
122
131
  <div className="flex flex-wrap items-center gap-3 pt-1">
123
132
  <Button variant="secondary" onClick={() => runIndex(false)} loading={busy}>
124
- <RefreshCw size={14} /> Reindexar
133
+ <RefreshCw size={14} /> {t("settings_ui.reindex")}
125
134
  </Button>
126
135
  <Button variant="secondary" onClick={() => runIndex(true)} loading={busy}>
127
- <RefreshCw size={14} /> Reindexar (forzado)
136
+ <RefreshCw size={14} /> {t("settings_ui.reindex_forced")}
128
137
  </Button>
129
138
  <span className="text-xs text-muted-foreground">
130
- El embedder sale de Memoria (RAG). Local con Ollama, u offline si no hay proveedor.
139
+ {t("settings_ui.embedder_source")}
131
140
  </span>
132
141
  </div>
133
142
  </div>
134
143
  </Section>
135
144
 
136
145
  <Section
137
- title="Umbrales y límites"
138
- description="Ajustá qué tan agresivo es el inspector. Subir los umbrales = menos falsos positivos pero más riesgo de perderse una skill; bajarlos = lo contrario."
146
+ title={t("settings_ui.thresholds_title")}
147
+ description={t("settings_ui.thresholds_desc")}
139
148
  >
140
149
  <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
141
- {KNOBS.map((k) => (
150
+ {knobs().map((k) => (
142
151
  <Field key={k.key} label={k.label} hint={k.hint}>
143
152
  <Input
144
153
  type="number"
@@ -159,21 +168,21 @@ export function SkillsInspectorPanel() {
159
168
  </Section>
160
169
 
161
170
  <Section
162
- title="Probar (dry-run)"
163
- description="Escribí un mensaje como lo haría un usuario y mirá qué skills cargaría/sugeriría el inspector — sin llamar al modelo. Fuerza el inspector activo aunque esté apagado arriba."
171
+ title={t("settings_ui.test_title")}
172
+ description={t("settings_ui.test_desc")}
164
173
  >
165
174
  <div className="space-y-3">
166
175
  <div className="flex flex-wrap items-center gap-2">
167
176
  <Input
168
177
  value={probe}
169
- placeholder="ej: necesito crear un video promocional con voz en off"
178
+ placeholder={t("settings_ui.test_placeholder")}
170
179
  disabled={busy}
171
180
  onChange={(ev) => setProbe(ev.target.value)}
172
181
  onKeyDown={(ev) => { if (ev.key === "Enter") runProbe(); }}
173
182
  className="max-w-xl flex-1"
174
183
  />
175
184
  <Button variant="primary" onClick={runProbe} loading={busy}>
176
- <Wand2 size={14} /> Probar
185
+ <Wand2 size={14} /> {t("settings_ui.test_btn")}
177
186
  </Button>
178
187
  </div>
179
188
 
@@ -182,7 +191,7 @@ export function SkillsInspectorPanel() {
182
191
  <div className="mb-2 flex flex-wrap items-center gap-2">
183
192
  <Sparkles size={14} className="text-muted-foreground" />
184
193
  <span className="text-muted-foreground">{probeResult.embedder || "—"}</span>
185
- {probeResult.jit ? <Badge tone="warning">JIT (índice vacío)</Badge> : null}
194
+ {probeResult.jit ? <Badge tone="warning">{t("settings_ui.jit_empty_index")}</Badge> : null}
186
195
  {probeResult.reason && !probeResult.loaded?.length && !probeResult.hinted?.length ? (
187
196
  <Badge tone="muted">{probeResult.reason}</Badge>
188
197
  ) : null}
@@ -190,7 +199,7 @@ export function SkillsInspectorPanel() {
190
199
 
191
200
  {probeResult.loaded?.length ? (
192
201
  <div className="mb-1">
193
- <span className="text-muted-foreground">Cargadas: </span>
202
+ <span className="text-muted-foreground">{t("settings_ui.loaded_label")} </span>
194
203
  {probeResult.loaded.map((s) => (
195
204
  <Badge key={s} tone="success" className="mr-1">{s}</Badge>
196
205
  ))}
@@ -199,7 +208,7 @@ export function SkillsInspectorPanel() {
199
208
 
200
209
  {probeResult.hinted?.length ? (
201
210
  <div className="mb-1">
202
- <span className="text-muted-foreground">Sugeridas: </span>
211
+ <span className="text-muted-foreground">{t("settings_ui.suggested_label")} </span>
203
212
  {probeResult.hinted.map((s) => (
204
213
  <Badge key={s} tone="info" className="mr-1">{s}</Badge>
205
214
  ))}
@@ -53,20 +53,20 @@ export function SuperAgentPanel() {
53
53
  };
54
54
 
55
55
  return (
56
- <Section title={t("settings.super_agent.title")} description="Comportamiento del super-agente. El modelo y la cadena de fallback se configuran en el Router de modelos.">
56
+ <Section title={t("settings.super_agent.title")} description={t("settings.super_agent.behavior_subtitle")}>
57
57
  <div className="space-y-4">
58
58
  <div className="flex items-center gap-3">
59
- <Switch checked={enabled} onChange={setEnabled} label="Super-agente habilitado" />
59
+ <Switch checked={enabled} onChange={setEnabled} label={t("settings.super_agent.enabled_label")} />
60
60
  </div>
61
61
 
62
62
  {/* Model lives in the Router now — single source of truth. */}
63
63
  <div className="flex items-center justify-between rounded-lg border border-border bg-muted/20 p-3">
64
64
  <div className="min-w-0">
65
- <div className="text-sm font-medium">Modelo activo (router)</div>
65
+ <div className="text-sm font-medium">{t("settings.super_agent.model_active")}</div>
66
66
  <div className="truncate font-mono text-xs text-muted-fg">{superAgent.model || "—"}</div>
67
67
  </div>
68
68
  <Button size="sm" variant="secondary" onClick={() => navigate("/p/0/models")}>
69
- <Cpu size={13} /> Configurar en Modelos
69
+ <Cpu size={13} /> {t("settings.super_agent.model_configure")}
70
70
  </Button>
71
71
  </div>
72
72
 
@@ -82,7 +82,7 @@ export function SuperAgentPanel() {
82
82
  className="font-mono text-xs"
83
83
  value={system}
84
84
  onChange={(e) => setSystem(e.target.value)}
85
- placeholder="(Vacío = se usa el prompt base de core/agent/prompts/super-agent-base.md)"
85
+ placeholder={t("settings.super_agent.system_ph")}
86
86
  />
87
87
  </Field>
88
88
 
@@ -49,7 +49,7 @@ export function TelegramChannelsPanel() {
49
49
  <ul className="space-y-2 text-sm">
50
50
  {channels.map((c) => {
51
51
  const ownerLabel = c.owner_user_id != null
52
- ? (nameByUserId.get(String(c.owner_user_id)) || `user_id ${c.owner_user_id}`)
52
+ ? (nameByUserId.get(String(c.owner_user_id)) || t("telegram_ui.user_id_fallback", { id: c.owner_user_id }))
53
53
  : t("telegram_channels.no_owner");
54
54
  return (
55
55
  <li key={c.name} className="rounded-md border border-border bg-muted/30 px-3 py-2">
@@ -67,8 +67,8 @@ export function TelegramChannelsPanel() {
67
67
  <div className="mt-1 grid grid-cols-2 gap-2 text-xs text-muted-fg">
68
68
  <span>chat_id: {c.chat_id || "—"}</span>
69
69
  <span>bot_token: {c.bot_token ? `…${secretSuffix(c.bot_token) ?? ""}` : "—"}</span>
70
- <span>route_to_agent: {c.route_to_agent || "default APX"}</span>
71
- <span>engine: {c.respond_with_engine ? "" : "no"}</span>
70
+ <span>route_to_agent: {c.route_to_agent || t("telegram_ui.default_apx")}</span>
71
+ <span>engine: {c.respond_with_engine ? t("telegram_ui.yes") : t("telegram_ui.no")}</span>
72
72
  <span className="col-span-2">{t("telegram_channels.owner_label")} {ownerLabel}</span>
73
73
  </div>
74
74
  </li>
@@ -28,7 +28,7 @@ export function TelegramContactsPanel({ bare = false }: Props) {
28
28
  const setRole = async (c: TelegramContact, role: string) => {
29
29
  try {
30
30
  await Telegram.contacts.patch(c.user_id, { role });
31
- toast.success(`${c.name || c.user_id} ${role}`);
31
+ toast.success(t("telegram_ui.role_assigned", { name: c.name || c.user_id, role }));
32
32
  mutate();
33
33
  } catch (e) { toast.error((e as Error).message); }
34
34
  };
@@ -76,14 +76,14 @@ export function TelegramGlobalPanel() {
76
76
  <Field
77
77
  label={t("settings.telegram_global.bot_token")}
78
78
  hint={channel?.bot_token
79
- ? `…${secretSuffix(channel.bot_token) ?? ""} (seteado — escribí para reemplazar)`
80
- : "Token del BotFather."}
79
+ ? `…${secretSuffix(channel.bot_token) ?? ""} ${t("telegram_ui.secret_set_replace")}`
80
+ : t("telegram_ui.bot_token_hint_short")}
81
81
  >
82
82
  <Input
83
83
  type="password"
84
84
  value={botToken}
85
85
  onChange={(e) => setBotToken(e.target.value)}
86
- placeholder={channel?.bot_token ? `…${secretSuffix(channel.bot_token) ?? ""} (ya seteado)` : ""}
86
+ placeholder={channel?.bot_token ? `…${secretSuffix(channel.bot_token) ?? ""} ${t("telegram_ui.secret_already_set")}` : ""}
87
87
  />
88
88
  </Field>
89
89
  <Field label={t("settings.telegram_global.chat_id")}>
@@ -71,7 +71,7 @@ export function TelegramRolesPanel() {
71
71
  <Button size="sm" variant="destructive" onClick={() => remove(n)}>{t("telegram_roles.delete_btn")}</Button>
72
72
  )}
73
73
  </div>
74
- <div className="mt-1 text-xs text-muted-fg">tools: {toolsLabel}</div>
74
+ <div className="mt-1 text-xs text-muted-fg">{t("telegram_contacts.tools_label")} {toolsLabel}</div>
75
75
  </li>
76
76
  );
77
77
  })}
@@ -55,7 +55,7 @@ export function ProviderCard({
55
55
  )}
56
56
  >
57
57
  <span className={cn("size-1.5 rounded-full", active ? "bg-emerald-400" : "bg-muted-fg/40")} />
58
- {active ? "Active" : "Off"}
58
+ {active ? t("providers_card.active") : t("providers_card.off")}
59
59
  </button>
60
60
  </Tip>
61
61
  <Tip content={t("providers_modal.delete")}>
@@ -72,14 +72,14 @@ export function ProviderCard({
72
72
 
73
73
  {/* Body */}
74
74
  <div className="mt-auto space-y-1 text-xs">
75
- <Row label="Modelo" value={provider.default_model || "—"} mono />
76
- {provider.base_url && <Row label="Base URL" value={provider.base_url} mono truncate />}
77
- <Row label="API key" value={hasKey ? (keySuffix ? `…${keySuffix}` : "✓ seteada") : "—"} mono={!!keySuffix} />
75
+ <Row label={t("providers_card.model")} value={provider.default_model || "—"} mono />
76
+ {provider.base_url && <Row label={t("providers_card.base_url")} value={provider.base_url} mono truncate />}
77
+ <Row label={t("providers_card.api_key")} value={hasKey ? (keySuffix ? `…${keySuffix}` : t("providers_card.key_set")) : "—"} mono={!!keySuffix} />
78
78
  {provider.default_temperature !== undefined && (
79
- <Row label="Temp" value={provider.default_temperature.toFixed(1)} />
79
+ <Row label={t("providers_card.temp")} value={provider.default_temperature.toFixed(1)} />
80
80
  )}
81
81
  {provider.pricing?.input_per_million !== undefined && (
82
- <Row label="$ in/out (1M)" value={`${provider.pricing.input_per_million ?? 0} / ${provider.pricing.output_per_million ?? 0}`} />
82
+ <Row label={t("providers_card.price_io")} value={`${provider.pricing.input_per_million ?? 0} / ${provider.pricing.output_per_million ?? 0}`} />
83
83
  )}
84
84
  </div>
85
85
  </div>
@@ -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>