SkillsPage.tsx 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666
  1. import * as React from "react";
  2. import { useTranslation } from "react-i18next";
  3. import { CheckCircle2, Copy, FileText, Link2, Pencil, Plus, Puzzle, RefreshCw, Search, Trash2, Wrench, X } from "lucide-react";
  4. import { createSkill, deleteSkill, listAllSkills, listToolConnections, listTools, updateSkill } from "@/api";
  5. import { translateApiError } from "@/api/errors";
  6. import { ApiErrorState } from "@/components/shared/ApiErrorState";
  7. import { EmptyState } from "@/components/shared/EmptyState";
  8. import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
  9. import { MetricCard } from "@/components/shared/MetricCard";
  10. import { PageHeader } from "@/components/shared/PageHeader";
  11. import { SearchInput } from "@/components/shared/SearchInput";
  12. import { Badge } from "@/components/ui/badge";
  13. import { Button } from "@/components/ui/button";
  14. import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
  15. import { Dialog } from "@/components/ui/dialog";
  16. import { Input, Textarea } from "@/components/ui/input";
  17. import { Select } from "@/components/ui/select";
  18. import { toast } from "@/components/ui/toaster";
  19. import type { SkillDefinition, ToolConnection, ToolDefinition } from "@/types";
  20. type ToolOption = {
  21. id: string;
  22. name: string;
  23. description: string;
  24. toolType: string;
  25. exposedTools: Array<{
  26. name: string;
  27. description?: string;
  28. }>;
  29. };
  30. type SkillFormState = {
  31. name: string;
  32. description: string;
  33. instruction: string;
  34. category: string;
  35. selectedToolIds: string[];
  36. };
  37. const emptyForm: SkillFormState = {
  38. name: "",
  39. description: "",
  40. instruction: "",
  41. category: "service",
  42. selectedToolIds: [],
  43. };
  44. export function SkillsPage() {
  45. const { t } = useTranslation();
  46. const [skills, setSkills] = React.useState<SkillDefinition[]>([]);
  47. const [tools, setTools] = React.useState<ToolOption[]>([]);
  48. const [search, setSearch] = React.useState("");
  49. const [categoryFilter, setCategoryFilter] = React.useState("all");
  50. const [createOpen, setCreateOpen] = React.useState(false);
  51. const [editOpen, setEditOpen] = React.useState(false);
  52. const [editingSkill, setEditingSkill] = React.useState<SkillDefinition>();
  53. const [form, setForm] = React.useState<SkillFormState>(emptyForm);
  54. const [loading, setLoading] = React.useState(true);
  55. const [saving, setSaving] = React.useState(false);
  56. const [error, setError] = React.useState<string>();
  57. const toolById = React.useMemo(() => new Map(tools.map((tool) => [tool.id, tool])), [tools]);
  58. const categories = Array.from(new Set(skills.map((skill) => skill.category || "service"))).sort();
  59. const filtered = skills
  60. .filter((skill) => skill.status !== "archived")
  61. .filter((skill) => {
  62. const toolNames = skill.toolIds.map((toolId) => toolOptionSearchText(toolById.get(toolId)) || toolId).join(" ");
  63. const text = `${skill.name} ${skill.description ?? ""} ${skill.category} ${skill.instruction} ${toolNames}`.toLowerCase();
  64. return text.includes(search.toLowerCase()) && (categoryFilter === "all" || skill.category === categoryFilter);
  65. })
  66. .sort((first, second) => first.name.localeCompare(second.name));
  67. const boundToolsCount = filtered.reduce((count, skill) => count + skill.toolIds.length, 0);
  68. const load = React.useCallback(async () => {
  69. setLoading(true);
  70. setError(undefined);
  71. try {
  72. const [skillItems, toolItems, connectionItems] = await Promise.all([
  73. listAllSkills(),
  74. listTools().catch(() => [] as ToolDefinition[]),
  75. listToolConnections().catch(() => [] as ToolConnection[]),
  76. ]);
  77. const connectionByTool = new Map<string, ToolConnection>();
  78. connectionItems.forEach((connection) => {
  79. if (!connectionByTool.has(connection.tool_id)) {
  80. connectionByTool.set(connection.tool_id, connection);
  81. }
  82. });
  83. const mappedTools = toolItems.map((tool) => ({
  84. id: tool.id,
  85. name: tool.name,
  86. description: tool.description ?? tool.tool_type,
  87. toolType: tool.tool_type,
  88. exposedTools: getMcpExposedTools(connectionByTool.get(tool.id)),
  89. }));
  90. setSkills(skillItems);
  91. setTools(mappedTools);
  92. } catch (err) {
  93. setError(translateApiError(err));
  94. } finally {
  95. setLoading(false);
  96. }
  97. }, [t]);
  98. React.useEffect(() => {
  99. void load();
  100. }, [load]);
  101. function openCreate() {
  102. setForm(emptyForm);
  103. setCreateOpen(true);
  104. }
  105. function openEdit(skill: SkillDefinition) {
  106. setEditingSkill(skill);
  107. setForm(fromSkill(skill));
  108. setEditOpen(true);
  109. }
  110. async function createSkillFromForm() {
  111. if (!form.name.trim()) return;
  112. setSaving(true);
  113. try {
  114. const created = await createSkill({
  115. name: form.name.trim(),
  116. description: form.description.trim() || null,
  117. category: form.category,
  118. instruction: form.instruction.trim(),
  119. toolIds: form.selectedToolIds,
  120. });
  121. setSkills((current) => [created, ...current]);
  122. setCreateOpen(false);
  123. toast.success(t("skills.created"));
  124. } catch (err) {
  125. toast.error(translateApiError(err));
  126. } finally {
  127. setSaving(false);
  128. }
  129. }
  130. async function updateSkillFromForm() {
  131. if (!editingSkill || !form.name.trim()) return;
  132. setSaving(true);
  133. try {
  134. const updated = await updateSkill({
  135. skillId: editingSkill.id,
  136. name: form.name.trim(),
  137. description: form.description.trim() || null,
  138. category: form.category,
  139. instruction: form.instruction.trim(),
  140. toolIds: form.selectedToolIds,
  141. });
  142. setSkills((current) => current.map((skill) => (skill.id === updated.id ? updated : skill)));
  143. setEditingSkill(updated);
  144. setEditOpen(false);
  145. toast.success(t("skills.saved"));
  146. } catch (err) {
  147. toast.error(translateApiError(err));
  148. } finally {
  149. setSaving(false);
  150. }
  151. }
  152. async function removeSkill(skill: SkillDefinition) {
  153. try {
  154. await deleteSkill(skill.id);
  155. setSkills((current) => current.filter((item) => item.id !== skill.id));
  156. toast.success(t("skills.deleted"));
  157. } catch (err) {
  158. toast.error(translateApiError(err));
  159. }
  160. }
  161. if (loading) return <LoadingSpinner label={t("agents.loadingSkills")} />;
  162. if (error) return <ApiErrorState message={error} onRetry={() => void load()} />;
  163. return (
  164. <div className="space-y-6">
  165. <PageHeader
  166. title={t("skills.title")}
  167. description={t("skills.description")}
  168. actions={
  169. <>
  170. <Button variant="outline" onClick={() => void load()}>
  171. <RefreshCw className="h-4 w-4" /> {t("common.refresh")}
  172. </Button>
  173. <Button onClick={openCreate}>
  174. <Plus className="h-4 w-4" /> {t("skills.new")}
  175. </Button>
  176. </>
  177. }
  178. />
  179. <div className="grid gap-4 md:grid-cols-3">
  180. <MetricCard label={t("skills.title")} value={filtered.length} icon={Puzzle} />
  181. <MetricCard label={t("skills.toolsCount")} value={boundToolsCount} icon={Wrench} />
  182. <MetricCard label={t("skills.category")} value={categories.length} icon={FileText} />
  183. </div>
  184. <Card>
  185. <CardHeader>
  186. <div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
  187. <div>
  188. <CardTitle>{t("skills.title")}</CardTitle>
  189. <CardDescription>{filtered.length} / {skills.filter((skill) => skill.status !== "archived").length}</CardDescription>
  190. </div>
  191. <Badge className="w-fit border-border bg-muted/50 text-muted-foreground">{t("skills.selectTools")}</Badge>
  192. </div>
  193. </CardHeader>
  194. <CardContent className="space-y-4">
  195. <div className="grid gap-3 lg:grid-cols-[1fr_220px]">
  196. <SearchInput value={search} onChange={setSearch} placeholder={t("skills.search")} />
  197. <Select
  198. value={categoryFilter}
  199. onChange={(event) => setCategoryFilter(event.target.value)}
  200. options={[
  201. { value: "all", label: t("skills.allCategories") },
  202. ...categories.map((category) => ({ value: category, label: categoryLabel(category, t) })),
  203. ]}
  204. />
  205. </div>
  206. {filtered.length ? (
  207. <div className="overflow-hidden rounded-md border border-border">
  208. <div className="hidden grid-cols-[minmax(260px,1fr)_150px_minmax(260px,1fr)_110px] gap-4 border-b border-border bg-muted/35 px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground lg:grid">
  209. <span>{t("common.name")}</span>
  210. <span>{t("skills.category")}</span>
  211. <span>{t("skills.toolsCount")}</span>
  212. <span className="text-right">{t("common.actions")}</span>
  213. </div>
  214. <div className="divide-y divide-border">
  215. {filtered.map((skill) => (
  216. <SkillRow
  217. key={skill.id}
  218. skill={skill}
  219. toolById={toolById}
  220. onEdit={() => openEdit(skill)}
  221. onDelete={() => void removeSkill(skill)}
  222. />
  223. ))}
  224. </div>
  225. </div>
  226. ) : (
  227. <EmptyState
  228. icon={Search}
  229. title={t("skills.empty")}
  230. description={t("skills.emptyHint")}
  231. actionLabel={t("skills.new")}
  232. onAction={openCreate}
  233. />
  234. )}
  235. </CardContent>
  236. </Card>
  237. <SkillDialog
  238. open={createOpen}
  239. title={t("skills.new")}
  240. form={form}
  241. tools={tools}
  242. submitLabel={t("common.create")}
  243. saving={saving}
  244. onOpenChange={setCreateOpen}
  245. onChange={setForm}
  246. onSubmit={() => void createSkillFromForm()}
  247. />
  248. <SkillDialog
  249. open={editOpen}
  250. title={editingSkill?.name ?? t("common.edit")}
  251. form={form}
  252. tools={tools}
  253. submitLabel={t("common.save")}
  254. saving={saving}
  255. onOpenChange={setEditOpen}
  256. onChange={setForm}
  257. onSubmit={() => void updateSkillFromForm()}
  258. />
  259. </div>
  260. );
  261. }
  262. function SkillRow({
  263. skill,
  264. toolById,
  265. onEdit,
  266. onDelete,
  267. }: {
  268. skill: SkillDefinition;
  269. toolById: Map<string, ToolOption>;
  270. onEdit: () => void;
  271. onDelete: () => void;
  272. }) {
  273. const { t } = useTranslation();
  274. const boundTools = skill.toolIds.map((toolId) => ({
  275. id: toolId,
  276. label: toolDisplayName(toolById.get(toolId)) ?? toolId,
  277. missing: !toolById.has(toolId),
  278. }));
  279. return (
  280. <div className="grid gap-3 px-4 py-4 lg:grid-cols-[minmax(260px,1fr)_150px_minmax(260px,1fr)_110px] lg:items-center">
  281. <div className="min-w-0">
  282. <div className="flex items-center gap-2">
  283. <Puzzle className="h-4 w-4 text-primary" />
  284. <span className="truncate text-sm font-medium">{skill.name}</span>
  285. </div>
  286. <p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{skill.description || t("skills.emptyHint")}</p>
  287. </div>
  288. <Badge className="w-fit border-border bg-muted/50 text-muted-foreground">{categoryLabel(skill.category, t)}</Badge>
  289. <div className="min-w-0">
  290. {boundTools.length ? (
  291. <div className="flex flex-wrap gap-1.5">
  292. {boundTools.slice(0, 3).map((tool) => (
  293. <Badge
  294. key={tool.id}
  295. className={tool.missing ? "border-destructive/30 bg-destructive/10 text-destructive" : "border-primary/20 bg-primary/10 text-primary"}
  296. >
  297. <Link2 className="h-3.5 w-3.5" /> <span className="max-w-32 truncate">{tool.label}</span>
  298. </Badge>
  299. ))}
  300. {boundTools.length > 3 ? (
  301. <Badge className="border-border bg-muted text-muted-foreground">+{boundTools.length - 3}</Badge>
  302. ) : null}
  303. </div>
  304. ) : (
  305. <span className="text-xs text-muted-foreground">{t("skills.noToolsSelected", "No tools selected")}</span>
  306. )}
  307. </div>
  308. <div className="flex items-center justify-start gap-1.5 lg:justify-end">
  309. <Button size="icon" variant="ghost" onClick={onEdit} aria-label={`Edit ${skill.name}`}>
  310. <Pencil className="h-4 w-4" />
  311. </Button>
  312. <Button size="icon" variant="ghost" className="text-destructive hover:text-destructive" onClick={onDelete} aria-label={`Delete ${skill.name}`}>
  313. <Trash2 className="h-4 w-4" />
  314. </Button>
  315. </div>
  316. </div>
  317. );
  318. }
  319. function SkillDialog({
  320. open,
  321. title,
  322. form,
  323. tools,
  324. submitLabel,
  325. saving,
  326. onOpenChange,
  327. onChange,
  328. onSubmit,
  329. }: {
  330. open: boolean;
  331. title: string;
  332. form: SkillFormState;
  333. tools: ToolOption[];
  334. submitLabel: string;
  335. saving: boolean;
  336. onOpenChange: (open: boolean) => void;
  337. onChange: (form: SkillFormState) => void;
  338. onSubmit: () => void;
  339. }) {
  340. const { t } = useTranslation();
  341. const [toolSearch, setToolSearch] = React.useState("");
  342. const set = (key: keyof SkillFormState, value: string | string[]) => onChange({ ...form, [key]: value });
  343. const selectedTools = form.selectedToolIds
  344. .map((toolId) => tools.find((tool) => tool.id === toolId))
  345. .filter((tool): tool is ToolOption => Boolean(tool));
  346. const filteredTools = tools
  347. .filter((tool) => {
  348. const text = toolOptionSearchText(tool).toLowerCase();
  349. return text.includes(toolSearch.toLowerCase().trim());
  350. })
  351. .sort((first, second) => {
  352. const firstSelected = form.selectedToolIds.includes(first.id) ? 0 : 1;
  353. const secondSelected = form.selectedToolIds.includes(second.id) ? 0 : 1;
  354. return firstSelected - secondSelected || first.name.localeCompare(second.name);
  355. });
  356. React.useEffect(() => {
  357. if (open) setToolSearch("");
  358. }, [open]);
  359. function toggleTool(toolId: string) {
  360. const selectedToolIds = form.selectedToolIds.includes(toolId)
  361. ? form.selectedToolIds.filter((id) => id !== toolId)
  362. : [...form.selectedToolIds, toolId];
  363. set("selectedToolIds", selectedToolIds);
  364. }
  365. function selectVisibleTools() {
  366. const next = new Set(form.selectedToolIds);
  367. filteredTools.forEach((tool) => next.add(tool.id));
  368. set("selectedToolIds", Array.from(next));
  369. }
  370. async function copyToolName(name: string) {
  371. try {
  372. await navigator.clipboard.writeText(name);
  373. toast.success(t("common.copied", "Copied"));
  374. } catch {
  375. toast.error(t("errors.failedToCopy", "Failed to copy"));
  376. }
  377. }
  378. return (
  379. <Dialog open={open} onOpenChange={onOpenChange} title={title} className="max-w-6xl">
  380. <div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_430px]">
  381. <div className="space-y-4">
  382. <section className="rounded-md border border-border bg-surface-elevated p-4">
  383. <div className="mb-4 flex items-center justify-between gap-3">
  384. <div>
  385. <h3 className="text-sm font-semibold">{t("skills.skillSetup", "Skill setup")}</h3>
  386. <p className="mt-1 text-xs text-muted-foreground">{t("skills.skillSetupHint", "Name the skill and write the instruction it should follow.")}</p>
  387. </div>
  388. <Badge className={form.name.trim() ? "border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-200" : "border-border bg-muted text-muted-foreground"}>
  389. {form.name.trim() ? t("common.ready", "Ready") : t("common.required", "Required")}
  390. </Badge>
  391. </div>
  392. <div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_190px]">
  393. <Field label={t("common.name")}>
  394. <Input value={form.name} onChange={(event) => set("name", event.target.value)} placeholder={t("skills.namePlaceholder")} />
  395. </Field>
  396. <Field label={t("skills.category")}>
  397. <Select
  398. value={form.category}
  399. onChange={(event) => set("category", event.target.value)}
  400. options={[
  401. { value: "service", label: t("skills.catService") },
  402. { value: "analytics", label: t("skills.catAnalytics") },
  403. { value: "development", label: t("skills.catDevelopment") },
  404. { value: "processing", label: t("skills.catProcessing") },
  405. ]}
  406. />
  407. </Field>
  408. <div className="md:col-span-2">
  409. <Field label={t("common.description")}>
  410. <Input value={form.description} onChange={(event) => set("description", event.target.value)} placeholder={t("skills.descPlaceholder")} />
  411. </Field>
  412. </div>
  413. </div>
  414. </section>
  415. <section className="rounded-md border border-border bg-surface-elevated p-4">
  416. <div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
  417. <div>
  418. <h3 className="text-sm font-semibold">{t("skills.instruction")}</h3>
  419. <p className="mt-1 text-xs text-muted-foreground">{t("skills.instructionBuilderHint", "Write when to use the skill, what tools to call, and what output to return.")}</p>
  420. </div>
  421. <Badge className="w-fit border-border bg-muted text-muted-foreground">
  422. {form.instruction.trim().length} {t("common.characters", "characters")}
  423. </Badge>
  424. </div>
  425. <Textarea
  426. value={form.instruction}
  427. onChange={(event) => set("instruction", event.target.value)}
  428. placeholder={t("skills.instructionPlaceholder")}
  429. className="min-h-72 font-mono leading-6"
  430. />
  431. </section>
  432. </div>
  433. <aside className="space-y-4">
  434. <section className="rounded-md border border-border bg-surface-elevated">
  435. <div className="border-b border-border p-4">
  436. <div className="flex items-start justify-between gap-3">
  437. <div>
  438. <h3 className="text-sm font-semibold">{t("skills.selectTools")}</h3>
  439. <p className="mt-1 text-xs text-muted-foreground">{t("skills.toolsHint")}</p>
  440. </div>
  441. <Badge className="shrink-0 border-primary/20 bg-primary/10 text-primary">
  442. {form.selectedToolIds.length}
  443. </Badge>
  444. </div>
  445. <div className="mt-3 grid gap-2">
  446. <SearchInput
  447. value={toolSearch}
  448. onChange={setToolSearch}
  449. placeholder={t("skills.searchTools", "Search tools")}
  450. />
  451. <div className="grid gap-2 sm:grid-cols-2">
  452. <Button type="button" variant="outline" onClick={selectVisibleTools} disabled={!filteredTools.length}>
  453. <CheckCircle2 className="h-4 w-4" /> {t("common.selectAll", "Select all")}
  454. </Button>
  455. <Button type="button" variant="ghost" onClick={() => set("selectedToolIds", [])} disabled={!form.selectedToolIds.length}>
  456. <X className="h-4 w-4" /> {t("common.clear", "Clear")}
  457. </Button>
  458. </div>
  459. </div>
  460. <div className="mt-3 min-h-10 rounded-md border border-dashed border-border bg-background/60 p-2">
  461. {selectedTools.length ? (
  462. <div className="flex flex-wrap gap-1.5">
  463. {selectedTools.map((tool) => (
  464. <button
  465. key={tool.id}
  466. type="button"
  467. onClick={() => toggleTool(tool.id)}
  468. className="inline-flex max-w-full items-center gap-1.5 rounded-md border border-primary/20 bg-primary/10 px-2 py-1 text-xs font-medium text-primary transition hover:bg-primary/15"
  469. >
  470. <span className="truncate">{toolDisplayName(tool)}</span>
  471. <X className="h-3 w-3 shrink-0" />
  472. </button>
  473. ))}
  474. </div>
  475. ) : (
  476. <p className="px-1 py-1 text-xs text-muted-foreground">
  477. {t("skills.noToolsSelected", "No tools selected")}
  478. </p>
  479. )}
  480. </div>
  481. </div>
  482. <div className="max-h-[46dvh] overflow-auto">
  483. {filteredTools.length ? (
  484. <div className="divide-y divide-border">
  485. {filteredTools.map((tool) => {
  486. const selected = form.selectedToolIds.includes(tool.id);
  487. const displayName = toolDisplayName(tool) ?? tool.name;
  488. return (
  489. <div
  490. key={tool.id}
  491. className={[
  492. "grid grid-cols-[24px_minmax(0,1fr)] gap-3 px-3 py-3 transition hover:bg-muted/35",
  493. selected ? "bg-primary/5" : "bg-transparent",
  494. ].join(" ")}
  495. >
  496. <button
  497. type="button"
  498. onClick={() => toggleTool(tool.id)}
  499. className={[
  500. "mt-0.5 grid h-5 w-5 place-items-center rounded border",
  501. selected ? "border-primary bg-primary text-primary-foreground" : "border-muted-foreground/40",
  502. ].join(" ")}
  503. aria-label={selected ? t("common.selected", "Selected") : t("skills.selectTools")}
  504. >
  505. {selected ? <CheckCircle2 className="h-3.5 w-3.5" /> : null}
  506. </button>
  507. <div className="min-w-0">
  508. <button type="button" className="block w-full text-left" onClick={() => toggleTool(tool.id)}>
  509. <span className="flex min-w-0 items-center gap-2">
  510. <span className="truncate text-sm font-medium">{displayName}</span>
  511. <Badge className="shrink-0 border-border bg-muted text-muted-foreground">
  512. {tool.toolType === "mcp" ? t("tools.mcpServer", "MCP server") : tool.toolType}
  513. </Badge>
  514. </span>
  515. <span className="mt-1 block line-clamp-2 text-xs text-muted-foreground">
  516. {tool.exposedTools.length
  517. ? `${tool.name} / ${tool.exposedTools.length} ${t("tools.exposedTools", "exposed tools")}`
  518. : tool.description}
  519. </span>
  520. </button>
  521. <div className="mt-2 flex flex-wrap gap-1.5">
  522. {(tool.exposedTools.length ? tool.exposedTools.slice(0, 4) : [{ name: displayName }]).map((exposedTool) => (
  523. <span
  524. key={exposedTool.name}
  525. className="inline-flex items-center gap-1 rounded border border-border bg-background px-1.5 py-0.5 font-mono text-xs text-muted-foreground"
  526. >
  527. {exposedTool.name}
  528. <button
  529. type="button"
  530. className="grid h-5 w-5 place-items-center rounded text-muted-foreground transition hover:bg-muted hover:text-foreground"
  531. aria-label={t("skills.copyToolName", "Copy tool name")}
  532. onClick={() => void copyToolName(exposedTool.name)}
  533. >
  534. <Copy className="h-3 w-3" />
  535. </button>
  536. </span>
  537. ))}
  538. {tool.exposedTools.length > 4 ? (
  539. <span className="text-xs text-muted-foreground">+{tool.exposedTools.length - 4}</span>
  540. ) : null}
  541. </div>
  542. </div>
  543. </div>
  544. );
  545. })}
  546. </div>
  547. ) : (
  548. <div className="p-4 text-sm text-muted-foreground">
  549. {tools.length ? t("skills.noToolsMatch", "No tools match this search") : t("skills.noTools")}
  550. </div>
  551. )}
  552. </div>
  553. </section>
  554. </aside>
  555. </div>
  556. <div className="sticky bottom-0 -mx-4 -mb-4 mt-5 flex flex-col-reverse gap-3 border-t border-border bg-surface-elevated p-4 sm:-mx-5 sm:-mb-5 sm:flex-row sm:items-center sm:justify-between">
  557. <div className="text-xs text-muted-foreground">
  558. {form.name.trim() || t("skills.namePlaceholder")} / {form.selectedToolIds.length} {t("skills.toolsCount")}
  559. </div>
  560. <div className="flex flex-col-reverse gap-2 sm:flex-row">
  561. <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>{t("common.cancel")}</Button>
  562. <Button type="button" onClick={onSubmit} disabled={!form.name.trim() || saving}>{saving ? t("common.saving") : submitLabel}</Button>
  563. </div>
  564. </div>
  565. </Dialog>
  566. );
  567. }
  568. function Field({ label, children }: { label: string; children: React.ReactNode }) {
  569. return (
  570. <label className="block space-y-2 text-sm font-medium">
  571. <span>{label}</span>
  572. {children}
  573. </label>
  574. );
  575. }
  576. function fromSkill(skill: SkillDefinition): SkillFormState {
  577. return {
  578. name: skill.name,
  579. description: skill.description ?? "",
  580. instruction: skill.instruction,
  581. category: skill.category || "service",
  582. selectedToolIds: skill.toolIds,
  583. };
  584. }
  585. function toolDisplayName(tool?: ToolOption) {
  586. if (!tool) return undefined;
  587. const onlyExposedTool = tool.exposedTools[0];
  588. if (tool.exposedTools.length === 1 && onlyExposedTool) return onlyExposedTool.name;
  589. return tool.name;
  590. }
  591. function toolOptionSearchText(tool?: ToolOption) {
  592. if (!tool) return "";
  593. const exposedText = tool.exposedTools
  594. .map((exposedTool) => `${exposedTool.name} ${exposedTool.description ?? ""}`)
  595. .join(" ");
  596. return `${tool.name} ${tool.description} ${tool.toolType} ${exposedText}`;
  597. }
  598. function getMcpExposedTools(connection?: ToolConnection) {
  599. const config = connection?.invoke_config_json;
  600. if (!config || typeof config !== "object") return [];
  601. const rawTools = config.mcp_tools ?? config.tools ?? config.tool_names;
  602. if (!Array.isArray(rawTools)) return [];
  603. return rawTools.flatMap((item) => {
  604. if (!item || typeof item !== "object" || Array.isArray(item)) return [];
  605. const record = item as Record<string, unknown>;
  606. if (typeof record.name !== "string" || !record.name) return [];
  607. return [{
  608. name: record.name,
  609. description: typeof record.description === "string" ? record.description : undefined,
  610. }];
  611. });
  612. }
  613. function categoryLabel(category: string, t: (key: string) => string) {
  614. const key = `skills.cat${formatCategoryKey(category)}`;
  615. const label = t(key);
  616. return label === key ? category : label;
  617. }
  618. function formatCategoryKey(category: string) {
  619. return category
  620. .split(/[_-]+/)
  621. .filter(Boolean)
  622. .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
  623. .join("");
  624. }