diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py index 44cf6f89..1828fb2b 100644 --- a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py @@ -49,6 +49,14 @@ class PipelinesRouterGroup(group.RouterGroup): return self.success() + @self.route('//copy', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _(pipeline_uuid: str) -> str: + try: + new_uuid = await self.ap.pipeline_service.copy_pipeline(pipeline_uuid) + return self.success(data={'uuid': new_uuid}) + except ValueError as e: + return self.http_status(404, -1, str(e)) + @self.route( '//extensions', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY ) diff --git a/src/langbot/pkg/api/http/service/pipeline.py b/src/langbot/pkg/api/http/service/pipeline.py index e3f2770c..00aece68 100644 --- a/src/langbot/pkg/api/http/service/pipeline.py +++ b/src/langbot/pkg/api/http/service/pipeline.py @@ -151,6 +151,52 @@ class PipelineService: ) await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid) + async def copy_pipeline(self, pipeline_uuid: str) -> str: + """Copy a pipeline with all its configurations""" + # Get the original pipeline + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( + persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid + ) + ) + + original_pipeline = result.first() + if original_pipeline is None: + raise ValueError(f'Pipeline {pipeline_uuid} not found') + + # Create new pipeline data + new_uuid = str(uuid.uuid4()) + new_pipeline_data = { + 'uuid': new_uuid, + 'name': f'{original_pipeline.name} (Copy)', + 'description': original_pipeline.description, + 'for_version': self.ap.ver_mgr.get_current_version(), + 'stages': original_pipeline.stages.copy() if original_pipeline.stages else default_stage_order.copy(), + 'config': original_pipeline.config.copy() if original_pipeline.config else {}, + 'is_default': False, + 'extensions_preferences': ( + original_pipeline.extensions_preferences.copy() + if original_pipeline.extensions_preferences + else { + 'enable_all_plugins': True, + 'enable_all_mcp_servers': True, + 'plugins': [], + 'mcp_servers': [], + } + ), + } + + # Insert the new pipeline + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_pipeline.LegacyPipeline).values(**new_pipeline_data) + ) + + # Load the new pipeline + pipeline = await self.get_pipeline(new_uuid) + await self.ap.pipeline_mgr.load_pipeline(pipeline) + + return new_uuid + async def update_pipeline_extensions( self, pipeline_uuid: str, diff --git a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx index 7de82e04..6f86d6ef 100644 --- a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx +++ b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx @@ -52,6 +52,7 @@ export default function PipelineFormComponent({ }) { const { t } = useTranslation(); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [showCopyConfirm, setShowCopyConfirm] = useState(false); const [isDefaultPipeline, setIsDefaultPipeline] = useState(false); const formSchema = isEditMode @@ -345,25 +346,17 @@ export default function PipelineFormComponent({ }; const handleCopy = () => { + setShowCopyConfirm(true); + }; + + const confirmCopy = () => { if (pipelineId) { - let newPipelineName = ''; httpClient - .getPipeline(pipelineId) - .then((resp) => { - const originalPipeline = resp.pipeline; - newPipelineName = `${originalPipeline.name}${t( - 'pipelines.copySuffix', - )}`; - const newPipeline: Pipeline = { - name: newPipelineName, - description: originalPipeline.description, - config: originalPipeline.config, - }; - return httpClient.createPipeline(newPipeline); - }) + .copyPipeline(pipelineId) .then(() => { onFinish(); - toast.success(`${t('common.copySuccess')}: ${newPipelineName}`); + toast.success(t('common.copySuccess')); + setShowCopyConfirm(false); onCancel(); }) .catch((err) => { @@ -547,6 +540,22 @@ export default function PipelineFormComponent({ + + {/* 复制确认对话框 */} + + + + {t('pipelines.copyConfirmTitle')} + +
{t('pipelines.copyConfirmation')}
+ + + + +
+
); } diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 4dfd2860..8dd7f1a0 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -172,6 +172,10 @@ export class BackendClient extends BaseHttpClient { return this.delete(`/api/v1/pipelines/${uuid}`); } + public copyPipeline(uuid: string): Promise<{ uuid: string }> { + return this.post(`/api/v1/pipelines/${uuid}/copy`); + } + public getPipelineExtensions(uuid: string): Promise<{ enable_all_plugins: boolean; enable_all_mcp_servers: boolean; diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 144744d2..f8f363d5 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -493,6 +493,9 @@ const enUS = { defaultPipelineCannotDelete: 'Default pipeline cannot be deleted', deleteSuccess: 'Deleted successfully', deleteError: 'Delete failed: ', + copyConfirmTitle: 'Confirm Copy', + copyConfirmation: + 'Are you sure you want to copy this pipeline? This will create a new pipeline with all configurations.', extensions: { title: 'Extensions', loadError: 'Failed to load plugins', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 8d3ab932..0037295f 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -496,6 +496,9 @@ const jaJP = { defaultPipelineCannotDelete: 'デフォルトパイプラインは削除できません', deleteSuccess: '削除に成功しました', deleteError: '削除に失敗しました:', + copyConfirmTitle: 'コピーの確認', + copyConfirmation: + 'このパイプラインをコピーしますか?すべての設定を含む新しいパイプラインが作成されます。', extensions: { title: 'プラグイン統合', loadError: 'プラグインリストの読み込みに失敗しました', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index b967c4a3..5e466f0c 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -475,6 +475,9 @@ const zhHans = { defaultPipelineCannotDelete: '默认流水线不可删除', deleteSuccess: '删除成功', deleteError: '删除失败:', + copyConfirmTitle: '确认复制', + copyConfirmation: + '确定要复制这个流水线吗?复制将创建一个包含完整配置的新流水线。', extensions: { title: '扩展集成', loadError: '加载插件列表失败', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index e3fc323c..7aa11d2d 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -473,6 +473,9 @@ const zhHant = { defaultPipelineCannotDelete: '預設流程線不可刪除', deleteSuccess: '刪除成功', deleteError: '刪除失敗:', + copyConfirmTitle: '確認複製', + copyConfirmation: + '確定要複製這個流程線嗎?複製將創建一個包含完整配置的新流程線。', extensions: { title: '擴展集成', loadError: '載入插件清單失敗',