Feat/pipeline specified plugins (#1752)

* feat: add persistence field

* feat: add basic extension page in pipeline config

* Merge pull request #1751 from langbot-app/copilot/add-plugin-extension-tab

Implement pipeline-scoped plugin binding system

* fix: i18n keys

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Junyan Qin (Chin)
2025-11-06 12:51:33 +08:00
committed by GitHub
parent 2c2a89d9db
commit 4a84bf2355
30 changed files with 525 additions and 41 deletions

View File

@@ -83,7 +83,7 @@ export default function DynamicFormItemComponent({
setLlmModels(resp.models);
})
.catch((err) => {
toast.error('获取 LLM 模型列表失败:' + err.message);
toast.error('Failed to get LLM model list: ' + err.message);
});
}
}, [config.type]);
@@ -96,7 +96,7 @@ export default function DynamicFormItemComponent({
setKnowledgeBases(resp.bases);
})
.catch((err) => {
toast.error('获取知识库列表失败:' + err.message);
toast.error('Failed to get knowledge base list: ' + err.message);
});
}
}, [config.type]);

View File

@@ -18,6 +18,7 @@ import {
} from '@/components/ui/sidebar';
import PipelineFormComponent from './components/pipeline-form/PipelineFormComponent';
import DebugDialog from './components/debug-dialog/DebugDialog';
import PipelineExtension from './components/pipeline-extensions/PipelineExtension';
interface PipelineDialogProps {
open: boolean;
@@ -31,7 +32,7 @@ interface PipelineDialogProps {
onCancel: () => void;
}
type DialogMode = 'config' | 'debug';
type DialogMode = 'config' | 'debug' | 'extensions';
export default function PipelineDialog({
open,
@@ -81,6 +82,19 @@ export default function PipelineDialog({
</svg>
),
},
{
key: 'extensions',
label: t('pipelines.extensions.title'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M7 5C7 2.79086 8.79086 1 11 1C13.2091 1 15 2.79086 15 5H18C18.5523 5 19 5.44772 19 6V9C21.2091 9 23 10.7909 23 13C23 15.2091 21.2091 17 19 17V20C19 20.5523 18.5523 21 18 21H4C3.44772 21 3 20.5523 3 20V6C3 5.44772 3.44772 5 4 5H7ZM11 3C9.89543 3 9 3.89543 9 5C9 5.23554 9.0403 5.45952 9.11355 5.66675C9.22172 5.97282 9.17461 6.31235 8.98718 6.57739C8.79974 6.84243 8.49532 7 8.17071 7H5V19H17V15.8293C17 15.5047 17.1576 15.2003 17.4226 15.0128C17.6877 14.8254 18.0272 14.7783 18.3332 14.8865C18.5405 14.9597 18.7645 15 19 15C20.1046 15 21 14.1046 21 13C21 11.8954 20.1046 11 19 11C18.7645 11 18.5405 11.0403 18.3332 11.1135C18.0272 11.2217 17.6877 11.1746 17.4226 10.9872C17.1576 10.7997 17 10.4953 17 10.1707V7H13.8293C13.5047 7 13.2003 6.84243 13.0128 6.57739C12.8254 6.31235 12.7783 5.97282 12.8865 5.66675C12.9597 5.45952 13 5.23555 13 5C13 3.89543 12.1046 3 11 3Z"></path>
</svg>
),
},
{
key: 'debug',
label: t('pipelines.debugChat'),
@@ -102,6 +116,9 @@ export default function PipelineDialog({
? t('pipelines.editPipeline')
: t('pipelines.createPipeline');
}
if (currentMode === 'extensions') {
return t('pipelines.extensions.title');
}
return t('pipelines.debugDialog.title');
};
@@ -193,6 +210,11 @@ export default function PipelineDialog({
}}
/>
)}
{currentMode === 'extensions' && pipelineId && (
<PipelineExtension pipelineId={pipelineId} />
)}
{currentMode === 'debug' && pipelineId && (
<DebugDialog
open={true}

View File

@@ -0,0 +1,259 @@
'use client';
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 { Plus, X } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Plugin } from '@/app/infra/entities/plugin';
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
export default function PipelineExtension({
pipelineId,
}: {
pipelineId: string;
}) {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [selectedPlugins, setSelectedPlugins] = useState<Plugin[]>([]);
const [allPlugins, setAllPlugins] = useState<Plugin[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [tempSelectedIds, setTempSelectedIds] = 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);
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);
} catch (error) {
console.error('Failed to load extensions:', error);
toast.error(t('pipelines.extensions.loadError'));
} finally {
setLoading(false);
}
};
const saveToBackend = async (plugins: Plugin[]) => {
try {
const boundPluginsArray = plugins.map((plugin) => {
const metadata = plugin.manifest.manifest.metadata;
return {
author: metadata.author || '',
name: metadata.name,
};
});
await backendClient.updatePipelineExtensions(
pipelineId,
boundPluginsArray,
);
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);
};
const handleOpenDialog = () => {
setTempSelectedIds(selectedPlugins.map((p) => getPluginId(p)));
setDialogOpen(true);
};
const handleTogglePlugin = (pluginId: string) => {
setTempSelectedIds((prev) =>
prev.includes(pluginId)
? prev.filter((id) => id !== pluginId)
: [...prev, pluginId],
);
};
const handleConfirmSelection = async () => {
const newSelected = allPlugins.filter((p) =>
tempSelectedIds.includes(getPluginId(p)),
);
setSelectedPlugins(newSelected);
setDialogOpen(false);
await saveToBackend(newSelected);
};
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-4">
<div className="space-y-2">
{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={plugin.components}
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={handleOpenDialog} variant="outline" className="w-full">
<Plus className="mr-2 h-4 w-4" />
{t('pipelines.extensions.addPlugin')}
</Button>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>{t('pipelines.extensions.selectPlugins')}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
{allPlugins.map((plugin) => {
const pluginId = getPluginId(plugin);
const metadata = plugin.manifest.manifest.metadata;
const isSelected = tempSelectedIds.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={plugin.components}
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={() => setDialogOpen(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleConfirmSelection}>
{t('common.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -9,6 +9,9 @@ export interface IDynamicFormItemSchema {
type: DynamicFormItemType;
description?: I18nObject;
options?: IDynamicFormItemOption[];
/** when type is PLUGIN_SELECTOR, the scopes is the scopes of components(plugin contains), the default is all */
scopes?: string[];
accept?: string; // For file type: accepted MIME types
}
@@ -26,6 +29,7 @@ export enum DynamicFormItemType {
PROMPT_EDITOR = 'prompt-editor',
UNKNOWN = 'unknown',
KNOWLEDGE_BASE_SELECTOR = 'knowledge-base-selector',
PLUGIN_SELECTOR = 'plugin-selector',
}
export interface IFileConfig {

View File

@@ -37,6 +37,7 @@ import {
ApiRespMCPServer,
MCPServer,
} from '@/app/infra/entities/api';
import { Plugin } from '@/app/infra/entities/plugin';
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
import { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
@@ -169,6 +170,22 @@ export class BackendClient extends BaseHttpClient {
return this.delete(`/api/v1/pipelines/${uuid}`);
}
public getPipelineExtensions(uuid: string): Promise<{
bound_plugins: Array<{ author: string; name: string }>;
available_plugins: Plugin[];
}> {
return this.get(`/api/v1/pipelines/${uuid}/extensions`);
}
public updatePipelineExtensions(
uuid: string,
bound_plugins: Array<{ author: string; name: string }>,
): Promise<object> {
return this.put(`/api/v1/pipelines/${uuid}/extensions`, {
bound_plugins,
});
}
// ============ Debug WebChat API ============
// ============ Debug WebChat API ============