|
|
@@ -1,7 +1,7 @@
|
|
|
import * as React from "react";
|
|
|
import { useTranslation } from "react-i18next";
|
|
|
-import { FileText, Link2, Pencil, Plus, Puzzle, RefreshCw, Search, Trash2, Wrench } from "lucide-react";
|
|
|
-import { createSkill, deleteSkill, listAllSkills, listTools, updateSkill } from "@/api";
|
|
|
+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 { ApiErrorState } from "@/components/shared/ApiErrorState";
|
|
|
import { EmptyState } from "@/components/shared/EmptyState";
|
|
|
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
|
|
@@ -15,12 +15,17 @@ 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, ToolDefinition } from "@/types";
|
|
|
+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 = {
|
|
|
@@ -55,28 +60,37 @@ export function SkillsPage() {
|
|
|
|
|
|
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 boundToolsCount = new Set(skills.flatMap((skill) => skill.toolIds)).size;
|
|
|
const filtered = skills
|
|
|
.filter((skill) => skill.status !== "archived")
|
|
|
.filter((skill) => {
|
|
|
- const toolNames = skill.toolIds.map((toolId) => toolById.get(toolId)?.name ?? toolId).join(" ");
|
|
|
+ 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] = await Promise.all([
|
|
|
+ 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);
|
|
|
@@ -207,9 +221,8 @@ export function SkillsPage() {
|
|
|
|
|
|
{filtered.length ? (
|
|
|
<div className="overflow-hidden rounded-md border border-border">
|
|
|
- <div className="hidden grid-cols-[1.1fr_1.4fr_150px_140px_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">
|
|
|
+ <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.instruction")}</span>
|
|
|
<span>{t("skills.category")}</span>
|
|
|
<span>{t("skills.toolsCount")}</span>
|
|
|
<span className="text-right">{t("common.actions")}</span>
|
|
|
@@ -277,8 +290,14 @@ function SkillRow({
|
|
|
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-[1.1fr_1.4fr_150px_140px_110px] lg:items-center">
|
|
|
+ <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" />
|
|
|
@@ -286,15 +305,25 @@ function SkillRow({
|
|
|
</div>
|
|
|
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{skill.description || t("skills.emptyHint")}</p>
|
|
|
</div>
|
|
|
- <p className="line-clamp-2 text-sm text-muted-foreground">{skill.instruction || t("skills.noInstruction")}</p>
|
|
|
<Badge className="w-fit border-border bg-muted/50 text-muted-foreground">{categoryLabel(skill.category, t)}</Badge>
|
|
|
- <div className="flex flex-wrap gap-1.5">
|
|
|
- <Badge className="gap-1 border-primary/20 bg-primary/10 text-primary">
|
|
|
- <Link2 className="h-3.5 w-3.5" /> {skill.toolIds.length}
|
|
|
- </Badge>
|
|
|
- {skill.toolIds.slice(0, 1).map((toolId) => (
|
|
|
- <Badge key={toolId} className="border-border bg-muted text-muted-foreground">{toolById.get(toolId)?.name ?? toolId}</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}`}>
|
|
|
@@ -330,7 +359,25 @@ function SkillDialog({
|
|
|
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)
|
|
|
@@ -339,83 +386,212 @@ function SkillDialog({
|
|
|
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-4xl">
|
|
|
- <div className="space-y-5">
|
|
|
- <div className="grid gap-4 md:grid-cols-2">
|
|
|
- <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") },
|
|
|
- ]}
|
|
|
+ <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"
|
|
|
/>
|
|
|
- </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 className="md:col-span-2">
|
|
|
- <Field label={t("skills.instruction")}>
|
|
|
- <Textarea
|
|
|
- value={form.instruction}
|
|
|
- onChange={(event) => set("instruction", event.target.value)}
|
|
|
- placeholder={t("skills.instructionPlaceholder")}
|
|
|
- className="min-h-36 font-mono"
|
|
|
- />
|
|
|
- </Field>
|
|
|
- </div>
|
|
|
+ </section>
|
|
|
</div>
|
|
|
|
|
|
- <div>
|
|
|
- <div className="mb-2">
|
|
|
- <p className="text-sm font-medium">{t("skills.selectTools")}</p>
|
|
|
- <p className="text-xs text-muted-foreground">{t("skills.toolsHint")}</p>
|
|
|
- </div>
|
|
|
- <div className="grid max-h-60 gap-2 overflow-auto rounded-md border border-border p-2 sm:grid-cols-2">
|
|
|
- {tools.length ? tools.map((tool) => {
|
|
|
- const selected = form.selectedToolIds.includes(tool.id);
|
|
|
- return (
|
|
|
- <button
|
|
|
- key={tool.id}
|
|
|
- type="button"
|
|
|
- onClick={() => toggleTool(tool.id)}
|
|
|
- className={[
|
|
|
- "rounded-md border p-3 text-left transition hover:bg-muted",
|
|
|
- selected ? "border-primary/40 bg-primary/10" : "border-border bg-muted/20",
|
|
|
- ].join(" ")}
|
|
|
- >
|
|
|
- <div className="flex items-start gap-3">
|
|
|
- <span 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(" ")}>
|
|
|
- {selected ? <FileText className="h-3 w-3" /> : null}
|
|
|
- </span>
|
|
|
- <span className="min-w-0">
|
|
|
- <span className="block truncate text-sm font-medium">{tool.name}</span>
|
|
|
- <span className="mt-1 block line-clamp-2 text-xs text-muted-foreground">{tool.description}</span>
|
|
|
- </span>
|
|
|
+ <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>
|
|
|
- </button>
|
|
|
- );
|
|
|
- }) : (
|
|
|
- <div className="rounded-md border border-dashed border-border bg-muted/20 p-4 text-sm text-muted-foreground sm:col-span-2">
|
|
|
- {t("skills.noTools")}
|
|
|
+ ) : (
|
|
|
+ <p className="px-1 py-1 text-xs text-muted-foreground">
|
|
|
+ {t("skills.noToolsSelected", "No tools selected")}
|
|
|
+ </p>
|
|
|
+ )}
|
|
|
</div>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
+ </div>
|
|
|
|
|
|
- <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
|
|
+ <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>
|
|
|
@@ -443,6 +619,37 @@ function fromSkill(skill: SkillDefinition): SkillFormState {
|
|
|
};
|
|
|
}
|
|
|
|
|
|
+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);
|