@agentprojectcontext/apx 1.66.0 → 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 (49) 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 +61 -0
  18. package/src/core/config/secret-values.js +132 -0
  19. package/src/core/engines/mock.js +15 -1
  20. package/src/core/logging.js +10 -3
  21. package/src/core/memory/compactor.js +65 -56
  22. package/src/core/memory/summarizer.js +125 -0
  23. package/src/core/stores/conversations-compactor.js +24 -31
  24. package/src/host/daemon/api/admin-config.js +5 -0
  25. package/src/host/daemon/api/artifact-preview.js +82 -0
  26. package/src/host/daemon/api/web.js +1 -1
  27. package/src/host/daemon/api.js +2 -0
  28. package/src/host/daemon/index.js +16 -1
  29. package/src/interfaces/acp/index.js +363 -0
  30. package/src/interfaces/acp/jsonrpc.js +180 -0
  31. package/src/interfaces/acp/session.js +205 -0
  32. package/src/interfaces/cli/commands/acp.js +10 -0
  33. package/src/interfaces/cli/commands/artifact.js +115 -0
  34. package/src/interfaces/cli/index.js +74 -0
  35. package/src/interfaces/web/dist/assets/index-B3pEwe1m.js +803 -0
  36. package/src/interfaces/web/dist/assets/index-B3pEwe1m.js.map +1 -0
  37. package/src/interfaces/web/dist/assets/index-BPGECxzm.css +1 -0
  38. package/src/interfaces/web/dist/index.html +2 -2
  39. package/src/interfaces/web/package-lock.json +6 -6
  40. package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +145 -2
  41. package/src/interfaces/web/src/components/settings/RoutingPanel.tsx +236 -0
  42. package/src/interfaces/web/src/i18n/en.ts +47 -0
  43. package/src/interfaces/web/src/i18n/es.ts +47 -0
  44. package/src/interfaces/web/src/lib/api/artifacts.ts +38 -0
  45. package/src/interfaces/web/src/screens/base/ModelsTab.tsx +4 -2
  46. package/src/interfaces/web/src/types/daemon.ts +16 -0
  47. package/src/interfaces/web/dist/assets/index-BuII-tAi.css +0 -1
  48. package/src/interfaces/web/dist/assets/index-YmMRG--4.js +0 -778
  49. package/src/interfaces/web/dist/assets/index-YmMRG--4.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
+ }
@@ -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",
@@ -28,6 +28,29 @@ export interface ArtifactRunResult {
28
28
  error?: string;
29
29
  }
30
30
 
31
+ // A running ephemeral preview server for an artifact. `url` is the local
32
+ // http://localhost:<port>/ address; `tunnel` is set once shared publicly.
33
+ export interface ArtifactPreview {
34
+ id: string;
35
+ projectId: string | number | null;
36
+ name: string;
37
+ kind: "html" | "react" | "static" | "text";
38
+ port: number;
39
+ url: string;
40
+ watch: boolean;
41
+ createdAt: string;
42
+ hits: number;
43
+ tunnel: { id: string; url: string; provider: string } | null;
44
+ }
45
+
46
+ export interface ArtifactTunnel {
47
+ id: string;
48
+ url: string;
49
+ provider: string;
50
+ port: number;
51
+ createdAt: string;
52
+ }
53
+
31
54
  export const Artifacts = {
32
55
  list: (pid: string) =>
33
56
  http.get<ArtifactEntry[]>(`/projects/${encodeURIComponent(pid)}/artifacts`),
@@ -54,4 +77,19 @@ export const Artifacts = {
54
77
  `/projects/${encodeURIComponent(pid)}/artifacts/${encodeURIComponent(name)}`,
55
78
  { newName },
56
79
  ),
80
+
81
+ // Start (or reuse) an ephemeral local preview server for an artifact.
82
+ preview: (pid: string, name: string, watch = true) =>
83
+ http.post<ArtifactPreview>(
84
+ `/projects/${encodeURIComponent(pid)}/artifacts/${encodeURIComponent(name)}/preview`,
85
+ { watch },
86
+ ),
87
+ // List running preview servers for a project.
88
+ previews: (pid: string) =>
89
+ http.get<ArtifactPreview[]>(`/projects/${encodeURIComponent(pid)}/previews`),
90
+ stopPreview: (id: string) => http.del<void>(`/previews/${encodeURIComponent(id)}`),
91
+ // Open / close a public tunnel to a running preview.
92
+ openTunnel: (id: string, provider?: string) =>
93
+ http.post<ArtifactTunnel>(`/previews/${encodeURIComponent(id)}/tunnel`, { provider }),
94
+ closeTunnel: (id: string) => http.del<void>(`/previews/${encodeURIComponent(id)}/tunnel`),
57
95
  };
@@ -1,12 +1,14 @@
1
1
  import { DefaultRouterCard } from "../../components/settings/DefaultRouterCard";
2
+ import { RoutingPanel } from "../../components/settings/RoutingPanel";
2
3
  import { EnginesPanel } from "../../components/settings/EnginesPanel";
3
4
 
4
- // Base "Models" page: general default router (no per-task cases) on top of the
5
- // provider list. EnginesPanel is reused as-is from Settings.
5
+ // Base "Models" page: default failover router + content-based routing on top of
6
+ // the provider list. EnginesPanel is reused as-is from Settings.
6
7
  export function ModelsTab() {
7
8
  return (
8
9
  <div className="space-y-6">
9
10
  <DefaultRouterCard />
11
+ <RoutingPanel />
10
12
  <EnginesPanel />
11
13
  </div>
12
14
  );
@@ -293,6 +293,22 @@ export interface SuperAgentConfig {
293
293
  models?: string[];
294
294
  order?: string[];
295
295
  };
296
+ // Content-based routing (RouterLLM pattern): prefer a model per turn by
297
+ // features. Composes with model_fallback (failover) — see RoutingPanel.
298
+ routing?: {
299
+ enabled?: boolean;
300
+ rules?: Array<{
301
+ model: string;
302
+ when?: {
303
+ has_image?: boolean;
304
+ min_prompt_chars?: number;
305
+ max_prompt_chars?: number;
306
+ min_context_chars?: number;
307
+ channels?: string[];
308
+ keywords?: string[];
309
+ };
310
+ }>;
311
+ };
296
312
  }
297
313
 
298
314
  /** ~/.apx/config.json shape (partial — only what we read/write today). */