@agentprojectcontext/apx 1.65.3 → 1.67.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 (55) hide show
  1. package/package.json +3 -2
  2. package/skills/apx/SKILL.md +3 -0
  3. package/src/core/agent/index.js +2 -0
  4. package/src/core/agent/judge.js +174 -0
  5. package/src/core/agent/model-router.js +107 -5
  6. package/src/core/agent/prompts/modes/code-build.md +1 -1
  7. package/src/core/agent/run-agent.js +149 -12
  8. package/src/core/agent/security.js +97 -0
  9. package/src/core/agent/stuck-detector.js +89 -0
  10. package/src/core/agent/super-agent.js +58 -17
  11. package/src/core/agent/tools/handlers/run-subagent.js +117 -0
  12. package/src/core/agent/tools/helpers.js +11 -1
  13. package/src/core/agent/tools/names.js +2 -0
  14. package/src/core/agent/tools/registry.js +10 -0
  15. package/src/core/artifacts/preview.js +392 -0
  16. package/src/core/artifacts/tunnel.js +169 -0
  17. package/src/core/config/index.js +62 -1
  18. package/src/core/config/secret-values.js +132 -0
  19. package/src/core/engines/mock.js +15 -1
  20. package/src/core/engines/presets.js +102 -0
  21. package/src/core/logging.js +10 -3
  22. package/src/core/memory/compactor.js +65 -56
  23. package/src/core/memory/summarizer.js +125 -0
  24. package/src/core/stores/conversations-compactor.js +24 -31
  25. package/src/host/daemon/api/admin-config.js +5 -0
  26. package/src/host/daemon/api/artifact-preview.js +82 -0
  27. package/src/host/daemon/api/engines.js +6 -0
  28. package/src/host/daemon/api/web.js +1 -1
  29. package/src/host/daemon/api.js +2 -0
  30. package/src/host/daemon/index.js +16 -1
  31. package/src/interfaces/acp/index.js +363 -0
  32. package/src/interfaces/acp/jsonrpc.js +180 -0
  33. package/src/interfaces/acp/session.js +205 -0
  34. package/src/interfaces/cli/commands/acp.js +10 -0
  35. package/src/interfaces/cli/commands/artifact.js +115 -0
  36. package/src/interfaces/cli/commands/setup.js +6 -3
  37. package/src/interfaces/cli/index.js +74 -0
  38. package/src/interfaces/web/dist/assets/index-B3pEwe1m.js +803 -0
  39. package/src/interfaces/web/dist/assets/index-B3pEwe1m.js.map +1 -0
  40. package/src/interfaces/web/dist/assets/index-BPGECxzm.css +1 -0
  41. package/src/interfaces/web/dist/index.html +2 -2
  42. package/src/interfaces/web/package-lock.json +6 -6
  43. package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +145 -2
  44. package/src/interfaces/web/src/components/settings/RoutingPanel.tsx +236 -0
  45. package/src/interfaces/web/src/components/settings/providers/typeStyles.ts +44 -25
  46. package/src/interfaces/web/src/i18n/en.ts +47 -0
  47. package/src/interfaces/web/src/i18n/es.ts +47 -0
  48. package/src/interfaces/web/src/lib/api/artifacts.ts +38 -0
  49. package/src/interfaces/web/src/lib/api/engines.ts +14 -0
  50. package/src/interfaces/web/src/main.tsx +5 -0
  51. package/src/interfaces/web/src/screens/base/ModelsTab.tsx +4 -2
  52. package/src/interfaces/web/src/types/daemon.ts +16 -0
  53. package/src/interfaces/web/dist/assets/index-BuII-tAi.css +0 -1
  54. package/src/interfaces/web/dist/assets/index-CFcs16SV.js +0 -778
  55. package/src/interfaces/web/dist/assets/index-CFcs16SV.js.map +0 -1
@@ -1,10 +1,13 @@
1
1
  import { useRef, useState } from "react";
2
2
  import useSWR from "swr";
3
- import { Copy, RefreshCw, Trash2, FileCode2, Play, Pencil, Eye, SquarePen } from "lucide-react";
3
+ import { Copy, RefreshCw, Trash2, FileCode2, Play, Pencil, Eye, SquarePen, ExternalLink, Share2, Square } from "lucide-react";
4
4
  import { cn } from "../../lib/cn";
5
5
  import { t } from "../../i18n";
6
6
  import { Empty, Spinner } from "../ui";
7
- import { Artifacts, type ArtifactEntry, type ArtifactRunResult } from "../../lib/api/artifacts";
7
+ import { Artifacts, type ArtifactEntry, type ArtifactRunResult, type ArtifactPreview } from "../../lib/api/artifacts";
8
+
9
+ // Artifacts we can render in a browser via an ephemeral preview server.
10
+ const PREVIEWABLE = /\.(html?|jsx|tsx|js)$/i;
8
11
  import { useToast } from "../Toast";
