@agentprojectcontext/apx 1.58.0 → 1.60.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 +1 -1
- package/src/core/agent/skills/index.js +9 -0
- package/src/core/agent/skills/inspector.js +7 -2
- package/src/core/agent/skills/policy.js +128 -0
- package/src/core/agent/super-agent.js +8 -1
- package/src/core/agent/tools/handlers/_asana.js +34 -0
- package/src/core/agent/tools/handlers/asana-create-task.js +38 -0
- package/src/core/agent/tools/handlers/asana-list-projects.js +19 -0
- package/src/core/agent/tools/handlers/asana-list-tasks.js +27 -0
- package/src/core/agent/tools/handlers/asana-update-task.js +32 -0
- package/src/core/agent/tools/handlers/list-skills.js +6 -3
- package/src/core/agent/tools/handlers/load-skill.js +9 -2
- package/src/core/agent/tools/names.js +10 -0
- package/src/core/agent/tools/registry.js +11 -0
- package/src/core/integrations/catalog.js +66 -0
- package/src/core/integrations/index.js +10 -0
- package/src/core/integrations/plugins/asana.js +231 -0
- package/src/core/integrations/sources.js +56 -0
- package/src/core/integrations/store.js +118 -0
- package/src/host/daemon/api/integrations.js +191 -0
- package/src/host/daemon/api/skills.js +301 -17
- package/src/host/daemon/api.js +2 -0
- package/src/interfaces/cli/commands/skills.js +3 -2
- package/src/interfaces/web/dist/assets/index-DFNV6BWh.js +761 -0
- package/src/interfaces/web/dist/assets/{index-DPAuXATr.js.map → index-DFNV6BWh.js.map} +1 -1
- package/src/interfaces/web/dist/assets/index-HU-Wt2l9.css +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/src/components/integrations/AsanaPlugin.tsx +275 -0
- package/src/interfaces/web/src/components/integrations/ComingSoonPlugin.tsx +44 -0
- package/src/interfaces/web/src/components/integrations/PluginCard.tsx +61 -0
- package/src/interfaces/web/src/components/integrations/PluginToolsSection.tsx +39 -0
- package/src/interfaces/web/src/components/settings/SkillsManager.tsx +465 -0
- package/src/interfaces/web/src/components/settings/SkillsSettings.tsx +49 -0
- package/src/interfaces/web/src/i18n/en.ts +67 -0
- package/src/interfaces/web/src/i18n/es.ts +67 -0
- package/src/interfaces/web/src/lib/api/integrations.ts +106 -0
- package/src/interfaces/web/src/lib/api/skills.ts +79 -8
- package/src/interfaces/web/src/lib/api.ts +1 -0
- package/src/interfaces/web/src/screens/ProjectScreen.tsx +12 -4
- package/src/interfaces/web/src/screens/SettingsScreen.tsx +3 -3
- package/src/interfaces/web/src/screens/project/IntegrationsTab.tsx +146 -0
- package/src/interfaces/web/src/screens/project/SkillsTab.tsx +13 -0
- package/src/interfaces/web/dist/assets/index-Cl0WXtxF.css +0 -1
- package/src/interfaces/web/dist/assets/index-DPAuXATr.js +0 -705
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import { useMemo, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import useSWR from "swr";
|
|
3
|
+
import {
|
|
4
|
+
ChevronDown, Code2, FileText, GitBranch, Lock, PencilLine,
|
|
5
|
+
Plus, RotateCcw, Trash2, Upload,
|
|
6
|
+
} from "lucide-react";
|
|
7
|
+
import { Button, Field, Input, Textarea, Switch, Badge, Loading, Tip, Dialog } from "../ui";
|
|
8
|
+
import { UiSelect } from "../UiSelect";
|
|
9
|
+
import {
|
|
10
|
+
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem,
|
|
11
|
+
} from "../ui/dropdown-menu";
|
|
12
|
+
import { useToast } from "../Toast";
|
|
13
|
+
import { Skills, type SkillEntry } from "../../lib/api/skills";
|
|
14
|
+
import { Projects } from "../../lib/api/projects";
|
|
15
|
+
import { t } from "../../i18n";
|
|
16
|
+
|
|
17
|
+
// Claude-Desktop-style skills manager: a left list of installed skills + a right
|
|
18
|
+
// viewer that renders the selected SKILL.md, with per-scope on/off, delete, and
|
|
19
|
+
// an "Add" dropdown (create online / upload .zip / clone git repo).
|
|
20
|
+
//
|
|
21
|
+
// Scope: "default" = the super-agent baseline; a project path = that project.
|
|
22
|
+
// Reusable with a FIXED scope (embedded in a project screen) or a SELECTABLE
|
|
23
|
+
// scope (the global settings tab, which shows a scope picker).
|
|
24
|
+
|
|
25
|
+
const SUPER = "default";
|
|
26
|
+
|
|
27
|
+
function basename(p: string): string {
|
|
28
|
+
const parts = p.replace(/\/+$/, "").split("/");
|
|
29
|
+
return parts[parts.length - 1] || p;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function sourceBadge(source: string): { label: string; tone: "info" | "success" | "muted" } {
|
|
33
|
+
if (source === "builtin") return { label: t("skills_page.source_builtin"), tone: "info" };
|
|
34
|
+
if (source === "project") return { label: t("skills_page.source_project"), tone: "success" };
|
|
35
|
+
return { label: t("skills_page.source_global"), tone: "muted" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Minimal Markdown renderer (no dependency) — headings, lists, code, bold.
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
function inline(s: string): ReactNode {
|
|
43
|
+
const parts: ReactNode[] = [];
|
|
44
|
+
const re = /(\*\*[^*]+\*\*|`[^`]+`)/g;
|
|
45
|
+
let last = 0, m: RegExpExecArray | null, k = 0;
|
|
46
|
+
while ((m = re.exec(s))) {
|
|
47
|
+
if (m.index > last) parts.push(s.slice(last, m.index));
|
|
48
|
+
const tok = m[0];
|
|
49
|
+
if (tok.startsWith("**")) parts.push(<strong key={k++}>{tok.slice(2, -2)}</strong>);
|
|
50
|
+
else parts.push(<code key={k++} className="rounded bg-muted px-1 py-0.5 text-[0.85em]">{tok.slice(1, -1)}</code>);
|
|
51
|
+
last = m.index + tok.length;
|
|
52
|
+
}
|
|
53
|
+
if (last < s.length) parts.push(s.slice(last));
|
|
54
|
+
return parts;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function renderMarkdown(md: string): ReactNode {
|
|
58
|
+
const lines = md.split("\n");
|
|
59
|
+
const out: ReactNode[] = [];
|
|
60
|
+
let i = 0, key = 0;
|
|
61
|
+
while (i < lines.length) {
|
|
62
|
+
const line = lines[i];
|
|
63
|
+
if (line.trim().startsWith("```")) {
|
|
64
|
+
const buf: string[] = [];
|
|
65
|
+
i++;
|
|
66
|
+
while (i < lines.length && !lines[i].trim().startsWith("```")) { buf.push(lines[i]); i++; }
|
|
67
|
+
i++;
|
|
68
|
+
out.push(
|
|
69
|
+
<pre key={key++} className="my-2 overflow-x-auto rounded-md border border-border bg-muted/50 p-3 text-xs">
|
|
70
|
+
<code>{buf.join("\n")}</code>
|
|
71
|
+
</pre>,
|
|
72
|
+
);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const h = line.match(/^(#{1,4})\s+(.*)$/);
|
|
76
|
+
if (h) {
|
|
77
|
+
const lvl = h[1].length;
|
|
78
|
+
const cls = lvl === 1 ? "mt-4 mb-1 text-lg font-semibold"
|
|
79
|
+
: lvl === 2 ? "mt-3 mb-1 text-base font-semibold"
|
|
80
|
+
: "mt-2 mb-0.5 text-sm font-semibold";
|
|
81
|
+
out.push(<div key={key++} className={cls}>{inline(h[2])}</div>);
|
|
82
|
+
i++; continue;
|
|
83
|
+
}
|
|
84
|
+
if (/^\s*[-*]\s+/.test(line)) {
|
|
85
|
+
const items: ReactNode[] = [];
|
|
86
|
+
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
|
|
87
|
+
items.push(<li key={items.length}>{inline(lines[i].replace(/^\s*[-*]\s+/, ""))}</li>);
|
|
88
|
+
i++;
|
|
89
|
+
}
|
|
90
|
+
out.push(<ul key={key++} className="my-1 list-disc space-y-0.5 pl-5 text-sm">{items}</ul>);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (line.trim() === "") { i++; continue; }
|
|
94
|
+
const buf: string[] = [];
|
|
95
|
+
while (
|
|
96
|
+
i < lines.length && lines[i].trim() !== "" &&
|
|
97
|
+
!/^(#{1,4})\s/.test(lines[i]) && !/^\s*[-*]\s+/.test(lines[i]) &&
|
|
98
|
+
!lines[i].trim().startsWith("```")
|
|
99
|
+
) { buf.push(lines[i]); i++; }
|
|
100
|
+
out.push(<p key={key++} className="my-1.5 text-sm leading-relaxed">{inline(buf.join(" "))}</p>);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function fileToBase64(file: File): Promise<string> {
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
const r = new FileReader();
|
|
108
|
+
r.onload = () => resolve(String(r.result).replace(/^data:.*;base64,/, ""));
|
|
109
|
+
r.onerror = () => reject(new Error("read failed"));
|
|
110
|
+
r.readAsDataURL(file);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Main
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
export function SkillsManager({ scope: fixedScope, selectable = false }: { scope?: string; selectable?: boolean }) {
|
|
119
|
+
const toast = useToast();
|
|
120
|
+
const [scopeState, setScopeState] = useState<string>(fixedScope ?? SUPER);
|
|
121
|
+
const scope = fixedScope ?? scopeState;
|
|
122
|
+
const projectPath = scope === SUPER ? undefined : scope;
|
|
123
|
+
|
|
124
|
+
const [busy, setBusy] = useState(false);
|
|
125
|
+
const [picked, setPicked] = useState<string | null>(null);
|
|
126
|
+
const [view, setView] = useState<"preview" | "source">("preview");
|
|
127
|
+
const [createOpen, setCreateOpen] = useState(false);
|
|
128
|
+
const [repoOpen, setRepoOpen] = useState(false);
|
|
129
|
+
const fileRef = useRef<HTMLInputElement>(null);
|
|
130
|
+
|
|
131
|
+
const { data: projects } = useSWR(selectable ? "/projects" : null, () => Projects.list());
|
|
132
|
+
const scopeOptions = useMemo(() => [
|
|
133
|
+
{ value: SUPER, label: t("skills_page.scope_super_agent") },
|
|
134
|
+
...(projects ?? []).map((p) => ({ value: p.path, label: p.name || basename(p.path) })),
|
|
135
|
+
], [projects]);
|
|
136
|
+
|
|
137
|
+
const { data, mutate, isLoading } = useSWR(["/skills", scope], () => Skills.list(projectPath));
|
|
138
|
+
const skills = useMemo(() => data?.skills ?? [], [data]);
|
|
139
|
+
const onCount = skills.filter((s) => s.enabled !== false).length;
|
|
140
|
+
|
|
141
|
+
// Derived selection: keep the picked slug if still present, else first.
|
|
142
|
+
const selected = picked && skills.some((s) => s.slug === picked) ? picked : (skills[0]?.slug ?? null);
|
|
143
|
+
const { data: detail } = useSWR(
|
|
144
|
+
selected ? ["/skill-detail", scope, selected] : null,
|
|
145
|
+
() => Skills.detail(selected!, projectPath),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const setEnabled = async (slug: string, enabled: boolean | null) => {
|
|
149
|
+
setBusy(true);
|
|
150
|
+
try { await Skills.setEnabled({ slug, enabled, scope }); await mutate(); }
|
|
151
|
+
catch (e) { toast.error(t("skills_page.toggle_failed", { msg: (e as Error).message })); }
|
|
152
|
+
finally { setBusy(false); }
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const remove = async (slug: string) => {
|
|
156
|
+
if (!window.confirm(t("skills_page.delete_confirm", { slug }))) return;
|
|
157
|
+
setBusy(true);
|
|
158
|
+
try {
|
|
159
|
+
await Skills.remove(slug, projectPath);
|
|
160
|
+
toast.success(t("skills_page.deleted_ok", { slug }));
|
|
161
|
+
if (picked === slug) setPicked(null);
|
|
162
|
+
await mutate();
|
|
163
|
+
} catch (e) { toast.error(t("skills_page.delete_failed", { msg: (e as Error).message })); }
|
|
164
|
+
finally { setBusy(false); }
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const afterAdd = async (slug: string, okMsg: string) => {
|
|
168
|
+
toast.success(okMsg);
|
|
169
|
+
setPicked(slug);
|
|
170
|
+
await mutate();
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const createSkill = async (slug: string, description: string, body: string) => {
|
|
174
|
+
await Skills.create({ slug, description, body, project_path: projectPath });
|
|
175
|
+
await afterAdd(slug, t("skills_page.created_ok", { slug }));
|
|
176
|
+
};
|
|
177
|
+
const importRepo = async (url: string) => {
|
|
178
|
+
const r = await Skills.importRepo({ url, project_path: projectPath });
|
|
179
|
+
await afterAdd(r.slug, t("skills_page.imported_ok", { slug: r.slug }));
|
|
180
|
+
};
|
|
181
|
+
const onPickZip = async (file: File | undefined) => {
|
|
182
|
+
if (!file) return;
|
|
183
|
+
setBusy(true);
|
|
184
|
+
try {
|
|
185
|
+
const data64 = await fileToBase64(file);
|
|
186
|
+
const r = await Skills.importZip({ data: data64, project_path: projectPath });
|
|
187
|
+
await afterAdd(r.slug, t("skills_page.imported_ok", { slug: r.slug }));
|
|
188
|
+
} catch (e) { toast.error(t("skills_page.import_failed", { msg: (e as Error).message })); }
|
|
189
|
+
finally { setBusy(false); if (fileRef.current) fileRef.current.value = ""; }
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<div className="space-y-4">
|
|
194
|
+
{/* Header: scope + count + Add */}
|
|
195
|
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
196
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
197
|
+
{selectable ? (
|
|
198
|
+
<div className="w-64">
|
|
199
|
+
<UiSelect value={scope} onChange={(v) => { setScopeState(v); setPicked(null); }}
|
|
200
|
+
options={scopeOptions} placeholder={t("skills_page.scope_ph")} />
|
|
201
|
+
</div>
|
|
202
|
+
) : null}
|
|
203
|
+
<Badge tone="muted">{t("skills_page.count_label", { n: skills.length, on: onCount })}</Badge>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
<DropdownMenu>
|
|
207
|
+
<DropdownMenuTrigger
|
|
208
|
+
disabled={busy}
|
|
209
|
+
className="inline-flex h-9 items-center gap-1.5 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
|
|
210
|
+
>
|
|
211
|
+
<Plus size={15} /> {t("skills_page.add_menu")} <ChevronDown size={14} />
|
|
212
|
+
</DropdownMenuTrigger>
|
|
213
|
+
<DropdownMenuContent align="end" sideOffset={6} className="w-72">
|
|
214
|
+
<DropdownMenuItem onClick={() => setCreateOpen(true)}>
|
|
215
|
+
<PencilLine size={15} className="text-muted-fg" />
|
|
216
|
+
<AddItemLabel title={t("skills_page.add_online")} hint={t("skills_page.add_online_hint")} />
|
|
217
|
+
</DropdownMenuItem>
|
|
218
|
+
<DropdownMenuItem onClick={() => fileRef.current?.click()}>
|
|
219
|
+
<Upload size={15} className="text-muted-fg" />
|
|
220
|
+
<AddItemLabel title={t("skills_page.add_zip")} hint={t("skills_page.add_zip_hint")} />
|
|
221
|
+
</DropdownMenuItem>
|
|
222
|
+
<DropdownMenuItem onClick={() => setRepoOpen(true)}>
|
|
223
|
+
<GitBranch size={15} className="text-muted-fg" />
|
|
224
|
+
<AddItemLabel title={t("skills_page.add_repo")} hint={t("skills_page.add_repo_hint")} />
|
|
225
|
+
</DropdownMenuItem>
|
|
226
|
+
</DropdownMenuContent>
|
|
227
|
+
</DropdownMenu>
|
|
228
|
+
<input ref={fileRef} type="file" accept=".zip" className="hidden"
|
|
229
|
+
onChange={(e) => onPickZip(e.target.files?.[0])} />
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
{/* Body: list + viewer */}
|
|
233
|
+
{isLoading || !data ? (
|
|
234
|
+
<Loading />
|
|
235
|
+
) : (
|
|
236
|
+
<div className="grid min-h-[62vh] gap-4 lg:grid-cols-[20rem_1fr]">
|
|
237
|
+
{/* List */}
|
|
238
|
+
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
|
239
|
+
<ul className="max-h-[62vh] divide-y divide-border overflow-y-auto">
|
|
240
|
+
{skills.length === 0 ? (
|
|
241
|
+
<li className="px-3 py-4 text-sm text-muted-fg">{t("skills_page.empty")}</li>
|
|
242
|
+
) : skills.map((s) => (
|
|
243
|
+
<SkillRow key={s.slug} skill={s} active={s.slug === selected} busy={busy}
|
|
244
|
+
onSelect={() => setPicked(s.slug)}
|
|
245
|
+
onToggle={(v) => setEnabled(s.slug, v)} />
|
|
246
|
+
))}
|
|
247
|
+
</ul>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
{/* Viewer */}
|
|
251
|
+
<div className="min-w-0 overflow-hidden rounded-xl border border-border bg-card">
|
|
252
|
+
{!selected ? (
|
|
253
|
+
<div className="grid h-full place-items-center p-8 text-sm text-muted-fg">
|
|
254
|
+
{t("skills_page.select_a_skill")}
|
|
255
|
+
</div>
|
|
256
|
+
) : !detail ? (
|
|
257
|
+
<div className="p-6"><Loading /></div>
|
|
258
|
+
) : (
|
|
259
|
+
<div className="flex h-full max-h-[62vh] flex-col">
|
|
260
|
+
{/* Viewer header */}
|
|
261
|
+
<div className="flex items-start justify-between gap-3 border-b border-border px-5 py-4">
|
|
262
|
+
<div className="min-w-0">
|
|
263
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
264
|
+
<code className="text-sm font-semibold">{detail.slug}</code>
|
|
265
|
+
{(() => { const b = sourceBadge(detail.source); return <Badge tone={b.tone}>{b.label}</Badge>; })()}
|
|
266
|
+
{detail.private && (
|
|
267
|
+
<span className="inline-flex items-center gap-1 text-xs text-muted-fg">
|
|
268
|
+
<Lock size={11} /> {t("skills_page.private_badge")}
|
|
269
|
+
</span>
|
|
270
|
+
)}
|
|
271
|
+
</div>
|
|
272
|
+
{detail.description && (
|
|
273
|
+
<p className="mt-1 text-sm text-muted-fg">{detail.description}</p>
|
|
274
|
+
)}
|
|
275
|
+
<div className="mt-2 flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-fg">
|
|
276
|
+
<span>{t("skills_page.added_by")}: <span className="text-foreground">
|
|
277
|
+
{detail.private ? t("skills_page.by_apx") : t("skills_page.by_you")}</span></span>
|
|
278
|
+
<span>{t("skills_page.activator")}: <span className="text-foreground">
|
|
279
|
+
{t("skills_page.activator_value")}</span></span>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
283
|
+
{(detail.source === "global" || detail.source === "project") && (
|
|
284
|
+
<Tip content={t("skills_page.delete_btn")}>
|
|
285
|
+
<Button variant="ghost" size="sm" disabled={busy}
|
|
286
|
+
onClick={() => remove(detail.slug)} aria-label={t("skills_page.delete_btn")}>
|
|
287
|
+
<Trash2 size={14} />
|
|
288
|
+
</Button>
|
|
289
|
+
</Tip>
|
|
290
|
+
)}
|
|
291
|
+
<Switch checked={detail.private ? true : detail.enabled}
|
|
292
|
+
disabled={busy || detail.private}
|
|
293
|
+
onChange={(v) => setEnabled(detail.slug, v)}
|
|
294
|
+
label={(detail.private ? true : detail.enabled) ? t("skills_page.on") : t("skills_page.off")} />
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
{/* View toggle */}
|
|
299
|
+
<div className="flex items-center gap-1 border-b border-border px-4 py-2">
|
|
300
|
+
<ViewTab active={view === "preview"} onClick={() => setView("preview")}
|
|
301
|
+
icon={FileText} label={t("skills_page.tab_preview")} />
|
|
302
|
+
<ViewTab active={view === "source"} onClick={() => setView("source")}
|
|
303
|
+
icon={Code2} label={t("skills_page.tab_source")} />
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
{/* Body */}
|
|
307
|
+
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
|
308
|
+
{view === "preview" ? (
|
|
309
|
+
<div className="prose-none">{renderMarkdown(detail.body || "")}</div>
|
|
310
|
+
) : (
|
|
311
|
+
<pre className="overflow-x-auto whitespace-pre-wrap break-words text-xs leading-relaxed text-muted-fg">
|
|
312
|
+
{detail.body}
|
|
313
|
+
</pre>
|
|
314
|
+
)}
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
)}
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
)}
|
|
321
|
+
|
|
322
|
+
{/* Dialogs */}
|
|
323
|
+
<CreateDialog open={createOpen} onClose={() => setCreateOpen(false)}
|
|
324
|
+
onCreate={createSkill} />
|
|
325
|
+
<RepoDialog open={repoOpen} onClose={() => setRepoOpen(false)}
|
|
326
|
+
onImport={importRepo} />
|
|
327
|
+
</div>
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
// Sub-components
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
function AddItemLabel({ title, hint }: { title: string; hint: string }) {
|
|
336
|
+
return (
|
|
337
|
+
<span className="flex min-w-0 flex-col leading-tight">
|
|
338
|
+
<span className="font-medium">{title}</span>
|
|
339
|
+
<span className="text-[11px] text-muted-fg">{hint}</span>
|
|
340
|
+
</span>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function ViewTab({ active, onClick, icon: Icon, label }: {
|
|
345
|
+
active: boolean; onClick: () => void; icon: React.ElementType; label: string;
|
|
346
|
+
}) {
|
|
347
|
+
return (
|
|
348
|
+
<button type="button" onClick={onClick}
|
|
349
|
+
className={`inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition ${
|
|
350
|
+
active ? "bg-accent text-foreground" : "text-muted-fg hover:text-foreground"}`}>
|
|
351
|
+
<Icon size={13} /> {label}
|
|
352
|
+
</button>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function SkillRow({ skill, active, busy, onSelect, onToggle }: {
|
|
357
|
+
skill: SkillEntry; active: boolean; busy: boolean;
|
|
358
|
+
onSelect: () => void; onToggle: (v: boolean) => void;
|
|
359
|
+
}) {
|
|
360
|
+
const b = sourceBadge(skill.source);
|
|
361
|
+
const enabled = skill.private ? true : skill.enabled !== false;
|
|
362
|
+
return (
|
|
363
|
+
<li>
|
|
364
|
+
<div className={`flex items-center gap-2 px-3 py-2.5 ${active ? "bg-accent/50" : "hover:bg-accent/25"}`}>
|
|
365
|
+
<button type="button" onClick={onSelect} className="min-w-0 flex-1 text-left">
|
|
366
|
+
<div className="flex items-center gap-1.5">
|
|
367
|
+
<code className="truncate text-[13px] font-medium">{skill.slug}</code>
|
|
368
|
+
{skill.private && <Lock size={10} className="shrink-0 text-muted-fg" />}
|
|
369
|
+
</div>
|
|
370
|
+
<div className="mt-0.5 flex items-center gap-1.5">
|
|
371
|
+
<Badge tone={b.tone}>{b.label}</Badge>
|
|
372
|
+
{skill.overridden && <Badge tone="warning">{t("skills_page.overridden_badge")}</Badge>}
|
|
373
|
+
</div>
|
|
374
|
+
</button>
|
|
375
|
+
<Switch checked={enabled} disabled={busy || skill.private} onChange={onToggle} />
|
|
376
|
+
</div>
|
|
377
|
+
</li>
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function CreateDialog({ open, onClose, onCreate }: {
|
|
382
|
+
open: boolean; onClose: () => void;
|
|
383
|
+
onCreate: (slug: string, description: string, body: string) => Promise<void>;
|
|
384
|
+
}) {
|
|
385
|
+
const toast = useToast();
|
|
386
|
+
const [slug, setSlug] = useState("");
|
|
387
|
+
const [description, setDescription] = useState("");
|
|
388
|
+
const [body, setBody] = useState("");
|
|
389
|
+
const [busy, setBusy] = useState(false);
|
|
390
|
+
const valid = /^[a-z0-9][a-z0-9-]*$/.test(slug);
|
|
391
|
+
|
|
392
|
+
const reset = () => { setSlug(""); setDescription(""); setBody(""); };
|
|
393
|
+
const submit = async () => {
|
|
394
|
+
if (!valid) return;
|
|
395
|
+
setBusy(true);
|
|
396
|
+
try { await onCreate(slug, description, body); reset(); onClose(); }
|
|
397
|
+
catch (e) { toast.error(t("skills_page.create_failed", { msg: (e as Error).message })); }
|
|
398
|
+
finally { setBusy(false); }
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
return (
|
|
402
|
+
<Dialog open={open} onClose={onClose} title={t("skills_page.create_dialog_title")}
|
|
403
|
+
description={t("skills_page.add_desc")} size="lg"
|
|
404
|
+
footer={
|
|
405
|
+
<>
|
|
406
|
+
<Button variant="ghost" onClick={onClose} disabled={busy}>{t("skills_page.cancel")}</Button>
|
|
407
|
+
<Button variant="primary" onClick={submit} disabled={busy || !valid} loading={busy}>
|
|
408
|
+
<Plus size={14} /> {t("skills_page.add_btn")}
|
|
409
|
+
</Button>
|
|
410
|
+
</>
|
|
411
|
+
}>
|
|
412
|
+
<div className="space-y-4">
|
|
413
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
414
|
+
<Field label={t("skills_page.add_slug_label")}>
|
|
415
|
+
<Input value={slug} placeholder={t("skills_page.add_slug_ph")} disabled={busy}
|
|
416
|
+
onChange={(e) => setSlug(e.target.value.toLowerCase())} />
|
|
417
|
+
</Field>
|
|
418
|
+
<Field label={t("skills_page.add_desc_label")}>
|
|
419
|
+
<Input value={description} placeholder={t("skills_page.add_desc_ph")} disabled={busy}
|
|
420
|
+
onChange={(e) => setDescription(e.target.value)} />
|
|
421
|
+
</Field>
|
|
422
|
+
</div>
|
|
423
|
+
<Field label={t("skills_page.add_body_label")}>
|
|
424
|
+
<Textarea value={body} placeholder={t("skills_page.add_body_ph")} disabled={busy} rows={10}
|
|
425
|
+
onChange={(e) => setBody(e.target.value)} />
|
|
426
|
+
</Field>
|
|
427
|
+
</div>
|
|
428
|
+
</Dialog>
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function RepoDialog({ open, onClose, onImport }: {
|
|
433
|
+
open: boolean; onClose: () => void; onImport: (url: string) => Promise<void>;
|
|
434
|
+
}) {
|
|
435
|
+
const toast = useToast();
|
|
436
|
+
const [url, setUrl] = useState("");
|
|
437
|
+
const [busy, setBusy] = useState(false);
|
|
438
|
+
const valid = /^(https?:\/\/|git@|ssh:\/\/|git:\/\/)\S+$/.test(url.trim());
|
|
439
|
+
|
|
440
|
+
const submit = async () => {
|
|
441
|
+
if (!valid) return;
|
|
442
|
+
setBusy(true);
|
|
443
|
+
try { await onImport(url.trim()); setUrl(""); onClose(); }
|
|
444
|
+
catch (e) { toast.error(t("skills_page.import_failed", { msg: (e as Error).message })); }
|
|
445
|
+
finally { setBusy(false); }
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
return (
|
|
449
|
+
<Dialog open={open} onClose={onClose} title={t("skills_page.repo_dialog_title")} size="md"
|
|
450
|
+
footer={
|
|
451
|
+
<>
|
|
452
|
+
<Button variant="ghost" onClick={onClose} disabled={busy}>{t("skills_page.cancel")}</Button>
|
|
453
|
+
<Button variant="primary" onClick={submit} disabled={busy || !valid} loading={busy}>
|
|
454
|
+
<GitBranch size={14} /> {t("skills_page.import_btn")}
|
|
455
|
+
</Button>
|
|
456
|
+
</>
|
|
457
|
+
}>
|
|
458
|
+
<Field label={t("skills_page.repo_url_label")} hint={t("skills_page.repo_url_hint")}>
|
|
459
|
+
<Input value={url} placeholder={t("skills_page.repo_url_ph")} disabled={busy}
|
|
460
|
+
onChange={(e) => setUrl(e.target.value)}
|
|
461
|
+
onKeyDown={(e) => { if (e.key === "Enter") submit(); }} />
|
|
462
|
+
</Field>
|
|
463
|
+
</Dialog>
|
|
464
|
+
);
|
|
465
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useSearchParams } from "react-router-dom";
|
|
2
|
+
import { Sparkles, SlidersHorizontal } from "lucide-react";
|
|
3
|
+
import { SkillsManager } from "./SkillsManager";
|
|
4
|
+
import { SkillsInspectorPanel } from "./SkillsInspectorPanel";
|
|
5
|
+
import { t } from "../../i18n";
|
|
6
|
+
|
|
7
|
+
// One settings entry ("Skills") with two inner tabs, deep-linkable via ?tab=:
|
|
8
|
+
// ?tab=manager (default) → the scope-aware skills manager
|
|
9
|
+
// ?tab=rag → the Skill Inspector (per-turn RAG) config
|
|
10
|
+
type Tab = "manager" | "rag";
|
|
11
|
+
|
|
12
|
+
export function SkillsSettings() {
|
|
13
|
+
const [params, setParams] = useSearchParams();
|
|
14
|
+
const tab: Tab = params.get("tab") === "rag" ? "rag" : "manager";
|
|
15
|
+
|
|
16
|
+
const setTab = (v: Tab) => {
|
|
17
|
+
const next = new URLSearchParams(params);
|
|
18
|
+
next.set("tab", v);
|
|
19
|
+
setParams(next, { replace: true });
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div className="space-y-5">
|
|
24
|
+
<div className="flex items-center gap-1 border-b border-border">
|
|
25
|
+
<SubTab active={tab === "manager"} onClick={() => setTab("manager")}
|
|
26
|
+
icon={Sparkles} label={t("skills_page.manager_tab")} />
|
|
27
|
+
<SubTab active={tab === "rag"} onClick={() => setTab("rag")}
|
|
28
|
+
icon={SlidersHorizontal} label={t("skills_page.rag_tab")} />
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
{tab === "manager" ? <SkillsManager selectable /> : <SkillsInspectorPanel />}
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function SubTab({ active, onClick, icon: Icon, label }: {
|
|
37
|
+
active: boolean; onClick: () => void; icon: React.ElementType; label: string;
|
|
38
|
+
}) {
|
|
39
|
+
return (
|
|
40
|
+
<button type="button" onClick={onClick}
|
|
41
|
+
className={`-mb-px inline-flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm font-medium transition ${
|
|
42
|
+
active
|
|
43
|
+
? "border-foreground text-foreground"
|
|
44
|
+
: "border-transparent text-muted-fg hover:text-foreground"
|
|
45
|
+
}`}>
|
|
46
|
+
<Icon size={15} /> {label}
|
|
47
|
+
</button>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -1685,6 +1685,73 @@ export const en = {
|
|
|
1685
1685
|
cfg_apx_storage_id: "APX storage id",
|
|
1686
1686
|
},
|
|
1687
1687
|
|
|
1688
|
+
skills_page: {
|
|
1689
|
+
title: "Skills",
|
|
1690
|
+
desc: "Turn skills on or off per agent. Pick a scope: the super-agent (global) or a specific project.",
|
|
1691
|
+
list_title: "Installed skills",
|
|
1692
|
+
list_desc: "APX's private skills are always active and can't be changed.",
|
|
1693
|
+
scope_label: "Scope",
|
|
1694
|
+
scope_super_agent: "Super-agent (global)",
|
|
1695
|
+
scope_hint: "The super-agent uses the global scope. Each project can override skills independently.",
|
|
1696
|
+
count_label: "{n} skills · {on} on",
|
|
1697
|
+
empty: "No skills yet. Create one below or install via the CLI.",
|
|
1698
|
+
source_builtin: "APX",
|
|
1699
|
+
source_global: "Global",
|
|
1700
|
+
source_project: "Project",
|
|
1701
|
+
private_badge: "Private",
|
|
1702
|
+
private_hint: "Built-in APX skill — always active, can't be disabled or deleted.",
|
|
1703
|
+
overridden_badge: "Override",
|
|
1704
|
+
inherited_hint: "Inherited from global",
|
|
1705
|
+
reset_to_global: "Reset to global",
|
|
1706
|
+
on: "on",
|
|
1707
|
+
off: "off",
|
|
1708
|
+
toggle_failed: "Could not change state: {msg}",
|
|
1709
|
+
add_title: "Add a skill",
|
|
1710
|
+
add_desc: "Creates a user skill at ~/.apx/skills/<slug>/SKILL.md. Available across all scopes.",
|
|
1711
|
+
add_slug_label: "Slug",
|
|
1712
|
+
add_slug_ph: "my-skill",
|
|
1713
|
+
add_desc_label: "Description",
|
|
1714
|
+
add_desc_ph: "One line describing when to use it",
|
|
1715
|
+
add_body_label: "Body (Markdown)",
|
|
1716
|
+
add_body_ph: "# My skill\n\nInstructions for the agent…",
|
|
1717
|
+
add_btn: "Create skill",
|
|
1718
|
+
created_ok: "Skill \"{slug}\" created.",
|
|
1719
|
+
create_failed: "Could not create: {msg}",
|
|
1720
|
+
delete_btn: "Delete",
|
|
1721
|
+
delete_confirm: "Delete skill \"{slug}\"? This can't be undone.",
|
|
1722
|
+
deleted_ok: "Skill \"{slug}\" deleted.",
|
|
1723
|
+
delete_failed: "Could not delete: {msg}",
|
|
1724
|
+
inspector_section_title: "Skill Inspector (per-turn RAG)",
|
|
1725
|
+
inspector_section_desc: "Advanced: local RAG that injects only the skills a message needs.",
|
|
1726
|
+
scope_ph: "— choose scope —",
|
|
1727
|
+
select_a_skill: "Pick a skill from the list to see its content.",
|
|
1728
|
+
added_by: "Added by",
|
|
1729
|
+
activator: "Activator",
|
|
1730
|
+
by_apx: "APX (built-in)",
|
|
1731
|
+
by_you: "You",
|
|
1732
|
+
activator_value: "Semantic match (RAG)",
|
|
1733
|
+
tab_preview: "Preview",
|
|
1734
|
+
tab_source: "Source",
|
|
1735
|
+
add_menu: "Add",
|
|
1736
|
+
add_online: "Create with editor",
|
|
1737
|
+
add_online_hint: "Write slug + description + content",
|
|
1738
|
+
add_zip: "Upload .zip",
|
|
1739
|
+
add_zip_hint: "Import a packaged skill",
|
|
1740
|
+
add_repo: "From git repo",
|
|
1741
|
+
add_repo_hint: "Clone from a URL",
|
|
1742
|
+
create_dialog_title: "Create skill",
|
|
1743
|
+
repo_dialog_title: "Import from git repo",
|
|
1744
|
+
repo_url_label: "Repo URL",
|
|
1745
|
+
repo_url_ph: "https://github.com/user/my-skill.git",
|
|
1746
|
+
repo_url_hint: "The repo (or its subfolder) must contain a SKILL.md.",
|
|
1747
|
+
import_btn: "Import",
|
|
1748
|
+
imported_ok: "Skill \"{slug}\" imported.",
|
|
1749
|
+
import_failed: "Could not import: {msg}",
|
|
1750
|
+
cancel: "Cancel",
|
|
1751
|
+
manager_tab: "Skills",
|
|
1752
|
+
rag_tab: "Config (RAG)",
|
|
1753
|
+
},
|
|
1754
|
+
|
|
1688
1755
|
shared_ui: {
|
|
1689
1756
|
skill_inspector_title: "Skill Inspector ({embedder}) chose these skills for this turn",
|
|
1690
1757
|
tools_count: "{n} tools",
|
|
@@ -1683,6 +1683,73 @@ export const es = {
|
|
|
1683
1683
|
cfg_apx_storage_id: "ID de storage APX",
|
|
1684
1684
|
},
|
|
1685
1685
|
|
|
1686
|
+
skills_page: {
|
|
1687
|
+
title: "Skills",
|
|
1688
|
+
desc: "Activá o desactivá qué skills carga cada agente. Elegí el scope: el super-agent (global) o un proyecto puntual.",
|
|
1689
|
+
list_title: "Skills instaladas",
|
|
1690
|
+
list_desc: "Las privadas de APX están siempre activas y no se pueden tocar.",
|
|
1691
|
+
scope_label: "Scope",
|
|
1692
|
+
scope_super_agent: "Super-agent (global)",
|
|
1693
|
+
scope_hint: "El super-agent usa el scope global. Cada proyecto puede sobrescribir skills de forma independiente.",
|
|
1694
|
+
count_label: "{n} skills · {on} activas",
|
|
1695
|
+
empty: "No hay skills. Creá una abajo o instalá con la CLI.",
|
|
1696
|
+
source_builtin: "APX",
|
|
1697
|
+
source_global: "Global",
|
|
1698
|
+
source_project: "Proyecto",
|
|
1699
|
+
private_badge: "Privada",
|
|
1700
|
+
private_hint: "Skill interna de APX — siempre activa, no se puede desactivar ni borrar.",
|
|
1701
|
+
overridden_badge: "Override",
|
|
1702
|
+
inherited_hint: "Heredada del global",
|
|
1703
|
+
reset_to_global: "Volver al global",
|
|
1704
|
+
on: "activa",
|
|
1705
|
+
off: "inactiva",
|
|
1706
|
+
toggle_failed: "No se pudo cambiar el estado: {msg}",
|
|
1707
|
+
add_title: "Agregar skill",
|
|
1708
|
+
add_desc: "Crea una skill de usuario en ~/.apx/skills/<slug>/SKILL.md. Queda disponible para todos los scopes.",
|
|
1709
|
+
add_slug_label: "Slug",
|
|
1710
|
+
add_slug_ph: "mi-skill",
|
|
1711
|
+
add_desc_label: "Descripción",
|
|
1712
|
+
add_desc_ph: "Una línea que explique cuándo usarla",
|
|
1713
|
+
add_body_label: "Cuerpo (Markdown)",
|
|
1714
|
+
add_body_ph: "# Mi skill\n\nInstrucciones para el agente…",
|
|
1715
|
+
add_btn: "Crear skill",
|
|
1716
|
+
created_ok: "Skill \"{slug}\" creada.",
|
|
1717
|
+
create_failed: "No se pudo crear: {msg}",
|
|
1718
|
+
delete_btn: "Borrar",
|
|
1719
|
+
delete_confirm: "¿Borrar la skill \"{slug}\"? No se puede deshacer.",
|
|
1720
|
+
deleted_ok: "Skill \"{slug}\" borrada.",
|
|
1721
|
+
delete_failed: "No se pudo borrar: {msg}",
|
|
1722
|
+
inspector_section_title: "Skill Inspector (RAG por turno)",
|
|
1723
|
+
inspector_section_desc: "Config avanzada: RAG local que inyecta solo las skills que el mensaje necesita.",
|
|
1724
|
+
scope_ph: "— elegir scope —",
|
|
1725
|
+
select_a_skill: "Elegí una skill de la lista para ver su contenido.",
|
|
1726
|
+
added_by: "Agregado por",
|
|
1727
|
+
activator: "Activador",
|
|
1728
|
+
by_apx: "APX (built-in)",
|
|
1729
|
+
by_you: "Vos",
|
|
1730
|
+
activator_value: "Coincidencia semántica (RAG)",
|
|
1731
|
+
tab_preview: "Vista",
|
|
1732
|
+
tab_source: "Fuente",
|
|
1733
|
+
add_menu: "Agregar",
|
|
1734
|
+
add_online: "Crear con el editor",
|
|
1735
|
+
add_online_hint: "Escribí slug + descripción + contenido",
|
|
1736
|
+
add_zip: "Subir .zip",
|
|
1737
|
+
add_zip_hint: "Importar una skill empaquetada",
|
|
1738
|
+
add_repo: "Desde repo git",
|
|
1739
|
+
add_repo_hint: "Clonar desde una URL",
|
|
1740
|
+
create_dialog_title: "Crear skill",
|
|
1741
|
+
repo_dialog_title: "Importar desde repo git",
|
|
1742
|
+
repo_url_label: "URL del repo",
|
|
1743
|
+
repo_url_ph: "https://github.com/usuario/mi-skill.git",
|
|
1744
|
+
repo_url_hint: "El repo (o su subcarpeta) debe tener un SKILL.md.",
|
|
1745
|
+
import_btn: "Importar",
|
|
1746
|
+
imported_ok: "Skill \"{slug}\" importada.",
|
|
1747
|
+
import_failed: "No se pudo importar: {msg}",
|
|
1748
|
+
cancel: "Cancelar",
|
|
1749
|
+
manager_tab: "Skills",
|
|
1750
|
+
rag_tab: "Config (RAG)",
|
|
1751
|
+
},
|
|
1752
|
+
|
|
1686
1753
|
shared_ui: {
|
|
1687
1754
|
skill_inspector_title: "Skill Inspector ({embedder}) eligió estas skills para este turno",
|
|
1688
1755
|
tools_count: "{n} tools",
|