@agentprojectcontext/apx 1.66.0 → 1.68.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 (61) 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/redact.js +44 -0
  19. package/src/core/config/secret-values.js +132 -0
  20. package/src/core/engines/mock.js +15 -1
  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/config.js +17 -5
  28. package/src/host/daemon/api/sessions.js +9 -0
  29. package/src/host/daemon/api/web.js +1 -1
  30. package/src/host/daemon/api.js +2 -0
  31. package/src/host/daemon/index.js +16 -1
  32. package/src/interfaces/acp/index.js +363 -0
  33. package/src/interfaces/acp/jsonrpc.js +180 -0
  34. package/src/interfaces/acp/session.js +205 -0
  35. package/src/interfaces/cli/commands/acp.js +10 -0
  36. package/src/interfaces/cli/commands/artifact.js +115 -0
  37. package/src/interfaces/cli/index.js +74 -0
  38. package/src/interfaces/web/dist/assets/index-D4BmWoDM.css +1 -0
  39. package/src/interfaces/web/dist/assets/index-vwd6yQVw.js +803 -0
  40. package/src/interfaces/web/dist/assets/index-vwd6yQVw.js.map +1 -0
  41. package/src/interfaces/web/dist/index.html +2 -2
  42. package/src/interfaces/web/package-lock.json +9 -9
  43. package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +145 -2
  44. package/src/interfaces/web/src/components/config/ConfigTabsEditor.tsx +46 -31
  45. package/src/interfaces/web/src/components/config/project-config-sections.ts +9 -11
  46. package/src/interfaces/web/src/components/memory/MemoryBrowser.tsx +162 -0
  47. package/src/interfaces/web/src/components/settings/RoutingPanel.tsx +236 -0
  48. package/src/interfaces/web/src/i18n/en.ts +53 -0
  49. package/src/interfaces/web/src/i18n/es.ts +53 -0
  50. package/src/interfaces/web/src/lib/api/artifacts.ts +38 -0
  51. package/src/interfaces/web/src/lib/api/sessions.ts +2 -1
  52. package/src/interfaces/web/src/screens/ProjectScreen.tsx +50 -60
  53. package/src/interfaces/web/src/screens/base/ModelsTab.tsx +4 -2
  54. package/src/interfaces/web/src/screens/base/SessionsTab.tsx +11 -5
  55. package/src/interfaces/web/src/screens/project/ConfigTab.tsx +110 -25
  56. package/src/interfaces/web/src/screens/project/MemoriesTab.tsx +7 -128
  57. package/src/interfaces/web/src/screens/project/Overview.tsx +3 -2
  58. package/src/interfaces/web/src/types/daemon.ts +16 -0
  59. package/src/interfaces/web/dist/assets/index-BuII-tAi.css +0 -1
  60. package/src/interfaces/web/dist/assets/index-YmMRG--4.js +0 -778
  61. package/src/interfaces/web/dist/assets/index-YmMRG--4.js.map +0 -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
+ }
@@ -711,6 +711,8 @@ export const en = {
711
711
  save_fields_success: "Overrides saved.",
712
712
  save_meta_success: "Project metadata saved.",
713
713
  no_data: "No data.",
714
+ tab_settings: "Settings",
715
+ tab_project: "Project",
714
716
  },
715
717
 