9
12
  import {
10
13
  Dialog,
@@ -41,6 +44,12 @@ function ArtifactRow({
41
44
  const [runResult, setRunResult] = useState<ArtifactRunResult | null>(null);
42
45
  const toast = useToast();
43
46
 
47
+ // Preview / share state
48
+ const [preview, setPreview] = useState<ArtifactPreview | null>(null);
49
+ const [previewing, setPreviewing] = useState(false);
50
+ const [sharing, setSharing] = useState(false);
51
+ const previewable = PREVIEWABLE.test(entry.name);
52
+
44
53
  // Rename state
45
54
  const [renaming, setRenaming] = useState(false);
46
55
  const [renameValue, setRenameValue] = useState(entry.name);
@@ -84,6 +93,45 @@ function ArtifactRow({
84
93
  }
85
94
  };
86
95
 
96
+ // Start (or reuse) an ephemeral preview server and open it in a new tab.
97
+ const startPreview = async () => {
98
+ setPreviewing(true);
99
+ try {
100
+ const p = await Artifacts.preview(pid, entry.name);
101
+ setPreview(p);
102
+ window.open(p.url, "_blank", "noopener,noreferrer");
103
+ } catch (e) {
104
+ toast.error((e as Error).message);
105
+ } finally {
106
+ setPreviewing(false);
107
+ }
108
+ };
109
+
110
+ // Expose the running preview through a public tunnel and open it.
111
+ const share = async () => {
112
+ if (!preview) return;
113
+ setSharing(true);
114
+ try {
115
+ const tn = await Artifacts.openTunnel(preview.id);
116
+ setPreview({ ...preview, tunnel: { id: tn.id, url: tn.url, provider: tn.provider } });
117
+ window.open(tn.url, "_blank", "noopener,noreferrer");
118
+ } catch (e) {
119
+ toast.error((e as Error).message);
120
+ } finally {
121
+ setSharing(false);
122
+ }
123
+ };
124
+
125
+ const stopPreview = async () => {
126
+ if (!preview) return;
127
+ try {
128
+ await Artifacts.stopPreview(preview.id);
129
+ } catch {
130
+ /* ignore — already gone */
131
+ }
132
+ setPreview(null);
133
+ };
134
+
87
135
  const remove = async () => {
88
136
  setDeleting(true);
89
137
  try {
@@ -232,6 +280,37 @@ function ArtifactRow({
232
280
  </Tip>
233
281
  )}
234
282
 
283
+ {/* Preview — serve on an ephemeral local server and open in a tab.
284
+ Only for browser-renderable artifacts (HTML / React / JS). */}
285
+ {previewable && (
286
+ <Tip content={t("code_module.artifacts_preview_hint")}>
287
+ <button
288
+ type="button"
289
+ disabled={previewing}
290
+ onClick={() => void startPreview()}
291
+ className="inline-flex items-center gap-1 rounded px-1.5 py-1 text-[10px] font-medium bg-sky-500/15 text-sky-700 hover:bg-sky-500/25 disabled:opacity-60 dark:text-sky-300"
292
+ >
293
+ {previewing ? <Spinner size={10} /> : <ExternalLink className="size-3" />}
294
+ {t("code_module.artifacts_preview")}
295
+ </button>
296
+ </Tip>
297
+ )}
298
+
299
+ {/* Share — open a public tunnel to the running preview. */}
300
+ {preview && !preview.tunnel && (
301
+ <Tip content={t("code_module.artifacts_share_hint")}>
302
+ <button
303
+ type="button"
304
+ disabled={sharing}
305
+ onClick={() => void share()}
306
+ className="inline-flex items-center gap-1 rounded px-1.5 py-1 text-[10px] font-medium bg-amber-500/15 text-amber-700 hover:bg-amber-500/25 disabled:opacity-60 dark:text-amber-300"
307
+ >
308
+ {sharing ? <Spinner size={10} /> : <Share2 className="size-3" />}
309
+ {t("code_module.artifacts_share")}
310
+ </button>
311
+ </Tip>
312
+ )}
313
+
235
314
  {/* Eliminar — confirmation dialog */}
236
315
  <Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
237
316
  <Tip content={t("code_module.artifacts_delete")}>
@@ -289,6 +368,70 @@ function ArtifactRow({
289
368
  </code>
290
369
  </div>
291
370
 
371
+ {/* Running preview: local + optional public URL, with a stop button. */}
372
+ {preview && (
373
+ <div className="space-y-1 rounded border border-sky-500/30 bg-sky-500/5 p-2">
374
+ <div className="flex items-center gap-2">
375
+ <span className="shrink-0 text-[10px] font-medium text-muted-foreground">
376
+ {t("code_module.artifacts_preview_local")}
377
+ </span>
378
+ <a
379
+ href={preview.url}
380
+ target="_blank"
381
+ rel="noopener noreferrer"
382
+ className="min-w-0 flex-1 truncate font-mono text-[10px] text-sky-700 underline hover:text-sky-900 dark:text-sky-300"
383
+ >
384
+ {preview.url}
385
+ </a>
386
+ <Tip content={t("code_module.artifacts_copy_url")}>
387
+ <button
388
+ type="button"
389
+ onClick={() => void copy(preview.url)}
390
+ className="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
391
+ >
392
+ <Copy className="size-3" />
393
+ </button>
394
+ </Tip>
395
+ <Tip content={t("code_module.artifacts_stop_preview")}>
396
+ <button
397
+ type="button"
398
+ onClick={() => void stopPreview()}
399
+ className="shrink-0 rounded p-0.5 text-rose-600 hover:bg-rose-50 dark:text-rose-400 dark:hover:bg-rose-950"
400
+ >
401
+ <Square className="size-3" />
402
+ </button>
403
+ </Tip>
404
+ </div>
405
+ {preview.tunnel && (
406
+ <div className="flex items-center gap-2">
407
+ <span className="shrink-0 text-[10px] font-medium text-amber-700 dark:text-amber-300">
408
+ {t("code_module.artifacts_preview_public")}
409
+ </span>
410
+ <a
411
+ href={preview.tunnel.url}
412
+ target="_blank"
413
+ rel="noopener noreferrer"
414
+ className="min-w-0 flex-1 truncate font-mono text-[10px] text-amber-700 underline hover:text-amber-900 dark:text-amber-300"
415
+ >
416
+ {preview.tunnel.url}
417
+ </a>
418
+ <span className="shrink-0 font-mono text-[9px] text-muted-foreground">
419
+ {preview.tunnel.provider}
420
+ </span>
421
+ <Tip content={t("code_module.artifacts_copy_url")}>
422
+ <button
423
+ type="button"
424
+ onClick={() => void copy(preview.tunnel!.url)}
425
+ className="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
426
+ >
427
+ <Copy className="size-3" />
428
+ </button>
429
+ </Tip>
430
+ </div>
431
+ )}
432
+ </div>
433
+ )}
434
+
292
435
  {/* Run result display */}
293
436
  {runResult && (
294
437
  <div className="space-y-1">
@@ -0,0 +1,236 @@
1
+ import { useEffect, useMemo, useState, type ReactNode } from "react";
2
+ import { Route as RouteIcon, Image, Ruler, Radio, Hash, ChevronRight } from "lucide-react";
3
+ import { Section } from "../Section";
4
+ import { Badge, Button, Dialog, Field, Loading, Switch, Textarea, Tip } from "../ui";
5
+ import { useToast } from "../Toast";
6
+ import { useGlobalConfig } from "../../hooks/useGlobalConfig";
7
+ import { t } from "../../i18n";
8
+
9
+ // Content-based model routing (OpenHands RouterLLM pattern). NOT the failover
10
+ // router (that is DefaultRouterCard). Here each rule prefers a model for a turn
11
+ // based on features (image/size/channel/keywords); it composes with failover —
12
+ // a routed model that is down still falls back down the regular chain.
13
+ interface RoutingWhen {
14
+ has_image?: boolean;
15
+ min_prompt_chars?: number;
16
+ max_prompt_chars?: number;
17
+ min_context_chars?: number;
18
+ channels?: string[];
19
+ keywords?: string[];
20
+ }
21
+ interface RoutingRule {
22
+ model: string;
23
+ when?: RoutingWhen;
24
+ }
25
+
26
+ const EXAMPLE_RULES: RoutingRule[] = [
27
+ { model: "openai:gpt-4o", when: { has_image: true } },
28
+ { model: "anthropic:claude-3-5-haiku", when: { max_prompt_chars: 400 } },
29
+ ];
30
+
31
+ // Render a rule's `when` block as readable condition chips.
32
+ function WhenChips({ when }: { when?: RoutingWhen }) {
33
+ const chips: { icon: ReactNode; label: string }[] = [];
34
+ if (!when || Object.keys(when).length === 0) {
35
+ chips.push({ icon: <ChevronRight size={11} />, label: t("routing_panel.when_any") });
36
+ } else {
37
+ if (when.has_image === true) chips.push({ icon: <Image size={11} />, label: t("routing_panel.when_image") });
38
+ if (when.has_image === false) chips.push({ icon: <Image size={11} />, label: t("routing_panel.when_no_image") });
39
+ if (Number.isFinite(when.min_prompt_chars))
40
+ chips.push({ icon: <Ruler size={11} />, label: t("routing_panel.when_min_prompt", { n: String(when.min_prompt_chars) }) });
41
+ if (Number.isFinite(when.max_prompt_chars))
42
+ chips.push({ icon: <Ruler size={11} />, label: t("routing_panel.when_max_prompt", { n: String(when.max_prompt_chars) }) });
43
+ if (Number.isFinite(when.min_context_chars))
44
+ chips.push({ icon: <Ruler size={11} />, label: t("routing_panel.when_min_context", { n: String(when.min_context_chars) }) });
45
+ if (Array.isArray(when.channels) && when.channels.length > 0)
46
+ chips.push({ icon: <Radio size={11} />, label: t("routing_panel.when_channels", { list: when.channels.join(", ") }) });
47
+ if (Array.isArray(when.keywords) && when.keywords.length > 0)
48
+ chips.push({ icon: <Hash size={11} />, label: t("routing_panel.when_keywords", { list: when.keywords.join(", ") }) });
49
+ }
50
+ return (
51
+ <div className="flex flex-wrap items-center gap-1.5">
52
+ {chips.map((c, i) => (
53
+ <span key={i} className="inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-fg">
54
+ {c.icon} {c.label}
55
+ </span>
56
+ ))}
57
+ </div>
58
+ );
59
+ }
60
+
61
+ export function RoutingPanel() {
62
+ const toast = useToast();
63
+ const { config, isLoading, patch } = useGlobalConfig();
64
+
65
+ const [enabled, setEnabled] = useState(false);
66
+ const [rulesText, setRulesText] = useState("[]");
67
+ const [editing, setEditing] = useState(false);
68
+ const [busy, setBusy] = useState(false);
69
+ const [confirmOpen, setConfirmOpen] = useState(false);
70
+ // Snapshot of the saved state, for dirty tracking.
71
+ const [saved, setSaved] = useState<{ enabled: boolean; rulesText: string }>({ enabled: false, rulesText: "[]" });
72
+
73
+ useEffect(() => {
74
+ const routing = (config.super_agent?.routing || {}) as { enabled?: boolean; rules?: RoutingRule[] };
75
+ const rules = Array.isArray(routing.rules) ? routing.rules : [];
76
+ const text = JSON.stringify(rules, null, 2);
77
+ setEnabled(routing.enabled === true);
78
+ setRulesText(text);
79
+ setSaved({ enabled: routing.enabled === true, rulesText: text });
80
+ }, [config.super_agent?.routing]);
81
+
82
+ // Parse the editor buffer live: the list preview and save both use this.
83
+ const parsed = useMemo<{ rules: RoutingRule[]; error: string | null }>(() => {
84
+ try {
85
+ const v = JSON.parse(rulesText);
86
+ if (!Array.isArray(v)) return { rules: [], error: t("routing_panel.json_not_array") };
87
+ return { rules: v as RoutingRule[], error: null };
88
+ } catch (e) {
89
+ return { rules: [], error: t("routing_panel.json_error", { msg: (e as Error).message }) };
90
+ }
91
+ }, [rulesText]);
92
+
93
+ if (isLoading) return <Loading />;
94
+
95
+ const rules = parsed.rules;
96
+ const ruleCount = rules.length;
97
+ // Normalize for dirty compare so whitespace-only edits don't count.
98
+ const normalizedText = parsed.error ? rulesText : JSON.stringify(rules);
99
+ const savedNormalized = (() => { try { return JSON.stringify(JSON.parse(saved.rulesText)); } catch { return saved.rulesText; } })();
100
+ const dirty = enabled !== saved.enabled || normalizedText !== savedNormalized;
101
+
102
+ const doSave = async () => {
103
+ if (parsed.error) return;
104
+ setBusy(true);
105
+ try {
106
+ await patch({ "super_agent.routing": { enabled, rules } });
107
+ toast.success(t("routing_panel.saved_toast"));
108
+ const text = JSON.stringify(rules, null, 2);
109
+ setRulesText(text);
110
+ setSaved({ enabled, rulesText: text });
111
+ } catch (e) {
112
+ toast.error((e as Error).message);
113
+ } finally {
114
+ setBusy(false);
115
+ setConfirmOpen(false);
116
+ }
117
+ };
118
+
119
+ const signalOn = enabled && ruleCount > 0;
120
+
121
+ return (
122
+ <div data-testid="routing-panel">
123
+ <Section title={t("routing_panel.title")} description={t("routing_panel.description")}>
124
+ <div className="space-y-4">
125
+ {/* Active/inactive signal — the user wants a clear "it's on" cue. */}
126
+ <div className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-muted/20 p-3">
127
+ <span data-testid="routing-signal">
128
+ {signalOn ? (
129
+ <Badge tone="success">
130
+ <RouteIcon size={11} /> {t("routing_panel.signal_on", { n: String(ruleCount) })}
131
+ </Badge>
132
+ ) : enabled ? (
133
+ <Badge tone="warning">
134
+ <RouteIcon size={11} /> {t("routing_panel.signal_on_empty")}
135
+ </Badge>
136
+ ) : (
137
+ <Badge tone="muted">
138
+ <RouteIcon size={11} /> {t("routing_panel.signal_off")}
139
+ </Badge>
140
+ )}
141
+ </span>
142
+ <Tip content={t("routing_panel.helper")}>
143
+ <span className="text-xs text-muted-fg underline decoration-dotted underline-offset-2">
144
+ {t("routing_panel.how_it_works")}
145
+ </span>
146
+ </Tip>
147
+ </div>
148
+
149
+ <Switch checked={enabled} onChange={setEnabled} label={t("routing_panel.enable_label")} />
150
+
151
+ {/* Rules preview */}
152
+ <div className="rounded-lg border border-border bg-muted/20 p-3">
153
+ <div className="mb-2 flex items-center justify-between gap-2">
154
+ <div>
155
+ <div className="text-sm font-medium">{t("routing_panel.rules_title")}</div>
156
+ <div className="text-xs text-muted-fg">{t("routing_panel.rules_desc")}</div>
157
+ </div>
158
+ <Button size="sm" variant="secondary" onClick={() => setEditing((v) => !v)}>
159
+ {editing ? t("routing_panel.hide_editor") : t("routing_panel.edit_rules")}
160
+ </Button>
161
+ </div>
162
+
163
+ <ul className="space-y-1.5">
164
+ {rules.map((r, i) => (
165
+ <li key={i} className="rounded-md bg-card px-2.5 py-2 text-xs">
166
+ <div className="mb-1 flex items-center gap-2">
167
+ <span className="w-6 text-muted-fg">#{i + 1}</span>
168
+ <span className="font-mono text-[12px]">{r.model || "—"}</span>
169
+ </div>
170
+ <div className="pl-8">
171
+ <WhenChips when={r.when} />
172
+ </div>
173
+ </li>
174
+ ))}
175
+ {ruleCount === 0 && !parsed.error && (
176
+ <li className="text-xs text-muted-fg">{t("routing_panel.rules_empty")}</li>
177
+ )}
178
+ </ul>
179
+ </div>
180
+
181
+ {/* JSON editor for the rules array */}
182
+ {editing && (
183
+ <Field label={t("routing_panel.editor_label")} hint={t("routing_panel.json_hint")}>
184
+ <Textarea
185
+ rows={10}
186
+ className="font-mono text-xs"
187
+ value={rulesText}
188
+ onChange={(e) => setRulesText(e.target.value)}
189
+ spellCheck={false}
190
+ />
191
+ {parsed.error ? (
192
+ <span className="mt-1 block text-[11px] text-red-400">{parsed.error}</span>
193
+ ) : (
194
+ <button
195
+ type="button"
196
+ className="mt-1 text-[11px] text-muted-fg underline decoration-dotted underline-offset-2"
197
+ onClick={() => setRulesText(JSON.stringify(EXAMPLE_RULES, null, 2))}
198
+ >
199
+ {t("routing_panel.insert_example")}
200
+ </button>
201
+ )}
202
+ </Field>
203
+ )}
204
+
205
+ <p className="text-[11px] leading-relaxed text-muted-fg">{t("routing_panel.helper")}</p>
206
+
207
+ <Button
208
+ variant="primary"
209
+ loading={busy}
210
+ disabled={!dirty || !!parsed.error}
211
+ onClick={() => setConfirmOpen(true)}
212
+ >
213
+ {dirty ? t("routing_panel.save") : t("routing_panel.saved")}
214
+ </Button>
215
+ </div>
216
+ </Section>
217
+
218
+ <Dialog
219
+ open={confirmOpen}
220
+ onClose={() => setConfirmOpen(false)}
221
+ title={t("routing_panel.confirm_title")}
222
+ description={t("routing_panel.confirm_body")}
223
+ footer={
224
+ <>
225
+ <Button variant="ghost" onClick={() => setConfirmOpen(false)}>{t("routing_panel.cancel")}</Button>
226
+ <Button variant="primary" loading={busy} onClick={doSave}>{t("routing_panel.confirm_apply")}</Button>
227
+ </>
228
+ }
229
+ >
230
+ <p className="text-sm text-muted-fg">
231
+ {enabled ? t("routing_panel.confirm_on", { n: String(ruleCount) }) : t("routing_panel.confirm_off")}
232
+ </p>
233
+ </Dialog>
234
+ </div>
235
+ );
236
+ }
@@ -65,6 +65,11 @@ export const ENGINE_ICONS: Record<EngineType, LucideIcon> = {
65
65
  // Sensible defaults per engine so the form auto-fills base_url, suggests
66
66
  // models, and hints the api-key env var. base_url "" = adapter has a built-in
67
67
  // default (e.g. Anthropic SDK).
68
+ //
69
+ // NOTE: the values below are only an OFFLINE FALLBACK. The source of truth is
70
+ // src/core/engines/presets.js, served by `GET /engines/presets`. Call
71
+ // `loadEnginePresets()` once at app boot to hydrate this object in place so the
72
+ // model lists stay in sync with the CLI wizard and never drift.
68
73
  export interface EnginePreset {
69
74
  base_url: string;
70
75
  default_model: string;
@@ -73,34 +78,32 @@ export interface EnginePreset {
73
78
  }
74
79
 
75
80
  export const ENGINE_PRESETS: Record<EngineType, EnginePreset> = {
81
+ // Keep these in sync with src/core/engines/presets.js. They are the offline
82
+ // fallback only — loadEnginePresets() overrides them from the daemon at boot.
76
83
  anthropic: {
77
84
  base_url: "",
78
- default_model: "claude-sonnet-4.6",
85
+ default_model: "claude-sonnet-5",
79
86
  api_key_env: "ANTHROPIC_API_KEY",
80
87
  known_models: [
81
- "claude-opus-4.8",
82
- "claude-opus-4.7",
83
- "claude-opus-4.6",
84
- "claude-sonnet-4.6",
85
- "claude-sonnet-4.5",
86
- "claude-haiku-4.5",
88
+ "claude-opus-4-8",
89
+ "claude-sonnet-5",
90
+ "claude-haiku-4-5",
91
+ "claude-fable-5",
87
92
  ],
88
93
  },
89
94
  openai: {
90
95
  base_url: "https://api.openai.com/v1",
91
- default_model: "gpt-4o-mini",
96
+ default_model: "gpt-5.4-mini",
92
97
  api_key_env: "OPENAI_API_KEY",
93
- known_models: ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "o3-mini"],
98
+ known_models: ["gpt-5.5", "gpt-5.4-mini", "gpt-5.4-nano", "gpt-5.1", "gpt-4.1-mini"],
94
99
  },
95
100
  gemini: {
96
101
  base_url: "https://generativelanguage.googleapis.com/v1beta/openai",
97
102
  default_model: "gemini-2.5-flash",
98
103
  api_key_env: "GEMINI_API_KEY",
99
104
  known_models: [
100
- "gemini-3.5-pro",
101
105
  "gemini-3.5-flash",
102
- "gemini-3.1-pro",
103
- "gemini-3.1-flash",
106
+ "gemini-3.1-pro-preview",
104
107
  "gemini-2.5-pro",
105
108
  "gemini-2.5-flash",
106
109
  "gemini-2.5-flash-lite",
@@ -108,17 +111,14 @@ export const ENGINE_PRESETS: Record<EngineType, EnginePreset> = {
108
111
  },
109
112
  groq: {
110
113
  base_url: "https://api.groq.com/openai/v1",
111
- default_model: "llama-3.3-70b-versatile",
114
+ default_model: "openai/gpt-oss-20b",
112
115
  api_key_env: "GROQ_API_KEY",
113
116
  known_models: [
114
- "llama-3.3-70b-versatile",
115
- "llama-3.1-8b-instant",
116
- "meta-llama/llama-4-scout-17b-16e-instruct",
117
117
  "openai/gpt-oss-120b",
118
118
  "openai/gpt-oss-20b",
119
+ "qwen/qwen3.6-27b",
119
120
  "groq/compound",
120
121
  "groq/compound-mini",
121
- "qwen/qwen3-32b",
122
122
  "whisper-large-v3-turbo",
123
123
  ],
124
124
  },
@@ -130,12 +130,9 @@ export const ENGINE_PRESETS: Record<EngineType, EnginePreset> = {
130
130
  known_models: [
131
131
  "openrouter/auto",
132
132
  "openrouter/free",
133
- "deepseek/deepseek-r1:free",
134
- "meta-llama/llama-3.3-70b-instruct:free",
135
- "google/gemini-2.0-flash-exp:free",
136
- "qwen/qwen3-235b-a22b:free",
137
- "anthropic/claude-sonnet-4.5",
138
- "openai/gpt-4o-mini",
133
+ "anthropic/claude-sonnet-5",
134
+ "openai/gpt-5.4-mini",
135
+ "google/gemini-2.5-flash",
139
136
  ],
140
137
  },
141
138
  ollama: {
@@ -146,10 +143,32 @@ export const ENGINE_PRESETS: Record<EngineType, EnginePreset> = {
146
143
  },
147
144
  azure: {
148
145
  base_url: "",
149
- default_model: "gpt-4o-mini",
146
+ default_model: "",
150
147
  api_key_env: "AZURE_OPENAI_API_KEY",
151
- known_models: ["gpt-4o", "gpt-4o-mini"],
148
+ known_models: [],
152
149
  },
153
150
  mock: { base_url: "", default_model: "mock", api_key_env: "", known_models: ["mock"] },
154
151
  custom: { base_url: "", default_model: "", api_key_env: "", known_models: [] },
155
152
  };
153
+
154
+ // Hydrate ENGINE_PRESETS from the daemon's shared catalog (GET /engines/presets,
155
+ // backed by src/core/engines/presets.js). Mutates the object in place so every
156
+ // consumer that reads ENGINE_PRESETS[engine] lazily (form handlers, model
157
+ // dropdowns) picks up the fresh lists. Best-effort: on failure we keep the
158
+ // baked-in fallback above. Call once at app boot.
159
+ let presetsLoaded = false;
160
+ export async function loadEnginePresets(): Promise<void> {
161
+ if (presetsLoaded) return;
162
+ try {
163
+ const { Engines } = await import("../../../lib/api/engines");
164
+ const { presets } = await Engines.presets();
165
+ for (const [engine, preset] of Object.entries(presets || {})) {
166
+ if (engine in ENGINE_PRESETS && preset) {
167
+ Object.assign(ENGINE_PRESETS[engine as EngineType], preset);
168
+ }
169
+ }
170
+ presetsLoaded = true;
171
+ } catch {
172
+ // Daemon unreachable or old build without the endpoint — keep the fallback.
173
+ }
174
+ }
@@ -976,6 +976,15 @@ export const en = {
976
976
  artifacts_rename: "Rename",
977
977
  artifacts_view: "View contents",
978
978
  artifacts_edit: "Edit contents",
979
+ artifacts_preview: "Preview",
980
+ artifacts_preview_hint: "Open a live preview in a local browser tab",
981
+ artifacts_share: "Share",
982
+ artifacts_share_hint: "Create a public tunnel URL to share this preview",
983
+ artifacts_stop_preview: "Stop preview",
984
+ artifacts_preview_local: "Local preview",
985
+ artifacts_preview_public:"Public URL",
986
+ artifacts_copy_url: "Copy URL",
987
+ artifacts_preview_started: "Preview running at {url}",
979
988
  tree_collapse_all: "Collapse all",
980
989
  terminal_clear: "Clear",
981
990
  terminal_close: "Close terminal",
@@ -1077,6 +1086,44 @@ export const en = {
1077
1086
  provider_not_configured: "The provider \"{name}\" is not configured.",
1078
1087
  },
1079
1088
 
1089
+ routing_panel: {
1090
+ title: "Content routing",
1091
+ description: "Prefer a different model per message based on its content (image, size, channel, keywords). Separate from the fallback chain above.",
1092
+ signal_on: "Content routing: ON ({n} rules)",
1093
+ signal_on_empty: "Content routing: ON (no rules yet)",
1094
+ signal_off: "Content routing: OFF",
1095
+ how_it_works: "How does it work?",
1096
+ enable_label: "Enable content routing",
1097
+ rules_title: "Routing rules",
1098
+ rules_desc: "Evaluated top to bottom; the first rule whose conditions all match wins.",
1099
+ rules_empty: "No rules yet. Add some in the editor.",
1100
+ edit_rules: "Edit rules (JSON)",
1101
+ hide_editor: "Hide editor",
1102
+ editor_label: "Rules (JSON array)",
1103
+ json_hint: "Array of { model, when }. when keys: has_image, min_prompt_chars, max_prompt_chars, min_context_chars, channels[], keywords[]. Empty when = matches every message.",
1104
+ json_error: "Invalid JSON: {msg}",
1105
+ json_not_array: "The rules must be a JSON array.",
1106
+ insert_example: "Insert an example",
1107
+ when_any: "any message",
1108
+ when_image: "has image",
1109
+ when_no_image: "no image",
1110
+ when_min_prompt: "prompt ≥ {n} chars",
1111
+ when_max_prompt: "prompt ≤ {n} chars",
1112
+ when_min_context: "context ≥ {n} chars",
1113
+ when_channels: "channels: {list}",
1114
+ when_keywords: "keywords: {list}",
1115
+ helper: "Routing picks a model per message (image, size, channel, keywords). It composes with failover: a routed model that is down falls back down the chain. An explicit per-request model override always wins.",
1116
+ save: "Save routing",
1117
+ saved: "Saved",
1118
+ saved_toast: "Content routing saved.",
1119
+ confirm_title: "Apply routing changes?",
1120
+ confirm_body: "This changes which model handles each message. Failover still applies if a routed model is down.",
1121
+ confirm_on: "Content routing will be ON with {n} rules.",
1122
+ confirm_off: "Content routing will be OFF (every message uses the default router).",
1123
+ confirm_apply: "Apply",
1124
+ cancel: "Cancel",
1125
+ },
1126
+
1080
1127
  engines_panel: {
1081
1128
  title: "Providers",
1082
1129
  new_btn: "New provider",
@@ -974,6 +974,15 @@ export const es = {
974
974
  artifacts_rename: "Renombrar",
975
975
  artifacts_view: "Ver contenido",
976
976
  artifacts_edit: "Editar contenido",
977
+ artifacts_preview: "Previsualizar",
978
+ artifacts_preview_hint: "Abrir una previsualización en vivo en una pestaña local",
979
+ artifacts_share: "Compartir",
980
+ artifacts_share_hint: "Crear una URL pública por túnel para compartir esta preview",
981
+ artifacts_stop_preview: "Detener preview",
982
+ artifacts_preview_local: "Preview local",
983
+ artifacts_preview_public:"URL pública",
984
+ artifacts_copy_url: "Copiar URL",
985
+ artifacts_preview_started: "Preview activa en {url}",
977
986
  tree_collapse_all: "Colapsar todo",
978
987
  terminal_clear: "Limpiar",
979
988
  terminal_close: "Cerrar terminal",
@@ -1075,6 +1084,44 @@ export const es = {
1075
1084
  provider_not_configured: "El proveedor \"{name}\" no está configurado.",
1076
1085
  },
1077
1086
 
1087
+ routing_panel: {
1088
+ title: "Ruteo por contenido",
1089
+ description: "Elegí un modelo distinto por mensaje según su contenido (imagen, tamaño, canal, keywords). Aparte de la cadena de fallback de arriba.",
1090
+ signal_on: "Ruteo por contenido: ON ({n} reglas)",
1091
+ signal_on_empty: "Ruteo por contenido: ON (todavía sin reglas)",
1092
+ signal_off: "Ruteo por contenido: OFF",
1093
+ how_it_works: "¿Cómo funciona?",
1094
+ enable_label: "Activar ruteo por contenido",
1095
+ rules_title: "Reglas de ruteo",
1096
+ rules_desc: "Se evalúan de arriba hacia abajo; gana la primera regla que cumpla todas sus condiciones.",
1097
+ rules_empty: "Todavía sin reglas. Agregá algunas en el editor.",
1098
+ edit_rules: "Editar reglas (JSON)",
1099
+ hide_editor: "Ocultar editor",
1100
+ editor_label: "Reglas (array JSON)",
1101
+ json_hint: "Array de { model, when }. Claves de when: has_image, min_prompt_chars, max_prompt_chars, min_context_chars, channels[], keywords[]. when vacío = matchea todos los mensajes.",
1102
+ json_error: "JSON inválido: {msg}",
1103
+ json_not_array: "Las reglas tienen que ser un array JSON.",
1104
+ insert_example: "Insertar un ejemplo",
1105
+ when_any: "cualquier mensaje",
1106
+ when_image: "tiene imagen",
1107
+ when_no_image: "sin imagen",
1108
+ when_min_prompt: "prompt ≥ {n} chars",
1109
+ when_max_prompt: "prompt ≤ {n} chars",
1110
+ when_min_context: "contexto ≥ {n} chars",
1111
+ when_channels: "canales: {list}",
1112
+ when_keywords: "keywords: {list}",
1113
+ helper: "El ruteo elige un modelo por mensaje (imagen, tamaño, canal, keywords). Se compone con el failover: un modelo ruteado que esté caído cae por la cadena. Un override de modelo explícito por request siempre gana.",
1114
+ save: "Guardar ruteo",
1115
+ saved: "Guardado",
1116
+ saved_toast: "Ruteo por contenido guardado.",
1117
+ confirm_title: "¿Aplicar los cambios de ruteo?",
1118
+ confirm_body: "Esto cambia qué modelo atiende cada mensaje. El failover sigue aplicando si un modelo ruteado está caído.",
1119
+ confirm_on: "El ruteo por contenido va a quedar ON con {n} reglas.",
1120
+ confirm_off: "El ruteo por contenido va a quedar OFF (cada mensaje usa el router default).",
1121
+ confirm_apply: "Aplicar",
1122
+ cancel: "Cancelar",
1123
+ },
1124
+
1078
1125
  engines_panel: {
1079
1126
  title: "Proveedores",
1080
1127
  new_btn: "Nuevo proveedor",