@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.
- package/package.json +3 -2
- package/skills/apx/SKILL.md +3 -0
- package/src/core/agent/index.js +2 -0
- package/src/core/agent/judge.js +174 -0
- package/src/core/agent/model-router.js +107 -5
- package/src/core/agent/prompts/modes/code-build.md +1 -1
- package/src/core/agent/run-agent.js +149 -12
- package/src/core/agent/security.js +97 -0
- package/src/core/agent/stuck-detector.js +89 -0
- package/src/core/agent/super-agent.js +58 -17
- package/src/core/agent/tools/handlers/run-subagent.js +117 -0
- package/src/core/agent/tools/helpers.js +11 -1
- package/src/core/agent/tools/names.js +2 -0
- package/src/core/agent/tools/registry.js +10 -0
- package/src/core/artifacts/preview.js +392 -0
- package/src/core/artifacts/tunnel.js +169 -0
- package/src/core/config/index.js +62 -1
- package/src/core/config/secret-values.js +132 -0
- package/src/core/engines/mock.js +15 -1
- package/src/core/engines/presets.js +102 -0
- package/src/core/logging.js +10 -3
- package/src/core/memory/compactor.js +65 -56
- package/src/core/memory/summarizer.js +125 -0
- package/src/core/stores/conversations-compactor.js +24 -31
- package/src/host/daemon/api/admin-config.js +5 -0
- package/src/host/daemon/api/artifact-preview.js +82 -0
- package/src/host/daemon/api/engines.js +6 -0
- package/src/host/daemon/api/web.js +1 -1
- package/src/host/daemon/api.js +2 -0
- package/src/host/daemon/index.js +16 -1
- package/src/interfaces/acp/index.js +363 -0
- package/src/interfaces/acp/jsonrpc.js +180 -0
- package/src/interfaces/acp/session.js +205 -0
- package/src/interfaces/cli/commands/acp.js +10 -0
- package/src/interfaces/cli/commands/artifact.js +115 -0
- package/src/interfaces/cli/commands/setup.js +6 -3
- package/src/interfaces/cli/index.js +74 -0
- package/src/interfaces/web/dist/assets/index-B3pEwe1m.js +803 -0
- package/src/interfaces/web/dist/assets/index-B3pEwe1m.js.map +1 -0
- package/src/interfaces/web/dist/assets/index-BPGECxzm.css +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/package-lock.json +6 -6
- package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +145 -2
- package/src/interfaces/web/src/components/settings/RoutingPanel.tsx +236 -0
- package/src/interfaces/web/src/components/settings/providers/typeStyles.ts +44 -25
- package/src/interfaces/web/src/i18n/en.ts +47 -0
- package/src/interfaces/web/src/i18n/es.ts +47 -0
- package/src/interfaces/web/src/lib/api/artifacts.ts +38 -0
- package/src/interfaces/web/src/lib/api/engines.ts +14 -0
- package/src/interfaces/web/src/main.tsx +5 -0
- package/src/interfaces/web/src/screens/base/ModelsTab.tsx +4 -2
- package/src/interfaces/web/src/types/daemon.ts +16 -0
- package/src/interfaces/web/dist/assets/index-BuII-tAi.css +0 -1
- package/src/interfaces/web/dist/assets/index-CFcs16SV.js +0 -778
- 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-
|
|
85
|
+
default_model: "claude-sonnet-5",
|
|
79
86
|
api_key_env: "ANTHROPIC_API_KEY",
|
|
80
87
|
known_models: [
|
|
81
|
-
"claude-opus-4
|
|
82
|
-
"claude-
|
|
83
|
-
"claude-
|
|
84
|
-
"claude-
|
|
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-
|
|
96
|
+
default_model: "gpt-5.4-mini",
|
|
92
97
|
api_key_env: "OPENAI_API_KEY",
|
|
93
|
-
known_models: ["gpt-
|
|
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: "
|
|
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
|
-
"
|
|
134
|
-
"
|
|
135
|
-
"google/gemini-2.
|
|
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: "
|
|
146
|
+
default_model: "",
|
|
150
147
|
api_key_env: "AZURE_OPENAI_API_KEY",
|
|
151
|
-
known_models: [
|
|
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",
|