| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666 |
- import * as React from "react";
- import { useTranslation } from "react-i18next";
- import { CheckCircle2, Copy, FileText, Link2, Pencil, Plus, Puzzle, RefreshCw, Search, Trash2, Wrench, X } from "lucide-react";
- import { createSkill, deleteSkill, listAllSkills, listToolConnections, listTools, updateSkill } from "@/api";
- import { translateApiError } from "@/api/errors";
- import { ApiErrorState } from "@/components/shared/ApiErrorState";
- import { EmptyState } from "@/components/shared/EmptyState";
- import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
- import { MetricCard } from "@/components/shared/MetricCard";
- import { PageHeader } from "@/components/shared/PageHeader";
- import { SearchInput } from "@/components/shared/SearchInput";
- import { Badge } from "@/components/ui/badge";
- import { Button } from "@/components/ui/button";
- import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
- import { Dialog } from "@/components/ui/dialog";
- import { Input, Textarea } from "@/components/ui/input";
- import { Select } from "@/components/ui/select";
- import { toast } from "@/components/ui/toaster";
- import type { SkillDefinition, ToolConnection, ToolDefinition } from "@/types";
- type ToolOption = {
- id: string;
- name: string;
- description: string;
- toolType: string;
- exposedTools: Array<{
- name: string;
- description?: string;
- }>;
- };
- type SkillFormState = {
- name: string;
- description: string;
- instruction: string;
- category: string;
- selectedToolIds: string[];
- };
- const emptyForm: SkillFormState = {
- name: "",
- description: "",
- instruction: "",
- category: "service",
- selectedToolIds: [],
- };
- export function SkillsPage() {
- const { t } = useTranslation();
- const [skills, setSkills] = React.useState<SkillDefinition[]>([]);
- const [tools, setTools] = React.useState<ToolOption[]>([]);
- const [search, setSearch] = React.useState("");
- const [categoryFilter, setCategoryFilter] = React.useState("all");
- const [createOpen, setCreateOpen] = React.useState(false);
- const [editOpen, setEditOpen] = React.useState(false);
- const [editingSkill, setEditingSkill] = React.useState<SkillDefinition>();
- const [form, setForm] = React.useState<SkillFormState>(emptyForm);
- const [loading, setLoading] = React.useState(true);
- const [saving, setSaving] = React.useState(false);
- const [error, setError] = React.useState<string>();
- const toolById = React.useMemo(() => new Map(tools.map((tool) => [tool.id, tool])), [tools]);
- const categories = Array.from(new Set(skills.map((skill) => skill.category || "service"))).sort();
- const filtered = skills
- .filter((skill) => skill.status !== "archived")
- .filter((skill) => {
- const toolNames = skill.toolIds.map((toolId) => toolOptionSearchText(toolById.get(toolId)) || toolId).join(" ");
- const text = `${skill.name} ${skill.description ?? ""} ${skill.category} ${skill.instruction} ${toolNames}`.toLowerCase();
- return text.includes(search.toLowerCase()) && (categoryFilter === "all" || skill.category === categoryFilter);
- })
- .sort((first, second) => first.name.localeCompare(second.name));
- const boundToolsCount = filtered.reduce((count, skill) => count + skill.toolIds.length, 0);
- const load = React.useCallback(async () => {
- setLoading(true);
- setError(undefined);
- try {
- const [skillItems, toolItems, connectionItems] = await Promise.all([
- listAllSkills(),
- listTools().catch(() => [] as ToolDefinition[]),
- listToolConnections().catch(() => [] as ToolConnection[]),
- ]);
- const connectionByTool = new Map<string, ToolConnection>();
- connectionItems.forEach((connection) => {
- if (!connectionByTool.has(connection.tool_id)) {
- connectionByTool.set(connection.tool_id, connection);
- }
- });
- const mappedTools = toolItems.map((tool) => ({
- id: tool.id,
- name: tool.name,
- description: tool.description ?? tool.tool_type,
- toolType: tool.tool_type,
- exposedTools: getMcpExposedTools(connectionByTool.get(tool.id)),
- }));
- setSkills(skillItems);
- setTools(mappedTools);
- } catch (err) {
- setError(translateApiError(err));
- } finally {
- setLoading(false);
- }
- }, [t]);
- React.useEffect(() => {
- void load();
- }, [load]);
- function openCreate() {
- setForm(emptyForm);
- setCreateOpen(true);
- }
- function openEdit(skill: SkillDefinition) {
- setEditingSkill(skill);
- setForm(fromSkill(skill));
- setEditOpen(true);
- }
- async function createSkillFromForm() {
- if (!form.name.trim()) return;
- setSaving(true);
- try {
- const created = await createSkill({
- name: form.name.trim(),
- description: form.description.trim() || null,
- category: form.category,
- instruction: form.instruction.trim(),
- toolIds: form.selectedToolIds,
- });
- setSkills((current) => [created, ...current]);
- setCreateOpen(false);
- toast.success(t("skills.created"));
- } catch (err) {
- toast.error(translateApiError(err));
- } finally {
- setSaving(false);
- }
- }
- async function updateSkillFromForm() {
- if (!editingSkill || !form.name.trim()) return;
- setSaving(true);
- try {
- const updated = await updateSkill({
- skillId: editingSkill.id,
- name: form.name.trim(),
- description: form.description.trim() || null,
- category: form.category,
- instruction: form.instruction.trim(),
- toolIds: form.selectedToolIds,
- });
- setSkills((current) => current.map((skill) => (skill.id === updated.id ? updated : skill)));
- setEditingSkill(updated);
- setEditOpen(false);
- toast.success(t("skills.saved"));
- } catch (err) {
- toast.error(translateApiError(err));
- } finally {
- setSaving(false);
- }
- }
- async function removeSkill(skill: SkillDefinition) {
- try {
- await deleteSkill(skill.id);
- setSkills((current) => current.filter((item) => item.id !== skill.id));
- toast.success(t("skills.deleted"));
- } catch (err) {
- toast.error(translateApiError(err));
- }
- }
- if (loading) return <LoadingSpinner label={t("agents.loadingSkills")} />;
- if (error) return <ApiErrorState message={error} onRetry={() => void load()} />;
- return (
- <div className="space-y-6">
- <PageHeader
- title={t("skills.title")}
- description={t("skills.description")}
- actions={
- <>
- <Button variant="outline" onClick={() => void load()}>
- <RefreshCw className="h-4 w-4" /> {t("common.refresh")}
- </Button>
- <Button onClick={openCreate}>
- <Plus className="h-4 w-4" /> {t("skills.new")}
- </Button>
- </>
- }
- />
- <div className="grid gap-4 md:grid-cols-3">
- <MetricCard label={t("skills.title")} value={filtered.length} icon={Puzzle} />
- <MetricCard label={t("skills.toolsCount")} value={boundToolsCount} icon={Wrench} />
- <MetricCard label={t("skills.category")} value={categories.length} icon={FileText} />
- </div>
- <Card>
- <CardHeader>
- <div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
- <div>
- <CardTitle>{t("skills.title")}</CardTitle>
- <CardDescription>{filtered.length} / {skills.filter((skill) => skill.status !== "archived").length}</CardDescription>
- </div>
- <Badge className="w-fit border-border bg-muted/50 text-muted-foreground">{t("skills.selectTools")}</Badge>
- </div>
- </CardHeader>
- <CardContent className="space-y-4">
- <div className="grid gap-3 lg:grid-cols-[1fr_220px]">
- <SearchInput value={search} onChange={setSearch} placeholder={t("skills.search")} />
- <Select
- value={categoryFilter}
- onChange={(event) => setCategoryFilter(event.target.value)}
- options={[
- { value: "all", label: t("skills.allCategories") },
- ...categories.map((category) => ({ value: category, label: categoryLabel(category, t) })),
- ]}
- />
- </div>
- {filtered.length ? (
- <div className="overflow-hidden rounded-md border border-border">
- <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">
- <span>{t("common.name")}</span>
- <span>{t("skills.category")}</span>
- <span>{t("skills.toolsCount")}</span>
- <span className="text-right">{t("common.actions")}</span>
- </div>
- <div className="divide-y divide-border">
- {filtered.map((skill) => (
- <SkillRow
- key={skill.id}
- skill={skill}
- toolById={toolById}
- onEdit={() => openEdit(skill)}
- onDelete={() => void removeSkill(skill)}
- />
- ))}
- </div>
- </div>
- ) : (
- <EmptyState
- icon={Search}
- title={t("skills.empty")}
- description={t("skills.emptyHint")}
- actionLabel={t("skills.new")}
- onAction={openCreate}
- />
- )}
- </CardContent>
- </Card>
- <SkillDialog
- open={createOpen}
- title={t("skills.new")}
- form={form}
- tools={tools}
- submitLabel={t("common.create")}
- saving={saving}
- onOpenChange={setCreateOpen}
- onChange={setForm}
- onSubmit={() => void createSkillFromForm()}
- />
- <SkillDialog
- open={editOpen}
- title={editingSkill?.name ?? t("common.edit")}
- form={form}
- tools={tools}
- submitLabel={t("common.save")}
- saving={saving}
- onOpenChange={setEditOpen}
- onChange={setForm}
- onSubmit={() => void updateSkillFromForm()}
- />
- </div>
- );
- }
- function SkillRow({
- skill,
- toolById,
- onEdit,
- onDelete,
- }: {
- skill: SkillDefinition;
- toolById: Map<string, ToolOption>;
- onEdit: () => void;
- onDelete: () => void;
- }) {
- const { t } = useTranslation();
- const boundTools = skill.toolIds.map((toolId) => ({
- id: toolId,
- label: toolDisplayName(toolById.get(toolId)) ?? toolId,
- missing: !toolById.has(toolId),
- }));
- return (
- <div className="grid gap-3 px-4 py-4 lg:grid-cols-[minmax(260px,1fr)_150px_minmax(260px,1fr)_110px] lg:items-center">
- <div className="min-w-0">
- <div className="flex items-center gap-2">
- <Puzzle className="h-4 w-4 text-primary" />
- <span className="truncate text-sm font-medium">{skill.name}</span>
- </div>
- <p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{skill.description || t("skills.emptyHint")}</p>
- </div>
- <Badge className="w-fit border-border bg-muted/50 text-muted-foreground">{categoryLabel(skill.category, t)}</Badge>
- <div className="min-w-0">
- {boundTools.length ? (
- <div className="flex flex-wrap gap-1.5">
- {boundTools.slice(0, 3).map((tool) => (
- <Badge
- key={tool.id}
- className={tool.missing ? "border-destructive/30 bg-destructive/10 text-destructive" : "border-primary/20 bg-primary/10 text-primary"}
- >
- <Link2 className="h-3.5 w-3.5" /> <span className="max-w-32 truncate">{tool.label}</span>
- </Badge>
- ))}
- {boundTools.length > 3 ? (
- <Badge className="border-border bg-muted text-muted-foreground">+{boundTools.length - 3}</Badge>
- ) : null}
- </div>
- ) : (
- <span className="text-xs text-muted-foreground">{t("skills.noToolsSelected", "No tools selected")}</span>
- )}
- </div>
- <div className="flex items-center justify-start gap-1.5 lg:justify-end">
- <Button size="icon" variant="ghost" onClick={onEdit} aria-label={`Edit ${skill.name}`}>
- <Pencil className="h-4 w-4" />
- </Button>
- <Button size="icon" variant="ghost" className="text-destructive hover:text-destructive" onClick={onDelete} aria-label={`Delete ${skill.name}`}>
- <Trash2 className="h-4 w-4" />
- </Button>
- </div>
- </div>
- );
- }
- function SkillDialog({
- open,
- title,
- form,
- tools,
- submitLabel,
- saving,
- onOpenChange,
- onChange,
- onSubmit,
- }: {
- open: boolean;
- title: string;
- form: SkillFormState;
- tools: ToolOption[];
- submitLabel: string;
- saving: boolean;
- onOpenChange: (open: boolean) => void;
- onChange: (form: SkillFormState) => void;
- onSubmit: () => void;
- }) {
- const { t } = useTranslation();
- const [toolSearch, setToolSearch] = React.useState("");
- const set = (key: keyof SkillFormState, value: string | string[]) => onChange({ ...form, [key]: value });
- const selectedTools = form.selectedToolIds
- .map((toolId) => tools.find((tool) => tool.id === toolId))
- .filter((tool): tool is ToolOption => Boolean(tool));
- const filteredTools = tools
- .filter((tool) => {
- const text = toolOptionSearchText(tool).toLowerCase();
- return text.includes(toolSearch.toLowerCase().trim());
- })
- .sort((first, second) => {
- const firstSelected = form.selectedToolIds.includes(first.id) ? 0 : 1;
- const secondSelected = form.selectedToolIds.includes(second.id) ? 0 : 1;
- return firstSelected - secondSelected || first.name.localeCompare(second.name);
- });
- React.useEffect(() => {
- if (open) setToolSearch("");
- }, [open]);
- function toggleTool(toolId: string) {
- const selectedToolIds = form.selectedToolIds.includes(toolId)
- ? form.selectedToolIds.filter((id) => id !== toolId)
- : [...form.selectedToolIds, toolId];
- set("selectedToolIds", selectedToolIds);
- }
- function selectVisibleTools() {
- const next = new Set(form.selectedToolIds);
- filteredTools.forEach((tool) => next.add(tool.id));
- set("selectedToolIds", Array.from(next));
- }
- async function copyToolName(name: string) {
- try {
- await navigator.clipboard.writeText(name);
- toast.success(t("common.copied", "Copied"));
- } catch {
- toast.error(t("errors.failedToCopy", "Failed to copy"));
- }
- }
- return (
- <Dialog open={open} onOpenChange={onOpenChange} title={title} className="max-w-6xl">
- <div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_430px]">
- <div className="space-y-4">
- <section className="rounded-md border border-border bg-surface-elevated p-4">
- <div className="mb-4 flex items-center justify-between gap-3">
- <div>
- <h3 className="text-sm font-semibold">{t("skills.skillSetup", "Skill setup")}</h3>
- <p className="mt-1 text-xs text-muted-foreground">{t("skills.skillSetupHint", "Name the skill and write the instruction it should follow.")}</p>
- </div>
- <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"}>
- {form.name.trim() ? t("common.ready", "Ready") : t("common.required", "Required")}
- </Badge>
- </div>
- <div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_190px]">
- <Field label={t("common.name")}>
- <Input value={form.name} onChange={(event) => set("name", event.target.value)} placeholder={t("skills.namePlaceholder")} />
- </Field>
- <Field label={t("skills.category")}>
- <Select
- value={form.category}
- onChange={(event) => set("category", event.target.value)}
- options={[
- { value: "service", label: t("skills.catService") },
- { value: "analytics", label: t("skills.catAnalytics") },
- { value: "development", label: t("skills.catDevelopment") },
- { value: "processing", label: t("skills.catProcessing") },
- ]}
- />
- </Field>
- <div className="md:col-span-2">
- <Field label={t("common.description")}>
- <Input value={form.description} onChange={(event) => set("description", event.target.value)} placeholder={t("skills.descPlaceholder")} />
- </Field>
- </div>
- </div>
- </section>
- <section className="rounded-md border border-border bg-surface-elevated p-4">
- <div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
- <div>
- <h3 className="text-sm font-semibold">{t("skills.instruction")}</h3>
- <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>
- </div>
- <Badge className="w-fit border-border bg-muted text-muted-foreground">
- {form.instruction.trim().length} {t("common.characters", "characters")}
- </Badge>
- </div>
- <Textarea
- value={form.instruction}
- onChange={(event) => set("instruction", event.target.value)}
- placeholder={t("skills.instructionPlaceholder")}
- className="min-h-72 font-mono leading-6"
- />
- </section>
- </div>
- <aside className="space-y-4">
- <section className="rounded-md border border-border bg-surface-elevated">
- <div className="border-b border-border p-4">
- <div className="flex items-start justify-between gap-3">
- <div>
- <h3 className="text-sm font-semibold">{t("skills.selectTools")}</h3>
- <p className="mt-1 text-xs text-muted-foreground">{t("skills.toolsHint")}</p>
- </div>
- <Badge className="shrink-0 border-primary/20 bg-primary/10 text-primary">
- {form.selectedToolIds.length}
- </Badge>
- </div>
- <div className="mt-3 grid gap-2">
- <SearchInput
- value={toolSearch}
- onChange={setToolSearch}
- placeholder={t("skills.searchTools", "Search tools")}
- />
- <div className="grid gap-2 sm:grid-cols-2">
- <Button type="button" variant="outline" onClick={selectVisibleTools} disabled={!filteredTools.length}>
- <CheckCircle2 className="h-4 w-4" /> {t("common.selectAll", "Select all")}
- </Button>
- <Button type="button" variant="ghost" onClick={() => set("selectedToolIds", [])} disabled={!form.selectedToolIds.length}>
- <X className="h-4 w-4" /> {t("common.clear", "Clear")}
- </Button>
- </div>
- </div>
- <div className="mt-3 min-h-10 rounded-md border border-dashed border-border bg-background/60 p-2">
- {selectedTools.length ? (
- <div className="flex flex-wrap gap-1.5">
- {selectedTools.map((tool) => (
- <button
- key={tool.id}
- type="button"
- onClick={() => toggleTool(tool.id)}
- 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"
- >
- <span className="truncate">{toolDisplayName(tool)}</span>
- <X className="h-3 w-3 shrink-0" />
- </button>
- ))}
- </div>
- ) : (
- <p className="px-1 py-1 text-xs text-muted-foreground">
- {t("skills.noToolsSelected", "No tools selected")}
- </p>
- )}
- </div>
- </div>
- <div className="max-h-[46dvh] overflow-auto">
- {filteredTools.length ? (
- <div className="divide-y divide-border">
- {filteredTools.map((tool) => {
- const selected = form.selectedToolIds.includes(tool.id);
- const displayName = toolDisplayName(tool) ?? tool.name;
- return (
- <div
- key={tool.id}
- className={[
- "grid grid-cols-[24px_minmax(0,1fr)] gap-3 px-3 py-3 transition hover:bg-muted/35",
- selected ? "bg-primary/5" : "bg-transparent",
- ].join(" ")}
- >
- <button
- type="button"
- onClick={() => toggleTool(tool.id)}
- className={[
- "mt-0.5 grid h-5 w-5 place-items-center rounded border",
- selected ? "border-primary bg-primary text-primary-foreground" : "border-muted-foreground/40",
- ].join(" ")}
- aria-label={selected ? t("common.selected", "Selected") : t("skills.selectTools")}
- >
- {selected ? <CheckCircle2 className="h-3.5 w-3.5" /> : null}
- </button>
- <div className="min-w-0">
- <button type="button" className="block w-full text-left" onClick={() => toggleTool(tool.id)}>
- <span className="flex min-w-0 items-center gap-2">
- <span className="truncate text-sm font-medium">{displayName}</span>
- <Badge className="shrink-0 border-border bg-muted text-muted-foreground">
- {tool.toolType === "mcp" ? t("tools.mcpServer", "MCP server") : tool.toolType}
- </Badge>
- </span>
- <span className="mt-1 block line-clamp-2 text-xs text-muted-foreground">
- {tool.exposedTools.length
- ? `${tool.name} / ${tool.exposedTools.length} ${t("tools.exposedTools", "exposed tools")}`
- : tool.description}
- </span>
- </button>
- <div className="mt-2 flex flex-wrap gap-1.5">
- {(tool.exposedTools.length ? tool.exposedTools.slice(0, 4) : [{ name: displayName }]).map((exposedTool) => (
- <span
- key={exposedTool.name}
- 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"
- >
- {exposedTool.name}
- <button
- type="button"
- className="grid h-5 w-5 place-items-center rounded text-muted-foreground transition hover:bg-muted hover:text-foreground"
- aria-label={t("skills.copyToolName", "Copy tool name")}
- onClick={() => void copyToolName(exposedTool.name)}
- >
- <Copy className="h-3 w-3" />
- </button>
- </span>
- ))}
- {tool.exposedTools.length > 4 ? (
- <span className="text-xs text-muted-foreground">+{tool.exposedTools.length - 4}</span>
- ) : null}
- </div>
- </div>
- </div>
- );
- })}
- </div>
- ) : (
- <div className="p-4 text-sm text-muted-foreground">
- {tools.length ? t("skills.noToolsMatch", "No tools match this search") : t("skills.noTools")}
- </div>
- )}
- </div>
- </section>
- </aside>
- </div>
- <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">
- <div className="text-xs text-muted-foreground">
- {form.name.trim() || t("skills.namePlaceholder")} / {form.selectedToolIds.length} {t("skills.toolsCount")}
- </div>
- <div className="flex flex-col-reverse gap-2 sm:flex-row">
- <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>{t("common.cancel")}</Button>
- <Button type="button" onClick={onSubmit} disabled={!form.name.trim() || saving}>{saving ? t("common.saving") : submitLabel}</Button>
- </div>
- </div>
- </Dialog>
- );
- }
- function Field({ label, children }: { label: string; children: React.ReactNode }) {
- return (
- <label className="block space-y-2 text-sm font-medium">
- <span>{label}</span>
- {children}
- </label>
- );
- }
- function fromSkill(skill: SkillDefinition): SkillFormState {
- return {
- name: skill.name,
- description: skill.description ?? "",
- instruction: skill.instruction,
- category: skill.category || "service",
- selectedToolIds: skill.toolIds,
- };
- }
- function toolDisplayName(tool?: ToolOption) {
- if (!tool) return undefined;
- const onlyExposedTool = tool.exposedTools[0];
- if (tool.exposedTools.length === 1 && onlyExposedTool) return onlyExposedTool.name;
- return tool.name;
- }
- function toolOptionSearchText(tool?: ToolOption) {
- if (!tool) return "";
- const exposedText = tool.exposedTools
- .map((exposedTool) => `${exposedTool.name} ${exposedTool.description ?? ""}`)
- .join(" ");
- return `${tool.name} ${tool.description} ${tool.toolType} ${exposedText}`;
- }
- function getMcpExposedTools(connection?: ToolConnection) {
- const config = connection?.invoke_config_json;
- if (!config || typeof config !== "object") return [];
- const rawTools = config.mcp_tools ?? config.tools ?? config.tool_names;
- if (!Array.isArray(rawTools)) return [];
- return rawTools.flatMap((item) => {
- if (!item || typeof item !== "object" || Array.isArray(item)) return [];
- const record = item as Record<string, unknown>;
- if (typeof record.name !== "string" || !record.name) return [];
- return [{
- name: record.name,
- description: typeof record.description === "string" ? record.description : undefined,
- }];
- });
- }
- function categoryLabel(category: string, t: (key: string) => string) {
- const key = `skills.cat${formatCategoryKey(category)}`;
- const label = t(key);
- return label === key ? category : label;
- }
- function formatCategoryKey(category: string) {
- return category
- .split(/[_-]+/)
- .filter(Boolean)
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
- .join("");
- }
|