716
718
  telegram: {
@@ -731,6 +733,9 @@ export const en = {
731
733
  },
732
734
 
733
735
  memories: {
736
+ sidebar_title: "Memories",
737
+ general_group: "General",
738
+ general_item: "Project memory",
734
739
  project_title: "Project memory",
735
740
  project_desc: "Durable facts at the project level. .apc/memory.md — read by agents and the super-agent.",
736
741
  project_ph: "# Project Memory\n\nStable facts that any agent should know…",
@@ -756,6 +761,7 @@ export const en = {
756
761
  workspaces_empty: "No projects. Add one with the button above.",
757
762
  sessions_title: "Sessions",
758
763
  sessions_desc: "Sessions from all engines (apx · claude · codex), newest first.",
764
+ sessions_desc_scoped: "Sessions in this project's folder ({path}), all engines, newest first.",
759
765
  sessions_all: "All engines",
760
766
  sessions_empty: "No sessions.",
761
767
  sessions_error: "Could not read sessions: {msg}",
@@ -976,6 +982,15 @@ export const en = {
976
982
  artifacts_rename: "Rename",
977
983
  artifacts_view: "View contents",
978
984
  artifacts_edit: "Edit contents",
985
+ artifacts_preview: "Preview",
986
+ artifacts_preview_hint: "Open a live preview in a local browser tab",
987
+ artifacts_share: "Share",
988
+ artifacts_share_hint: "Create a public tunnel URL to share this preview",
989
+ artifacts_stop_preview: "Stop preview",
990
+ artifacts_preview_local: "Local preview",
991
+ artifacts_preview_public:"Public URL",
992
+ artifacts_copy_url: "Copy URL",
993
+ artifacts_preview_started: "Preview running at {url}",
979
994
  tree_collapse_all: "Collapse all",
980
995
  terminal_clear: "Clear",
981
996
  terminal_close: "Close terminal",
@@ -1077,6 +1092,44 @@ export const en = {
1077
1092
  provider_not_configured: "The provider \"{name}\" is not configured.",
1078
1093
  },
1079
1094
 
1095
+ routing_panel: {
1096
+ title: "Content routing",
1097
+ description: "Prefer a different model per message based on its content (image, size, channel, keywords). Separate from the fallback chain above.",
1098
+ signal_on: "Content routing: ON ({n} rules)",
1099
+ signal_on_empty: "Content routing: ON (no rules yet)",
1100
+ signal_off: "Content routing: OFF",
1101
+ how_it_works: "How does it work?",
1102
+ enable_label: "Enable content routing",
1103
+ rules_title: "Routing rules",
1104
+ rules_desc: "Evaluated top to bottom; the first rule whose conditions all match wins.",
1105
+ rules_empty: "No rules yet. Add some in the editor.",
1106
+ edit_rules: "Edit rules (JSON)",
1107
+ hide_editor: "Hide editor",
1108
+ editor_label: "Rules (JSON array)",
1109
+ 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.",
1110
+ json_error: "Invalid JSON: {msg}",
1111
+ json_not_array: "The rules must be a JSON array.",
1112
+ insert_example: "Insert an example",
1113
+ when_any: "any message",
1114
+ when_image: "has image",
1115
+ when_no_image: "no image",
1116
+ when_min_prompt: "prompt ≥ {n} chars",
1117
+ when_max_prompt: "prompt ≤ {n} chars",
1118
+ when_min_context: "context ≥ {n} chars",
1119
+ when_channels: "channels: {list}",
1120
+ when_keywords: "keywords: {list}",
1121
+ 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.",
1122
+ save: "Save routing",
1123
+ saved: "Saved",
1124
+ saved_toast: "Content routing saved.",
1125
+ confirm_title: "Apply routing changes?",
1126
+ confirm_body: "This changes which model handles each message. Failover still applies if a routed model is down.",
1127
+ confirm_on: "Content routing will be ON with {n} rules.",
1128
+ confirm_off: "Content routing will be OFF (every message uses the default router).",
1129
+ confirm_apply: "Apply",
1130
+ cancel: "Cancel",
1131
+ },
1132
+
1080
1133
  engines_panel: {
1081
1134
  title: "Providers",
1082
1135
  new_btn: "New provider",
@@ -709,6 +709,8 @@ export const es = {
709
709
  save_fields_success: "Overrides guardados.",
710
710
  save_meta_success: "Project metadata guardado.",
711
711
  no_data: "Sin datos.",
712
+ tab_settings: "Settings",
713
+ tab_project: "Project",
712
714
  },
713
715
 
714
716
  telegram: {
@@ -729,6 +731,9 @@ export const es = {
729
731
  },
730
732
 
731
733
  memories: {
734
+ sidebar_title: "Memorias",
735
+ general_group: "General",
736
+ general_item: "Memoria del proyecto",
732
737
  project_title: "Memoria del proyecto",
733
738
  project_desc: "Hechos durables a nivel proyecto. .apc/memory.md — la leen los agentes y el super-agente.",
734
739
  project_ph: "# Memoria del proyecto\n\nHechos estables que cualquier agente debería saber…",
@@ -754,6 +759,7 @@ export const es = {
754
759
  workspaces_empty: "Sin proyectos. Agregá uno con el botón de arriba.",
755
760
  sessions_title: "Sessions",
756
761
  sessions_desc: "Sesiones de todos los engines (apx · claude · codex), más nuevas primero.",
762
+ sessions_desc_scoped: "Sesiones en la carpeta de este proyecto ({path}), todos los engines, más nuevas primero.",
757
763
  sessions_all: "Todos los engines",
758
764
  sessions_empty: "Sin sesiones.",
759
765
  sessions_error: "No pude leer las sesiones: {msg}",
@@ -974,6 +980,15 @@ export const es = {
974
980
  artifacts_rename: "Renombrar",
975
981
  artifacts_view: "Ver contenido",
976
982
  artifacts_edit: "Editar contenido",
983
+ artifacts_preview: "Previsualizar",
984
+ artifacts_preview_hint: "Abrir una previsualización en vivo en una pestaña local",
985
+ artifacts_share: "Compartir",
986
+ artifacts_share_hint: "Crear una URL pública por túnel para compartir esta preview",
987
+ artifacts_stop_preview: "Detener preview",
988
+ artifacts_preview_local: "Preview local",
989
+ artifacts_preview_public:"URL pública",
990
+ artifacts_copy_url: "Copiar URL",
991
+ artifacts_preview_started: "Preview activa en {url}",
977
992
  tree_collapse_all: "Colapsar todo",
978
993
  terminal_clear: "Limpiar",
979
994
  terminal_close: "Cerrar terminal",
@@ -1075,6 +1090,44 @@ export const es = {
1075
1090
  provider_not_configured: "El proveedor \"{name}\" no está configurado.",
1076
1091
  },
1077
1092
 
1093
+ routing_panel: {
1094
+ title: "Ruteo por contenido",
1095
+ description: "Elegí un modelo distinto por mensaje según su contenido (imagen, tamaño, canal, keywords). Aparte de la cadena de fallback de arriba.",
1096
+ signal_on: "Ruteo por contenido: ON ({n} reglas)",
1097
+ signal_on_empty: "Ruteo por contenido: ON (todavía sin reglas)",
1098
+ signal_off: "Ruteo por contenido: OFF",
1099
+ how_it_works: "¿Cómo funciona?",
1100
+ enable_label: "Activar ruteo por contenido",
1101
+ rules_title: "Reglas de ruteo",
1102
+ rules_desc: "Se evalúan de arriba hacia abajo; gana la primera regla que cumpla todas sus condiciones.",
1103
+ rules_empty: "Todavía sin reglas. Agregá algunas en el editor.",
1104
+ edit_rules: "Editar reglas (JSON)",
1105
+ hide_editor: "Ocultar editor",
1106
+ editor_label: "Reglas (array JSON)",
1107
+ 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.",
1108
+ json_error: "JSON inválido: {msg}",
1109
+ json_not_array: "Las reglas tienen que ser un array JSON.",
1110
+ insert_example: "Insertar un ejemplo",
1111
+ when_any: "cualquier mensaje",
1112
+ when_image: "tiene imagen",
1113
+ when_no_image: "sin imagen",
1114
+ when_min_prompt: "prompt ≥ {n} chars",
1115
+ when_max_prompt: "prompt ≤ {n} chars",
1116
+ when_min_context: "contexto ≥ {n} chars",
1117
+ when_channels: "canales: {list}",
1118
+ when_keywords: "keywords: {list}",
1119
+ 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.",
1120
+ save: "Guardar ruteo",
1121
+ saved: "Guardado",
1122
+ saved_toast: "Ruteo por contenido guardado.",
1123
+ confirm_title: "¿Aplicar los cambios de ruteo?",
1124
+ confirm_body: "Esto cambia qué modelo atiende cada mensaje. El failover sigue aplicando si un modelo ruteado está caído.",
1125
+ confirm_on: "El ruteo por contenido va a quedar ON con {n} reglas.",
1126
+ confirm_off: "El ruteo por contenido va a quedar OFF (cada mensaje usa el router default).",
1127
+ confirm_apply: "Aplicar",
1128
+ cancel: "Cancelar",
1129
+ },
1130
+
1078
1131
  engines_panel: {
1079
1132
  title: "Proveedores",
1080
1133
  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
  };
@@ -19,11 +19,12 @@ export const Sessions = {
19
19
  .then((b) => ({ sessions: unwrapPage<SessionRow>(b).items })),
20
20
  // Server-paginated page. Optional `q` runs the same search core as
21
21
  // `apx session find` (title; + transcript content when `deep`).
22
- page: ({ engine, q, deep, limit, offset }: { engine?: string; q?: string; deep?: boolean; limit: number; offset: number }) => {
22
+ page: ({ engine, q, deep, cwd, limit, offset }: { engine?: string; q?: string; deep?: boolean; cwd?: string; limit: number; offset: number }) => {
23
23
  const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
24
24
  if (engine) params.set("engine", engine);
25
25
  if (q?.trim()) params.set("q", q.trim());
26
26
  if (deep) params.set("deep", "1");
27
+ if (cwd?.trim()) params.set("cwd", cwd.trim());
27
28
  return http.get<unknown>(`/sessions?${params.toString()}`).then((b) => unwrapPage<SessionRow>(b));
28
29
  },
29
30
  };
@@ -1,8 +1,8 @@
1
1
  import { useMemo } from "react";
2
2
  import { useParams, Routes, Route, Navigate, useLocation, useNavigate } from "react-router-dom";
3
3
  import {
4
- Bot, Heart, Zap, Puzzle, FolderKanban, Settings,
5
- MessagesSquare, Send, KeyRound,
4
+ Bot, Heart, Zap, Puzzle, Settings,
5
+ MessagesSquare, KeyRound,
6
6
  LayoutDashboard, Boxes, Cpu, ScrollText, History, Brain, FileCode2, Cable,
7
7
  Building2, FileText, FolderTree, Sparkles,
8
8
  } from "lucide-react";
@@ -40,7 +40,7 @@ import { SkillsTab } from "./project/SkillsTab";
40
40
  type NavKey =
41
41
  | "" | "chat" | "config" | "telegram"
42
42
  | "agents" | "routines" | "tasks" | "mcps" | "integrations" | "vars" | "logs" | "memories" | "artifacts"
43
- | "structure" | "docs" | "files" | "skills";
43
+ | "structure" | "docs" | "files" | "skills" | "sessions";
44
44
 
45
45
  export function ProjectScreen() {
46
46
  const navigate = useNavigate();
@@ -51,85 +51,75 @@ export function ProjectScreen() {
51
51
 
52
52
  const isBase = String(pid) === "0";
53
53
  const sections: TabSection[] = useMemo(() => {
54
- if (isBase) {
55
- // Base = menú global / admin del daemon (distinto al de un proyecto).
56
- return [
57
- {
58
- title: t("base.nav_general"),
59
- items: [
60
- { key: "", label: "Dashboard", icon: LayoutDashboard },
61
- { key: "workspaces", label: t("base.workspaces_title"), icon: Boxes },
62
- { key: "models", label: t("settings.tabs.engines"), icon: Cpu },
63
- { key: "agent-defaults", label: t("base.defaults_title"), icon: Bot },
64
- ],
65
- },
66
- {
67
- title: t("base.nav_activity"),
68
- items: [
69
- { key: "chat", label: t("project.nav.chat"), icon: MessagesSquare },
70
- { key: "sessions", label: t("base.sessions_title"), icon: History },
71
- { key: "tasks", label: t("project.nav.tasks"), icon: Zap },
72
- { key: "logs", label: t("project.nav.logs"), icon: ScrollText },
73
- ],
74
- },
75
- {
76
- title: t("base.nav_system"),
77
- items: [
78
- { key: "agents", label: t("project.nav.agents"), icon: Bot },
79
- { key: "memories", label: t("project.nav.memories"), icon: Brain },
80
- { key: "skills", label: t("skills_page.title"), icon: Sparkles },
81
- { key: "routines", label: t("project.nav.routines"), icon: Heart },
82
- { key: "mcps", label: t("project.nav.mcps"), icon: Puzzle },
83
- { key: "integrations", label: "Integrations", icon: Cable },
84
- { key: "vars", label: t("project.nav.vars"), icon: KeyRound },
85
- { key: "artifacts", label: t("project.nav.artifacts"), icon: FileCode2 },
86
- { key: "config", label: t("project.nav.config"), icon: Settings },
87
- ],
88
- },
89
- ];
90
- }
91
- // Structure (org roles/areas) is only meaningful for company/enterprise
92
- // projects — gate it on the project kind.
93
- const isCompany = project?.kind === "company";
94
- return [
54
+ // One shared taxonomy for both Base and projects, in the same order, so the
55
+ // two menus mirror each other. Base additionally gets a "General" admin
56
+ // section (workspaces / engines / agent defaults) and drops "Content"
57
+ // (no docs/files surface). "Workspace" and "Automation" are identical on
58
+ // both. Structure (org roles/areas) only makes sense for company projects.
59
+ const isCompany = !isBase && project?.kind === "company";
60
+
61
+ const out: (TabSection | null)[] = [
62
+ // General Base-only daemon admin.
63
+ isBase ? {
64
+ title: t("base.nav_general"),
65
+ items: [
66
+ { key: "workspaces", label: t("base.workspaces_title"), icon: Boxes },
67
+ { key: "models", label: t("settings.tabs.engines"), icon: Cpu },
68
+ { key: "agent-defaults", label: t("base.defaults_title"), icon: Bot },
69
+ ],
70
+ } : null,
71
+ // Workspace the overview plus the team's own building blocks
72
+ // (agents / memories / skills / artifacts). "Overview" on both sides.
95
73
  {
96
74
  title: t("project.sections.workspace"),
97
75
  items: [
98
- { key: "", label: t("project.nav.overview"), icon: FolderKanban },
99
- { key: "telegram", label: t("project.nav.telegram"), icon: Send },
100
- { key: "chat", label: t("project.nav.chat"), icon: MessagesSquare },
101
- { key: "agents", label: t("project.nav.agents"), icon: Bot },
76
+ { key: "", label: t("project.nav.overview"), icon: LayoutDashboard },
102
77
  ...(isCompany ? [{ key: "structure", label: t("project.nav.structure"), icon: Building2 }] : []),
103
- { key: "memories", label: t("project.nav.memories"), icon: Brain },
104
- { key: "skills", label: t("skills_page.title"), icon: Sparkles },
78
+ { key: "agents", label: t("project.nav.agents"), icon: Bot },
79
+ { key: "memories", label: t("project.nav.memories"), icon: Brain },
80
+ { key: "skills", label: t("skills_page.title"), icon: Sparkles },
81
+ { key: "artifacts", label: t("project.nav.artifacts"), icon: FileCode2 },
105
82
  ],
106
83
  },
84
+ // Activity — chat / sessions / logs.
107
85
  {
86
+ title: t("base.nav_activity"),
87
+ items: [
88
+ { key: "chat", label: t("project.nav.chat"), icon: MessagesSquare },
89
+ { key: "sessions", label: t("base.sessions_title"), icon: History },
90
+ { key: "logs", label: t("project.nav.logs"), icon: ScrollText },
91
+ ],
92
+ },
93
+ // Content — docs / files. Project-only (Base has no such surface).
94
+ !isBase ? {
108
95
  title: t("project.sections.content"),
109
96
  items: [
110
97
  { key: "docs", label: t("project.nav.docs"), icon: FileText },
111
98
  { key: "files", label: t("project.nav.files"), icon: FolderTree },
112
99
  ],
113
- },
100
+ } : null,
101
+ // Automation — routines / tasks / mcps / integrations / vars.
102
+ // Identical on both sides.
114
103
  {
115
104
  title: t("project.sections.automation"),
116
105
  items: [
117
- { key: "routines", label: t("project.nav.routines"), icon: Heart },
118
- { key: "tasks", label: t("project.nav.tasks"), icon: Zap },
119
- { key: "mcps", label: t("project.nav.mcps"), icon: Puzzle },
120
- { key: "integrations", label: "Integrations", icon: Cable },
121
- { key: "vars", label: t("project.nav.vars"), icon: KeyRound },
122
- { key: "artifacts", label: t("project.nav.artifacts"), icon: FileCode2 },
123
- { key: "logs", label: t("project.nav.logs"), icon: ScrollText },
106
+ { key: "routines", label: t("project.nav.routines"), icon: Heart },
107
+ { key: "tasks", label: t("project.nav.tasks"), icon: Zap },
108
+ { key: "mcps", label: t("project.nav.mcps"), icon: Puzzle },
109
+ { key: "integrations", label: "Integrations", icon: Cable },
110
+ { key: "vars", label: t("project.nav.vars"), icon: KeyRound },
124
111
  ],
125
112
  },
113
+ // Config — the general project/daemon config.
126
114
  {
127
115
  title: t("project.sections.config"),
128
116
  items: [
129
- { key: "config", label: t("project.nav.config"), icon: Settings },
117
+ { key: "config", label: t("project.nav.config"), icon: Settings },
130
118
  ],
131
119
  },
132
120
  ];
121
+
122
+ return out.filter(Boolean) as TabSection[];
133
123
  }, [isBase, project?.kind]);
134
124
 
135
125
  // First path segment after /p/:pid — so deep routes like agents/:slug still
@@ -171,7 +161,7 @@ export function ProjectScreen() {
171
161
  <Route path="workspaces" element={<WorkspacesTab />} />
172
162
  <Route path="models" element={<ModelsTab />} />
173
163
  <Route path="agent-defaults" element={<AgentDefaultsTab />} />
174
- <Route path="sessions" element={<SessionsTab />} />
164
+ <Route path="sessions" element={<SessionsTab pid={pid} />} />
175
165
  <Route path="logs" element={<LogsTab pid={pid} />} />
176
166
  <Route path="config" element={<ConfigTab pid={pid} />} />
177
167
  <Route path="telegram" element={<TelegramTab pid={pid} />} />
@@ -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
  );