mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-31 06:37:14 +00:00
096ec1a8ce
* feat(mcp): support mcp resources * feat(web): split MCP resources into tab * docs: add MCP resources PR review * feat(mcp): productionize resource support * feat(mcp): scope local agent tools and resources * fix(web): gate space embedding models behind login * fix(web): prevent clipped space model CTA * test: update preproc resource tool expectations * fix(web): expose skill authoring tools in selector --------- Co-authored-by: yang.xiang <yang.xiang@advancegroup.com> Co-authored-by: Hyu <chenhyu@proton.me> Co-authored-by: Junyan Qin <rockchinq@gmail.com>
884 lines
32 KiB
TypeScript
884 lines
32 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { backendClient } from '@/app/infra/http';
|
|
import { Button } from '@/components/ui/button';
|
|
import { toast } from 'sonner';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { CircleHelp, Plus, X, Server, Wrench, Sparkles } from 'lucide-react';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { Label } from '@/components/ui/label';
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from '@/components/ui/tooltip';
|
|
import { Plugin } from '@/app/infra/entities/plugin';
|
|
import { MCPServer, Skill } from '@/app/infra/entities/api';
|
|
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
|
|
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
|
|
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
|
|
|
|
function InfoTooltip({ label }: { label: string }) {
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="inline-flex size-4 items-center justify-center rounded-full text-muted-foreground transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
|
aria-label={label}
|
|
>
|
|
<CircleHelp className="size-3.5" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" className="max-w-[280px]">
|
|
{label}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
export default function PipelineExtension({
|
|
pipelineId,
|
|
}: {
|
|
pipelineId: string;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const {
|
|
available: boxAvailable,
|
|
hint: boxHint,
|
|
reason: boxReason,
|
|
} = useBoxStatus();
|
|
const [loading, setLoading] = useState(true);
|
|
const [enableAllPlugins, setEnableAllPlugins] = useState(true);
|
|
const [enableAllMCPServers, setEnableAllMCPServers] = useState(true);
|
|
const [enableAllSkills, setEnableAllSkills] = useState(true);
|
|
const [selectedPlugins, setSelectedPlugins] = useState<Plugin[]>([]);
|
|
const [allPlugins, setAllPlugins] = useState<Plugin[]>([]);
|
|
const [selectedMCPServers, setSelectedMCPServers] = useState<MCPServer[]>([]);
|
|
const [allMCPServers, setAllMCPServers] = useState<MCPServer[]>([]);
|
|
const [selectedSkills, setSelectedSkills] = useState<Skill[]>([]);
|
|
const [allSkills, setAllSkills] = useState<Skill[]>([]);
|
|
const [pluginDialogOpen, setPluginDialogOpen] = useState(false);
|
|
const [mcpDialogOpen, setMcpDialogOpen] = useState(false);
|
|
const [skillDialogOpen, setSkillDialogOpen] = useState(false);
|
|
const [tempSelectedPluginIds, setTempSelectedPluginIds] = useState<string[]>(
|
|
[],
|
|
);
|
|
const [tempSelectedMCPIds, setTempSelectedMCPIds] = useState<string[]>([]);
|
|
const [tempSelectedSkillIds, setTempSelectedSkillIds] = useState<string[]>(
|
|
[],
|
|
);
|
|
|
|
useEffect(() => {
|
|
loadExtensions();
|
|
}, [pipelineId]);
|
|
|
|
const getPluginId = (plugin: Plugin): string => {
|
|
const author = plugin.manifest.manifest.metadata.author;
|
|
const name = plugin.manifest.manifest.metadata.name;
|
|
return `${author}/${name}`;
|
|
};
|
|
|
|
const loadExtensions = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const data = await backendClient.getPipelineExtensions(pipelineId);
|
|
|
|
setEnableAllPlugins(data.enable_all_plugins ?? true);
|
|
setEnableAllMCPServers(data.enable_all_mcp_servers ?? true);
|
|
setEnableAllSkills(data.enable_all_skills ?? true);
|
|
|
|
const boundPluginIds = new Set(
|
|
data.bound_plugins.map((p) => `${p.author}/${p.name}`),
|
|
);
|
|
|
|
const selected = data.available_plugins.filter((plugin) =>
|
|
boundPluginIds.has(getPluginId(plugin)),
|
|
);
|
|
|
|
setSelectedPlugins(selected);
|
|
setAllPlugins(data.available_plugins);
|
|
|
|
// Load MCP servers
|
|
const boundMCPServerIds = new Set(data.bound_mcp_servers || []);
|
|
const selectedMCP = data.available_mcp_servers.filter((server) =>
|
|
boundMCPServerIds.has(server.uuid || ''),
|
|
);
|
|
|
|
setSelectedMCPServers(selectedMCP);
|
|
setAllMCPServers(data.available_mcp_servers);
|
|
|
|
// Load Skills
|
|
const boundSkillNames = new Set(data.bound_skills || []);
|
|
const selectedSkill = (data.available_skills || []).filter((skill) =>
|
|
boundSkillNames.has(skill.name),
|
|
);
|
|
|
|
setSelectedSkills(selectedSkill);
|
|
setAllSkills(data.available_skills || []);
|
|
} catch (error) {
|
|
console.error('Failed to load extensions:', error);
|
|
toast.error(t('pipelines.extensions.loadError'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const saveToBackend = async (
|
|
plugins: Plugin[],
|
|
mcpServers: MCPServer[],
|
|
skills: Skill[],
|
|
newEnableAllPlugins?: boolean,
|
|
newEnableAllMCPServers?: boolean,
|
|
newEnableAllSkills?: boolean,
|
|
) => {
|
|
try {
|
|
const boundPluginsArray = plugins.map((plugin) => {
|
|
const metadata = plugin.manifest.manifest.metadata;
|
|
return {
|
|
author: metadata.author || '',
|
|
name: metadata.name,
|
|
};
|
|
});
|
|
|
|
const boundMCPServerIds = mcpServers.map((server) => server.uuid || '');
|
|
const boundSkillIds = skills.map((skill) => skill.name);
|
|
|
|
await backendClient.updatePipelineExtensions(
|
|
pipelineId,
|
|
boundPluginsArray,
|
|
boundMCPServerIds,
|
|
newEnableAllPlugins ?? enableAllPlugins,
|
|
newEnableAllMCPServers ?? enableAllMCPServers,
|
|
boundSkillIds,
|
|
newEnableAllSkills ?? enableAllSkills,
|
|
);
|
|
toast.success(t('pipelines.extensions.saveSuccess'));
|
|
} catch (error) {
|
|
console.error('Failed to save extensions:', error);
|
|
toast.error(t('pipelines.extensions.saveError'));
|
|
// Reload on error to restore correct state
|
|
loadExtensions();
|
|
}
|
|
};
|
|
|
|
const handleRemovePlugin = async (pluginId: string) => {
|
|
const newPlugins = selectedPlugins.filter(
|
|
(p) => getPluginId(p) !== pluginId,
|
|
);
|
|
setSelectedPlugins(newPlugins);
|
|
await saveToBackend(newPlugins, selectedMCPServers, selectedSkills);
|
|
};
|
|
|
|
const handleRemoveMCPServer = async (serverUuid: string) => {
|
|
const newServers = selectedMCPServers.filter((s) => s.uuid !== serverUuid);
|
|
setSelectedMCPServers(newServers);
|
|
await saveToBackend(selectedPlugins, newServers, selectedSkills);
|
|
};
|
|
|
|
const handleRemoveSkill = async (skillName: string) => {
|
|
const newSkills = selectedSkills.filter((s) => s.name !== skillName);
|
|
setSelectedSkills(newSkills);
|
|
await saveToBackend(selectedPlugins, selectedMCPServers, newSkills);
|
|
};
|
|
|
|
const handleOpenPluginDialog = () => {
|
|
setTempSelectedPluginIds(selectedPlugins.map((p) => getPluginId(p)));
|
|
setPluginDialogOpen(true);
|
|
};
|
|
|
|
const handleOpenMCPDialog = () => {
|
|
setTempSelectedMCPIds(selectedMCPServers.map((s) => s.uuid || ''));
|
|
setMcpDialogOpen(true);
|
|
};
|
|
|
|
const handleOpenSkillDialog = () => {
|
|
setTempSelectedSkillIds(selectedSkills.map((s) => s.name));
|
|
setSkillDialogOpen(true);
|
|
};
|
|
|
|
const handleTogglePlugin = (pluginId: string) => {
|
|
setTempSelectedPluginIds((prev) =>
|
|
prev.includes(pluginId)
|
|
? prev.filter((id) => id !== pluginId)
|
|
: [...prev, pluginId],
|
|
);
|
|
};
|
|
|
|
const handleToggleMCPServer = (serverUuid: string) => {
|
|
setTempSelectedMCPIds((prev) =>
|
|
prev.includes(serverUuid)
|
|
? prev.filter((id) => id !== serverUuid)
|
|
: [...prev, serverUuid],
|
|
);
|
|
};
|
|
|
|
const handleToggleSkill = (skillName: string) => {
|
|
setTempSelectedSkillIds((prev) =>
|
|
prev.includes(skillName)
|
|
? prev.filter((id) => id !== skillName)
|
|
: [...prev, skillName],
|
|
);
|
|
};
|
|
|
|
const handleToggleAllPlugins = () => {
|
|
if (tempSelectedPluginIds.length === allPlugins.length) {
|
|
setTempSelectedPluginIds([]);
|
|
} else {
|
|
setTempSelectedPluginIds(allPlugins.map((p) => getPluginId(p)));
|
|
}
|
|
};
|
|
|
|
const handleToggleAllMCPServers = () => {
|
|
if (tempSelectedMCPIds.length === allMCPServers.length) {
|
|
setTempSelectedMCPIds([]);
|
|
} else {
|
|
setTempSelectedMCPIds(allMCPServers.map((s) => s.uuid || ''));
|
|
}
|
|
};
|
|
|
|
const handleToggleAllSkills = () => {
|
|
if (tempSelectedSkillIds.length === allSkills.length) {
|
|
setTempSelectedSkillIds([]);
|
|
} else {
|
|
setTempSelectedSkillIds(allSkills.map((s) => s.name));
|
|
}
|
|
};
|
|
|
|
const handleConfirmPluginSelection = async () => {
|
|
const newSelected = allPlugins.filter((p) =>
|
|
tempSelectedPluginIds.includes(getPluginId(p)),
|
|
);
|
|
setSelectedPlugins(newSelected);
|
|
setPluginDialogOpen(false);
|
|
await saveToBackend(newSelected, selectedMCPServers, selectedSkills);
|
|
};
|
|
|
|
const handleConfirmMCPSelection = async () => {
|
|
const newSelected = allMCPServers.filter((s) =>
|
|
tempSelectedMCPIds.includes(s.uuid || ''),
|
|
);
|
|
setSelectedMCPServers(newSelected);
|
|
setMcpDialogOpen(false);
|
|
await saveToBackend(selectedPlugins, newSelected, selectedSkills);
|
|
};
|
|
|
|
const handleConfirmSkillSelection = async () => {
|
|
const newSelected = allSkills.filter((s) =>
|
|
tempSelectedSkillIds.includes(s.name),
|
|
);
|
|
setSelectedSkills(newSelected);
|
|
setSkillDialogOpen(false);
|
|
await saveToBackend(selectedPlugins, selectedMCPServers, newSelected);
|
|
};
|
|
|
|
const handleToggleEnableAllPlugins = async (checked: boolean) => {
|
|
setEnableAllPlugins(checked);
|
|
await saveToBackend(
|
|
selectedPlugins,
|
|
selectedMCPServers,
|
|
selectedSkills,
|
|
checked,
|
|
undefined,
|
|
undefined,
|
|
);
|
|
};
|
|
|
|
const handleToggleEnableAllMCPServers = async (checked: boolean) => {
|
|
setEnableAllMCPServers(checked);
|
|
await saveToBackend(
|
|
selectedPlugins,
|
|
selectedMCPServers,
|
|
selectedSkills,
|
|
undefined,
|
|
checked,
|
|
undefined,
|
|
);
|
|
};
|
|
|
|
const handleToggleEnableAllSkills = async (checked: boolean) => {
|
|
setEnableAllSkills(checked);
|
|
await saveToBackend(
|
|
selectedPlugins,
|
|
selectedMCPServers,
|
|
selectedSkills,
|
|
undefined,
|
|
undefined,
|
|
checked,
|
|
);
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<Skeleton className="h-20 w-full" />
|
|
<Skeleton className="h-20 w-full" />
|
|
<Skeleton className="h-20 w-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Plugins Section */}
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-semibold text-foreground">
|
|
{t('pipelines.extensions.pluginsTitle')}
|
|
</h3>
|
|
<div className="flex items-center gap-2">
|
|
<Label
|
|
htmlFor="enable-all-plugins"
|
|
className="text-sm font-normal cursor-pointer"
|
|
>
|
|
{t('pipelines.extensions.enableAllPlugins')}
|
|
</Label>
|
|
<Switch
|
|
id="enable-all-plugins"
|
|
checked={enableAllPlugins}
|
|
onCheckedChange={handleToggleEnableAllPlugins}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{enableAllPlugins ? (
|
|
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30">
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('pipelines.extensions.allPluginsEnabled')}
|
|
</p>
|
|
</div>
|
|
) : selectedPlugins.length === 0 ? (
|
|
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('pipelines.extensions.noPluginsSelected')}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{selectedPlugins.map((plugin) => {
|
|
const pluginId = getPluginId(plugin);
|
|
const metadata = plugin.manifest.manifest.metadata;
|
|
return (
|
|
<div
|
|
key={pluginId}
|
|
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
|
|
>
|
|
<div className="flex-1 flex items-center gap-3">
|
|
<img
|
|
src={backendClient.getPluginIconURL(
|
|
metadata.author || '',
|
|
metadata.name,
|
|
)}
|
|
alt={metadata.name}
|
|
className="w-10 h-10 rounded-lg border bg-muted object-cover flex-shrink-0"
|
|
/>
|
|
<div className="flex-1">
|
|
<div className="font-medium">{metadata.name}</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
{metadata.author} • v{metadata.version}
|
|
</div>
|
|
<div className="flex gap-1 mt-1">
|
|
<PluginComponentList
|
|
components={(() => {
|
|
const componentKindCount: Record<string, number> =
|
|
{};
|
|
for (const component of plugin.components) {
|
|
const kind = component.manifest.manifest.kind;
|
|
if (componentKindCount[kind]) {
|
|
componentKindCount[kind]++;
|
|
} else {
|
|
componentKindCount[kind] = 1;
|
|
}
|
|
}
|
|
return componentKindCount;
|
|
})()}
|
|
showComponentName={true}
|
|
showTitle={false}
|
|
useBadge={true}
|
|
t={t}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{!plugin.enabled && (
|
|
<Badge variant="secondary">
|
|
{t('pipelines.extensions.disabled')}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => handleRemovePlugin(pluginId)}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Button
|
|
onClick={handleOpenPluginDialog}
|
|
variant="outline"
|
|
className="w-full"
|
|
disabled={enableAllPlugins}
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
{t('pipelines.extensions.addPlugin')}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* MCP Servers Section */}
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-1.5">
|
|
<h3 className="text-sm font-semibold text-foreground">
|
|
{t('pipelines.extensions.mcpServersTitle')}
|
|
</h3>
|
|
<InfoTooltip
|
|
label={t('pipelines.extensions.mcpServersScopeTooltip')}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Label
|
|
htmlFor="enable-all-mcp-servers"
|
|
className="text-sm font-normal cursor-pointer"
|
|
>
|
|
{t('pipelines.extensions.enableAllMCPServers')}
|
|
</Label>
|
|
<InfoTooltip
|
|
label={t('pipelines.extensions.enableAllMCPServersTooltip')}
|
|
/>
|
|
<Switch
|
|
id="enable-all-mcp-servers"
|
|
checked={enableAllMCPServers}
|
|
onCheckedChange={handleToggleEnableAllMCPServers}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{enableAllMCPServers ? (
|
|
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30">
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('pipelines.extensions.allMCPServersEnabled')}
|
|
</p>
|
|
</div>
|
|
) : selectedMCPServers.length === 0 ? (
|
|
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('pipelines.extensions.noMCPServersSelected')}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{selectedMCPServers.map((server) => (
|
|
<div
|
|
key={server.uuid}
|
|
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
|
|
>
|
|
<div className="flex-1 flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center flex-shrink-0">
|
|
<Server className="h-5 w-5 text-muted-foreground" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="font-medium">{server.name}</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
{server.mode}
|
|
</div>
|
|
{server.runtime_info &&
|
|
server.runtime_info.status === 'connected' && (
|
|
<Badge
|
|
variant="outline"
|
|
className="flex items-center gap-1 mt-1"
|
|
>
|
|
<Wrench className="h-3 w-3 text-black dark:text-white" />
|
|
<span className="text-xs text-black dark:text-white">
|
|
{t('pipelines.extensions.toolCount', {
|
|
count: server.runtime_info.tool_count || 0,
|
|
})}
|
|
</span>
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
{!server.enable && (
|
|
<Badge variant="secondary">
|
|
{t('pipelines.extensions.disabled')}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => handleRemoveMCPServer(server.uuid || '')}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Button
|
|
onClick={handleOpenMCPDialog}
|
|
variant="outline"
|
|
className="w-full"
|
|
disabled={enableAllMCPServers}
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
{t('pipelines.extensions.addMCPServer')}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Skills Section */}
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-semibold text-foreground">
|
|
{t('pipelines.extensions.skillsTitle')}
|
|
</h3>
|
|
<div className="flex items-center gap-2">
|
|
<Label
|
|
htmlFor="enable-all-skills"
|
|
className="text-sm font-normal cursor-pointer"
|
|
>
|
|
{t('pipelines.extensions.enableAllSkills')}
|
|
</Label>
|
|
<Switch
|
|
id="enable-all-skills"
|
|
checked={enableAllSkills}
|
|
onCheckedChange={handleToggleEnableAllSkills}
|
|
disabled={!boxAvailable}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{!boxAvailable && (
|
|
<BoxUnavailableNotice hint={boxHint} reason={boxReason} />
|
|
)}
|
|
<div className="space-y-2">
|
|
{enableAllSkills ? (
|
|
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30">
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('pipelines.extensions.allSkillsEnabled')}
|
|
</p>
|
|
</div>
|
|
) : selectedSkills.length === 0 ? (
|
|
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('pipelines.extensions.noSkillsSelected')}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{selectedSkills.map((skill) => (
|
|
<div
|
|
key={skill.name}
|
|
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
|
|
>
|
|
<div className="flex-1 flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center flex-shrink-0">
|
|
<Sparkles className="h-5 w-5 text-muted-foreground" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="font-medium">
|
|
{skill.display_name || skill.name}
|
|
</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
{skill.description}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => handleRemoveSkill(skill.name)}
|
|
disabled={!boxAvailable}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Button
|
|
onClick={handleOpenSkillDialog}
|
|
variant="outline"
|
|
className="w-full"
|
|
disabled={enableAllSkills || !boxAvailable}
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
{t('pipelines.extensions.addSkill')}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Plugin Selection Dialog */}
|
|
<Dialog open={pluginDialogOpen} onOpenChange={setPluginDialogOpen}>
|
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
|
<DialogHeader>
|
|
<DialogTitle>{t('pipelines.extensions.selectPlugins')}</DialogTitle>
|
|
</DialogHeader>
|
|
{allPlugins.length > 0 && (
|
|
<div
|
|
className="flex items-center gap-3 px-1 py-2 border-b cursor-pointer"
|
|
onClick={handleToggleAllPlugins}
|
|
>
|
|
<Checkbox
|
|
checked={
|
|
tempSelectedPluginIds.length === allPlugins.length &&
|
|
allPlugins.length > 0
|
|
}
|
|
onCheckedChange={handleToggleAllPlugins}
|
|
/>
|
|
<span className="text-sm font-medium">
|
|
{t('pipelines.extensions.selectAll')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
|
|
{allPlugins.length === 0 ? (
|
|
<div className="flex h-full items-center justify-center">
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('pipelines.extensions.noPluginsInstalled')}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
allPlugins.map((plugin) => {
|
|
const pluginId = getPluginId(plugin);
|
|
const metadata = plugin.manifest.manifest.metadata;
|
|
const isSelected = tempSelectedPluginIds.includes(pluginId);
|
|
return (
|
|
<div
|
|
key={pluginId}
|
|
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
|
|
onClick={() => handleTogglePlugin(pluginId)}
|
|
>
|
|
<Checkbox checked={isSelected} />
|
|
<img
|
|
src={backendClient.getPluginIconURL(
|
|
metadata.author || '',
|
|
metadata.name,
|
|
)}
|
|
alt={metadata.name}
|
|
className="w-10 h-10 rounded-lg border bg-muted object-cover flex-shrink-0"
|
|
/>
|
|
<div className="flex-1">
|
|
<div className="font-medium">{metadata.name}</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
{metadata.author} • v{metadata.version}
|
|
</div>
|
|
<div className="flex gap-1 mt-1">
|
|
<PluginComponentList
|
|
components={(() => {
|
|
const componentKindCount: Record<string, number> =
|
|
{};
|
|
for (const component of plugin.components) {
|
|
const kind = component.manifest.manifest.kind;
|
|
if (componentKindCount[kind]) {
|
|
componentKindCount[kind]++;
|
|
} else {
|
|
componentKindCount[kind] = 1;
|
|
}
|
|
}
|
|
return componentKindCount;
|
|
})()}
|
|
showComponentName={true}
|
|
showTitle={false}
|
|
useBadge={true}
|
|
t={t}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{!plugin.enabled && (
|
|
<Badge variant="secondary">
|
|
{t('pipelines.extensions.disabled')}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setPluginDialogOpen(false)}
|
|
>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
<Button onClick={handleConfirmPluginSelection}>
|
|
{t('common.confirm')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* MCP Server Selection Dialog */}
|
|
<Dialog open={mcpDialogOpen} onOpenChange={setMcpDialogOpen}>
|
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{t('pipelines.extensions.selectMCPServers')}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
{allMCPServers.length > 0 && (
|
|
<div
|
|
className="flex items-center gap-3 px-1 py-2 border-b cursor-pointer"
|
|
onClick={handleToggleAllMCPServers}
|
|
>
|
|
<Checkbox
|
|
checked={
|
|
tempSelectedMCPIds.length === allMCPServers.length &&
|
|
allMCPServers.length > 0
|
|
}
|
|
onCheckedChange={handleToggleAllMCPServers}
|
|
/>
|
|
<span className="text-sm font-medium">
|
|
{t('pipelines.extensions.selectAll')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
|
|
{allMCPServers.length === 0 ? (
|
|
<div className="flex h-full items-center justify-center">
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('pipelines.extensions.noMCPServersConfigured')}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
allMCPServers.map((server) => {
|
|
const isSelected = tempSelectedMCPIds.includes(
|
|
server.uuid || '',
|
|
);
|
|
return (
|
|
<div
|
|
key={server.uuid}
|
|
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
|
|
onClick={() => handleToggleMCPServer(server.uuid || '')}
|
|
>
|
|
<Checkbox checked={isSelected} />
|
|
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center flex-shrink-0">
|
|
<Server className="h-5 w-5 text-muted-foreground" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="font-medium">{server.name}</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
{server.mode}
|
|
</div>
|
|
{server.runtime_info &&
|
|
server.runtime_info.status === 'connected' && (
|
|
<Badge
|
|
variant="outline"
|
|
className="flex items-center gap-1 mt-1"
|
|
>
|
|
<Wrench className="h-3 w-3 text-black dark:text-white" />
|
|
<span className="text-xs text-black dark:text-white">
|
|
{t('pipelines.extensions.toolCount', {
|
|
count: server.runtime_info.tool_count || 0,
|
|
})}
|
|
</span>
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
{!server.enable && (
|
|
<Badge variant="secondary">
|
|
{t('pipelines.extensions.disabled')}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setMcpDialogOpen(false)}>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
<Button onClick={handleConfirmMCPSelection}>
|
|
{t('common.confirm')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Skill Selection Dialog */}
|
|
<Dialog open={skillDialogOpen} onOpenChange={setSkillDialogOpen}>
|
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
|
<DialogHeader>
|
|
<DialogTitle>{t('pipelines.extensions.selectSkills')}</DialogTitle>
|
|
</DialogHeader>
|
|
{allSkills.length > 0 && (
|
|
<div
|
|
className="flex items-center gap-3 px-1 py-2 border-b cursor-pointer"
|
|
onClick={handleToggleAllSkills}
|
|
>
|
|
<Checkbox
|
|
checked={
|
|
tempSelectedSkillIds.length === allSkills.length &&
|
|
allSkills.length > 0
|
|
}
|
|
onCheckedChange={handleToggleAllSkills}
|
|
/>
|
|
<span className="text-sm font-medium">
|
|
{t('pipelines.extensions.selectAll')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
|
|
{allSkills.length === 0 ? (
|
|
<div className="flex h-full items-center justify-center">
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('pipelines.extensions.noSkillsAvailable')}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
allSkills.map((skill) => {
|
|
const isSelected = tempSelectedSkillIds.includes(skill.name);
|
|
return (
|
|
<div
|
|
key={skill.name}
|
|
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
|
|
onClick={() => handleToggleSkill(skill.name)}
|
|
>
|
|
<Checkbox checked={isSelected} />
|
|
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center flex-shrink-0">
|
|
<Sparkles className="h-5 w-5 text-muted-foreground" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="font-medium">
|
|
{skill.display_name || skill.name}
|
|
</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
{skill.description}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setSkillDialogOpen(false)}>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
<Button onClick={handleConfirmSkillSelection}>
|
|
{t('common.confirm')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|