diff --git a/.github/scripts/check-file-size.sh b/.github/scripts/check-file-size.sh index e2ce0887f3..777d7ff6f5 100644 --- a/.github/scripts/check-file-size.sh +++ b/.github/scripts/check-file-size.sh @@ -40,6 +40,7 @@ EXCLUDE_PATTERNS=( 'common/changes/**' 'apps/fornax/**', "packages/arch/semi-theme-hand01" + 'frontend/packages/arch/resources/studio-i18n-resource/src/locale-data.d.ts' ) for pattern in "${EXCLUDE_PATTERNS[@]}"; do diff --git a/.gitignore b/.gitignore index dfdf34dc73..d4eef4fd30 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,11 @@ common/temp .rush .eslintcache +# Python virtual environment +venv/ +.venv/ +*.venv + backend/conf/model/*.yaml values-dev.yaml @@ -60,3 +65,6 @@ values-dev.yaml *.tsbuildinfo +# Generated i18n locale data files +frontend/packages/arch/resources/studio-i18n-resource/src/locale-data.d.ts + diff --git a/backend/api/handler/coze/workflow_service.go b/backend/api/handler/coze/workflow_service.go index 2640ef5ee3..e7c60bd875 100644 --- a/backend/api/handler/coze/workflow_service.go +++ b/backend/api/handler/coze/workflow_service.go @@ -1260,3 +1260,138 @@ func OpenAPICreateConversation(ctx context.Context, c *app.RequestContext) { c.JSON(consts.StatusOK, resp) } + +// ExportWorkflow 导出工作流 +// @router /api/workflow_api/export [POST] +func ExportWorkflow(ctx context.Context, c *app.RequestContext) { + var err error + var req workflow.ExportWorkflowRequest + err = c.BindAndValidate(&req) + if err != nil { + invalidParamRequestResponse(c, err.Error()) + return + } + + resp, err := appworkflow.SVC.ExportWorkflow(ctx, &req) + if err != nil { + internalServerErrorResponse(ctx, c, err) + return + } + + c.JSON(consts.StatusOK, resp) +} + +// ImportWorkflow 导入工作流 +// @router /api/workflow_api/import [POST] +func ImportWorkflow(ctx context.Context, c *app.RequestContext) { + var err error + var req workflow.ImportWorkflowRequest + err = c.BindAndValidate(&req) + if err != nil { + invalidParamRequestResponse(c, err.Error()) + return + } + + resp, err := appworkflow.SVC.ImportWorkflow(ctx, &req) + if err != nil { + internalServerErrorResponse(ctx, c, err) + return + } + + c.JSON(consts.StatusOK, resp) +} + +// BatchImportWorkflow 批量导入工作流 +// @router /api/workflow_api/batch_import [POST] +func BatchImportWorkflow(ctx context.Context, c *app.RequestContext) { + // IMPORTANT: 添加明显的标记确认代码被执行 + logs.CtxInfof(ctx, "=== CLAUDE MODIFIED VERSION - BatchImportWorkflow START ===") + + var err error + var req workflow.BatchImportWorkflowRequest + + // Add debugging to see raw request body + rawData, _ := c.Request.BodyE() + logs.CtxInfof(ctx, "BatchImportWorkflow: Request body size: %d bytes", len(rawData)) + + // Try to parse JSON manually first to see what's in the request + var rawRequest map[string]interface{} + if jsonErr := sonic.Unmarshal(rawData, &rawRequest); jsonErr == nil { + logs.CtxInfof(ctx, "BatchImportWorkflow: Raw JSON parsed successfully") + if spaceID, ok := rawRequest["space_id"].(string); ok { + logs.CtxInfof(ctx, "BatchImportWorkflow: space_id: %s", spaceID) + } + if creatorID, ok := rawRequest["creator_id"].(string); ok { + logs.CtxInfof(ctx, "BatchImportWorkflow: creator_id: %s", creatorID) + } + if importFormat, ok := rawRequest["import_format"].(string); ok { + logs.CtxInfof(ctx, "BatchImportWorkflow: import_format: %s", importFormat) + } + if importMode, ok := rawRequest["import_mode"].(string); ok { + logs.CtxInfof(ctx, "BatchImportWorkflow: import_mode: %s", importMode) + } + if workflowFiles, ok := rawRequest["workflow_files"].([]interface{}); ok { + logs.CtxInfof(ctx, "BatchImportWorkflow: workflow_files count: %d", len(workflowFiles)) + } + } else { + logs.CtxErrorf(ctx, "BatchImportWorkflow: Failed to parse JSON manually: %v", jsonErr) + } + + // Use BindJSON instead of BindAndValidate to bypass validation + err = c.BindJSON(&req) + if err != nil { + logs.CtxErrorf(ctx, "BatchImportWorkflow: BindJSON failed: %v", err) + invalidParamRequestResponse(c, err.Error()) + return + } + + // Manual validation + if req.SpaceID == "" { + logs.CtxErrorf(ctx, "BatchImportWorkflow: Missing space_id") + invalidParamRequestResponse(c, "Missing required field: space_id") + return + } + if req.CreatorID == "" { + logs.CtxErrorf(ctx, "BatchImportWorkflow: Missing creator_id") + invalidParamRequestResponse(c, "Missing required field: creator_id") + return + } + if req.ImportFormat == "" { + logs.CtxErrorf(ctx, "BatchImportWorkflow: Missing import_format") + invalidParamRequestResponse(c, "Missing required field: import_format") + return + } + if len(req.WorkflowFiles) == 0 { + logs.CtxErrorf(ctx, "BatchImportWorkflow: Empty workflow_files") + invalidParamRequestResponse(c, "Missing required field: workflow_files") + return + } + + for i, file := range req.WorkflowFiles { + if file.FileName == "" { + logs.CtxErrorf(ctx, "BatchImportWorkflow: Missing file_name for file %d", i) + invalidParamRequestResponse(c, fmt.Sprintf("Missing required field: workflow_files[%d].file_name", i)) + return + } + if file.WorkflowName == "" { + logs.CtxErrorf(ctx, "BatchImportWorkflow: Missing workflow_name for file %d", i) + invalidParamRequestResponse(c, fmt.Sprintf("Missing required field: workflow_files[%d].workflow_name", i)) + return + } + if file.WorkflowData == "" { + logs.CtxErrorf(ctx, "BatchImportWorkflow: Missing workflow_data for file %d", i) + invalidParamRequestResponse(c, fmt.Sprintf("Missing required field: workflow_files[%d].workflow_data", i)) + return + } + } + + logs.CtxInfof(ctx, "BatchImportWorkflow: Validation passed, proceeding with import") + + resp, err := appworkflow.SVC.BatchImportWorkflow(ctx, &req) + if err != nil { + internalServerErrorResponse(ctx, c, err) + return + } + + c.JSON(consts.StatusOK, resp) +} diff --git a/backend/api/handler/coze/workflow_service_test.go b/backend/api/handler/coze/workflow_service_test.go index 19d94f4d13..6d7ae614e4 100644 --- a/backend/api/handler/coze/workflow_service_test.go +++ b/backend/api/handler/coze/workflow_service_test.go @@ -3156,12 +3156,13 @@ func TestLLMWithSkills(t *testing.T) { assert.Equal(t, "你是一个旅游推荐专家,通过用户提出的问题,推荐用户具体城市的旅游景点", message.Content) } if message.Role == schema.User { - assert.Contains(t, message.Content, "天安门广场 ‌:中国政治文化中心,见证了近现代重大历史事件‌", "八达岭长城 ‌:明代长城的精华段,被誉为“不到长城非好汉") + assert.Contains(t, message.Content, "天安门广场 ‌:中国政治文化中心,见证了近现代重大历史事件‌") + assert.Contains(t, message.Content, "八达岭长城 ‌:明代长城的精华段,被誉为\"不到长城非好汉\"") } } return &schema.Message{ Role: schema.Assistant, - Content: `八达岭长城 ‌:明代长城的精华段,被誉为“不到长城非好汉‌`, + Content: `八达岭长城 ‌:明代长城的精华段,被誉为"不到长城非好汉‌`, }, nil } return nil, fmt.Errorf("unexpected index: %d", index) @@ -3178,7 +3179,7 @@ func TestLLMWithSkills(t *testing.T) { // r.knowledge.EXPECT().Retrieve(gomock.Any(), gomock.Any()).Return(&knowledge.RetrieveResponse{ // RetrieveSlices: []*knowledge.RetrieveSlice{ // {Slice: &knowledge.Slice{DocumentID: 1, Output: "天安门广场 ‌:中国政治文化中心,见证了近现代重大历史事件‌"}, Score: 0.9}, - // {Slice: &knowledge.Slice{DocumentID: 2, Output: "八达岭长城 ‌:明代长城的精华段,被誉为“不到长城非好汉"}, Score: 0.8}, + // {Slice: &knowledge.Slice{DocumentID: 2, Output: "八达岭长城 ‌:明代长城的精华段,被誉为"不到长城非好汉"}, Score: 0.8}, // }, // }, nil).AnyTimes() @@ -3189,7 +3190,7 @@ func TestLLMWithSkills(t *testing.T) { // }) // e := r.getProcess(id, exeID) // e.assertSuccess() - // assert.Equal(t, `{"output":"八达岭长城 ‌:明代长城的精华段,被誉为“不到长城非好汉‌"}`, e.output) + // assert.Equal(t, `{"output":"八达岭长城 ‌:明代长城的精华段,被誉为"不到长城非好汉‌"}`, e.output) // }) }) } diff --git a/backend/api/model/resource/common/resource_common.go b/backend/api/model/resource/common/resource_common.go index 9d87dbaf57..1daa86659e 100644 --- a/backend/api/model/resource/common/resource_common.go +++ b/backend/api/model/resource/common/resource_common.go @@ -1,4 +1,20 @@ -// Code generated by thriftgo (0.4.1). DO NOT EDIT. +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Code generated by thriftgo (0.4.2). DO NOT EDIT. package common @@ -89,10 +105,8 @@ func (p *ResType) Value() (driver.Value, error) { type PublishStatus int64 const ( - // unpublished PublishStatus_UnPublished PublishStatus = 1 - // Published - PublishStatus_Published PublishStatus = 2 + PublishStatus_Published PublishStatus = 2 ) func (p PublishStatus) String() string { @@ -133,20 +147,14 @@ func (p *PublishStatus) Value() (driver.Value, error) { type ActionKey int64 const ( - // copy - ActionKey_Copy ActionKey = 1 - // delete - ActionKey_Delete ActionKey = 2 - // enable/disable - ActionKey_EnableSwitch ActionKey = 3 - // edit - ActionKey_Edit ActionKey = 4 - // Switch to funcflow + ActionKey_Copy ActionKey = 1 + ActionKey_Delete ActionKey = 2 + ActionKey_EnableSwitch ActionKey = 3 + ActionKey_Edit ActionKey = 4 + ActionKey_Export ActionKey = 5 ActionKey_SwitchToFuncflow ActionKey = 8 - // Switch to chatflow ActionKey_SwitchToChatflow ActionKey = 9 - // Cross-space copy - ActionKey_CrossSpaceCopy ActionKey = 10 + ActionKey_CrossSpaceCopy ActionKey = 10 ) func (p ActionKey) String() string { @@ -159,6 +167,8 @@ func (p ActionKey) String() string { return "EnableSwitch" case ActionKey_Edit: return "Edit" + case ActionKey_Export: + return "Export" case ActionKey_SwitchToFuncflow: return "SwitchToFuncflow" case ActionKey_SwitchToChatflow: @@ -179,6 +189,8 @@ func ActionKeyFromString(s string) (ActionKey, error) { return ActionKey_EnableSwitch, nil case "Edit": return ActionKey_Edit, nil + case "Export": + return ActionKey_Export, nil case "SwitchToFuncflow": return ActionKey_SwitchToFuncflow, nil case "SwitchToChatflow": @@ -207,26 +219,16 @@ func (p *ActionKey) Value() (driver.Value, error) { type ProjectResourceActionKey int64 const ( - //rename - ProjectResourceActionKey_Rename ProjectResourceActionKey = 1 - //Create a copy/copy to the current project - ProjectResourceActionKey_Copy ProjectResourceActionKey = 2 - //Copy to Library - ProjectResourceActionKey_CopyToLibrary ProjectResourceActionKey = 3 - //Move to Library - ProjectResourceActionKey_MoveToLibrary ProjectResourceActionKey = 4 - //delete - ProjectResourceActionKey_Delete ProjectResourceActionKey = 5 - //enable - ProjectResourceActionKey_Enable ProjectResourceActionKey = 6 - //disable - ProjectResourceActionKey_Disable ProjectResourceActionKey = 7 - // Switch to funcflow + ProjectResourceActionKey_Rename ProjectResourceActionKey = 1 + ProjectResourceActionKey_Copy ProjectResourceActionKey = 2 + ProjectResourceActionKey_CopyToLibrary ProjectResourceActionKey = 3 + ProjectResourceActionKey_MoveToLibrary ProjectResourceActionKey = 4 + ProjectResourceActionKey_Delete ProjectResourceActionKey = 5 + ProjectResourceActionKey_Enable ProjectResourceActionKey = 6 + ProjectResourceActionKey_Disable ProjectResourceActionKey = 7 ProjectResourceActionKey_SwitchToFuncflow ProjectResourceActionKey = 8 - // Switch to chatflow ProjectResourceActionKey_SwitchToChatflow ProjectResourceActionKey = 9 - // Modify description - ProjectResourceActionKey_UpdateDesc ProjectResourceActionKey = 10 + ProjectResourceActionKey_UpdateDesc ProjectResourceActionKey = 10 ) func (p ProjectResourceActionKey) String() string { @@ -346,32 +348,19 @@ func (p *ProjectResourceGroupType) Value() (driver.Value, error) { type ResourceCopyScene int64 const ( - //Copy resources within the project, shallow copy - ResourceCopyScene_CopyProjectResource ResourceCopyScene = 1 - //Copy the project resources to the Library, and publish after copying - ResourceCopyScene_CopyResourceToLibrary ResourceCopyScene = 2 - //Move project resources to Library, copy to publish, and delete project resources later - ResourceCopyScene_MoveResourceToLibrary ResourceCopyScene = 3 - //Copy Library Resources to Project + ResourceCopyScene_CopyProjectResource ResourceCopyScene = 1 + ResourceCopyScene_CopyResourceToLibrary ResourceCopyScene = 2 + ResourceCopyScene_MoveResourceToLibrary ResourceCopyScene = 3 ResourceCopyScene_CopyResourceFromLibrary ResourceCopyScene = 4 - //Copy the project, along with the resources. Copy the current draft. - ResourceCopyScene_CopyProject ResourceCopyScene = 5 - //The project is published to the channel, and the associated resources need to be published (including the store). Publish with the current draft. - ResourceCopyScene_PublishProject ResourceCopyScene = 6 - // Copy the project template. - ResourceCopyScene_CopyProjectTemplate ResourceCopyScene = 7 - // The project is published to a template, and the specified version of the project is published as a temporary template. - ResourceCopyScene_PublishProjectTemplate ResourceCopyScene = 8 - // The template is approved, put on the shelves, and the official template is copied according to the temporary template. - ResourceCopyScene_LaunchTemplate ResourceCopyScene = 9 - // Draft version archive - ResourceCopyScene_ArchiveProject ResourceCopyScene = 10 - // Online version loaded into draft, draft version loaded into draft - ResourceCopyScene_RollbackProject ResourceCopyScene = 11 - // Cross-space copy of a single resource - ResourceCopyScene_CrossSpaceCopy ResourceCopyScene = 12 - // item cross-space copy - ResourceCopyScene_CrossSpaceCopyProject ResourceCopyScene = 13 + ResourceCopyScene_CopyProject ResourceCopyScene = 5 + ResourceCopyScene_PublishProject ResourceCopyScene = 6 + ResourceCopyScene_CopyProjectTemplate ResourceCopyScene = 7 + ResourceCopyScene_PublishProjectTemplate ResourceCopyScene = 8 + ResourceCopyScene_LaunchTemplate ResourceCopyScene = 9 + ResourceCopyScene_ArchiveProject ResourceCopyScene = 10 + ResourceCopyScene_RollbackProject ResourceCopyScene = 11 + ResourceCopyScene_CrossSpaceCopy ResourceCopyScene = 12 + ResourceCopyScene_CrossSpaceCopyProject ResourceCopyScene = 13 ) func (p ResourceCopyScene) String() string { @@ -505,12 +494,9 @@ func (p *TaskStatus) Value() (driver.Value, error) { return int64(*p), nil } -// Library Resource Operations type ResourceAction struct { - // An operation corresponds to a unique key, and the key is constrained by the resource side - Key ActionKey `thrift:"Key,1,required" json:"key" form:"Key,required" query:"Key,required"` - //ture = can operate this Action, false = grey out - Enable bool `thrift:"Enable,2,required" json:"enable" form:"Enable,required" query:"Enable,required"` + Key ActionKey `thrift:"Key,1,required" json:"key"` + Enable bool `thrift:"Enable,2,required" json:"enable"` } func NewResourceAction() *ResourceAction { @@ -707,47 +693,26 @@ func (p *ResourceAction) String() string { } -// front end type ResourceInfo struct { - // Resource ID - ResID *int64 `thrift:"ResID,1,optional" form:"res_id" json:"res_id,string,omitempty"` - // resource type - ResType *ResType `thrift:"ResType,2,optional" json:"res_type" form:"ResType" query:"ResType"` - // Resource subtype, defined by the resource implementer. - // Plugin:1-Http; 2-App; 6-Local;Knowledge:0-text; 1-table; 2-image;UI:1-Card - ResSubType *int32 `thrift:"ResSubType,3,optional" json:"res_sub_type" form:"ResSubType" query:"ResSubType"` - // resource name - Name *string `thrift:"Name,4,optional" json:"name" form:"Name" query:"Name"` - // resource description - Desc *string `thrift:"Desc,5,optional" json:"desc" form:"Desc" query:"Desc"` - // Resource Icon, full url - Icon *string `thrift:"Icon,6,optional" json:"icon" form:"Icon" query:"Icon"` - // Resource creator - CreatorID *int64 `thrift:"CreatorID,7,optional" form:"creator_id" json:"creator_id,string,omitempty"` - // Resource creator - CreatorAvatar *string `thrift:"CreatorAvatar,8,optional" json:"creator_avatar" form:"CreatorAvatar" query:"CreatorAvatar"` - // Resource creator - CreatorName *string `thrift:"CreatorName,9,optional" json:"creator_name" form:"CreatorName" query:"CreatorName"` - // Resource creator - UserName *string `thrift:"UserName,10,optional" json:"user_name" form:"UserName" query:"UserName"` - // Resource release status, 1 - unpublished, 2 - published - PublishStatus *PublishStatus `thrift:"PublishStatus,11,optional" json:"publish_status" form:"PublishStatus" query:"PublishStatus"` - // Resource status, each type of resource defines itself - BizResStatus *int32 `thrift:"BizResStatus,12,optional" json:"biz_res_status" form:"BizResStatus" query:"BizResStatus"` - // Whether to enable multi-person editing - CollaborationEnable *bool `thrift:"CollaborationEnable,13,optional" json:"collaboration_enable" form:"CollaborationEnable" query:"CollaborationEnable"` - // Last edited, unix timestamp - EditTime *int64 `thrift:"EditTime,14,optional" form:"edit_time" json:"edit_time,string,omitempty"` - // Resource Ownership Space ID - SpaceID *int64 `thrift:"SpaceID,15,optional" form:"space_id" json:"space_id,string,omitempty"` - // Business carry extended information to res_type distinguish, each res_type defined schema and meaning is not the same, need to judge before use res_type - BizExtend map[string]string `thrift:"BizExtend,16,optional" json:"biz_extend" form:"BizExtend" query:"BizExtend"` - // Different types of different operation buttons are agreed upon by the resource implementer and the front end. Return is displayed, if you want to hide a button, do not return; - Actions []*ResourceAction `thrift:"Actions,17,optional" json:"actions" form:"Actions" query:"Actions"` - // Whether to ban entering the details page - DetailDisable *bool `thrift:"DetailDisable,18,optional" json:"detail_disable" form:"DetailDisable" query:"DetailDisable"` - // [Data delay optimization] Delete identifier, true-deleted-frontend hides the item, false-normal - DelFlag *bool `thrift:"DelFlag,19,optional" json:"del_flag" form:"DelFlag" query:"DelFlag"` + ResID *int64 `thrift:"ResID,1,optional" json:"ResID,omitempty"` + ResType *ResType `thrift:"ResType,2,optional" json:"res_type"` + ResSubType *int32 `thrift:"ResSubType,3,optional" json:"res_sub_type"` + Name *string `thrift:"Name,4,optional" json:"name"` + Desc *string `thrift:"Desc,5,optional" json:"desc"` + Icon *string `thrift:"Icon,6,optional" json:"icon"` + CreatorID *int64 `thrift:"CreatorID,7,optional" json:"CreatorID,omitempty"` + CreatorAvatar *string `thrift:"CreatorAvatar,8,optional" json:"creator_avatar"` + CreatorName *string `thrift:"CreatorName,9,optional" json:"creator_name"` + UserName *string `thrift:"UserName,10,optional" json:"user_name"` + PublishStatus *PublishStatus `thrift:"PublishStatus,11,optional" json:"publish_status"` + BizResStatus *int32 `thrift:"BizResStatus,12,optional" json:"biz_res_status"` + CollaborationEnable *bool `thrift:"CollaborationEnable,13,optional" json:"collaboration_enable"` + EditTime *int64 `thrift:"EditTime,14,optional" json:"EditTime,omitempty"` + SpaceID *int64 `thrift:"SpaceID,15,optional" json:"SpaceID,omitempty"` + BizExtend map[string]string `thrift:"BizExtend,16,optional" json:"biz_extend"` + Actions []*ResourceAction `thrift:"Actions,17,optional" json:"actions"` + DetailDisable *bool `thrift:"DetailDisable,18,optional" json:"detail_disable"` + DelFlag *bool `thrift:"DelFlag,19,optional" json:"del_flag"` } func NewResourceInfo() *ResourceInfo { @@ -1938,12 +1903,9 @@ func (p *ResourceInfo) String() string { } type ProjectResourceAction struct { - // An operation corresponds to a unique key, and the key is constrained by the resource side - Key ProjectResourceActionKey `thrift:"Key,1,required" json:"key" form:"Key,required" query:"Key,required"` - //ture = can operate this Action, false = grey out - Enable bool `thrift:"Enable,2,required" json:"enable" form:"Enable,required" query:"Enable,required"` - // When enable = false, prompt the copywriter. The backend returns the Starling Key, be careful to put it under the same space. - Hint *string `thrift:"Hint,3,optional" json:"hint" form:"Hint" query:"Hint"` + Key ProjectResourceActionKey `thrift:"Key,1,required" json:"key"` + Enable bool `thrift:"Enable,2,required" json:"enable"` + Hint *string `thrift:"Hint,3,optional" json:"hint"` } func NewProjectResourceAction() *ProjectResourceAction { @@ -2195,26 +2157,15 @@ func (p *ProjectResourceAction) String() string { } -// The implementer provides display information type ProjectResourceInfo struct { - // Resource ID - ResID int64 `thrift:"ResID,1" form:"res_id" json:"res_id,string"` - // resource name - Name string `thrift:"Name,2" json:"name" form:"Name" query:"Name"` - // Different types of different operation buttons are agreed upon by the resource implementer and the front end. Return is displayed, if you want to hide a button, do not return; - Actions []*ProjectResourceAction `thrift:"Actions,3" json:"actions" form:"Actions" query:"Actions"` - // Is the user read-only to the resource? - // 4: bool ReadOnly (go.tag = "json:\"read_only\"", agw.key = "read_only") - // resource type - ResType ResType `thrift:"ResType,5" json:"res_type" form:"ResType" query:"ResType"` - // Resource subtype, defined by the resource implementer. Plugin: 1-Http; 2-App; 6-Local; Knowledge: 0-text; 1-table; 2-image; UI: 1-Card - ResSubType *int32 `thrift:"ResSubType,6,optional" json:"res_sub_type" form:"ResSubType" query:"ResSubType"` - // Business carry extended information to res_type distinguish, each res_type defined schema and meaning is not the same, need to judge before use res_type - BizExtend map[string]string `thrift:"BizExtend,7,optional" json:"biz_extend" form:"BizExtend" query:"BizExtend"` - // Resource status, each type of resource defines itself. The front end agrees with each resource party. - BizResStatus *int32 `thrift:"BizResStatus,8,optional" json:"biz_res_status" form:"BizResStatus" query:"BizResStatus"` - // The edited version of the current resource - VersionStr *string `thrift:"VersionStr,9,optional" json:"version_str" form:"VersionStr" query:"VersionStr"` + ResID int64 `thrift:"ResID,1" json:"ResID"` + Name string `thrift:"Name,2" json:"name"` + Actions []*ProjectResourceAction `thrift:"Actions,3" json:"actions"` + ResType ResType `thrift:"ResType,5" json:"res_type"` + ResSubType *int32 `thrift:"ResSubType,6,optional" json:"res_sub_type"` + BizExtend map[string]string `thrift:"BizExtend,7,optional" json:"biz_extend"` + BizResStatus *int32 `thrift:"BizResStatus,8,optional" json:"biz_res_status"` + VersionStr *string `thrift:"VersionStr,9,optional" json:"version_str"` } func NewProjectResourceInfo() *ProjectResourceInfo { @@ -2754,9 +2705,8 @@ func (p *ProjectResourceInfo) String() string { } type ProjectResourceGroup struct { - // resource grouping - GroupType ProjectResourceGroupType `thrift:"GroupType,1" json:"group_type" form:"GroupType" query:"GroupType"` - ResourceList []*ProjectResourceInfo `thrift:"ResourceList,2,optional" json:"resource_list" form:"ResourceList" query:"ResourceList"` + GroupType ProjectResourceGroupType `thrift:"GroupType,1" json:"group_type"` + ResourceList []*ProjectResourceInfo `thrift:"ResourceList,2,optional" json:"resource_list"` } func NewProjectResourceGroup() *ProjectResourceGroup { @@ -2970,14 +2920,12 @@ func (p *ProjectResourceGroup) String() string { } type ResourceCopyFailedReason struct { - ResID int64 `thrift:"ResID,1" form:"res_id" json:"res_id,string"` - ResType ResType `thrift:"ResType,2" json:"res_type" form:"ResType" query:"ResType"` - ResName string `thrift:"ResName,3" json:"res_name" form:"ResName" query:"ResName"` - Reason string `thrift:"Reason,4" json:"reason" form:"Reason" query:"Reason"` - // abandoned - PublishVersion *int64 `thrift:"PublishVersion,5,optional" json:"publish_version" form:"PublishVersion" query:"PublishVersion"` - // The current version of the resource, either nil or empty string, is considered the latest version. Project release or Library release. - PublishVersionStr *string `thrift:"PublishVersionStr,6,optional" json:"publish_version_str" form:"PublishVersionStr" query:"PublishVersionStr"` + ResID int64 `thrift:"ResID,1" json:"ResID"` + ResType ResType `thrift:"ResType,2" json:"res_type"` + ResName string `thrift:"ResName,3" json:"res_name"` + Reason string `thrift:"Reason,4" json:"reason"` + PublishVersion *int64 `thrift:"PublishVersion,5,optional" json:"publish_version"` + PublishVersionStr *string `thrift:"PublishVersionStr,6,optional" json:"publish_version_str"` } func NewResourceCopyFailedReason() *ResourceCopyFailedReason { @@ -3358,15 +3306,12 @@ func (p *ResourceCopyFailedReason) String() string { } type ResourceCopyTaskDetail struct { - TaskID string `thrift:"task_id,1" form:"task_id" json:"task_id" query:"task_id"` - // task status - Status TaskStatus `thrift:"status,2" form:"status" json:"status" query:"status"` - // Replicated resource id - ResID int64 `thrift:"res_id,3" form:"res_id" json:"res_id,string" query:"res_id"` - ResType ResType `thrift:"res_type,4" form:"res_type" json:"res_type" query:"res_type"` - Scene ResourceCopyScene `thrift:"scene,5" form:"scene" json:"scene" query:"scene"` - // Resource name before copy - ResName *string `thrift:"res_name,6,optional" form:"res_name" json:"res_name,omitempty" query:"res_name"` + TaskID string `thrift:"task_id,1" json:"task_id"` + Status TaskStatus `thrift:"status,2" json:"status"` + ResID int64 `thrift:"res_id,3" json:"res_id"` + ResType ResType `thrift:"res_type,4" json:"res_type"` + Scene ResourceCopyScene `thrift:"scene,5" json:"scene"` + ResName *string `thrift:"res_name,6,optional" json:"res_name,omitempty"` } func NewResourceCopyTaskDetail() *ResourceCopyTaskDetail { diff --git a/backend/api/model/workflow/batch_import.go b/backend/api/model/workflow/batch_import.go new file mode 100644 index 0000000000..ba4e3166db --- /dev/null +++ b/backend/api/model/workflow/batch_import.go @@ -0,0 +1,129 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package workflow + +import "github.com/coze-dev/coze-studio/backend/api/model/base" + +// BatchImportWorkflowRequest 工作流批量导入请求 +type BatchImportWorkflowRequest struct { + WorkflowFiles []WorkflowFileData `json:"workflow_files"` // 工作流文件数据列表 + SpaceID string `json:"space_id"` // 工作空间ID + CreatorID string `json:"creator_id"` // 创建者ID + ImportFormat string `json:"import_format"` // 导入格式,支持 "json", "yml", "yaml", "zip", "mixed" + ImportMode string `json:"import_mode"` // 导入模式:batch(批量) 或 transaction(事务) + base.Base +} + +// WorkflowFileData 单个工作流文件数据 +type WorkflowFileData struct { + FileName string `json:"file_name"` // 文件名 + WorkflowData string `json:"workflow_data"` // 工作流数据(JSON或YAML格式) + WorkflowName string `json:"workflow_name"` // 工作流名称 +} + +// BatchImportWorkflowResponse 工作流批量导入响应 +type BatchImportWorkflowResponse struct { + Code int64 `json:"code"` + Msg string `json:"msg"` + Data BatchImportResponseData `json:"data"` + base.BaseResp +} + +// BatchImportResponseData 批量导入响应数据 +type BatchImportResponseData struct { + TotalCount int `json:"total_count"` // 总数量 + SuccessCount int `json:"success_count"` // 成功数量 + FailedCount int `json:"failed_count"` // 失败数量 + SuccessList []WorkflowImportResult `json:"success_list,omitempty"` // 成功列表 + FailedList []WorkflowImportFailedResult `json:"failed_list,omitempty"` // 失败列表 + ImportSummary ImportSummary `json:"import_summary,omitempty"` // 导入摘要 +} + +// WorkflowImportResult 工作流导入成功结果 +type WorkflowImportResult struct { + FileName string `json:"file_name"` // 原文件名 + WorkflowName string `json:"workflow_name"` // 工作流名称 + WorkflowID string `json:"workflow_id"` // 新创建的工作流ID + NodeCount int `json:"node_count"` // 节点数量 + EdgeCount int `json:"edge_count"` // 连接数量 +} + +// WorkflowImportFailedResult 工作流导入失败结果 +type WorkflowImportFailedResult struct { + FileName string `json:"file_name"` // 原文件名 + WorkflowName string `json:"workflow_name"` // 工作流名称 + ErrorCode int64 `json:"error_code"` // 错误码 + ErrorMessage string `json:"error_message"` // 错误信息 + FailReason string `json:"fail_reason"` // 失败原因分类 +} + +// ImportSummary 导入摘要 +type ImportSummary struct { + StartTime int64 `json:"start_time"` // 开始时间 + EndTime int64 `json:"end_time"` // 结束时间 + Duration int64 `json:"duration_ms"` // 耗时(毫秒) + ErrorStats map[string]int `json:"error_stats,omitempty"` // 错误统计 + ImportConfig BatchImportConfig `json:"import_config"` // 导入配置 + ResourceInfo BatchImportResourceInfo `json:"resource_info"` // 资源信息 +} + +// BatchImportConfig 批量导入配置 +type BatchImportConfig struct { + ImportMode string `json:"import_mode"` // 导入模式 + MaxConcurrency int `json:"max_concurrency"` // 最大并发数 + ContinueOnError bool `json:"continue_on_error"` // 是否在出错时继续 + ValidateBeforeImport bool `json:"validate_before_import"` // 是否预先验证 +} + +// BatchImportResourceInfo 资源信息统计 +type BatchImportResourceInfo struct { + TotalFiles int `json:"total_files"` // 总文件数 + TotalSize int64 `json:"total_size_bytes"` // 总大小(字节) + TotalNodes int `json:"total_nodes"` // 总节点数 + TotalEdges int `json:"total_edges"` // 总连接数 + UniqueNodeTypes []string `json:"unique_node_types"` // 唯一节点类型 +} + +// BatchImportStatus 批量导入状态枚举 +type BatchImportStatus string + +const ( + BatchImportStatusPending BatchImportStatus = "pending" // 等待中 + BatchImportStatusProcessing BatchImportStatus = "processing" // 处理中 + BatchImportStatusCompleted BatchImportStatus = "completed" // 已完成 + BatchImportStatusFailed BatchImportStatus = "failed" // 失败 +) + +// BatchImportMode 批量导入模式枚举 +type BatchImportMode string + +const ( + BatchImportModeBatch BatchImportMode = "batch" // 批量模式:允许部分失败 + BatchImportModeTransaction BatchImportMode = "transaction" // 事务模式:全部成功或全部失败 +) + +// FailReason 失败原因枚举 +type FailReason string + +const ( + FailReasonInvalidFormat FailReason = "invalid_format" // 格式错误 + FailReasonInvalidName FailReason = "invalid_name" // 名称错误 + FailReasonDuplicateName FailReason = "duplicate_name" // 名称重复 + FailReasonInvalidData FailReason = "invalid_data" // 数据错误 + FailReasonPermissionDenied FailReason = "permission_denied" // 权限不足 + FailReasonSystemError FailReason = "system_error" // 系统错误 +) diff --git a/backend/api/model/workflow/export.go b/backend/api/model/workflow/export.go new file mode 100644 index 0000000000..93d5b5c21e --- /dev/null +++ b/backend/api/model/workflow/export.go @@ -0,0 +1,54 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package workflow + +import "github.com/coze-dev/coze-studio/backend/api/model/base" + +// ExportWorkflowRequest 工作流导出请求 +type ExportWorkflowRequest struct { + WorkflowID string `json:"workflow_id" binding:"required"` // 工作流ID + IncludeDependencies bool `json:"include_dependencies"` // 是否包含依赖资源 + ExportFormat string `json:"export_format" binding:"required"` // 导出格式,支持 "json", "yml", "yaml" + base.Base +} + +// ExportWorkflowResponse 工作流导出响应 +type ExportWorkflowResponse struct { + Code int64 `json:"code"` + Msg string `json:"msg"` + Data struct { + WorkflowExport *WorkflowExportData `json:"workflow_export,omitempty"` + } `json:"data"` + base.BaseResp +} + +// WorkflowExportData 工作流导出数据 +type WorkflowExportData struct { + WorkflowID string `json:"workflow_id"` + Name string `json:"name"` + Description string `json:"description"` + Version string `json:"version"` + CreateTime int64 `json:"create_time"` + UpdateTime int64 `json:"update_time"` + Schema map[string]interface{} `json:"schema"` + Nodes []interface{} `json:"nodes"` + Edges []interface{} `json:"edges"` + Metadata map[string]interface{} `json:"metadata"` + Dependencies []interface{} `json:"dependencies,omitempty"` + ExportFormat string `json:"export_format"` // 导出格式 + SerializedData string `json:"serialized_data,omitempty"` // 序列化后的数据(yml格式时使用) +} diff --git a/backend/api/model/workflow/import.go b/backend/api/model/workflow/import.go new file mode 100644 index 0000000000..ecfbd721de --- /dev/null +++ b/backend/api/model/workflow/import.go @@ -0,0 +1,39 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package workflow + +import "github.com/coze-dev/coze-studio/backend/api/model/base" + +// ImportWorkflowRequest 工作流导入请求 +type ImportWorkflowRequest struct { + WorkflowData string `json:"workflow_data" binding:"required"` // 工作流数据(JSON或YAML格式) + WorkflowName string `json:"workflow_name" binding:"required"` // 工作流名称 + SpaceID string `json:"space_id" binding:"required"` // 工作空间ID + CreatorID string `json:"creator_id" binding:"required"` // 创建者ID + ImportFormat string `json:"import_format" binding:"required"` // 导入格式,支持 "json", "yml", "yaml" + base.Base +} + +// ImportWorkflowResponse 工作流导入响应 +type ImportWorkflowResponse struct { + Code int64 `json:"code"` + Msg string `json:"msg"` + Data struct { + WorkflowID string `json:"workflow_id,omitempty"` // 新创建的工作流ID + } `json:"data"` + base.BaseResp +} diff --git a/backend/api/router/coze/api.go b/backend/api/router/coze/api.go index b095c2b8b3..2a911cc27c 100644 --- a/backend/api/router/coze/api.go +++ b/backend/api/router/coze/api.go @@ -383,6 +383,9 @@ func Register(r *server.Hertz) { _workflow_api.POST("/delete", append(_deleteworkflowMw(), coze.DeleteWorkflow)...) _workflow_api.POST("/delete_strategy", append(_getdeletestrategyMw(), coze.GetDeleteStrategy)...) _workflow_api.POST("/example_workflow_list", append(_getexampleworkflowlistMw(), coze.GetExampleWorkFlowList)...) + _workflow_api.POST("/export", append(_exportworkflowMw(), coze.ExportWorkflow)...) + _workflow_api.POST("/import", append(_importworkflowMw(), coze.ImportWorkflow)...) + _workflow_api.POST("/batch_import", append(_batchimportworkflowMw(), coze.BatchImportWorkflow)...) _workflow_api.GET("/get_node_execute_history", append(_getnodeexecutehistoryMw(), coze.GetNodeExecuteHistory)...) _workflow_api.GET("/get_process", append(_getworkflowprocessMw(), coze.GetWorkFlowProcess)...) _workflow_api.POST("/get_trace", append(_gettracesdkMw(), coze.GetTraceSDK)...) diff --git a/backend/api/router/coze/middleware.go b/backend/api/router/coze/middleware.go index 271edfc953..f0406e8daf 100644 --- a/backend/api/router/coze/middleware.go +++ b/backend/api/router/coze/middleware.go @@ -1441,6 +1441,21 @@ func _getexampleworkflowlistMw() []app.HandlerFunc { return nil } +func _exportworkflowMw() []app.HandlerFunc { + // your code... + return nil +} + +func _importworkflowMw() []app.HandlerFunc { + // your code... + return nil +} + +func _batchimportworkflowMw() []app.HandlerFunc { + // your code... + return nil +} + func _bot0Mw() []app.HandlerFunc { // your code... return nil diff --git a/backend/application/search/resource_pack.go b/backend/application/search/resource_pack.go index 03e2b0bff3..d19bedc02b 100644 --- a/backend/application/search/resource_pack.go +++ b/backend/application/search/resource_pack.go @@ -173,6 +173,10 @@ func (w *workflowPacker) GetActions(ctx context.Context) []*common.ResourceActio Key: common.ActionKey_Copy, Enable: true, }, + { + Key: common.ActionKey_Export, + Enable: true, + }, } meta, err := w.appContext.WorkflowDomainSVC.Get(ctx, &vo.GetPolicy{ ID: w.resID, diff --git a/backend/application/workflow/workflow.go b/backend/application/workflow/workflow.go index cd502618f6..b13a9475f3 100644 --- a/backend/application/workflow/workflow.go +++ b/backend/application/workflow/workflow.go @@ -17,9 +17,14 @@ package workflow import ( + "archive/zip" + "bytes" "context" + "encoding/base64" "errors" "fmt" + "io" + "regexp" "runtime/debug" "strconv" "strings" @@ -29,6 +34,7 @@ import ( "github.com/cloudwego/eino/schema" xmaps "golang.org/x/exp/maps" "golang.org/x/sync/errgroup" + "gopkg.in/yaml.v3" "github.com/coze-dev/coze-studio/backend/api/model/app/bot_common" model "github.com/coze-dev/coze-studio/backend/api/model/crossdomain/knowledge" @@ -83,6 +89,54 @@ var ( nodeIconURLCacheMu sync.Mutex ) +// getLocalizedMessage 获取本地化消息 +func getLocalizedMessage(ctx context.Context, key string) string { + locale := i18n.GetLocale(ctx) + + // 中英文消息映射 + messages := map[string]map[string]string{ + "zh-CN": { + "no_valid_files_to_import": "没有有效的文件可以导入", + "file_parse_failed": "文件 \"%s\" 解析失败:%v", + "file_missing_schema_nodes": "文件 %s 缺少必要字段:schema 或 nodes", + "workflow_name_duplicate": "工作流名称重复:\"%s\"", + "batch_import_files": "批量导入文件:", + "batch_import_failed_http": "批量导入失败,HTTP状态码:%d", + "invalid_response_format": "服务器返回了无效的响应格式,请检查API接口", + "batch_import_api_response": "批量导入API响应:", + "batch_import_failed": "批量导入失败", + }, + "en-US": { + "no_valid_files_to_import": "No valid files to import", + "file_parse_failed": "File \"%s\" parse failed: %v", + "file_missing_schema_nodes": "File %s missing required fields: schema or nodes", + "workflow_name_duplicate": "Duplicate workflow name: \"%s\"", + "batch_import_files": "Batch import files:", + "batch_import_failed_http": "Batch import failed, HTTP status code: %d", + "invalid_response_format": "Server returned invalid response format, please check API interface", + "batch_import_api_response": "Batch import API response:", + "batch_import_failed": "Batch import failed", + }, + } + + // 获取对应语言的消息 + if localeMessages, exists := messages[string(locale)]; exists { + if message, exists := localeMessages[key]; exists { + return message + } + } + + // 默认返回英文 + if enMessages, exists := messages["en-US"]; exists { + if message, exists := enMessages[key]; exists { + return message + } + } + + // 如果都没找到,返回key本身 + return key +} + func GetWorkflowDomainSVC() domainWorkflow.Service { return SVC.DomainSVC } @@ -106,7 +160,7 @@ func (w *ApplicationService) InitNodeIconURLCache(ctx context.Context) error { url, err := w.TosClient.GetObjectUrl(gCtx, nodeMeta.IconURI) if err != nil { logs.Warnf("failed to get object url for node %s: %v", nodeMeta.Name, err) - return err + return nil // Continue initialization even if icon URL fails } nodeTypeStr := entity.IDStrToNodeType(strconv.FormatInt(nodeMeta.ID, 10)) if len(nodeTypeStr) > 0 { @@ -3929,6 +3983,770 @@ func (w *ApplicationService) populateChatFlowRoleFields(role *workflow.ChatFlowR return nil } +// ExportWorkflow 导出工作流 +func (w *ApplicationService) ExportWorkflow(ctx context.Context, req *workflow.ExportWorkflowRequest) (*workflow.ExportWorkflowResponse, error) { + defer func() { + if panicErr := recover(); panicErr != nil { + err := safego.NewPanicErr(panicErr, debug.Stack()) + logs.CtxErrorf(ctx, "ExportWorkflow panic: %v", err) + } + }() + + // 参数验证 + if req.WorkflowID == "" { + return nil, vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("workflow_id is required")) + } + supportedFormats := map[string]bool{ + "json": true, + "yml": true, + "yaml": true, + } + if !supportedFormats[req.ExportFormat] { + return nil, vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("unsupported export format: %s, supported formats: json, yml, yaml", req.ExportFormat)) + } + + // 记录操作日志 + logs.CtxInfof(ctx, "ExportWorkflow started, workflowID=%s, includeDependencies=%v", req.WorkflowID, req.IncludeDependencies) + + // 获取工作流信息 + workflowID, err := strconv.ParseInt(req.WorkflowID, 10, 64) + if err != nil { + logs.CtxErrorf(ctx, "ExportWorkflow failed to parse workflow_id: %s, error: %v", req.WorkflowID, err) + return nil, vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("invalid workflow_id: %s", req.WorkflowID)) + } + + // 获取工作流详情 + workflowInfo, err := w.DomainSVC.Get(ctx, &vo.GetPolicy{ + ID: workflowID, + }) + if err != nil { + logs.CtxErrorf(ctx, "ExportWorkflow failed to get workflow: %d, error: %v", workflowID, err) + return nil, vo.WrapError(errno.ErrWorkflowNotFound, fmt.Errorf("failed to get workflow: %v", err)) + } + + // 验证用户权限(检查工作空间) + if err := checkUserSpace(ctx, ctxutil.MustGetUIDFromCtx(ctx), workflowInfo.SpaceID); err != nil { + logs.CtxErrorf(ctx, "ExportWorkflow permission denied, user=%d, space=%d, error: %v", ctxutil.MustGetUIDFromCtx(ctx), workflowInfo.SpaceID, err) + return nil, vo.WrapError(errno.ErrWorkflowOperationFail, fmt.Errorf("permission denied: %v", err)) + } + + // 构建导出数据 + exportData := &workflow.WorkflowExportData{ + WorkflowID: req.WorkflowID, + Name: workflowInfo.Name, + Description: workflowInfo.Desc, + Version: workflowInfo.GetVersion(), + CreateTime: workflowInfo.CreatedAt.Unix(), + UpdateTime: workflowInfo.UpdatedAt.Unix(), + Metadata: map[string]interface{}{ + "space_id": strconv.FormatInt(workflowInfo.SpaceID, 10), + "creator_id": strconv.FormatInt(workflowInfo.CreatorID, 10), + "content_type": strconv.FormatInt(int64(workflowInfo.ContentType), 10), + "mode": strconv.FormatInt(int64(workflowInfo.Mode), 10), + }, + } + + // 添加工作流Schema + if workflowInfo.Canvas != "" { + var canvas vo.Canvas + if err := sonic.UnmarshalString(workflowInfo.Canvas, &canvas); err == nil { + // 解析Schema + var schemaData map[string]interface{} + if err := sonic.UnmarshalString(workflowInfo.Canvas, &schemaData); err == nil { + exportData.Schema = schemaData + } + + // 设置节点 + exportData.Nodes = make([]interface{}, len(canvas.Nodes)) + for i, node := range canvas.Nodes { + exportData.Nodes[i] = node + } + + // 设置边 + exportData.Edges = make([]interface{}, len(canvas.Edges)) + for i, edge := range canvas.Edges { + edgeData := map[string]string{ + "from_node": edge.SourceNodeID, + "to_node": edge.TargetNodeID, + "from_port": edge.SourcePortID, + "to_port": edge.TargetPortID, + } + exportData.Edges[i] = edgeData + } + } + } + + // 添加依赖资源信息(如果启用) + if req.IncludeDependencies { + dependencies := make([]interface{}, 0) + + // 获取工作流中引用的资源 + if workflowInfo.Canvas != "" { + var canvas vo.Canvas + if err := sonic.UnmarshalString(workflowInfo.Canvas, &canvas); err == nil { + // 分析节点中的资源引用 + for _, node := range canvas.Nodes { + if node.Data != nil && node.Data.Meta != nil { + // 这里可以添加更多资源类型的检测逻辑 + // 目前先添加一个示例 + dependency := map[string]interface{}{ + "resource_id": fmt.Sprintf("node_%s", node.ID), + "resource_type": "node", + "resource_name": node.Data.Meta.Title, + "metadata": map[string]interface{}{ + "node_type": "workflow_node", + }, + } + dependencies = append(dependencies, dependency) + } + } + } + } + + exportData.Dependencies = dependencies + } + + logs.CtxInfof(ctx, "ExportWorkflow completed successfully, workflowID=%s, nodeCount=%d, edgeCount=%d", + req.WorkflowID, len(exportData.Nodes), len(exportData.Edges)) + + // 设置导出格式 + exportData.ExportFormat = req.ExportFormat + + // 根据导出格式进行序列化处理 + if req.ExportFormat == "yml" || req.ExportFormat == "yaml" { + // 创建一个不包含SerializedData的副本用于YAML序列化 + exportDataForYAML := &workflow.WorkflowExportData{ + WorkflowID: exportData.WorkflowID, + Name: exportData.Name, + Description: exportData.Description, + Version: exportData.Version, + CreateTime: exportData.CreateTime, + UpdateTime: exportData.UpdateTime, + Schema: exportData.Schema, + Nodes: exportData.Nodes, + Edges: exportData.Edges, + Metadata: exportData.Metadata, + Dependencies: exportData.Dependencies, + ExportFormat: exportData.ExportFormat, + // 不包含 SerializedData 字段 + } + + yamlData, err := yaml.Marshal(exportDataForYAML) + if err != nil { + logs.CtxErrorf(ctx, "ExportWorkflow failed to marshal YAML data: %v", err) + return nil, vo.WrapError(errno.ErrSerializationDeserializationFail, fmt.Errorf("failed to serialize workflow to YAML: %v", err)) + } + exportData.SerializedData = string(yamlData) + logs.CtxInfof(ctx, "ExportWorkflow YAML serialization completed, size=%d bytes", len(yamlData)) + } + + // 构建响应 + return &workflow.ExportWorkflowResponse{ + Code: 200, + Msg: "success", + Data: struct { + WorkflowExport *workflow.WorkflowExportData `json:"workflow_export,omitempty"` + }{ + WorkflowExport: exportData, + }, + }, nil +} + +// parseWorkflowData 解析工作流数据,支持JSON和YAML格式 +func (w *ApplicationService) parseWorkflowData(ctx context.Context, data string, format string) (*workflow.WorkflowExportData, error) { + var exportData workflow.WorkflowExportData + + if format == "yml" || format == "yaml" { + // YAML格式解析 + if err := yaml.Unmarshal([]byte(data), &exportData); err != nil { + return nil, fmt.Errorf("failed to parse YAML workflow data: %v", err) + } + } else if format == "zip" { + // ZIP格式解析和转换 + convertedData, err := w.parseAndConvertZipWorkflowData(ctx, data) + if err != nil { + return nil, fmt.Errorf("failed to parse ZIP workflow data: %v", err) + } + exportData = *convertedData + } else { + // JSON格式解析 + if err := sonic.UnmarshalString(data, &exportData); err != nil { + return nil, fmt.Errorf("failed to parse JSON workflow data: %v", err) + } + } + + return &exportData, nil +} + +// parseAndConvertZipWorkflowData 解析ZIP格式工作流数据并转换为开源格式 +func (w *ApplicationService) parseAndConvertZipWorkflowData(ctx context.Context, zipDataStr string) (*workflow.WorkflowExportData, error) { + // ZIP数据通过base64编码传输 + zipBytes, err := base64.StdEncoding.DecodeString(zipDataStr) + if err != nil { + return nil, fmt.Errorf("failed to decode base64 ZIP data: %v", err) + } + + // 解析ZIP内容 + zipReader, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes))) + if err != nil { + return nil, fmt.Errorf("failed to read ZIP file: %v", err) + } + + var workflowContent string + + // 遍历ZIP文件内容 + for _, file := range zipReader.File { + reader, err := file.Open() + if err != nil { + continue + } + + content, err := io.ReadAll(reader) + reader.Close() + if err != nil { + continue + } + + // 检查是否是工作流文件 + if strings.Contains(file.Name, "Workflow-") && strings.HasSuffix(file.Name, ".zip") { + // 这是内嵌的工作流文件,需要进一步解析 + workflowContent = string(content) + } + } + + if workflowContent == "" { + return nil, fmt.Errorf("no workflow content found in ZIP file") + } + + // 从工作流内容中提取JSON和MANIFEST + jsonData, manifestData, err := extractWorkflowDataFromContent(workflowContent) + if err != nil { + return nil, fmt.Errorf("failed to extract workflow data: %v", err) + } + + // 转换为开源格式 + convertedData, err := w.convertZipWorkflowToOpenSource(ctx, jsonData, manifestData) + if err != nil { + return nil, fmt.Errorf("failed to convert ZIP workflow: %v", err) + } + + return convertedData, nil +} + +// extractWorkflowDataFromContent 从工作流内容中提取JSON和MANIFEST数据 +func extractWorkflowDataFromContent(content string) (map[string]interface{}, map[string]interface{}, error) { + // 首先尝试找到JSON的开始位置 + jsonStart := strings.Index(content, "{\"edges\"") + if jsonStart == -1 { + jsonStart = strings.Index(content, `{"nodes"`) + } + if jsonStart == -1 { + return nil, nil, fmt.Errorf("no JSON start found") + } + + // 从JSON开始位置截取,找到完整的JSON + contentFromJson := content[jsonStart:] + + // 找到完整的JSON(通过括号匹配) + braceCount := 0 + jsonEnd := -1 + for i, char := range contentFromJson { + if char == '{' { + braceCount++ + } else if char == '}' { + braceCount-- + if braceCount == 0 { + jsonEnd = i + break + } + } + } + + if jsonEnd == -1 { + return nil, nil, fmt.Errorf("no complete JSON found") + } + + jsonMatch := contentFromJson[:jsonEnd+1] + + // 清理JSON字符串 + cleanJsonString := strings.ReplaceAll(jsonMatch, "\x00", "") + cleanJsonString = regexp.MustCompile(`[\x00-\x1F\x7F-\x9F]`).ReplaceAllString(cleanJsonString, "") + cleanJsonString = strings.TrimSpace(cleanJsonString) + + var jsonData map[string]interface{} + if err := sonic.UnmarshalString(cleanJsonString, &jsonData); err != nil { + return nil, nil, fmt.Errorf("failed to parse JSON data: %v", err) + } + + // 查找MANIFEST.yml数据 + manifestRegex := regexp.MustCompile(`MANIFEST\.yml[\s\S]*?type:\s*(\w+)[\s\S]*?version:\s*([^\n\r]+)[\s\S]*?main:\s*[\s\S]*?id:\s*([^\n\r]+)[\s\S]*?name:\s*([^\n\r]+)[\s\S]*?desc:\s*([^\n\r]+)`) + manifestMatch := manifestRegex.FindStringSubmatch(content) + + manifestData := make(map[string]interface{}) + if len(manifestMatch) >= 6 { + manifestData = map[string]interface{}{ + "type": strings.TrimSpace(manifestMatch[1]), + "version": strings.Trim(strings.TrimSpace(manifestMatch[2]), `"`), + "main": map[string]interface{}{ + "id": strings.Trim(strings.TrimSpace(manifestMatch[3]), `"`), + "name": strings.Trim(strings.TrimSpace(manifestMatch[4]), `"`), + "desc": strings.Trim(strings.TrimSpace(manifestMatch[5]), `"`), + }, + } + } + + return jsonData, manifestData, nil +} + +// convertZipWorkflowToOpenSource 转换ZIP格式到开源格式 +func (w *ApplicationService) convertZipWorkflowToOpenSource(ctx context.Context, zipData map[string]interface{}, manifest map[string]interface{}) (*workflow.WorkflowExportData, error) { + currentTime := time.Now().Unix() + + logs.CtxInfof(ctx, "Converting ZIP workflow to open source format, preserving original model IDs") + + // 提取edges数据并转换格式 + var convertedEdges []map[string]interface{} + if edgesData, ok := zipData["edges"].([]interface{}); ok { + for _, edge := range edgesData { + if edgeMap, ok := edge.(map[string]interface{}); ok { + convertedEdge := map[string]interface{}{ + "from_node": edgeMap["sourceNodeID"], + "from_port": getStringValue(edgeMap, "sourcePortID"), + "to_node": edgeMap["targetNodeID"], + "to_port": getStringValue(edgeMap, "targetPortID"), + } + convertedEdges = append(convertedEdges, convertedEdge) + } + } + } + + // 提取nodes数据并转换格式 + var convertedNodes []map[string]interface{} + var simplifiedNodes []map[string]interface{} + var dependencies []map[string]interface{} + + if nodesData, ok := zipData["nodes"].([]interface{}); ok { + for _, node := range nodesData { + if nodeMap, ok := node.(map[string]interface{}); ok { + // 移除blocks字段 + nodeWithoutBlocks := make(map[string]interface{}) + for k, v := range nodeMap { + if k != "blocks" { + nodeWithoutBlocks[k] = v + } + } + convertedNodes = append(convertedNodes, nodeWithoutBlocks) + + // 创建简化节点 + simplifiedNode := map[string]interface{}{ + "id": nodeMap["id"], + "type": nodeMap["type"], + "meta": nodeMap["meta"], + "data": extractNodeData(nodeMap), + } + simplifiedNodes = append(simplifiedNodes, simplifiedNode) + + // 创建依赖 + nodeTitle := "Node" + if dataMap, ok := nodeMap["data"].(map[string]interface{}); ok { + if metaMap, ok := dataMap["nodeMeta"].(map[string]interface{}); ok { + if title, ok := metaMap["title"].(string); ok && title != "" { + nodeTitle = title + } + } + } + + dependency := map[string]interface{}{ + "metadata": map[string]interface{}{ + "node_type": "workflow_node", + }, + "resource_id": fmt.Sprintf("node_%v", nodeMap["id"]), + "resource_name": nodeTitle, + "resource_type": "node", + } + dependencies = append(dependencies, dependency) + } + } + } + + // 构建schema中的edges + var schemaEdges []map[string]interface{} + for _, edge := range convertedEdges { + schemaEdge := map[string]interface{}{ + "sourceNodeID": edge["from_node"], + "sourcePortID": edge["from_port"], + "targetNodeID": edge["to_node"], + "targetPortID": edge["to_port"], + } + schemaEdges = append(schemaEdges, schemaEdge) + } + + // 构建schema + schema := map[string]interface{}{ + "edges": schemaEdges, + "nodes": convertedNodes, + } + + // 添加versions如果存在 + if versions, ok := zipData["versions"]; ok { + schema["versions"] = versions + } + + // 从manifest提取元数据 + var workflowID, name, description, version string + if mainData, ok := manifest["main"].(map[string]interface{}); ok { + workflowID = getStringValue(mainData, "id") + name = getStringValue(mainData, "name") + description = getStringValue(mainData, "desc") + } + if version == "" { + if v, ok := manifest["version"].(string); ok { + version = v + } + } + if version == "" { + version = "v1.0.0" + } + if workflowID == "" { + workflowID = fmt.Sprintf("imported_%d", currentTime) + } + if name == "" { + name = "Imported Workflow" + } + + // 转换切片类型 + convertedNodesInterface := make([]interface{}, len(simplifiedNodes)) + for i, node := range simplifiedNodes { + convertedNodesInterface[i] = node + } + + convertedEdgesInterface := make([]interface{}, len(convertedEdges)) + for i, edge := range convertedEdges { + convertedEdgesInterface[i] = edge + } + + convertedDepsInterface := make([]interface{}, len(dependencies)) + for i, dep := range dependencies { + convertedDepsInterface[i] = dep + } + + // 构建最终的WorkflowExportData + exportData := &workflow.WorkflowExportData{ + WorkflowID: workflowID, + Name: name, + Description: description, + Version: version, + CreateTime: currentTime, + UpdateTime: currentTime, + Schema: schema, + Nodes: convertedNodesInterface, + Edges: convertedEdgesInterface, + Metadata: map[string]interface{}{ + "content_type": "0", + "mode": "0", + "creator_id": "imported_user", + "space_id": "imported_space", + }, + Dependencies: convertedDepsInterface, + ExportFormat: "json", + } + + return exportData, nil +} + +// extractNodeData 提取节点数据 +func extractNodeData(nodeMap map[string]interface{}) map[string]interface{} { + dataMap := map[string]interface{}{} + + if data, ok := nodeMap["data"].(map[string]interface{}); ok { + if nodeMeta, ok := data["nodeMeta"]; ok { + dataMap["nodeMeta"] = nodeMeta + } + if outputs, ok := data["outputs"]; ok { + dataMap["outputs"] = outputs + } + if inputs, ok := data["inputs"]; ok { + dataMap["inputs"] = inputs + } + if triggerParams, ok := data["trigger_parameters"]; ok { + dataMap["trigger_parameters"] = triggerParams + } + } + + return dataMap +} + +// getStringValue 安全获取字符串值 +func getStringValue(m map[string]interface{}, key string) string { + if val, ok := m[key].(string); ok { + return val + } + return "" +} + +// ImportWorkflow 导入工作流 +func (w *ApplicationService) ImportWorkflow(ctx context.Context, req *workflow.ImportWorkflowRequest) (*workflow.ImportWorkflowResponse, error) { + defer func() { + if panicErr := recover(); panicErr != nil { + err := safego.NewPanicErr(panicErr, debug.Stack()) + logs.CtxErrorf(ctx, "ImportWorkflow panic: %v", err) + } + }() + + // 记录操作日志 + logs.CtxInfof(ctx, "ImportWorkflow started, workflowName=%s, spaceID=%s, creatorID=%s", + req.WorkflowName, req.SpaceID, req.CreatorID) + + // 验证请求参数 + if req.WorkflowData == "" { + return nil, vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("workflow_data is required")) + } + if req.WorkflowName == "" { + return nil, vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("workflow_name is required")) + } + if req.SpaceID == "" { + return nil, vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("space_id is required")) + } + if req.CreatorID == "" { + return nil, vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("creator_id is required")) + } + // 验证导入格式 + supportedImportFormats := map[string]bool{ + "json": true, + "yml": true, + "yaml": true, + "zip": true, // 支持ZIP格式 + } + if !supportedImportFormats[req.ImportFormat] { + return nil, vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("unsupported import format: %s, supported formats: json, yml, yaml, zip", req.ImportFormat)) + } + + // 验证工作流名称格式 + if !isValidWorkflowName(req.WorkflowName) { + return nil, vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("invalid workflow name format: %s", req.WorkflowName)) + } + + // 验证用户权限(检查工作空间) + spaceID, err := strconv.ParseInt(req.SpaceID, 10, 64) + if err != nil { + logs.CtxErrorf(ctx, "ImportWorkflow failed to parse space_id: %s, error: %v", req.SpaceID, err) + return nil, vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("invalid space_id: %s", req.SpaceID)) + } + + if err := checkUserSpace(ctx, ctxutil.MustGetUIDFromCtx(ctx), spaceID); err != nil { + logs.CtxErrorf(ctx, "ImportWorkflow permission denied, user=%d, space=%d, error: %v", ctxutil.MustGetUIDFromCtx(ctx), spaceID, err) + return nil, vo.WrapError(errno.ErrWorkflowOperationFail, fmt.Errorf("permission denied: %v", err)) + } + + // 验证导入格式 + if req.ImportFormat != "json" && req.ImportFormat != "yml" && req.ImportFormat != "yaml" { + return nil, vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("unsupported import format: %s, supported formats: json, yml, yaml", req.ImportFormat)) + } + + // 解析工作流数据 + exportData, err := w.parseWorkflowData(ctx, req.WorkflowData, req.ImportFormat) + if err != nil { + logs.CtxErrorf(ctx, "ImportWorkflow failed to parse workflow data: %v", err) + return nil, vo.WrapError(errno.ErrSerializationDeserializationFail, err) + } + + // 验证工作流数据结构 + if exportData.Schema == nil { + return nil, vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("invalid workflow data: missing schema")) + } + + // 验证工作流名称长度 + if len(req.WorkflowName) > 100 { + return nil, vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("workflow name too long: %d characters (max: 100)", len(req.WorkflowName))) + } + + // 构建工作流创建请求 + createReq := &workflow.CreateWorkflowRequest{ + Name: req.WorkflowName, + Desc: exportData.Description, + SpaceID: req.SpaceID, + } + + // 调用创建工作流服务 + createResp, err := w.CreateWorkflow(ctx, createReq) + if err != nil { + logs.CtxErrorf(ctx, "ImportWorkflow failed to create workflow: %v", err) + return nil, vo.WrapError(errno.ErrWorkflowOperationFail, fmt.Errorf("failed to create workflow: %v", err)) + } + + // 保存工作流架构数据 + canvasData, err := sonic.MarshalString(exportData.Schema) + if err != nil { + logs.CtxErrorf(ctx, "ImportWorkflow failed to marshal canvas data: %v", err) + return nil, vo.WrapError(errno.ErrSerializationDeserializationFail, fmt.Errorf("failed to marshal canvas data: %v", err)) + } + + // 构建保存工作流请求 + saveReq := &workflow.SaveWorkflowRequest{ + WorkflowID: createResp.Data.WorkflowID, + SpaceID: ptr.Of(req.SpaceID), + Schema: ptr.Of(canvasData), + } + + // 调用保存工作流服务 + _, err = w.SaveWorkflow(ctx, saveReq) + if err != nil { + logs.CtxErrorf(ctx, "ImportWorkflow failed to save workflow schema: %v", err) + return nil, vo.WrapError(errno.ErrWorkflowOperationFail, fmt.Errorf("failed to save workflow schema: %v", err)) + } + + logs.CtxInfof(ctx, "ImportWorkflow completed successfully, workflowID=%s, workflowName=%s", + createResp.Data.WorkflowID, req.WorkflowName) + + // 构建响应 + return &workflow.ImportWorkflowResponse{ + Code: 200, + Msg: "success", + Data: struct { + WorkflowID string `json:"workflow_id,omitempty"` + }{ + WorkflowID: createResp.Data.WorkflowID, + }, + }, nil +} + +// isValidWorkflowName 验证工作流名称格式 +func isValidWorkflowName(name string) bool { + if len(name) < 2 || len(name) > 100 { + return false + } + + // 检查是否以字母开头 + if !regexp.MustCompile(`^[a-zA-Z]`).MatchString(name) { + return false + } + + // 检查是否只包含字母、数字和下划线 + if !regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]*$`).MatchString(name) { + return false + } + + return true +} + +// BatchImportWorkflow 批量导入工作流 +func (w *ApplicationService) BatchImportWorkflow(ctx context.Context, req *workflow.BatchImportWorkflowRequest) (*workflow.BatchImportWorkflowResponse, error) { + defer func() { + if panicErr := recover(); panicErr != nil { + err := safego.NewPanicErr(panicErr, debug.Stack()) + logs.CtxErrorf(ctx, "BatchImportWorkflow panic: %v", err) + } + }() + + startTime := time.Now() + logs.CtxInfof(ctx, "BatchImportWorkflow started, fileCount=%d, spaceID=%s, mode=%s", + len(req.WorkflowFiles), req.SpaceID, req.ImportMode) + + // 1. 参数验证 + if err := w.validateBatchImportRequest(req); err != nil { + return nil, err + } + + // 2. 权限验证 + spaceID, err := strconv.ParseInt(req.SpaceID, 10, 64) + if err != nil { + return nil, vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("invalid space_id: %s", req.SpaceID)) + } + + if err := checkUserSpace(ctx, ctxutil.MustGetUIDFromCtx(ctx), spaceID); err != nil { + return nil, vo.WrapError(errno.ErrWorkflowOperationFail, fmt.Errorf("permission denied: %v", err)) + } + + // 3. 构建导入配置 + config := w.buildBatchImportConfig(req) + + // 4. 预验证所有文件(如果启用)- 改为警告模式,不阻止导入 + if config.ValidateBeforeImport { + w.preValidateWorkflowFilesWithWarning(ctx, req.WorkflowFiles) + } + + // 5. 执行批量导入 + results, err := w.executeBatchImport(ctx, req, config) + if err != nil { + return nil, err + } + + // 6. 构建响应 + endTime := time.Now() + response := w.buildBatchImportResponse(results, startTime, endTime, config, req.WorkflowFiles) + + logs.CtxInfof(ctx, "BatchImportWorkflow completed, total=%d, success=%d, failed=%d, duration=%dms", + response.Data.TotalCount, response.Data.SuccessCount, response.Data.FailedCount, response.Data.ImportSummary.Duration) + + return response, nil +} + +// validateBatchImportRequest 验证批量导入请求参数 +func (w *ApplicationService) validateBatchImportRequest(req *workflow.BatchImportWorkflowRequest) error { + if len(req.WorkflowFiles) == 0 { + return vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("workflow_files cannot be empty")) + } + + // 验证导入格式 + supportedImportFormats := map[string]bool{ + "json": true, + "yml": true, + "yaml": true, + "zip": true, // 支持ZIP格式 + "mixed": true, // 支持混合格式 + } + if !supportedImportFormats[req.ImportFormat] { + return vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("unsupported import format: %s, supported formats: json, yml, yaml, zip, mixed", req.ImportFormat)) + } + + // 限制批量导入数量 + maxBatchSize := 50 // 最大批量导入数量 + if len(req.WorkflowFiles) > maxBatchSize { + return vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("too many files: %d (max: %d)", len(req.WorkflowFiles), maxBatchSize)) + } + + if req.SpaceID == "" { + return vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("space_id is required")) + } + + if req.CreatorID == "" { + return vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("creator_id is required")) + } + + // 验证导入格式(重复验证,已在上面的supportedImportFormats中处理) + + // 验证导入模式 + if req.ImportMode != "" && req.ImportMode != string(workflow.BatchImportModeBatch) && req.ImportMode != string(workflow.BatchImportModeTransaction) { + return vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("invalid import mode: %s", req.ImportMode)) + } + + // 验证每个文件 + nameSet := make(map[string]bool) + for i, file := range req.WorkflowFiles { + if file.FileName == "" { + return vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("file_name is required for file %d", i)) + } + if file.WorkflowData == "" { + return vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("workflow_data is required for file %d", i)) + } + if file.WorkflowName == "" { + return vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("workflow_name is required for file %d", i)) + } + + // 验证工作流名称格式 + if !isValidWorkflowName(file.WorkflowName) { + return vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("invalid workflow name format: %s for file %s", file.WorkflowName, file.FileName)) + } + + // 检查名称重复 + if nameSet[file.WorkflowName] { + return vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("duplicate workflow name: %s", file.WorkflowName)) + } + nameSet[file.WorkflowName] = true + } + + return nil +} func IsChatFlow(wf *entity.Workflow) bool { if wf == nil || wf.ID == 0 { return false @@ -4251,3 +5069,390 @@ func (w *ApplicationService) OpenAPIGetWorkflowInfo(ctx context.Context, req *wo }, }, nil } + +// buildBatchImportConfig 构建批量导入配置 +func (w *ApplicationService) buildBatchImportConfig(req *workflow.BatchImportWorkflowRequest) workflow.BatchImportConfig { + config := workflow.BatchImportConfig{ + ImportMode: req.ImportMode, + MaxConcurrency: 5, // 最大并发数 + ContinueOnError: true, + ValidateBeforeImport: true, + } + + // 设置默认导入模式 + if config.ImportMode == "" { + config.ImportMode = string(workflow.BatchImportModeBatch) + } + + // 事务模式不允许在出错时继续 + if config.ImportMode == string(workflow.BatchImportModeTransaction) { + config.ContinueOnError = false + } + + return config +} + +// preValidateWorkflowFiles 预验证所有工作流文件 +func (w *ApplicationService) preValidateWorkflowFiles(ctx context.Context, files []workflow.WorkflowFileData) error { + for i, file := range files { + // 根据文件名确定格式 + fileName := strings.ToLower(file.FileName) + var format string + if strings.HasSuffix(fileName, ".yml") { + format = "yml" + } else if strings.HasSuffix(fileName, ".yaml") { + format = "yaml" + } else if strings.HasSuffix(fileName, ".zip") { + format = "zip" + } else { + format = "json" + } + + // 对于ZIP文件,数据是base64编码的,需要特殊处理 + if format == "zip" { + // ZIP文件验证:检查是否为有效的base64数据 + if _, err := base64.StdEncoding.DecodeString(file.WorkflowData); err != nil { + return vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("file %d (%s): invalid base64 ZIP data: %v", i, file.FileName, err)) + } + // 暂时跳过ZIP文件的详细验证,在实际导入时再进行 + continue + } + + exportData, err := w.parseWorkflowData(ctx, file.WorkflowData, format) + if err != nil { + return vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("file %d (%s): invalid %s format: %v", i, file.FileName, format, err)) + } + + // 验证工作流数据结构 + if exportData.Schema == nil { + return vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("file %d (%s): missing schema", i, file.FileName)) + } + + if exportData.Nodes == nil { + return vo.WrapError(errno.ErrInvalidParameter, fmt.Errorf("file %d (%s): missing nodes", i, file.FileName)) + } + } + + return nil +} + +// preValidateWorkflowFilesWithWarning 预验证所有工作流文件(警告模式,不阻断导入) +func (w *ApplicationService) preValidateWorkflowFilesWithWarning(ctx context.Context, files []workflow.WorkflowFileData) { + for i, file := range files { + // 根据文件名确定格式 + fileName := strings.ToLower(file.FileName) + var format string + if strings.HasSuffix(fileName, ".yml") { + format = "yml" + } else if strings.HasSuffix(fileName, ".yaml") { + format = "yaml" + } else if strings.HasSuffix(fileName, ".zip") { + format = "zip" + } else { + format = "json" + } + + // 对于ZIP文件,数据是base64编码的,需要特殊处理 + if format == "zip" { + // ZIP文件验证:检查是否为有效的base64数据 + if _, err := base64.StdEncoding.DecodeString(file.WorkflowData); err != nil { + logs.CtxWarnf(ctx, "File %d (%s): invalid base64 ZIP data: %v", i, file.FileName, err) + continue + } + // 暂时跳过ZIP文件的详细验证,在实际导入时再进行 + continue + } + + exportData, err := w.parseWorkflowData(ctx, file.WorkflowData, format) + if err != nil { + logs.CtxWarnf(ctx, "File %d (%s): invalid %s format: %v", i, file.FileName, format, err) + continue + } + + // 验证工作流数据结构 + if exportData.Schema == nil { + logs.CtxWarnf(ctx, "File %d (%s): missing schema", i, file.FileName) + continue + } + + if exportData.Nodes == nil { + logs.CtxWarnf(ctx, "File %d (%s): missing nodes", i, file.FileName) + continue + } + } +} + +// executeBatchImport 执行批量导入 +func (w *ApplicationService) executeBatchImport(ctx context.Context, req *workflow.BatchImportWorkflowRequest, config workflow.BatchImportConfig) ([]BatchImportResult, error) { + if config.ImportMode == string(workflow.BatchImportModeTransaction) { + // 事务模式:所有文件在同一事务中处理 + return w.executeBatchImportTransaction(ctx, req, config) + } else { + // 批量模式:每个文件独立处理 + return w.executeBatchImportParallel(ctx, req, config) + } +} + +// BatchImportResult 批量导入结果内部结构 +type BatchImportResult struct { + Index int + Success bool + WorkflowID string + NodeCount int + EdgeCount int + ErrorCode int64 + ErrorMessage string + FailReason workflow.FailReason +} + +// executeBatchImportParallel 并发执行批量导入 +func (w *ApplicationService) executeBatchImportParallel(ctx context.Context, req *workflow.BatchImportWorkflowRequest, config workflow.BatchImportConfig) ([]BatchImportResult, error) { + results := make([]BatchImportResult, len(req.WorkflowFiles)) + + // 使用信号量控制并发数 + sem := make(chan struct{}, config.MaxConcurrency) + var wg sync.WaitGroup + + for i, file := range req.WorkflowFiles { + wg.Add(1) + go func(index int, fileData workflow.WorkflowFileData) { + defer wg.Done() + sem <- struct{}{} // 获取信号量 + defer func() { <-sem }() // 释放信号量 + + result := w.importSingleWorkflow(ctx, fileData, req.SpaceID, req.CreatorID, "") + result.Index = index + results[index] = result + }(i, file) + } + + wg.Wait() + return results, nil +} + +// executeBatchImportTransaction 事务模式批量导入 +func (w *ApplicationService) executeBatchImportTransaction(ctx context.Context, req *workflow.BatchImportWorkflowRequest, config workflow.BatchImportConfig) ([]BatchImportResult, error) { + // 在事务中导入所有工作流 + results := make([]BatchImportResult, len(req.WorkflowFiles)) + createdWorkflowIDs := make([]string, 0) + + // 创建所有工作流 + for i, file := range req.WorkflowFiles { + result := w.importSingleWorkflow(ctx, file, req.SpaceID, req.CreatorID, "") + result.Index = i + results[i] = result + + if !result.Success { + // 如果任何一个失败,回滚所有已创建的工作流 + w.rollbackBatchCreatedWorkflows(ctx, createdWorkflowIDs) + return results, nil + } + + createdWorkflowIDs = append(createdWorkflowIDs, result.WorkflowID) + } + + return results, nil +} + +// importSingleWorkflow 导入单个工作流 +func (w *ApplicationService) importSingleWorkflow(ctx context.Context, file workflow.WorkflowFileData, spaceID, creatorID, format string) BatchImportResult { + result := BatchImportResult{Success: false} + + logs.CtxInfof(ctx, "Starting import for file: %s, workflow: %s", file.FileName, file.WorkflowName) + + // 1. 根据文件名确定格式(如果传入的format为空) + if format == "" { + fileName := strings.ToLower(file.FileName) + if strings.HasSuffix(fileName, ".yml") { + format = "yml" + } else if strings.HasSuffix(fileName, ".yaml") { + format = "yaml" + } else if strings.HasSuffix(fileName, ".zip") { + format = "zip" + } else { + format = "json" + } + } + + logs.CtxDebugf(ctx, "Detected format: %s for file: %s", format, file.FileName) + + // 2. 解析工作流数据 + exportData, err := w.parseWorkflowData(ctx, file.WorkflowData, format) + if err != nil { + result.ErrorCode = int64(errno.ErrSerializationDeserializationFail) + result.ErrorMessage = fmt.Sprintf(getLocalizedMessage(ctx, "file_parse_failed"), file.FileName, err) + result.FailReason = workflow.FailReasonInvalidFormat + logs.CtxErrorf(ctx, "Failed to parse file %s: %v", file.FileName, err) + return result + } + + // 3. 验证数据结构 + if exportData.Schema == nil || exportData.Nodes == nil { + result.ErrorCode = int64(errno.ErrInvalidParameter) + result.ErrorMessage = fmt.Sprintf(getLocalizedMessage(ctx, "file_missing_schema_nodes"), file.FileName) + result.FailReason = workflow.FailReasonInvalidData + logs.CtxErrorf(ctx, "Invalid data structure in file %s: missing schema or nodes", file.FileName) + return result + } + + logs.CtxDebugf(ctx, "File %s parsed successfully, nodes: %d, edges: %d", + file.FileName, len(exportData.Nodes), len(exportData.Edges)) + + // 4. 创建工作流 + createReq := &workflow.CreateWorkflowRequest{ + Name: file.WorkflowName, + Desc: exportData.Description, + SpaceID: spaceID, + } + + createResp, err := w.CreateWorkflow(ctx, createReq) + if err != nil { + result.ErrorCode = int64(errno.ErrWorkflowOperationFail) + result.ErrorMessage = fmt.Sprintf("文件 %s 创建工作流失败:%v", file.FileName, err) + result.FailReason = workflow.FailReasonSystemError + logs.CtxErrorf(ctx, "Failed to create workflow for file %s: %v", file.FileName, err) + return result + } + + logs.CtxDebugf(ctx, "Workflow created successfully for file %s, ID: %s", + file.FileName, createResp.Data.WorkflowID) + + // 5. 保存工作流架构 + canvasData, err := sonic.MarshalString(exportData.Schema) + if err != nil { + w.rollbackCreatedWorkflow(ctx, createResp.Data.WorkflowID) + result.ErrorCode = int64(errno.ErrSerializationDeserializationFail) + result.ErrorMessage = fmt.Sprintf("文件 %s 序列化工作流架构失败:%v", file.FileName, err) + result.FailReason = workflow.FailReasonSystemError + logs.CtxErrorf(ctx, "Failed to marshal schema for file %s: %v", file.FileName, err) + return result + } + + saveReq := &workflow.SaveWorkflowRequest{ + WorkflowID: createResp.Data.WorkflowID, + SpaceID: ptr.Of(spaceID), + Schema: ptr.Of(canvasData), + } + + _, err = w.SaveWorkflow(ctx, saveReq) + if err != nil { + w.rollbackCreatedWorkflow(ctx, createResp.Data.WorkflowID) + result.ErrorCode = int64(errno.ErrWorkflowOperationFail) + result.ErrorMessage = fmt.Sprintf("文件 %s 保存工作流架构失败:%v", file.FileName, err) + result.FailReason = workflow.FailReasonSystemError + logs.CtxErrorf(ctx, "Failed to save workflow schema for file %s: %v", file.FileName, err) + return result + } + + // 6. 成功 + result.Success = true + result.WorkflowID = createResp.Data.WorkflowID + result.NodeCount = len(exportData.Nodes) + result.EdgeCount = len(exportData.Edges) + + logs.CtxInfof(ctx, "Successfully imported file %s as workflow %s (ID: %s)", + file.FileName, file.WorkflowName, result.WorkflowID) + + return result +} + +// rollbackCreatedWorkflow 回滚单个创建的工作流 +func (w *ApplicationService) rollbackCreatedWorkflow(ctx context.Context, workflowID string) { + if workflowID == "" { + return + } + + id, err := strconv.ParseInt(workflowID, 10, 64) + if err != nil { + logs.CtxErrorf(ctx, "Failed to parse workflow ID for rollback: %s, error: %v", workflowID, err) + return + } + + if _, err := w.DomainSVC.Delete(ctx, &vo.DeletePolicy{ID: &id}); err != nil { + logs.CtxErrorf(ctx, "Failed to rollback workflow creation: %d, error: %v", id, err) + } else { + logs.CtxInfof(ctx, "Successfully rolled back workflow creation: %d", id) + } +} + +// rollbackBatchCreatedWorkflows 回滚批量创建的工作流 +func (w *ApplicationService) rollbackBatchCreatedWorkflows(ctx context.Context, workflowIDs []string) { + for _, workflowID := range workflowIDs { + w.rollbackCreatedWorkflow(ctx, workflowID) + } +} + +// buildBatchImportResponse 构建批量导入响应 +func (w *ApplicationService) buildBatchImportResponse(results []BatchImportResult, startTime, endTime time.Time, config workflow.BatchImportConfig, files []workflow.WorkflowFileData) *workflow.BatchImportWorkflowResponse { + successList := make([]workflow.WorkflowImportResult, 0) + failedList := make([]workflow.WorkflowImportFailedResult, 0) + errorStats := make(map[string]int) + + totalNodes := 0 + totalEdges := 0 + totalSize := int64(0) + nodeTypes := make(map[string]bool) + + for _, result := range results { + file := files[result.Index] + + if result.Success { + successList = append(successList, workflow.WorkflowImportResult{ + FileName: file.FileName, + WorkflowName: file.WorkflowName, + WorkflowID: result.WorkflowID, + NodeCount: result.NodeCount, + EdgeCount: result.EdgeCount, + }) + totalNodes += result.NodeCount + totalEdges += result.EdgeCount + } else { + failedList = append(failedList, workflow.WorkflowImportFailedResult{ + FileName: file.FileName, + WorkflowName: file.WorkflowName, + ErrorCode: result.ErrorCode, + ErrorMessage: result.ErrorMessage, + FailReason: string(result.FailReason), + }) + errorStats[string(result.FailReason)]++ + } + + totalSize += int64(len(file.WorkflowData)) + } + + // 构建资源信息 + resourceInfo := workflow.BatchImportResourceInfo{ + TotalFiles: len(files), + TotalSize: totalSize, + TotalNodes: totalNodes, + TotalEdges: totalEdges, + UniqueNodeTypes: make([]string, 0, len(nodeTypes)), + } + + for nodeType := range nodeTypes { + resourceInfo.UniqueNodeTypes = append(resourceInfo.UniqueNodeTypes, nodeType) + } + + // 构建导入摘要 + summary := workflow.ImportSummary{ + StartTime: startTime.Unix(), + EndTime: endTime.Unix(), + Duration: endTime.Sub(startTime).Milliseconds(), + ErrorStats: errorStats, + ImportConfig: config, + ResourceInfo: resourceInfo, + } + + return &workflow.BatchImportWorkflowResponse{ + Code: 200, + Msg: "success", + Data: workflow.BatchImportResponseData{ + TotalCount: len(results), + SuccessCount: len(successList), + FailedCount: len(failedList), + SuccessList: successList, + FailedList: failedList, + ImportSummary: summary, + }, + } +} diff --git a/backend/domain/workflow/internal/nodes/convert.go b/backend/domain/workflow/internal/nodes/convert.go index 6d549261cd..f3c8cb2fd3 100644 --- a/backend/domain/workflow/internal/nodes/convert.go +++ b/backend/domain/workflow/internal/nodes/convert.go @@ -26,6 +26,7 @@ import ( "strings" workflowModel "github.com/coze-dev/coze-studio/backend/api/model/crossdomain/workflow" + "github.com/coze-dev/coze-studio/backend/domain/workflow/entity/vo" "github.com/coze-dev/coze-studio/backend/pkg/errorx" "github.com/coze-dev/coze-studio/backend/pkg/lang/ptr" diff --git a/backend/domain/workflow/internal/repo/repository.go b/backend/domain/workflow/internal/repo/repository.go index 8b98c4db76..cb44cc707d 100644 --- a/backend/domain/workflow/internal/repo/repository.go +++ b/backend/domain/workflow/internal/repo/repository.go @@ -444,9 +444,15 @@ func (r *RepositoryImpl) GetMeta(ctx context.Context, id int64) (_ *vo.Meta, err } func (r *RepositoryImpl) convertMeta(ctx context.Context, meta *model.WorkflowMeta) (*vo.Meta, error) { - url, err := r.tos.GetObjectUrl(ctx, meta.IconURI) - if err != nil { - logs.Warnf("failed to get url for workflow meta %v", err) + var url string + var err error + + // 只有当 IconURI 不为空时才调用 GetObjectUrl + if meta.IconURI != "" { + url, err = r.tos.GetObjectUrl(ctx, meta.IconURI) + if err != nil { + logs.Warnf("failed to get url for workflow meta %v", err) + } } // Initialize the result entity wfMeta := &vo.Meta{ diff --git a/backend/infra/impl/es/es7.go b/backend/infra/impl/es/es7.go index a8e0045126..3d8e39a680 100644 --- a/backend/infra/impl/es/es7.go +++ b/backend/infra/impl/es/es7.go @@ -28,6 +28,8 @@ import ( "github.com/elastic/go-elasticsearch/v7/esapi" "github.com/elastic/go-elasticsearch/v7/esutil" + "github.com/coze-dev/coze-studio/backend/pkg/parsex" + "github.com/coze-dev/coze-studio/backend/infra/contract/es" "github.com/coze-dev/coze-studio/backend/pkg/lang/conv" "github.com/coze-dev/coze-studio/backend/pkg/lang/ptr" diff --git a/backend/infra/impl/es/es8.go b/backend/infra/impl/es/es8.go index dd5c34ad10..35cb869dff 100644 --- a/backend/infra/impl/es/es8.go +++ b/backend/infra/impl/es/es8.go @@ -32,6 +32,8 @@ import ( "github.com/elastic/go-elasticsearch/v8/typedapi/types/enums/sortorder" "github.com/elastic/go-elasticsearch/v8/typedapi/types/enums/textquerytype" + "github.com/coze-dev/coze-studio/backend/pkg/parsex" + "github.com/coze-dev/coze-studio/backend/infra/contract/es" "github.com/coze-dev/coze-studio/backend/pkg/lang/conv" "github.com/coze-dev/coze-studio/backend/pkg/lang/ptr" diff --git a/common/autoinstallers/plugins/pnpm-lock.yaml b/common/autoinstallers/plugins/pnpm-lock.yaml index b4c0b61f2e..0178fddad8 100644 --- a/common/autoinstallers/plugins/pnpm-lock.yaml +++ b/common/autoinstallers/plugins/pnpm-lock.yaml @@ -10,7 +10,7 @@ dependencies: version: 0.0.1-alpha.a8d5b5 '@coze-arch/rush-dep-level-check-plugin': specifier: 0.0.1-alpha.f89072 - version: 0.0.1-alpha.f89072(@types/node@18.19.112) + version: 0.0.1-alpha.f89072(@types/node@18.19.124) '@coze-arch/rush-fix-ts-refers-plugin': specifier: alpha version: 0.0.1-alpha.bf1426(commander@13.1.0) @@ -19,7 +19,7 @@ dependencies: version: 0.0.2-alpha.88b122(commander@13.1.0)(stylelint@15.11.0) '@coze-arch/rush-publish-plugin': specifier: alpha - version: 0.0.3-alpha.f72758(@types/node@18.19.112)(commander@13.1.0) + version: 0.0.5-alpha.0f1107(@types/node@18.19.124)(commander@13.1.0) '@coze-arch/rush-run-tsc-plugin': specifier: 0.0.1-alpha.5f6259 version: 0.0.1-alpha.5f6259 @@ -28,7 +28,7 @@ dependencies: version: 2.2.3 rush-init-project-plugin: specifier: ^0.11.0 - version: 0.11.1(@types/node@18.19.112)(typescript@5.8.3) + version: 0.11.1(@types/node@18.19.124)(typescript@5.8.3) typescript: specifier: ~5.8.2 version: 5.8.3 @@ -36,7 +36,7 @@ dependencies: devDependencies: '@types/node': specifier: ^18.6.3 - version: 18.19.112 + version: 18.19.124 packages: @@ -78,11 +78,11 @@ packages: engines: {node: '>=6.9.0'} dev: false - /@babel/runtime-corejs3@7.27.6: - resolution: {integrity: sha512-vDVrlmRAY8z9Ul/HxT+8ceAru95LQgkSKiXkSYZvqtbkPSfhZJgpRp45Cldbh1GJ1kxzQkI70AqyrTI58KpaWQ==} + /@babel/runtime-corejs3@7.28.4: + resolution: {integrity: sha512-h7iEYiW4HebClDEhtvFObtPmIvrd1SSfpI9EhOeKk4CtIK/ngBWFpuhCzhdmRKtg71ylcue+9I6dv54XYO1epQ==} engines: {node: '>=6.9.0'} dependencies: - core-js-pure: 3.43.0 + core-js-pure: 3.45.1 dev: false /@colors/colors@1.5.0: @@ -99,10 +99,10 @@ packages: json5: 2.2.3 dev: false - /@coze-arch/rush-dep-level-check-plugin@0.0.1-alpha.f89072(@types/node@18.19.112): + /@coze-arch/rush-dep-level-check-plugin@0.0.1-alpha.f89072(@types/node@18.19.124): resolution: {integrity: sha512-Qswnf+V32cGXspdHVn11krJ8NF85yh+ImL9VcQV3KZd/mbdrFwBRzO7Ea1ZncHZeprVjdA+5yYi1XBGnLteeYw==} dependencies: - '@rushstack/rush-sdk': 5.155.0(@types/node@18.19.112) + '@rushstack/rush-sdk': 5.158.1(@types/node@18.19.124) transitivePeerDependencies: - '@types/node' dev: false @@ -114,7 +114,7 @@ packages: commander: ^13.1.0 dependencies: commander: 13.1.0 - prettier: 3.5.3 + prettier: 3.6.2 shelljs: 0.9.2 dev: false @@ -132,19 +132,19 @@ packages: stylelint: 15.11.0(typescript@5.8.3) dev: false - /@coze-arch/rush-publish-plugin@0.0.3-alpha.f72758(@types/node@18.19.112)(commander@13.1.0): - resolution: {integrity: sha512-Uzy2k+TSyS4ggH1wRxaNtNpwjc8AeqJMKNSWaJIJ3pyz3IBZjltjEftESMiTQ3nZlGF5Q+XJmcz3Zs5pOiHy3w==} + /@coze-arch/rush-publish-plugin@0.0.5-alpha.0f1107(@types/node@18.19.124)(commander@13.1.0): + resolution: {integrity: sha512-U3+ctXQQlwDpTJGWtumWoETOkj1T3rykn9GjbhFhcKxefTXh0taU/sF/5XU+lEDAD+VHjly8zTGbrFwxgU/v+A==} hasBin: true peerDependencies: commander: ^13.1.0 dependencies: '@inquirer/prompts': 3.3.2 - '@rushstack/rush-sdk': 5.155.0(@types/node@18.19.112) + '@rushstack/rush-sdk': 5.158.1(@types/node@18.19.124) chalk: 4.1.2 commander: 13.1.0 conventional-changelog-angular: 5.0.13 conventional-commits-parser: 3.2.4 - dayjs: 1.11.13 + dayjs: 1.11.18 open: 10.1.2 semver: 7.7.2 shelljs: 0.9.2 @@ -228,7 +228,7 @@ packages: dependencies: '@inquirer/type': 1.5.5 '@types/mute-stream': 0.0.4 - '@types/node': 20.19.1 + '@types/node': 20.19.13 '@types/wrap-ansi': 3.0.0 ansi-escapes: 4.3.2 chalk: 4.1.2 @@ -262,6 +262,20 @@ packages: figures: 3.2.0 dev: false + /@inquirer/external-editor@1.0.1(@types/node@18.19.124): + resolution: {integrity: sha512-Oau4yL24d2B5IL4ma4UpbQigkVhzPDXLoqy1ggK4gnHg/stmkffJE4oOXHXF3uz0UEpywG68KcyXsyYpA1Re/Q==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + dependencies: + '@types/node': 18.19.124 + chardet: 2.1.0 + iconv-lite: 0.6.3 + dev: false + /@inquirer/input@1.2.16: resolution: {integrity: sha512-Ou0LaSWvj1ni+egnyQ+NBtfM1885UwhRCMtsRt2bBO47DoC1dwtCa+ZUNgrxlnCHHF0IXsbQHYtIIjFGAavI4g==} engines: {node: '>=14.18.0'} @@ -323,20 +337,32 @@ packages: mute-stream: 1.0.0 dev: false + /@isaacs/balanced-match@4.0.1: + resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} + engines: {node: 20 || >=22} + dev: false + + /@isaacs/brace-expansion@5.0.0: + resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} + engines: {node: 20 || >=22} + dependencies: + '@isaacs/balanced-match': 4.0.1 + dev: false + /@jridgewell/resolve-uri@3.1.2: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} dev: false - /@jridgewell/sourcemap-codec@1.5.0: - resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + /@jridgewell/sourcemap-codec@1.5.5: + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} dev: false /@jridgewell/trace-mapping@0.3.9: resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 dev: false /@nodelib/fs.scandir@2.1.5: @@ -378,15 +404,15 @@ packages: engines: {node: '>=18.12'} dev: false - /@rushstack/lookup-by-path@0.7.0(@types/node@18.19.112): - resolution: {integrity: sha512-nAFnkW8ortx7RI9CCPfArCBmj0/YkAqTS9E9poxstIlzdlznIMgUysRd3NV+aSCfAAI5+XThWA+9gWGtYqhQPg==} + /@rushstack/lookup-by-path@0.7.4(@types/node@18.19.124): + resolution: {integrity: sha512-8dRUCWMV5cxDLSEkewQ100YYZe7wExGofXnJvjs9mGH3GBvOpqzLD+/PhtI1J0iAX/vpjS+5r+tEfqm8xjYT5A==} peerDependencies: '@types/node': '*' peerDependenciesMeta: '@types/node': optional: true dependencies: - '@types/node': 18.19.112 + '@types/node': 18.19.124 dev: false /@rushstack/node-core-library@3.45.0: @@ -403,7 +429,7 @@ packages: z-schema: 5.0.5 dev: false - /@rushstack/node-core-library@3.62.0(@types/node@18.19.112): + /@rushstack/node-core-library@3.62.0(@types/node@18.19.124): resolution: {integrity: sha512-88aJn2h8UpSvdwuDXBv1/v1heM6GnBf3RjEy6ZPP7UnzHNCqOHA2Ut+ScYUbXcqIdfew9JlTAe3g+cnX9xQ/Aw==} peerDependencies: '@types/node': '*' @@ -411,7 +437,7 @@ packages: '@types/node': optional: true dependencies: - '@types/node': 18.19.112 + '@types/node': 18.19.124 colors: 1.2.5 fs-extra: 7.0.1 import-lazy: 4.0.0 @@ -421,41 +447,41 @@ packages: z-schema: 5.0.5 dev: false - /@rushstack/node-core-library@5.13.1(@types/node@18.19.112): - resolution: {integrity: sha512-5yXhzPFGEkVc9Fu92wsNJ9jlvdwz4RNb2bMso+/+TH0nMm1jDDDsOIf4l8GAkPxGuwPw5DH24RliWVfSPhlW/Q==} + /@rushstack/node-core-library@5.14.0(@types/node@18.19.124): + resolution: {integrity: sha512-eRong84/rwQUlATGFW3TMTYVyqL1vfW9Lf10PH+mVGfIb9HzU3h5AASNIw+axnBLjnD0n3rT5uQBwu9fvzATrg==} peerDependencies: '@types/node': '*' peerDependenciesMeta: '@types/node': optional: true dependencies: - '@types/node': 18.19.112 + '@types/node': 18.19.124 ajv: 8.13.0 ajv-draft-04: 1.0.0(ajv@8.13.0) ajv-formats: 3.0.1(ajv@8.13.0) - fs-extra: 11.3.0 + fs-extra: 11.3.1 import-lazy: 4.0.0 jju: 1.4.0 resolve: 1.22.10 semver: 7.5.4 dev: false - /@rushstack/package-deps-hash@4.4.1(@types/node@18.19.112): - resolution: {integrity: sha512-gu1YvwqripXR/27tWq7ekX2jet7BGFUA5TrLBa2/MikChYhY7AkL/dzP56ZECRgwffMMM+ArfQR3HMtzowyOQw==} + /@rushstack/package-deps-hash@4.4.5(@types/node@18.19.124): + resolution: {integrity: sha512-BcFAtlyfbuI1JOOUpfLUlB8N33tHUYbqYTZULMGpVnQiAPPFtQLgty4fKqO3gOWirMP+P/vOVkUAqLQ+17+qMg==} dependencies: - '@rushstack/node-core-library': 5.13.1(@types/node@18.19.112) + '@rushstack/node-core-library': 5.14.0(@types/node@18.19.124) transitivePeerDependencies: - '@types/node' dev: false - /@rushstack/rush-sdk@5.155.0(@types/node@18.19.112): - resolution: {integrity: sha512-6a8nKDG2x3PaakjTv+2Pzg6BwYNMnkaCBB0H66mwYRVnoarAdYjmKSvWUyOb2CNfR8t7lpm4m1ii0h8ZIWe4RQ==} + /@rushstack/rush-sdk@5.158.1(@types/node@18.19.124): + resolution: {integrity: sha512-4W+5NfGOU3mu5DTXz7/DqHFQFtLvhalzHd5ku5h1/+pbNfUucFKMdR4yHk0BKTQkAaGWKaFXDSYICK//LxyX9w==} dependencies: '@pnpm/lockfile.types': 1.0.3 - '@rushstack/lookup-by-path': 0.7.0(@types/node@18.19.112) - '@rushstack/node-core-library': 5.13.1(@types/node@18.19.112) - '@rushstack/package-deps-hash': 4.4.1(@types/node@18.19.112) - '@rushstack/terminal': 0.15.3(@types/node@18.19.112) + '@rushstack/lookup-by-path': 0.7.4(@types/node@18.19.124) + '@rushstack/node-core-library': 5.14.0(@types/node@18.19.124) + '@rushstack/package-deps-hash': 4.4.5(@types/node@18.19.124) + '@rushstack/terminal': 0.15.4(@types/node@18.19.124) tapable: 2.2.1 transitivePeerDependencies: - '@types/node' @@ -469,21 +495,21 @@ packages: tapable: 2.2.1 dev: false - /@rushstack/terminal@0.15.3(@types/node@18.19.112): - resolution: {integrity: sha512-DGJ0B2Vm69468kZCJkPj3AH5nN+nR9SPmC0rFHtzsS4lBQ7/dgOwtwVxYP7W9JPDMuRBkJ4KHmWKr036eJsj9g==} + /@rushstack/terminal@0.15.4(@types/node@18.19.124): + resolution: {integrity: sha512-OQSThV0itlwVNHV6thoXiAYZlQh4Fgvie2CzxFABsbO2MWQsI4zOh3LRNigYSTrmS+ba2j0B3EObakPzf/x6Zg==} peerDependencies: '@types/node': '*' peerDependenciesMeta: '@types/node': optional: true dependencies: - '@rushstack/node-core-library': 5.13.1(@types/node@18.19.112) - '@types/node': 18.19.112 + '@rushstack/node-core-library': 5.14.0(@types/node@18.19.124) + '@types/node': 18.19.124 supports-color: 8.1.1 dev: false - /@tsconfig/node14@14.1.4: - resolution: {integrity: sha512-S5G+j19uQt/7wiXFqGapQ1cSYHlAoxYreObGkOmawAknegyUjx8EXngqy6vAitLHqCFu3rzluPlk77pWMD4n1Q==} + /@tsconfig/node14@14.1.5: + resolution: {integrity: sha512-hsldDMdbjF18BgvqFX6rHwqb0wlDh4lxyXmo3VATa7LwL4AFrHijv8Or9ySXBSg9TyysRkldJyAC/kplyF/Mmg==} dev: false /@tsconfig/node16@16.1.4: @@ -501,14 +527,14 @@ packages: /@types/glob@7.2.0: resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} dependencies: - '@types/minimatch': 5.1.2 - '@types/node': 18.19.112 + '@types/minimatch': 6.0.0 + '@types/node': 18.19.124 dev: false /@types/inquirer-autocomplete-prompt@3.0.3: resolution: {integrity: sha512-OQCW09mEECgvhcppbQRgZSmWskWv58l+WwyUvWB1oxTu3CZj8keYSDZR9U8owUzJ5Zeux5kacN9iVPJLXcoLXg==} dependencies: - '@types/inquirer': 9.0.8 + '@types/inquirer': 9.0.9 dev: false /@types/inquirer@6.5.0: @@ -518,15 +544,18 @@ packages: rxjs: 6.6.7 dev: false - /@types/inquirer@9.0.8: - resolution: {integrity: sha512-CgPD5kFGWsb8HJ5K7rfWlifao87m4ph8uioU7OTncJevmE/VLIqAAjfQtko578JZg7/f69K4FgqYym3gNr7DeA==} + /@types/inquirer@9.0.9: + resolution: {integrity: sha512-/mWx5136gts2Z2e5izdoRCo46lPp5TMs9R15GTSsgg/XnZyxDWVqoVU3R9lWnccKpqwsJLvRoxbCjoJtZB7DSw==} dependencies: '@types/through': 0.0.33 rxjs: 7.8.2 dev: false - /@types/minimatch@5.1.2: - resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} + /@types/minimatch@6.0.0: + resolution: {integrity: sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA==} + deprecated: This is a stub types definition. minimatch provides its own type definitions, so you do not need this installed. + dependencies: + minimatch: 10.0.3 dev: false /@types/minimist@1.2.5: @@ -536,26 +565,26 @@ packages: /@types/mute-stream@0.0.4: resolution: {integrity: sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==} dependencies: - '@types/node': 18.19.112 + '@types/node': 18.19.124 dev: false /@types/node-fetch@1.6.9: resolution: {integrity: sha512-n2r6WLoY7+uuPT7pnEtKJCmPUGyJ+cbyBR8Avnu4+m1nzz7DwBVuyIvvlBzCZ/nrpC7rIgb3D6pNavL7rFEa9g==} dependencies: - '@types/node': 18.19.112 + '@types/node': 18.19.124 dev: false /@types/node@12.20.24: resolution: {integrity: sha512-yxDeaQIAJlMav7fH5AQqPH1u8YIuhYJXYBzxaQ4PifsU0GDO38MSdmEDeRlIxrKbC6NbEaaEHDanWb+y30U8SQ==} dev: false - /@types/node@18.19.112: - resolution: {integrity: sha512-i+Vukt9POdS/MBI7YrrkkI5fMfwFtOjphSmt4WXYLfwqsfr6z/HdCx7LqT9M7JktGob8WNgj8nFB4TbGNE4Cog==} + /@types/node@18.19.124: + resolution: {integrity: sha512-hY4YWZFLs3ku6D2Gqo3RchTd9VRCcrjqp/I0mmohYeUVA5Y8eCXKJEasHxLAJVZRJuQogfd1GiJ9lgogBgKeuQ==} dependencies: undici-types: 5.26.5 - /@types/node@20.19.1: - resolution: {integrity: sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA==} + /@types/node@20.19.13: + resolution: {integrity: sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==} dependencies: undici-types: 6.21.0 dev: false @@ -567,7 +596,7 @@ packages: /@types/through@0.0.33: resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==} dependencies: - '@types/node': 18.19.112 + '@types/node': 18.19.124 dev: false /@types/wrap-ansi@3.0.0: @@ -642,7 +671,7 @@ packages: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.0.6 + fast-uri: 3.1.0 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 dev: false @@ -1206,8 +1235,8 @@ packages: supports-color: 7.2.0 dev: false - /chalk@5.4.1: - resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} + /chalk@5.6.0: + resolution: {integrity: sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} dev: false @@ -1238,6 +1267,10 @@ packages: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} dev: false + /chardet@2.1.0: + resolution: {integrity: sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==} + dev: false + /charm@0.1.2: resolution: {integrity: sha512-syedaZ9cPe7r3hoQA9twWYKu5AIyCswN5+szkmPBe9ccdLrj4bYaCnLVPTLd2kgVRc7+zoX4tyPgRnFKCj5YjQ==} dev: false @@ -1385,8 +1418,8 @@ packages: engines: {node: '>=0.10.0'} dev: false - /core-js-pure@3.43.0: - resolution: {integrity: sha512-i/AgxU2+A+BbJdMxh3v7/vxi2SbFqxiFmg6VsDwYB4jkucrd1BZNA9a9gphC0fYMG5IBSgQcbQnk865VCLe7xA==} + /core-js-pure@3.45.1: + resolution: {integrity: sha512-OHnWFKgTUshEU8MK+lOs1H8kC8GkTi9Z1tvNkxrCcw9wl3MJIO7q2ld77wjWn4/xuGrVu2X+nME1iIIPBSdyEQ==} requiresBuild: true dev: false @@ -1458,8 +1491,8 @@ packages: - supports-color dev: false - /dayjs@1.11.13: - resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} + /dayjs@1.11.18: + resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==} dev: false /debug@2.6.9: @@ -1799,8 +1832,8 @@ packages: micromatch: 4.0.8 dev: false - /fast-uri@3.0.6: - resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} + /fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} dev: false /fastest-levenshtein@1.0.16: @@ -1898,12 +1931,12 @@ packages: engines: {node: '>=0.10.0'} dev: false - /fs-extra@11.3.0: - resolution: {integrity: sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==} + /fs-extra@11.3.1: + resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==} engines: {node: '>=14.14'} dependencies: graceful-fs: 4.2.11 - jsonfile: 6.1.0 + jsonfile: 6.2.0 universalify: 2.0.1 dev: false @@ -2283,6 +2316,13 @@ packages: safer-buffer: 2.1.2 dev: false + /iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + dependencies: + safer-buffer: 2.1.2 + dev: false + /ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} dev: false @@ -2341,7 +2381,7 @@ packages: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} dev: false - /inquirer-autocomplete-prompt@1.4.0(inquirer@8.2.6): + /inquirer-autocomplete-prompt@1.4.0(inquirer@8.2.7): resolution: {integrity: sha512-qHgHyJmbULt4hI+kCmwX92MnSxDs/Yhdt4wPA30qnoa01OF6uTXV8yvH4hKXgdaTNmkZ9D01MHjqKYEuJN+ONw==} engines: {node: '>=10'} peerDependencies: @@ -2350,7 +2390,7 @@ packages: ansi-escapes: 4.3.2 chalk: 4.1.2 figures: 3.2.0 - inquirer: 8.2.6 + inquirer: 8.2.7(@types/node@18.19.124) run-async: 2.4.1 rxjs: 6.6.7 dev: false @@ -2374,15 +2414,15 @@ packages: through: 2.3.8 dev: false - /inquirer@8.2.6: - resolution: {integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==} + /inquirer@8.2.7(@types/node@18.19.124): + resolution: {integrity: sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==} engines: {node: '>=12.0.0'} dependencies: + '@inquirer/external-editor': 1.0.1(@types/node@18.19.124) ansi-escapes: 4.3.2 chalk: 4.1.2 cli-cursor: 3.1.0 cli-width: 3.0.0 - external-editor: 3.1.0 figures: 3.2.0 lodash: 4.17.21 mute-stream: 0.0.8 @@ -2393,6 +2433,8 @@ packages: strip-ansi: 6.0.1 through: 2.3.8 wrap-ansi: 6.2.0 + transitivePeerDependencies: + - '@types/node' dev: false /interpret@1.4.0: @@ -2699,8 +2741,8 @@ packages: graceful-fs: 4.2.11 dev: false - /jsonfile@6.1.0: - resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + /jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} dependencies: universalify: 2.0.1 optionalDependencies: @@ -2907,7 +2949,7 @@ packages: dependencies: ansi-escapes: 6.2.1 cardinal: 2.1.1 - chalk: 5.4.1 + chalk: 5.6.0 cli-table3: 0.6.5 marked: 4.3.0 node-emoji: 1.11.0 @@ -3023,6 +3065,13 @@ packages: engines: {node: '>=4'} dev: false + /minimatch@10.0.3: + resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==} + engines: {node: 20 || >=22} + dependencies: + '@isaacs/brace-expansion': 5.0.0 + dev: false + /minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: @@ -3127,7 +3176,7 @@ packages: resolution: {integrity: sha512-Cov028YhBZ5aB7MdMWJEmwyBig43aGL5WT4vdoB28Oitau1zZAcHUn8Sgfk9HM33TqhtLJ9PlM/O0Mv+QpV/4Q==} engines: {node: '>=8.9.4'} dependencies: - '@babel/runtime-corejs3': 7.27.6 + '@babel/runtime-corejs3': 7.28.4 '@types/inquirer': 6.5.0 change-case: 3.1.0 del: 5.1.0 @@ -3431,8 +3480,8 @@ packages: source-map-js: 1.2.1 dev: false - /prettier@3.5.3: - resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==} + /prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} engines: {node: '>=14'} hasBin: true dev: false @@ -3694,11 +3743,11 @@ packages: queue-microtask: 1.2.3 dev: false - /rush-init-project-plugin@0.11.1(@types/node@18.19.112)(typescript@5.8.3): + /rush-init-project-plugin@0.11.1(@types/node@18.19.124)(typescript@5.8.3): resolution: {integrity: sha512-mhgYIxj3HDZJD9PTwzBC4k+TStjjawEME7mlHFwMHj8jQsd7vWioQ9iA4uudi3pwrfIM8Ru3cm+pDAy6b3+eIA==} hasBin: true dependencies: - '@rushstack/node-core-library': 3.62.0(@types/node@18.19.112) + '@rushstack/node-core-library': 3.62.0(@types/node@18.19.124) '@rushstack/rush-sdk': 5.62.4 '@types/inquirer-autocomplete-prompt': 3.0.3 blessed: 0.1.81 @@ -3706,15 +3755,15 @@ packages: chalk: 4.1.2 commander: 9.4.1 handlebars-helpers: 0.10.0 - inquirer: 8.2.6 - inquirer-autocomplete-prompt: 1.4.0(inquirer@8.2.6) + inquirer: 8.2.7(@types/node@18.19.124) + inquirer-autocomplete-prompt: 1.4.0(inquirer@8.2.7) lilconfig: 2.0.6 lodash: 4.17.21 node-plop: 0.26.3 ora: 5.4.1 sort-package-json: 1.54.0 - tapable: 2.2.2 - ts-node: 11.0.0-beta.1(@types/node@18.19.112)(typescript@5.8.3) + tapable: 2.2.3 + ts-node: 11.0.0-beta.1(@types/node@18.19.124)(typescript@5.8.3) validate-npm-package-name: 3.0.0 transitivePeerDependencies: - '@swc/core' @@ -3968,7 +4017,7 @@ packages: resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} dependencies: spdx-expression-parse: 3.0.1 - spdx-license-ids: 3.0.21 + spdx-license-ids: 3.0.22 dev: false /spdx-exceptions@2.5.0: @@ -3979,11 +4028,11 @@ packages: resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} dependencies: spdx-exceptions: 2.5.0 - spdx-license-ids: 3.0.21 + spdx-license-ids: 3.0.22 dev: false - /spdx-license-ids@3.0.21: - resolution: {integrity: sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==} + /spdx-license-ids@3.0.22: + resolution: {integrity: sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==} dev: false /split-string@3.1.0: @@ -4199,8 +4248,8 @@ packages: engines: {node: '>=6'} dev: false - /tapable@2.2.2: - resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==} + /tapable@2.2.3: + resolution: {integrity: sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==} engines: {node: '>=6'} dev: false @@ -4300,7 +4349,7 @@ packages: engines: {node: '>=12'} dev: false - /ts-node@11.0.0-beta.1(@types/node@18.19.112)(typescript@5.8.3): + /ts-node@11.0.0-beta.1(@types/node@18.19.124)(typescript@5.8.3): resolution: {integrity: sha512-WMSROP+1pU22Q/Tm40mjfRg130yD8i0g6ROST04ZpocfH8sl1zD75ON4XQMcBEVViXMVemJBH0alflE7xePdRA==} hasBin: true peerDependencies: @@ -4315,11 +4364,11 @@ packages: optional: true dependencies: '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node14': 14.1.4 + '@tsconfig/node14': 14.1.5 '@tsconfig/node16': 16.1.4 '@tsconfig/node18': 18.2.4 '@tsconfig/node20': 20.1.6 - '@types/node': 18.19.112 + '@types/node': 18.19.124 acorn: 8.15.0 acorn-walk: 8.3.4 arg: 4.1.3 diff --git a/common/autoinstallers/plugins/rush-plugins/@coze-arch/rush-publish-plugin/@coze-arch/rush-publish-plugin/command-line.json b/common/autoinstallers/plugins/rush-plugins/@coze-arch/rush-publish-plugin/@coze-arch/rush-publish-plugin/command-line.json index c6acc22e2f..1610f548ae 100644 --- a/common/autoinstallers/plugins/rush-plugins/@coze-arch/rush-publish-plugin/@coze-arch/rush-publish-plugin/command-line.json +++ b/common/autoinstallers/plugins/rush-plugins/@coze-arch/rush-publish-plugin/@coze-arch/rush-publish-plugin/command-line.json @@ -34,7 +34,7 @@ "required": false }, { - "parameterKind": "string", + "parameterKind": "stringList", "shortName": "-t", "longName": "--to", "description": "Publish specified packages and their downstream dependencies", @@ -43,7 +43,7 @@ "required": false }, { - "parameterKind": "string", + "parameterKind": "stringList", "shortName": "-f", "longName": "--from", "description": "Publish specified packages and their upstream/downstream dependencies", @@ -52,7 +52,7 @@ "required": false }, { - "parameterKind": "string", + "parameterKind": "stringList", "shortName": "-o", "longName": "--only", "description": "Only publish specified packages", @@ -135,6 +135,14 @@ "shortName": "-c", "description": "Git commit hash", "associatedCommands": ["release"] + }, + { + "parameterKind": "string", + "argumentName": "REGISTRY", + "longName": "--registry", + "shortName": "-r", + "description": "Registry", + "associatedCommands": ["release"] } ] } diff --git a/common/config/rush/command-line.json b/common/config/rush/command-line.json index ed2f476265..5b1d799d7f 100644 --- a/common/config/rush/command-line.json +++ b/common/config/rush/command-line.json @@ -7,7 +7,7 @@ "summary": "⭐️️ Use to run some task before commit", "safeForSimultaneousRushProcesses": true, "autoinstallerName": "rush-lint-staged", - "shellCommand": "lint-staged --config common/autoinstallers/rush-lint-staged/.lintstagedrc.js --shell '/bin/bash' --concurrent 8" + "shellCommand": "lint-staged --config common/autoinstallers/rush-lint-staged/.lintstagedrc.js --concurrent 8" }, { "commandKind": "bulk", diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index f58063eda7..7e40c45f75 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -120,6 +120,9 @@ importers: '@coze-workflow/playground-adapter': specifier: workspace:* version: link:../../packages/workflow/adapter/playground + js-yaml: + specifier: ^4.1.0 + version: 4.1.0 path-browserify: specifier: ^1.0.1 version: 1.0.1 @@ -193,6 +196,9 @@ importers: '@rspack/core': specifier: '>=0.7' version: 1.3.15 + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 '@types/node': specifier: 18.18.9 version: 18.18.9 @@ -5192,6 +5198,9 @@ importers: '@coze-studio/components': specifier: workspace:* version: link:../../studio/components + axios: + specifier: ^1.4.0 + version: 1.10.0(debug@4.3.3) classnames: specifier: ^2.3.2 version: 2.5.1 @@ -21588,6 +21597,9 @@ importers: immer: specifier: ^10.0.3 version: 10.1.1 + js-yaml: + specifier: ^4.1.0 + version: 4.1.0 lodash-es: specifier: ^4.17.21 version: 4.17.21 @@ -21646,6 +21658,9 @@ importers: '@testing-library/react-hooks': specifier: ^8.0.1 version: 8.0.1(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 '@types/lodash-es': specifier: ^4.17.10 version: 4.17.12 @@ -35446,6 +35461,10 @@ packages: /@types/js-cookie@2.2.7: resolution: {integrity: sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==} + /@types/js-yaml@4.0.9: + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + dev: true + /@types/json-bigint@1.0.4: resolution: {integrity: sha512-ydHooXLbOmxBbubnA7Eh+RpBzuaIiQjh8WGJYQB50JFGFrdxW7JzVlyEV7fAXw0T2sqJ1ysTneJbiyNLqZRAag==} dev: true diff --git a/frontend/apps/coze-studio/package.json b/frontend/apps/coze-studio/package.json index fa76827512..86a9f0429b 100644 --- a/frontend/apps/coze-studio/package.json +++ b/frontend/apps/coze-studio/package.json @@ -42,6 +42,7 @@ "@coze-studio/workspace-adapter": "workspace:*", "@coze-studio/workspace-base": "workspace:*", "@coze-workflow/playground-adapter": "workspace:*", + "js-yaml": "^4.1.0", "path-browserify": "^1.0.1", "react": "~18.2.0", "react-dom": "~18.2.0", @@ -68,6 +69,7 @@ "@rsbuild/core": "~1.1.0", "@rsdoctor/rspack-plugin": "1.0.0-rc.0", "@rspack/core": ">=0.7", + "@types/js-yaml": "^4.0.9", "@types/node": "18.18.9", "@types/react": "18.2.37", "@types/react-dom": "18.2.15", diff --git a/frontend/apps/coze-studio/src/pages/workflow-import.tsx b/frontend/apps/coze-studio/src/pages/workflow-import.tsx new file mode 100644 index 0000000000..e866c045ad --- /dev/null +++ b/frontend/apps/coze-studio/src/pages/workflow-import.tsx @@ -0,0 +1,132 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useNavigate, useParams } from 'react-router-dom'; +import React, { useState } from 'react'; + +import { createDragHandlers } from './workflow-import/utils/drag-handlers'; +import { createImportHandlers } from './workflow-import/utils/component-handlers'; +import { useImportHandler } from './workflow-import/hooks/use-import-handler'; +import { useFileProcessor } from './workflow-import/hooks/use-file-processor'; +import ImportResultModal from './workflow-import/components/ImportResultModal'; +import ImportForm from './workflow-import/components/ImportForm'; + +interface WorkflowImportProps { + visible: boolean; + onCancel: () => void; +} + +const WorkflowImport: React.FC = ({ + visible, + onCancel, +}) => { + const navigate = useNavigate(); + const { space_id } = useParams<{ space_id: string }>(); + + const { + selectedFiles, + addFiles, + removeFile, + updateWorkflowName, + clearAllFiles, + setSelectedFiles, + } = useFileProcessor(); + + const { + isImporting, + showResultModal, + resultModalData, + setShowResultModal, + navigateToWorkflow, + handleBatchImport, + } = useImportHandler(); + + const [dragActive, setDragActive] = useState(false); + + const dragHandlers = createDragHandlers(setDragActive, addFiles); + const importHandlers = createImportHandlers({ + navigate, + spaceId: space_id, + addFiles, + selectedFiles, + importMode: 'batch', + setSelectedFiles, + handleBatchImport, + resultModalData, + navigateToWorkflow, + setShowResultModal, + }); + + const validFileCount = selectedFiles.filter(f => f.status === 'valid').length; + + const handleImport = () => { + if (!space_id) { + alert('Missing workspace ID'); + return; + } + + handleBatchImport({ + selectedFiles, + spaceId: space_id, + importMode: 'batch', + setSelectedFiles, + }); + }; + + const handleResultCancel = () => { + setShowResultModal(false); + onCancel(); + }; + + const handleViewWorkflow = () => { + if (resultModalData.firstWorkflowId && space_id) { + navigate( + `/work_flow?workflow_id=${resultModalData.firstWorkflowId}&space_id=${space_id}`, + ); + setShowResultModal(false); + onCancel(); + } + }; + + return ( + <> + + + + + ); +}; + +export default WorkflowImport; +export type { WorkflowImportProps }; diff --git a/frontend/apps/coze-studio/src/pages/workflow-import/components/FileError.tsx b/frontend/apps/coze-studio/src/pages/workflow-import/components/FileError.tsx new file mode 100644 index 0000000000..3b9c4d779e --- /dev/null +++ b/frontend/apps/coze-studio/src/pages/workflow-import/components/FileError.tsx @@ -0,0 +1,74 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { t } from '../utils/i18n'; +import type { WorkflowFile } from '../types'; + +interface FileErrorProps { + file: WorkflowFile; +} + +const FileError: React.FC = ({ file }) => { + if (!file.error && file.status !== 'failed' && file.status !== 'invalid') { + return null; + } + + return ( +
+
+ 🚨 {file.status === 'failed' ? t('file_error_import_failed') : t('file_error_invalid_file')} +
+
{file.error || t('file_error_unknown')}
+ {file.status === 'failed' && ( +
+ {t('file_error_suggestion')} +
+ )} +
+ ); +}; + +export default FileError; diff --git a/frontend/apps/coze-studio/src/pages/workflow-import/components/FileList.tsx b/frontend/apps/coze-studio/src/pages/workflow-import/components/FileList.tsx new file mode 100644 index 0000000000..fc4ae35532 --- /dev/null +++ b/frontend/apps/coze-studio/src/pages/workflow-import/components/FileList.tsx @@ -0,0 +1,305 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, useCallback } from 'react'; + +import { I18n } from '@coze-arch/i18n'; + +import type { WorkflowFile } from '../types'; +import FileStatus from './FileStatus'; +import FilePreview from './FilePreview'; +import FileError from './FileError'; + +interface FileListProps { + selectedFiles: WorkflowFile[]; + isImporting: boolean; + onRemoveFile: (id: string) => void; + onUpdateWorkflowName: (id: string, name: string) => void; + onClearAll: () => void; +} + +const OPACITY_DISABLED = 0.6; +const MIN_NAME_LENGTH = 2; +const MAX_NAME_LENGTH = 50; + +const validateWorkflowName = ( + name: string, +): { isValid: boolean; message?: string } => { + if (!name || name.trim().length === 0) { + return { isValid: false, message: '工作流名称不能为空' }; + } + + const trimmedName = name.trim(); + if (trimmedName.length < MIN_NAME_LENGTH) { + return { + isValid: false, + message: `工作流名称至少需要${MIN_NAME_LENGTH}个字符`, + }; + } + + if (trimmedName.length > MAX_NAME_LENGTH) { + return { + isValid: false, + message: `工作流名称不能超过${MAX_NAME_LENGTH}个字符`, + }; + } + + if (!/^[a-zA-Z0-9\u4e00-\u9fa5_\-\s]+$/.test(trimmedName)) { + return { + isValid: false, + message: '工作流名称只能包含中文、英文、数字、下划线、短横线和空格', + }; + } + + return { isValid: true }; +}; + +const WorkflowNameInput: React.FC<{ + file: WorkflowFile; + isImporting: boolean; + onUpdateName: (id: string, name: string) => void; +}> = ({ file, isImporting, onUpdateName }) => { + const [nameError, setNameError] = useState(null); + const [isFocused, setIsFocused] = useState(false); + + const handleNameChange = useCallback( + (value: string) => { + onUpdateName(file.id, value); + + if (value.trim().length > 0) { + const validation = validateWorkflowName(value); + setNameError(validation.isValid ? null : validation.message || null); + } else { + setNameError(null); + } + }, + [file.id, onUpdateName], + ); + + const handleFocus = useCallback(() => { + setIsFocused(true); + }, []); + + const handleBlur = useCallback(() => { + setIsFocused(false); + if (file.workflowName.trim().length > 0) { + const validation = validateWorkflowName(file.workflowName); + setNameError(validation.isValid ? null : validation.message || null); + } + }, [file.workflowName]); + + return ( +
+ +
+ handleNameChange(e.target.value)} + onFocus={handleFocus} + onBlur={handleBlur} + placeholder={I18n.t('workflow_import_workflow_name_placeholder')} + disabled={isImporting} + style={{ + width: '250px', + padding: '8px 12px', + border: `1px solid ${ + nameError ? '#ef4444' : isFocused ? '#3b82f6' : '#e2e8f0' + }`, + borderRadius: '6px', + fontSize: '14px', + outline: 'none', + transition: 'border-color 0.2s ease', + }} + /> +
+
+ {nameError ? ( +
+ ⚠️ + {nameError} +
+ ) : null} +
+
+ ); +}; + +const FileItem: React.FC<{ + file: WorkflowFile; + isImporting: boolean; + onRemove: (id: string) => void; + onUpdateName: (id: string, name: string) => void; +}> = ({ file, isImporting, onRemove, onUpdateName }) => ( +
+
+
+
+ + {file.fileName} + + +
+ + {file.status === 'valid' && ( +
+ +
+ )} + + + +
+ + +
+
+); + +const FileList: React.FC = ({ + selectedFiles, + isImporting, + onRemoveFile, + onUpdateWorkflowName, + onClearAll, +}) => { + const validFileCount = selectedFiles.filter(f => f.status === 'valid').length; + const failedFileCount = selectedFiles.filter( + f => f.status === 'failed', + ).length; + + return ( +
+
+

+ 文件列表 ({selectedFiles.length}) - 有效: {validFileCount} + {failedFileCount > 0 && ( + + 失败: {failedFileCount} + + )} +

+ +
+ +
+ {selectedFiles.map(file => ( + + ))} +
+
+ ); +}; + +export default FileList; diff --git a/frontend/apps/coze-studio/src/pages/workflow-import/components/FilePreview.tsx b/frontend/apps/coze-studio/src/pages/workflow-import/components/FilePreview.tsx new file mode 100644 index 0000000000..8dd7d8a8d8 --- /dev/null +++ b/frontend/apps/coze-studio/src/pages/workflow-import/components/FilePreview.tsx @@ -0,0 +1,50 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { t } from '../utils/i18n'; +import type { WorkflowFile } from '../types'; + +interface FilePreviewProps { + preview: WorkflowFile['preview']; +} + +const FilePreview: React.FC = ({ preview }) => { + if (!preview) { + return null; + } + + return ( +
+
+ {t('file_preview_name')}: {preview.name} | {t('file_preview_nodes')}: {preview.nodeCount} | {t('file_preview_connections')}:{' '} + {preview.edgeCount} | {t('file_preview_version')}: {preview.version} +
+ {preview.description ?
{t('file_preview_description')}: {preview.description}
: null} +
+ ); +}; + +export default FilePreview; diff --git a/frontend/apps/coze-studio/src/pages/workflow-import/components/FileStatus.tsx b/frontend/apps/coze-studio/src/pages/workflow-import/components/FileStatus.tsx new file mode 100644 index 0000000000..ff3fcff966 --- /dev/null +++ b/frontend/apps/coze-studio/src/pages/workflow-import/components/FileStatus.tsx @@ -0,0 +1,95 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { t } from '../utils/i18n'; +import type { WorkflowFile } from '../types'; + +interface FileStatusProps { + status: WorkflowFile['status']; +} + +const getFileStatusStyle = (status: WorkflowFile['status']) => { + const baseStyle = { + padding: '4px 8px', + borderRadius: '4px', + fontSize: '12px', + fontWeight: '600', + }; + + switch (status) { + case 'pending': + return { ...baseStyle, background: '#f3f4f6', color: '#6b7280' }; + case 'validating': + return { ...baseStyle, background: '#fef3c7', color: '#92400e' }; + case 'valid': + return { ...baseStyle, background: '#d1fae5', color: '#065f46' }; + case 'invalid': + return { ...baseStyle, background: '#fee2e2', color: '#dc2626' }; + case 'importing': + return { ...baseStyle, background: '#dbeafe', color: '#1e40af' }; + case 'success': + return { ...baseStyle, background: '#d1fae5', color: '#065f46' }; + case 'failed': + return { ...baseStyle, background: '#fee2e2', color: '#dc2626' }; + default: + return baseStyle; + } +}; + +const getStatusText = (status: WorkflowFile['status']) => { + switch (status) { + case 'pending': + return t('file_status_pending'); + case 'validating': + return t('file_status_validating'); + case 'valid': + return t('file_status_valid'); + case 'invalid': + return t('file_status_invalid'); + case 'importing': + return t('file_status_importing'); + case 'success': + return t('file_status_success'); + case 'failed': + return t('file_status_failed'); + default: + return ''; + } +}; + +const FileStatus: React.FC = ({ status }) => ( + <> + {getStatusText(status)} + {status === 'failed' && ( + + {t('file_status_needs_check')} + + )} + +); + +export default FileStatus; diff --git a/frontend/apps/coze-studio/src/pages/workflow-import/components/FileUpload.tsx b/frontend/apps/coze-studio/src/pages/workflow-import/components/FileUpload.tsx new file mode 100644 index 0000000000..d9df341488 --- /dev/null +++ b/frontend/apps/coze-studio/src/pages/workflow-import/components/FileUpload.tsx @@ -0,0 +1,114 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { I18n } from '@coze-arch/i18n'; + +interface FileUploadProps { + dragActive: boolean; + isImporting: boolean; + onFilesSelected: (files: FileList) => void; + onDragEnter: (e: React.DragEvent) => void; + onDragLeave: (e: React.DragEvent) => void; + onDragOver: (e: React.DragEvent) => void; + onDrop: (e: React.DragEvent) => void; +} + +const FileUpload: React.FC = ({ + dragActive, + isImporting, + onFilesSelected, + onDragEnter, + onDragLeave, + onDragOver, + onDrop, +}) => { + const handleFileChange = (event: React.ChangeEvent) => { + const files = event.target.files; + if (files && files.length > 0) { + onFilesSelected(files); + } + }; + + return ( +
+
document.getElementById('file-input')?.click()} + onDragEnter={onDragEnter} + onDragLeave={onDragLeave} + onDragOver={onDragOver} + onDrop={onDrop} + > +
📁
+

+ {I18n.t('workflow_import_drag_and_drop')} +

+

+ {I18n.t('workflow_import_batch_description')} +

+ +
+ {I18n.t('workflow_import_select_file')} +
+
+
+ ); +}; + +export default FileUpload; diff --git a/frontend/apps/coze-studio/src/pages/workflow-import/components/ImportActions.tsx b/frontend/apps/coze-studio/src/pages/workflow-import/components/ImportActions.tsx new file mode 100644 index 0000000000..1bd5d0eb96 --- /dev/null +++ b/frontend/apps/coze-studio/src/pages/workflow-import/components/ImportActions.tsx @@ -0,0 +1,76 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +interface ImportActionsProps { + validFileCount: number; + isImporting: boolean; + onImport: () => void; +} + +const OPACITY_DISABLED = 0.6; + +const ImportActions: React.FC = ({ + validFileCount, + isImporting, + onImport, +}) => ( +
+ +
+); + +export default ImportActions; diff --git a/frontend/apps/coze-studio/src/pages/workflow-import/components/ImportButtons.tsx b/frontend/apps/coze-studio/src/pages/workflow-import/components/ImportButtons.tsx new file mode 100644 index 0000000000..8cde23f9c8 --- /dev/null +++ b/frontend/apps/coze-studio/src/pages/workflow-import/components/ImportButtons.tsx @@ -0,0 +1,105 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { t } from '../utils/i18n'; + +interface ImportButtonsProps { + isImporting: boolean; + validFileCount: number; + onGoBack: () => void; + onImport: () => void; +} + +const OPACITY_DISABLED = 0.6; + +const ImportButtons: React.FC = ({ + isImporting, + validFileCount, + onGoBack, + onImport, +}) => ( +
+ + + +
+); + +export default ImportButtons; diff --git a/frontend/apps/coze-studio/src/pages/workflow-import/components/ImportForm.tsx b/frontend/apps/coze-studio/src/pages/workflow-import/components/ImportForm.tsx new file mode 100644 index 0000000000..4b8c50eafd --- /dev/null +++ b/frontend/apps/coze-studio/src/pages/workflow-import/components/ImportForm.tsx @@ -0,0 +1,261 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { I18n } from '@coze-arch/i18n'; +import { Button, LoadingButton } from '@coze-arch/coze-design'; + +import FileUpload from './FileUpload'; +import FileList from './FileList'; +import type { WorkflowFile } from '../types'; + +interface DragHandlers { + handleDragEnter: (e: React.DragEvent) => void; + handleDragLeave: (e: React.DragEvent) => void; + handleDragOver: (e: React.DragEvent) => void; + handleDrop: (e: React.DragEvent) => void; +} + +interface ImportHandlers { + handleFileSelect: (files: FileList) => void; +} + +interface ImportFormProps { + visible: boolean; + selectedFiles: WorkflowFile[]; + isImporting: boolean; + validFileCount: number; + dragActive: boolean; + dragHandlers: DragHandlers; + importHandlers: ImportHandlers; + onCancel: () => void; + onImport: () => void; + onRemoveFile: (id: string) => void; + onUpdateWorkflowName: (id: string, name: string) => void; + onClearAll: () => void; +} + +const ImportModalOverlay: React.FC<{ + children: React.ReactNode; + onCancel: () => void; + isImporting: boolean; +}> = ({ children, onCancel, isImporting }) => ( +
{ + if (e.target === e.currentTarget && !isImporting) { + onCancel(); + } + }} + > + {children} +
+); + +const ImportModalContent: React.FC<{ + children: React.ReactNode; +}> = ({ children }) => ( +
e.stopPropagation()} + > + {children} +
+); + +const ImportButtons: React.FC<{ + isImporting: boolean; + validFileCount: number; + onCancel: () => void; + onImport: () => void; +}> = ({ isImporting, validFileCount, onCancel, onImport }) => ( +
+ + + { + if (!isImporting && validFileCount > 0) { + e.currentTarget.style.backgroundColor = '#059669'; + e.currentTarget.style.borderColor = '#059669'; + e.currentTarget.style.boxShadow = + '0 4px 12px rgba(16, 185, 129, 0.35)'; + } + }} + onMouseLeave={e => { + if (!isImporting && validFileCount > 0) { + e.currentTarget.style.backgroundColor = '#10b981'; + e.currentTarget.style.borderColor = '#10b981'; + e.currentTarget.style.boxShadow = + '0 2px 8px rgba(16, 185, 129, 0.25)'; + } + }} + > + {isImporting + ? I18n.t('workflow_import_importing') + : I18n.t('workflow_import_button_import', { + count: validFileCount.toString(), + })} + +
+); + +const ImportForm: React.FC = ({ + visible, + selectedFiles, + isImporting, + validFileCount, + dragActive, + dragHandlers, + importHandlers, + onCancel, + onImport, + onRemoveFile, + onUpdateWorkflowName, + onClearAll, +}) => { + if (!visible) { + return null; + } + + return ( + + +
+

+ {I18n.t('workflow_import')} +

+
+ + + + {selectedFiles.length > 0 && ( +
+ +
+ )} + + +
+ + +
+ ); +}; + +export default ImportForm; diff --git a/frontend/apps/coze-studio/src/pages/workflow-import/components/ImportHeader.tsx b/frontend/apps/coze-studio/src/pages/workflow-import/components/ImportHeader.tsx new file mode 100644 index 0000000000..179832d35c --- /dev/null +++ b/frontend/apps/coze-studio/src/pages/workflow-import/components/ImportHeader.tsx @@ -0,0 +1,78 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +interface ImportHeaderProps { + onGoBack: () => void; +} + +const ImportHeader: React.FC = ({ onGoBack }) => ( + <> +
+

+ 📦 工作流导入 +

+

+ 支持批量导入多个工作流文件,支持 JSON、YAML 格式 +

+
+ +
+ +
+ +); + +export default ImportHeader; diff --git a/frontend/apps/coze-studio/src/pages/workflow-import/components/ImportHelp.tsx b/frontend/apps/coze-studio/src/pages/workflow-import/components/ImportHelp.tsx new file mode 100644 index 0000000000..11fe851d9a --- /dev/null +++ b/frontend/apps/coze-studio/src/pages/workflow-import/components/ImportHelp.tsx @@ -0,0 +1,72 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +const ImportHelp: React.FC = () => ( +
+

+ 💡 使用说明 +

+
    +
  • + 支持格式: + 支持JSON、YAML和ZIP格式的工作流文件(.json、.yml、.yaml、.zip). ZIP文件将自动解析和转换. +
  • +
  • + 文件限制:单次最多支持50个文件 +
  • +
  • + ZIP文件处理: + 支持直接导入COZE官方导出的ZIP文件, 系统将自动解析和转换为开源格式 +
  • +
  • + 名称规则: + 工作流名称必须以字母开头, 支持单个字母, 只能包含字母、数字和下划线 +
  • +
  • + 批量模式:允许部分文件导入失败, 不影响其他文件 +
  • +
  • + 事务模式:要求所有文件都成功导入, 否则全部回滚 +
  • +
+
+); + +export default ImportHelp; diff --git a/frontend/apps/coze-studio/src/pages/workflow-import/components/ImportModeSelector.tsx b/frontend/apps/coze-studio/src/pages/workflow-import/components/ImportModeSelector.tsx new file mode 100644 index 0000000000..4e0be8e569 --- /dev/null +++ b/frontend/apps/coze-studio/src/pages/workflow-import/components/ImportModeSelector.tsx @@ -0,0 +1,85 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +interface ImportModeSelectorProps { + importMode: 'batch' | 'transaction'; + isImporting: boolean; + onChange: (mode: 'batch' | 'transaction') => void; +} + +const ImportModeSelector: React.FC = ({ + importMode, + isImporting, + onChange, +}) => ( +
+ +
+ + +
+
+); + +export default ImportModeSelector; diff --git a/frontend/apps/coze-studio/src/pages/workflow-import/components/ImportResultModal.tsx b/frontend/apps/coze-studio/src/pages/workflow-import/components/ImportResultModal.tsx new file mode 100644 index 0000000000..05e8d0dad5 --- /dev/null +++ b/frontend/apps/coze-studio/src/pages/workflow-import/components/ImportResultModal.tsx @@ -0,0 +1,412 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { I18n } from '@coze-arch/i18n'; + +interface ImportResultModalProps { + visible: boolean; + resultModalData: { + successCount: number; + failedCount: number; + firstWorkflowId?: string; + failedFiles?: Array<{ + file_name: string; + workflow_name: string; + error_message: string; + fail_reason?: string; + }>; + }; + onCancel: () => void; + onViewWorkflow: () => void; +} + +const ResultIcon: React.FC<{ + resultModalData: ImportResultModalProps['resultModalData']; +}> = ({ resultModalData }) => { + const { successCount, failedCount } = resultModalData; + if (successCount > 0 && failedCount > 0) { + return ⚠️; + } else if (successCount > 0) { + return ; + } else { + return ; + } +}; + +const ResultTitle: React.FC<{ + resultModalData: ImportResultModalProps['resultModalData']; +}> = ({ resultModalData }) => { + const { successCount, failedCount } = resultModalData; + if (successCount > 0 && failedCount > 0) { + return I18n.t('workflow_import_partial_complete'); + } else if (successCount > 0) { + return I18n.t('workflow_import_success'); + } else { + return I18n.t('workflow_import_failed'); + } +}; + +const ResultMessage: React.FC<{ + resultModalData: ImportResultModalProps['resultModalData']; +}> = ({ resultModalData }) => { + const { successCount, failedCount } = resultModalData; + const totalCount = successCount + failedCount; + + if (successCount > 0 && failedCount > 0) { + return ( + <> + {I18n.t('workflow_import_partial_message', { + total: totalCount.toString(), + success: successCount.toString(), + failed: failedCount.toString(), + })} + + ); + } else if (successCount > 0) { + return ( + <> + {I18n.t('workflow_import_success_message', { + count: successCount.toString(), + })} + + ); + } else { + return ( + <> + {I18n.t('workflow_import_failed_message', { + count: failedCount.toString(), + })} + + ); + } +}; + +const FailedFilesDetails: React.FC<{ + resultModalData: ImportResultModalProps['resultModalData']; +}> = ({ resultModalData }) => { + if ( + resultModalData.failedCount === 0 || + !resultModalData.failedFiles || + resultModalData.failedFiles.length === 0 + ) { + return null; + } + + return ( +
+
+
+ {I18n.t('workflow_import_failed_files_details')} ( + {resultModalData.failedCount}) +
+
+ {resultModalData.failedFiles.map((file, index) => ( +
+
+ {file.file_name} +
+
+ {I18n.t('workflow_import_workflow_name')}: {file.workflow_name} +
+
+ {I18n.t('workflow_import_error_reason')}:{' '} + {file.error_message || + file.fail_reason || + I18n.t('workflow_import_unknown_error')} +
+
+ ))} +
+
+
+ ); +}; + +const ModalButtons: React.FC<{ + resultModalData: ImportResultModalProps['resultModalData']; + onCancel: () => void; + onViewWorkflow: () => void; +}> = ({ resultModalData, onCancel, onViewWorkflow }) => ( +
+ + + {resultModalData.successCount > 0 && resultModalData.firstWorkflowId ? ( + + ) : null} +
+); + +const ImportResultModal: React.FC = ({ + visible, + resultModalData, + onCancel, + onViewWorkflow, +}) => { + if (!visible) { + return null; + } + + return ( + <> + + +
{ + if (e.target === e.currentTarget) { + onCancel(); + } + }} + > +
e.stopPropagation()} + > +
+ {I18n.t('workflow_import_result')} +
+ +
+
+ +
+ +

+ +

+ +

0 ? '16px' : '0', + lineHeight: '1.6', + textAlign: 'center', + }} + > + +

+ + +
+ + +
+
+ + ); +}; + +export default ImportResultModal; diff --git a/frontend/apps/coze-studio/src/pages/workflow-import/hooks/use-batch-import.ts b/frontend/apps/coze-studio/src/pages/workflow-import/hooks/use-batch-import.ts new file mode 100644 index 0000000000..db5b965ae3 --- /dev/null +++ b/frontend/apps/coze-studio/src/pages/workflow-import/hooks/use-batch-import.ts @@ -0,0 +1,196 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { t } from '../utils/i18n'; +import { validateWorkflowName } from '../utils'; +import type { WorkflowFile, ImportProgress, ImportResults } from '../types'; + +export interface BatchImportParams { + selectedFiles: WorkflowFile[]; + spaceId: string; + importMode: 'batch' | 'transaction'; + setImportProgress: (progress: ImportProgress | null) => void; + setSelectedFiles: React.Dispatch>; + setImportResults: (results: ImportResults | null) => void; +} + +export interface BatchImportAPIParams { + validFiles: WorkflowFile[]; + spaceId: string; + importMode: 'batch' | 'transaction'; + creatorId?: string; +} + +export const validateFiles = (selectedFiles: WorkflowFile[]): string[] => { + const validFiles = selectedFiles.filter(f => f.status === 'valid'); + if (validFiles.length === 0) { + return [t('no_valid_files_to_import')]; + } + + const nameErrors: string[] = []; + const nameSet = new Set(); + + validFiles.forEach(file => { + const error = validateWorkflowName(file.workflowName); + if (error) { + nameErrors.push(t('file_name_error', { fileName: file.fileName, error })); + } + + if (nameSet.has(file.workflowName)) { + nameErrors.push( + t('workflow_name_duplicate', { workflowName: file.workflowName }), + ); + } + nameSet.add(file.workflowName); + }); + + return nameErrors; +}; + +export const callBatchImportAPI = async ( + params: BatchImportAPIParams, +): Promise => { + const { validFiles, spaceId, importMode, creatorId } = params; + const workflowFiles = validFiles.map(file => { + console.log('Processing file:', { + fileName: file.fileName, + isZip: file.fileName.toLowerCase().endsWith('.zip'), + hasOriginalContent: !!file.originalContent, + originalContentLength: file.originalContent?.length || 0, + isValidBase64: file.originalContent + ? /^[A-Za-z0-9+/]*={0,2}$/.test(file.originalContent) + : false, + contentPreview: file.originalContent?.substring(0, 50) || 'no content', + }); + + // 对于ZIP文件,验证base64格式 + if (file.fileName.toLowerCase().endsWith('.zip')) { + if (!file.originalContent) { + console.error('ZIP file has no originalContent:', file.fileName); + throw new Error(`ZIP文件 "${file.fileName}" 缺少内容数据`); + } + + // 验证是否为有效的base64 + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(file.originalContent)) { + console.error('ZIP file has invalid base64 content:', file.fileName); + throw new Error(`ZIP文件 "${file.fileName}" 包含无效的base64数据`); + } + + return { + file_name: file.fileName, + workflow_data: file.originalContent, // ZIP文件的base64数据 + workflow_name: file.workflowName, + }; + } + + // 对于其他文件类型,使用原始内容 + return { + file_name: file.fileName, + workflow_data: file.originalContent, + workflow_name: file.workflowName, + }; + }); + + console.log( + t('batch_import_files'), + workflowFiles.map(f => ({ + name: f.file_name, + workflow_name: f.workflow_name, + has_workflow_data: !!f.workflow_data, + workflow_data_length: f.workflow_data?.length || 0, + })), + ); + + // 验证数据完整性 + for (let i = 0; i < workflowFiles.length; i++) { + const file = workflowFiles[i]; + if (!file.file_name) { + console.error(`Missing file_name for file ${i}:`, file); + } + if (!file.workflow_name) { + console.error(`Missing workflow_name for file ${i}:`, file); + } + if (!file.workflow_data) { + console.error(`Missing workflow_data for file ${i}:`, file); + } + } + + const response = await fetch('/api/workflow_api/batch_import', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + workflow_files: workflowFiles, + space_id: spaceId, + creator_id: creatorId || spaceId, // 使用传入的creator_id,fallback到space_id + import_format: 'mixed', + import_mode: importMode, + }), + }); + + if (!response.ok) { + let errorData; + try { + errorData = await response.json(); + } catch (parseError) { + console.warn('Failed to parse error response:', parseError); + throw new Error( + t('batch_import_failed_http', { status: response.status }), + ); + } + throw new Error(errorData.message || t('batch_import_failed')); + } + + let result; + try { + result = await response.json(); + } catch (parseError) { + console.warn('Failed to parse API response:', parseError); + throw new Error(t('invalid_response_format')); + } + + console.log(t('batch_import_api_response'), result); + return result.data || result || {}; +}; + +export const updateFileStatuses = ( + selectedFiles: WorkflowFile[], + responseData: ImportResults, + setSelectedFiles: React.Dispatch>, +): void => { + setSelectedFiles(prev => + prev.map(file => { + const successResult = responseData.success_list?.find( + s => s.file_name === file.fileName, + ); + const failedResult = responseData.failed_list?.find( + f => f.file_name === file.fileName, + ); + + if (successResult) { + return { ...file, status: 'success' as const }; + } else if (failedResult) { + return { + ...file, + status: 'failed' as const, + error: failedResult.error_message, + }; + } + return file; + }), + ); +}; diff --git a/frontend/apps/coze-studio/src/pages/workflow-import/hooks/use-file-processor.ts b/frontend/apps/coze-studio/src/pages/workflow-import/hooks/use-file-processor.ts new file mode 100644 index 0000000000..43c1ae9821 --- /dev/null +++ b/frontend/apps/coze-studio/src/pages/workflow-import/hooks/use-file-processor.ts @@ -0,0 +1,158 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useState, useCallback } from 'react'; + +import { + processZipFile, + processTextFile, + updateFileStatus, + createWorkflowPreview, + createWorkflowName, +} from '../utils/file-processor'; +import { + sanitizeWorkflowName, + generateRandomId, + convertFileToBase64, +} from '../utils'; +import type { WorkflowFile } from '../types'; + +const SUPPORTED_EXTENSIONS = ['.json', '.yml', '.yaml', '.zip']; + +export const useFileProcessor = () => { + const [selectedFiles, setSelectedFiles] = useState([]); + + const isFileSupported = (fileName: string): boolean => + SUPPORTED_EXTENSIONS.some(ext => fileName.toLowerCase().endsWith(ext)); + + const handleZipFileProcessing = async ( + workflowFile: WorkflowFile, + ): Promise => { + setSelectedFiles(prev => + updateFileStatus(prev, workflowFile.id, { status: 'validating' }), + ); + + const result = await processZipFile(workflowFile); + + if (result.error) { + setSelectedFiles(prev => + updateFileStatus(prev, workflowFile.id, { + status: 'invalid', + error: result.error, + }), + ); + return; + } + + const preview = createWorkflowPreview(result.workflowData); + const workflowName = createWorkflowName( + result.workflowData, + workflowFile.fileName, + ); + const originalContent = await convertFileToBase64(workflowFile.file); + + setSelectedFiles(prev => + updateFileStatus(prev, workflowFile.id, { + workflowName, + workflowData: JSON.stringify(result.workflowData), + originalContent, + status: 'valid', + preview, + }), + ); + }; + + const handleTextFileProcessing = async ( + workflowFile: WorkflowFile, + ): Promise => { + const result = await processTextFile(workflowFile); + + if (result.error) { + setSelectedFiles(prev => + updateFileStatus(prev, workflowFile.id, { + status: 'invalid', + error: result.error, + }), + ); + return; + } + + const preview = createWorkflowPreview(result.workflowData); + const workflowName = createWorkflowName( + result.workflowData, + workflowFile.fileName, + ); + const originalContent = await workflowFile.file.text(); + + setSelectedFiles(prev => + updateFileStatus(prev, workflowFile.id, { + workflowName, + workflowData: JSON.stringify(result.workflowData), + originalContent, + status: 'valid', + preview, + }), + ); + }; + + const addFiles = useCallback((files: File[]) => { + const newWorkflowFiles: WorkflowFile[] = files + .filter(file => isFileSupported(file.name)) + .map(file => ({ + id: generateRandomId(), + file, + fileName: file.name, + workflowName: sanitizeWorkflowName(file.name), + workflowData: '', + originalContent: '', + status: 'pending' as const, + })); + + setSelectedFiles(prev => [...prev, ...newWorkflowFiles]); + + newWorkflowFiles.forEach(workflowFile => { + const fileName = workflowFile.fileName.toLowerCase(); + if (fileName.endsWith('.zip')) { + handleZipFileProcessing(workflowFile); + } else { + handleTextFileProcessing(workflowFile); + } + }); + }, []); + + const removeFile = useCallback((id: string) => { + setSelectedFiles(prev => prev.filter(f => f.id !== id)); + }, []); + + const updateWorkflowName = useCallback((id: string, name: string) => { + setSelectedFiles(prev => + prev.map(f => (f.id === id ? { ...f, workflowName: name } : f)), + ); + }, []); + + const clearAllFiles = useCallback(() => { + setSelectedFiles([]); + }, []); + + return { + selectedFiles, + addFiles, + removeFile, + updateWorkflowName, + clearAllFiles, + setSelectedFiles, + }; +}; diff --git a/frontend/apps/coze-studio/src/pages/workflow-import/hooks/use-import-handler.ts b/frontend/apps/coze-studio/src/pages/workflow-import/hooks/use-import-handler.ts new file mode 100644 index 0000000000..be22b776cb --- /dev/null +++ b/frontend/apps/coze-studio/src/pages/workflow-import/hooks/use-import-handler.ts @@ -0,0 +1,189 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useNavigate } from 'react-router-dom'; +import { useState } from 'react'; + +import { useUserInfo } from '@coze-foundation/account-adapter'; + +import { t } from '../utils/i18n'; +import type { WorkflowFile, ImportProgress, ImportResults } from '../types'; +import { + validateFiles, + callBatchImportAPI, + updateFileStatuses, +} from './use-batch-import'; + +const DELAY_TIME_MS = 1000; + +interface ImportHandlerParams { + selectedFiles: WorkflowFile[]; + spaceId: string; + importMode: 'batch' | 'transaction'; + setSelectedFiles: React.Dispatch>; +} + +interface ImportResultModalData { + successCount: number; + failedCount: number; + firstWorkflowId?: string; + failedFiles?: Array<{ + file_name: string; + workflow_name: string; + error_code: string; + error_message: string; + fail_reason?: string; + }>; +} + +export const useImportHandler = () => { + const navigate = useNavigate(); + const userInfo = useUserInfo(); + const [isImporting, setIsImporting] = useState(false); + const [importProgress, setImportProgress] = useState( + null, + ); + const [importResults, setImportResults] = useState( + null, + ); + const [showResultModal, setShowResultModal] = useState(false); + const [resultModalData, setResultModalData] = useState<{ + successCount: number; + failedCount: number; + firstWorkflowId?: string; + failedFiles?: Array<{ + file_name: string; + workflow_name: string; + error_code: string; + error_message: string; + fail_reason?: string; + }>; + }>({ successCount: 0, failedCount: 0 }); + const showImportResultModal = (data: ImportResultModalData) => { + setResultModalData(data); + setShowResultModal(true); + }; + const navigateToWorkflow = (workflowId: string, spaceId: string) => { + navigate(`/work_flow?workflow_id=${workflowId}&space_id=${spaceId}`); + }; + const validateImportRequest = (params: ImportHandlerParams): boolean => { + if (!params.spaceId) { + alert(t('missing_workspace_id') || '缺少工作空间ID,请重新进入页面'); + return false; + } + if (params.selectedFiles.length === 0) { + alert(t('please_select_files') || 'Please select files first'); + return false; + } + const nameErrors = validateFiles(params.selectedFiles); + if (nameErrors.length > 0) { + alert( + `${t('name_validation_failed') || '名称验证失败'}:\n${nameErrors.join('\n')}`, + ); + return false; + } + return true; + }; + const processImportResult = ( + responseData: ImportResults, + params: ImportHandlerParams, + ) => { + setImportResults(responseData); + updateFileStatuses( + params.selectedFiles, + responseData, + params.setSelectedFiles, + ); + const successCount = + responseData.success_count || responseData.success_list?.length || 0; + const failedCount = + responseData.failed_count || responseData.failed_list?.length || 0; + setImportProgress({ + totalCount: + (responseData as ImportResults & { total_count?: number }) + .total_count || + params.selectedFiles.filter(f => f.status === 'valid').length, + successCount, + failedCount, + currentProcessing: '', + }); + if (successCount > 0) { + const firstWorkflowId = responseData.success_list?.length + ? responseData.success_list[0].workflow_id + : null; + setTimeout(() => { + showImportResultModal({ + successCount, + failedCount, + firstWorkflowId: firstWorkflowId || undefined, + failedFiles: responseData.failed_list, + }); + }, DELAY_TIME_MS); + } else if (failedCount > 0) { + // 如果没有成功的文件但有失败的文件,也显示结果模态框 + setTimeout(() => { + showImportResultModal({ + successCount, + failedCount, + firstWorkflowId: undefined, + failedFiles: responseData.failed_list, + }); + }, DELAY_TIME_MS); + } + }; + const handleBatchImport = async (params: ImportHandlerParams) => { + if (!validateImportRequest(params)) { + return; + } + const validFiles = params.selectedFiles.filter(f => f.status === 'valid'); + setIsImporting(true); + setImportProgress({ + totalCount: validFiles.length, + successCount: 0, + failedCount: 0, + currentProcessing: validFiles[0]?.fileName || '', + }); + try { + const responseData = await callBatchImportAPI({ + validFiles, + spaceId: params.spaceId, + importMode: params.importMode, + creatorId: (userInfo as { uid?: string })?.uid, + }); + processImportResult(responseData, params); + } catch (error) { + console.error(t('batch_import_failed') || '批量导入失败:', error); + alert( + error instanceof Error + ? error.message + : t('batch_import_failed_retry') || '批量导入失败,请重试', + ); + } finally { + setIsImporting(false); + } + }; + + return { + isImporting, + importProgress, + importResults, + showResultModal, + resultModalData, + setShowResultModal, + showImportResultModal, + navigateToWorkflow, + handleBatchImport, + }; +}; diff --git a/frontend/apps/coze-studio/src/pages/workflow-import/types.ts b/frontend/apps/coze-studio/src/pages/workflow-import/types.ts new file mode 100644 index 0000000000..796819b1ec --- /dev/null +++ b/frontend/apps/coze-studio/src/pages/workflow-import/types.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface WorkflowFile { + id: string; + file: File; + fileName: string; + workflowName: string; + workflowData: string; + originalContent: string; + status: + | 'pending' + | 'validating' + | 'valid' + | 'invalid' + | 'importing' + | 'success' + | 'failed'; + error?: string; + preview?: { + name: string; + description: string; + nodeCount: number; + edgeCount: number; + version: string; + }; +} + +export interface ImportProgress { + totalCount: number; + successCount: number; + failedCount: number; + currentProcessing: string; +} + +export interface ImportResults { + success_count?: number; + failed_count?: number; + success_list?: Array<{ + workflow_id: string; + file_name: string; + }>; + failed_list?: Array<{ + file_name: string; + workflow_name: string; + error_code: string; + error_message: string; + fail_reason?: string; + }>; +} diff --git a/frontend/apps/coze-studio/src/pages/workflow-import/utils.ts b/frontend/apps/coze-studio/src/pages/workflow-import/utils.ts new file mode 100644 index 0000000000..cd063b6186 --- /dev/null +++ b/frontend/apps/coze-studio/src/pages/workflow-import/utils.ts @@ -0,0 +1,126 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { load } from 'js-yaml'; +import { t } from './utils/i18n'; + +const RANDOM_ID_LENGTH = 9; +const MIN_WORKFLOW_NAME_LENGTH = 2; +const MAX_WORKFLOW_NAME_LENGTH = 100; +const SUBSTRING_START = 2; +const SUBSTRING_LENGTH = 6; +const RADIX_36 = 36; + +export const generateRandomId = (): string => + Math.random().toString(RADIX_36).substr(SUBSTRING_START, RANDOM_ID_LENGTH); + +export const sanitizeWorkflowName = (fileName: string): string => { + let workflowName = fileName; + const lowerName = fileName.toLowerCase(); + + if (lowerName.endsWith('.json')) { + workflowName = fileName.replace('.json', ''); + } else if (lowerName.endsWith('.yml')) { + workflowName = fileName.replace('.yml', ''); + } else if (lowerName.endsWith('.yaml')) { + workflowName = fileName.replace('.yaml', ''); + } else if (lowerName.endsWith('.zip')) { + workflowName = fileName.replace('.zip', ''); + } + + workflowName = workflowName.replace(/[^a-zA-Z0-9_]/g, '_'); + if (!/^[a-zA-Z]/.test(workflowName)) { + workflowName = `Workflow_${workflowName}`; + } + if (workflowName.length < MIN_WORKFLOW_NAME_LENGTH) { + workflowName = `Workflow_${Math.random().toString(RADIX_36).substr(SUBSTRING_START, SUBSTRING_LENGTH)}`; + } + + return workflowName; +}; + +export const validateWorkflowName = (name: string): string => { + if (!name.trim()) { + return t('workflow_name_empty'); + } + + if (!/^[a-zA-Z]/.test(name)) { + return t('workflow_name_must_start_letter'); + } + + if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(name)) { + return t('workflow_name_invalid_chars'); + } + + if ( + name.length < MIN_WORKFLOW_NAME_LENGTH || + name.length > MAX_WORKFLOW_NAME_LENGTH + ) { + return t('workflow_name_length_invalid'); + } + + return ''; +}; + +export const parseWorkflowData = ( + content: string, + isYamlFile: boolean, +): Record => { + if (isYamlFile) { + return load(content) as Record; + } else { + return JSON.parse(content); + } +}; + +export const convertFileToBase64 = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = e => { + const result = e.target?.result; + if (!result) { + reject(new Error('Failed to read file')); + return; + } + + // FileReader.readAsDataURL() returns "data:type;base64,base64data" + // We only need the base64 part + const base64 = (result as string).split(',')[1]; + if (!base64) { + reject(new Error('Failed to extract base64 data')); + return; + } + + resolve(base64); + }; + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsDataURL(file); + }); + +export const isValidWorkflowData = (data: Record): boolean => { + if (!data || typeof data !== 'object') { + return false; + } + + return !!( + data.schema || + data.nodes || + data.workflow_id || + data.name || + data.edges || + data.canvas + ); +}; diff --git a/frontend/apps/coze-studio/src/pages/workflow-import/utils/component-handlers.ts b/frontend/apps/coze-studio/src/pages/workflow-import/utils/component-handlers.ts new file mode 100644 index 0000000000..80e8e192ef --- /dev/null +++ b/frontend/apps/coze-studio/src/pages/workflow-import/utils/component-handlers.ts @@ -0,0 +1,84 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type React from 'react'; + +import type { WorkflowFile } from '../types'; + +export interface ImportHandlers { + handleGoBack: () => void; + handleFileSelect: (files: FileList) => void; + handleImport: () => void; + handleConfirmResult: () => void; + handleCancelResult: () => void; +} + +export interface ImportHandlerParams { + navigate: (path: string) => void; + spaceId: string | undefined; + addFiles: (files: File[]) => void; + selectedFiles: WorkflowFile[]; + importMode: 'batch' | 'transaction'; + setSelectedFiles: React.Dispatch>; + handleBatchImport: (params: { + selectedFiles: WorkflowFile[]; + spaceId: string; + importMode: 'batch' | 'transaction'; + setSelectedFiles: React.Dispatch>; + }) => void; + resultModalData: { firstWorkflowId?: string }; + navigateToWorkflow: (workflowId: string, spaceId: string) => void; + setShowResultModal: (show: boolean) => void; +} + +export const createImportHandlers = ( + params: ImportHandlerParams, +): ImportHandlers => ({ + handleGoBack: () => { + params.navigate(`/space/${params.spaceId}/library`); + }, + + handleFileSelect: (files: FileList) => { + const fileArray = Array.from(files); + params.addFiles(fileArray); + }, + + handleImport: () => { + if (!params.spaceId) { + return; + } + params.handleBatchImport({ + selectedFiles: params.selectedFiles, + spaceId: params.spaceId, + importMode: params.importMode, + setSelectedFiles: params.setSelectedFiles, + }); + }, + + handleConfirmResult: () => { + if (params.resultModalData.firstWorkflowId && params.spaceId) { + params.navigateToWorkflow( + params.resultModalData.firstWorkflowId, + params.spaceId, + ); + } + params.setShowResultModal(false); + }, + + handleCancelResult: () => { + params.setShowResultModal(false); + }, +}); diff --git a/frontend/apps/coze-studio/src/pages/workflow-import/utils/drag-handlers.ts b/frontend/apps/coze-studio/src/pages/workflow-import/utils/drag-handlers.ts new file mode 100644 index 0000000000..eae0238b8d --- /dev/null +++ b/frontend/apps/coze-studio/src/pages/workflow-import/utils/drag-handlers.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface DragHandlers { + handleDragEnter: (e: React.DragEvent) => void; + handleDragLeave: (e: React.DragEvent) => void; + handleDragOver: (e: React.DragEvent) => void; + handleDrop: (e: React.DragEvent) => void; +} + +export const createDragHandlers = ( + setDragActive: (active: boolean) => void, + addFiles: (files: File[]) => void, +): DragHandlers => ({ + handleDragEnter: (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(true); + }, + + handleDragLeave: (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + }, + + handleDragOver: (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, + + handleDrop: (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + const files = Array.from(e.dataTransfer.files); + addFiles(files); + }, +}); \ No newline at end of file diff --git a/frontend/apps/coze-studio/src/pages/workflow-import/utils/file-processor.ts b/frontend/apps/coze-studio/src/pages/workflow-import/utils/file-processor.ts new file mode 100644 index 0000000000..134f1ce2b1 --- /dev/null +++ b/frontend/apps/coze-studio/src/pages/workflow-import/utils/file-processor.ts @@ -0,0 +1,110 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + sanitizeWorkflowName, + parseWorkflowData, + convertFileToBase64, + isValidWorkflowData, +} from '../utils'; +import type { WorkflowFile } from '../types'; + +export const processZipFile = async ( + workflowFile: WorkflowFile, +): Promise<{ workflowData: Record; error?: string }> => { + try { + const zipBase64 = await convertFileToBase64(workflowFile.file); + + // ZIP文件直接作为工作流数据,让批量导入API处理 + return { + workflowData: { + name: sanitizeWorkflowName( + workflowFile.fileName.replace(/\.zip$/i, ''), + ), + description: 'ZIP工作流文件,将在导入时自动解析', + format: 'zip', + data: zipBase64, + }, + }; + } catch (error) { + console.error('ZIP处理失败:', error); + return { + workflowData: {}, + error: + error instanceof Error ? error.message : '处理ZIP文件时发生未知错误', + }; + } +}; + +export const processTextFile = async ( + workflowFile: WorkflowFile, +): Promise<{ workflowData: Record; error?: string }> => { + try { + const fileContent = await workflowFile.file.text(); + const fileName = workflowFile.fileName.toLowerCase(); + const isYamlFile = fileName.endsWith('.yml') || fileName.endsWith('.yaml'); + const workflowData = parseWorkflowData(fileContent, isYamlFile); + + if (!isValidWorkflowData(workflowData)) { + return { + workflowData: {}, + error: '无效的工作流数据格式,请检查文件内容', + }; + } + + return { workflowData }; + } catch (error) { + console.error('文本文件处理失败:', error); + return { + workflowData: {}, + error: error instanceof Error ? error.message : '解析文件时发生未知错误', + }; + } +}; + +export const createWorkflowPreview = ( + workflowData: Record, +) => ({ + name: (workflowData.name as string) || '未命名工作流', + description: (workflowData.description as string) || '', + nodeCount: + Array.isArray(workflowData.nodes) || Array.isArray(workflowData.steps) + ? (workflowData.nodes as unknown[])?.length || + (workflowData.steps as unknown[])?.length || + 0 + : 0, + edgeCount: Array.isArray(workflowData.edges) + ? (workflowData.edges as unknown[]).length + : 0, + version: (workflowData.version as string) || '1.0', +}); + +export const updateFileStatus = ( + files: WorkflowFile[], + fileId: string, + updates: Partial, +): WorkflowFile[] => + files.map(f => (f.id === fileId ? { ...f, ...updates } : f)); + +export const createWorkflowName = ( + workflowData: Record, + fileName: string, +): string => { + const baseName = + (workflowData.name as string) || + fileName.replace(/\.(json|yml|yaml|zip)$/i, ''); + return sanitizeWorkflowName(baseName); +}; diff --git a/frontend/apps/coze-studio/src/pages/workflow-import/utils/i18n.ts b/frontend/apps/coze-studio/src/pages/workflow-import/utils/i18n.ts new file mode 100644 index 0000000000..d40e1b2650 --- /dev/null +++ b/frontend/apps/coze-studio/src/pages/workflow-import/utils/i18n.ts @@ -0,0 +1,247 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +type Locale = 'zh-CN' | 'en-US'; + +const messages = { + 'zh-CN': { + no_valid_files_to_import: '没有有效的文件可以导入', + file_name_error: '文件 "{fileName}": {error}', + workflow_name_duplicate: '工作流名称重复: "{workflowName}"', + batch_import_files: '批量导入文件:', + batch_import_failed_http: '批量导入失败,HTTP状态码: {status}', + invalid_response_format: '服务器返回了无效的响应格式,请检查API接口', + batch_import_api_response: '批量导入API响应:', + batch_import_failed: '批量导入失败', + import_workflow: '导入工作流', + cancel: '取消', + import_count: '导入 ({count})', + upload_files: '上传文件', + drag_files_here: '拖拽文件到此处', + or: '或', + click_to_select: '点击选择文件', + supported_formats: '支持 JSON、YAML、ZIP 格式', + // Main import page + drag_and_drop_or_click: '拖拽文件到此处或点击选择文件', + batch_select_description: + '支持同时选择多个工作流文件(JSON、YAML、ZIP格式),最多50个文件。ZIP文件将自动解析。', + select_files: '选择文件', + // File list + file_list: '文件列表', + valid_files: '有效', + failed_files: '失败', + clear_all: '清空全部', + workflow_name_placeholder: '工作流名称', + // Result modal + import_result: '导入结果', + import_partial_complete: '导入部分完成', + import_success: '导入成功', + import_failed: '导入失败', + import_partial_message: + '共导入 {total} 个文件,成功 {success} 个,失败 {failed} 个', + import_success_message: '成功导入 {count} 个工作流', + import_failed_message: '导入失败,共 {count} 个文件未能成功导入', + close: '关闭', + complete: '完成', + view_workflow: '查看工作流', + // Validation messages + workflow_name_empty: '工作流名称不能为空', + workflow_name_must_start_letter: '工作流名称必须以字母开头', + workflow_name_invalid_chars: '工作流名称只能包含字母、数字和下划线', + workflow_name_length_invalid: '工作流名称长度应在2-100个字符之间', + // File status messages + file_status_pending: '等待中', + file_status_validating: '验证中...', + file_status_valid: '✅ 有效', + file_status_invalid: '❌ 无效', + file_status_importing: '导入中...', + file_status_success: '✅ 导入成功', + file_status_failed: '❌ 导入失败', + file_status_needs_check: '需要检查', + // File error messages + file_error_import_failed: '导入失败', + file_error_invalid_file: '文件无效', + file_error_unknown: '未知错误,请检查文件格式和内容', + file_error_suggestion: + '💡 建议:请检查文件内容格式,或查看后端日志获取详细信息', + // File preview messages + file_preview_name: '名称', + file_preview_nodes: '节点', + file_preview_connections: '连接', + file_preview_version: '版本', + file_preview_description: '描述', + // Import buttons messages + import_button_cancel: '❌ 取消', + import_button_importing: '导入中...', + import_button_import: '📦 导入工作流 ({count}个文件)', + // Common buttons + delete: '删除', + // Alert messages + missing_workspace_id: '缺少工作空间ID', + please_select_files: '请先选择文件', + batch_import_failed_retry: '批量导入失败,请重试', + failed_files_details: '失败文件详情', + show_failed_files: '查看失败详情', + hide_failed_files: '隐藏失败详情', + error_reason: '失败原因', + workflow: '工作流', + unknown_error: '未知错误', + }, + 'en-US': { + no_valid_files_to_import: 'No valid files to import', + file_name_error: 'File "{fileName}": {error}', + workflow_name_duplicate: 'Duplicate workflow name: "{workflowName}"', + batch_import_files: 'Batch import files:', + batch_import_failed_http: 'Batch import failed, HTTP status code: {status}', + invalid_response_format: + 'Server returned invalid response format, please check API interface', + batch_import_api_response: 'Batch import API response:', + batch_import_failed: 'Batch import failed', + import_workflow: 'Import Workflow', + cancel: 'Cancel', + import_count: 'Import ({count})', + upload_files: 'Upload Files', + drag_files_here: 'Drag files here', + or: 'or', + click_to_select: 'Click to select files', + supported_formats: 'Supports JSON, YAML, ZIP formats', + // Main import page + drag_and_drop_or_click: 'Drag files here or click to select', + batch_select_description: + 'Support selecting multiple workflow files (JSON, YAML, ZIP formats), up to 50 files. ZIP files will be parsed automatically.', + select_files: 'Select Files', + // File list + file_list: 'File List', + valid_files: 'Valid', + failed_files: 'Failed', + clear_all: 'Clear All', + workflow_name_placeholder: 'Workflow Name', + // Result modal + import_result: 'Import Result', + import_partial_complete: 'Import Partially Complete', + import_success: 'Import Successful', + import_failed: 'Import Failed', + import_partial_message: + 'Imported {total} files in total, {success} successful, {failed} failed', + import_success_message: 'Successfully imported {count} workflows', + import_failed_message: 'Import failed, {count} files could not be imported', + close: 'Close', + complete: 'Complete', + view_workflow: 'View Workflow', + // Validation messages + workflow_name_empty: 'Workflow name cannot be empty', + workflow_name_must_start_letter: 'Workflow name must start with a letter', + workflow_name_invalid_chars: + 'Workflow name can only contain letters, numbers and underscores', + workflow_name_length_invalid: + 'Workflow name length should be between 2-100 characters', + // File status messages + file_status_pending: 'Pending', + file_status_validating: 'Validating...', + file_status_valid: '✅ Valid', + file_status_invalid: '❌ Invalid', + file_status_importing: 'Importing...', + file_status_success: '✅ Import Successful', + file_status_failed: '❌ Import Failed', + file_status_needs_check: 'Needs Check', + // File error messages + file_error_import_failed: 'Import Failed', + file_error_invalid_file: 'Invalid File', + file_error_unknown: 'Unknown error, please check file format and content', + file_error_suggestion: + '💡 Suggestion: Please check file content format, or view backend logs for detailed information', + // File preview messages + file_preview_name: 'Name', + file_preview_nodes: 'Nodes', + file_preview_connections: 'Connections', + file_preview_version: 'Version', + file_preview_description: 'Description', + // Import buttons messages + import_button_cancel: '❌ Cancel', + import_button_importing: 'Importing...', + import_button_import: '📦 Import Workflows ({count} files)', + // Common buttons + delete: 'Delete', + // Alert messages + missing_workspace_id: 'Missing workspace ID', + please_select_files: 'Please select files first', + batch_import_failed_retry: 'Batch import failed, please retry', + failed_files_details: 'Failed Files Details', + show_failed_files: 'Show Failed Details', + hide_failed_files: 'Hide Failed Details', + error_reason: 'Error Reason', + workflow: 'Workflow', + unknown_error: 'Unknown error', + }, +}; + +// Get current locale from browser language or localStorage +function getCurrentLocale(): Locale { + // Check localStorage first + const savedLocale = localStorage.getItem('coze-locale'); + if (savedLocale && (savedLocale === 'zh-CN' || savedLocale === 'en-US')) { + return savedLocale as Locale; + } + + // Fallback to browser language + const browserLang = navigator.language || 'en-US'; + if (browserLang.startsWith('zh')) { + return 'zh-CN'; + } + return 'en-US'; +} + +// Translate function with parameter replacement +export function t( + key: string, + params?: Record, +): string { + const locale = getCurrentLocale(); + const messageMap = messages[locale]; + + let message = messageMap[key as keyof typeof messageMap]; + if (!message) { + // Fallback to English if key not found in current locale + message = messages['en-US'][key as keyof (typeof messages)['en-US']]; + } + + if (!message) { + // Return key itself if not found in any locale + return key; + } + + // Replace parameters if provided + if (params) { + return message.replace( + /\{(\w+)\}/g, + (match, paramKey) => params[paramKey]?.toString() || match, + ); + } + + return message; +} + +// Export current locale for conditional logic +export function getCurrentLanguage(): Locale { + return getCurrentLocale(); +} + +// Set locale and persist to localStorage +export function setLocale(locale: Locale): void { + localStorage.setItem('coze-locale', locale); + // Trigger a custom event to notify components about locale change + window.dispatchEvent(new CustomEvent('locale-changed', { detail: locale })); +} diff --git a/frontend/apps/coze-studio/src/routes/async-components.tsx b/frontend/apps/coze-studio/src/routes/async-components.tsx index 80574ab75b..dedcd73b86 100644 --- a/frontend/apps/coze-studio/src/routes/async-components.tsx +++ b/frontend/apps/coze-studio/src/routes/async-components.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { lazy } from 'react'; +import React, { lazy } from 'react'; // login page export const LoginPage = lazy(() => @@ -114,6 +114,20 @@ export const WorkflowPage = lazy(() => })), ); +// workflow import page +export const WorkflowImportPage = lazy(async () => { + const WorkflowImportModule = await import('../pages/workflow-import'); + return { + default: () => + React.createElement(WorkflowImportModule.default, { + visible: true, + onCancel: () => { + /* Empty handler for modal closure */ + }, + }), + }; +}); + // plugin resource page layout component export const PluginLayout = lazy(() => import('../pages/plugin/layout')); diff --git a/frontend/apps/coze-studio/src/routes/index.tsx b/frontend/apps/coze-studio/src/routes/index.tsx index a8b7995707..91eb047bfc 100644 --- a/frontend/apps/coze-studio/src/routes/index.tsx +++ b/frontend/apps/coze-studio/src/routes/index.tsx @@ -33,6 +33,7 @@ import { spaceSubMenu, exploreSubMenu, WorkflowPage, + WorkflowImportPage, ProjectIDE, ProjectIDEPublish, Library, @@ -180,6 +181,15 @@ export const router: ReturnType = }), }, + // workflow import + { + path: 'workflow/import', + Component: WorkflowImportPage, + loader: () => ({ + subMenuKey: SpaceSubModuleEnum.LIBRARY, + }), + }, + // Knowledge Base Resources { path: 'knowledge', diff --git a/frontend/config/tailwind-config/eslint.config.js b/frontend/config/tailwind-config/eslint.config.cjs similarity index 100% rename from frontend/config/tailwind-config/eslint.config.js rename to frontend/config/tailwind-config/eslint.config.cjs diff --git a/frontend/config/tailwind-config/package.json b/frontend/config/tailwind-config/package.json index f173abc8a3..960d4bdd89 100644 --- a/frontend/config/tailwind-config/package.json +++ b/frontend/config/tailwind-config/package.json @@ -3,6 +3,7 @@ "version": "0.0.1", "author": "huangjian@bytedance.com", "maintainers": [], + "type": "module", "exports": { ".": "./src/index.js", "./coze": "./src/coze.js", diff --git a/frontend/config/tailwind-config/src/design-token.ts b/frontend/config/tailwind-config/src/design-token.ts index 8884a2f345..8d2bcb8f9e 100644 --- a/frontend/config/tailwind-config/src/design-token.ts +++ b/frontend/config/tailwind-config/src/design-token.ts @@ -105,4 +105,4 @@ function borderRadiusTransformer(borderRadiusObj: Record) { // Get other packages and splice /src /**/*.{ ts, tsx} -export { getTailwindContents } from './tailwind-contents'; +export { getTailwindContents } from './tailwind-contents.js'; diff --git a/frontend/packages/agent-ide/plugin-content/package.json b/frontend/packages/agent-ide/plugin-content/package.json index c6d75f423a..a1fa7d1bc6 100644 --- a/frontend/packages/agent-ide/plugin-content/package.json +++ b/frontend/packages/agent-ide/plugin-content/package.json @@ -24,6 +24,7 @@ "@coze-arch/report-events": "workspace:*", "@coze-studio/bot-detail-store": "workspace:*", "@coze-studio/components": "workspace:*", + "axios": "^1.4.0", "classnames": "^2.3.2", "copy-to-clipboard": "^3.3.3" }, diff --git a/frontend/packages/agent-ide/plugin-content/src/components/plugin-content/index.tsx b/frontend/packages/agent-ide/plugin-content/src/components/plugin-content/index.tsx index ce6d8b07d7..af2a8d6d3c 100644 --- a/frontend/packages/agent-ide/plugin-content/src/components/plugin-content/index.tsx +++ b/frontend/packages/agent-ide/plugin-content/src/components/plugin-content/index.tsx @@ -17,6 +17,7 @@ import { type ReactNode, type FC, useMemo } from 'react'; import copy from 'copy-to-clipboard'; +import axios from 'axios'; import { ParametersPopover } from '@coze-studio/components/parameters-popover'; import { type EnabledPluginApi } from '@coze-studio/bot-detail-store/skill-types'; import { useBotSkillStore } from '@coze-studio/bot-detail-store/bot-skill'; @@ -25,7 +26,7 @@ import { REPORT_EVENTS as ReportEventNames, } from '@coze-arch/report-events'; import { I18n } from '@coze-arch/i18n'; -import { IconCozTrashCan } from '@coze-arch/coze-design/icons'; +import { IconCozTrashCan, IconCozDownload } from '@coze-arch/coze-design/icons'; import { IconButton, Tag, Toast } from '@coze-arch/coze-design'; import { EVENT_NAMES, sendTeaEvent } from '@coze-arch/bot-tea'; import { CustomError } from '@coze-arch/bot-error'; @@ -238,6 +239,28 @@ const Actions: FC = ({ setIsForceShowAction(visible); }; + const handleExport = async () => { + try { + const response = await axios.post( + '/api/workflow/export', + { + workflow_ids: [plugin.api.api_id], + }, + { responseType: 'blob' }, + ); + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `${plugin.api.name || 'workflow'}.json`); + document.body.appendChild(link); + link.click(); + link.parentNode?.removeChild(link); + Toast.success({ content: 'Export success', showClose: false }); + } catch (e) { + Toast.error({ content: 'Export failed', showClose: false }); + } + }; + return ( <> {/* Icons - Info Tips */} @@ -262,6 +285,15 @@ const Actions: FC = ({ {!readonly && ( <> {slot} + {/* Action Export */} + } + onClick={handleExport} + size="small" + title="导出" + data-testid="bot.editor.tool.plugin.export-button" + disabled={isBanned} + /> {/* Action settings */} { + try { + // Replace large integers with strings to prevent precision loss + // Pattern matches integers that are likely to be IDs (typically > 10^15) + const safeJsonString = jsonString.replace( + /"(ResID|CreatorID|EditTime|SpaceID|res_id|creator_id|edit_time|space_id)"\s*:\s*(\d{16,})/g, + '"$1":"$2"', + ); + + return JSON.parse(safeJsonString); + } catch (error) { + console.warn( + '[BigInt Fix] Failed to parse JSON safely, falling back to default parser:', + error, + ); + return JSON.parse(jsonString); + } +}; + +export const axiosInstance = axios.create({ + // Add custom transformResponse to handle large integers + transformResponse: [ + function (data: unknown) { + if (typeof data === 'string') { + try { + return safeBigIntJSONParse(data); + } catch (error) { + return data; + } + } + return data; + }, + ], +}); const HTTP_STATUS_COE_UNAUTHORIZED = 401; diff --git a/frontend/packages/arch/resources/studio-i18n-resource/src/locale-data.d.ts b/frontend/packages/arch/resources/studio-i18n-resource/src/locale-data.d.ts index 25f0063456..519b08da85 100644 --- a/frontend/packages/arch/resources/studio-i18n-resource/src/locale-data.d.ts +++ b/frontend/packages/arch/resources/studio-i18n-resource/src/locale-data.d.ts @@ -1573,6 +1573,10 @@ export interface I18nOptionsMap { }; Data_request_download_data_records: { date: ReactNode /* string */ }; datasets_botRefer_list_description_after: { num: ReactNode /* number */ }; + workflow_import_button_import: { count: ReactNode /* string */ }; + workflow_import_partial_message: { total: ReactNode /* string */; success: ReactNode /* string */; failed: ReactNode /* string */ }; + workflow_import_success_message: { count: ReactNode /* string */ }; + workflow_import_failed_message: { count: ReactNode /* string */ }; } // #endregion @@ -8405,6 +8409,7 @@ export type I18nKeysNoOptionsType = | 'evaluation_delete_failed' | 'evaluation_description' | 'evaluation_editTime' + | 'export' | 'evaluation_export_failed' | 'evaluation_leaderboard_details' | 'evaluation_leaderboard_details_vote_count' @@ -16079,6 +16084,57 @@ export type I18nKeysNoOptionsType = | 'workflowstore_workflow_copy_successful' | 'workflowstore_workflow_in_review_process' | 'workflowstore_workflow_information' + | 'workflow_import' + | 'workflow_import_back_to_library' + | 'workflow_import_batch_description' + | 'workflow_import_cancel' + | 'workflow_import_clear_all' + | 'workflow_import_click_upload' + | 'workflow_import_close' + | 'workflow_import_complete' + | 'workflow_import_delete' + | 'workflow_import_description' + | 'workflow_import_drag_and_drop' + | 'workflow_import_drag_drop' + | 'workflow_import_error_reason' + | 'workflow_import_failed' + | 'workflow_import_failed_files_details' + | 'workflow_import_file_list' + | 'workflow_import_file_selected' + | 'workflow_import_format_json' + | 'workflow_import_format_yaml' + | 'workflow_import_format_size' + | 'workflow_import_format_complete' + | 'workflow_import_importing' + | 'workflow_import_name' + | 'workflow_import_nodes' + | 'workflow_import_edges' + | 'workflow_import_partial_complete' + | 'workflow_import_preview' + | 'workflow_import_preview_loading' + | 'workflow_import_process' + | 'workflow_import_process_step1' + | 'workflow_import_process_step2' + | 'workflow_import_process_step3' + | 'workflow_import_process_step4' + | 'workflow_import_result' + | 'workflow_import_select_file' + | 'workflow_import_select_file_tip' + | 'workflow_import_select_workflow_file' + | 'workflow_import_success' + | 'workflow_import_support_format' + | 'workflow_import_supported_formats' + | 'workflow_import_tip' + | 'workflow_import_unknown_error' + | 'workflow_import_upload_area' + | 'workflow_import_usage_guide' + | 'workflow_import_view_workflow' + | 'workflow_import_workflow_name' + | 'workflow_import_workflow_name_max_length' + | 'workflow_import_workflow_name_min_length' + | 'workflow_import_workflow_name_placeholder' + | 'workflow_import_workflow_name_required' + | 'workflow_name_invalid_chars' | 'worklfow_condition_add_branch' | 'worklfow_condition_add_condition' | 'worklfow_condition_condition_branch' @@ -16224,7 +16280,21 @@ export type I18nKeysNoOptionsType = | 'data_request_request_failed' | 'data_request_requesting' | 'other_knowledge' - | 'recall_knowledge_empty'; + | 'recall_knowledge_empty' + | 'workflow_export' + | 'workflow_export_success' + | 'workflow_export_failed' + | 'workflow_export_format_title' + | 'workflow_export_format_json_desc' + | 'workflow_export_format_yaml_desc' + | 'workflow_export_format_json_badge' + | 'workflow_export_format_yaml_badge' + | 'workflow_export_confirm' + | 'workflow_export_error_empty_data' + | 'workflow_export_error_invalid_format' + | 'workflow_export_error_network' + | 'workflow_export_error_permission' + | 'workflow_export_error_not_found'; // #endregion // #region LocaleData diff --git a/frontend/packages/arch/resources/studio-i18n-resource/src/locales/en.json b/frontend/packages/arch/resources/studio-i18n-resource/src/locales/en.json index 1f3189b556..c1fdbec7ee 100644 --- a/frontend/packages/arch/resources/studio-i18n-resource/src/locales/en.json +++ b/frontend/packages/arch/resources/studio-i18n-resource/src/locales/en.json @@ -217,6 +217,93 @@ "analytic_query_export": "Export to Excel", "analytic_query_export_content": "The maximum number of items that can be exported at one time is {maxExportCount}, currently {selectedExportCount} items are selected. Are you sure you want to export the first {maxExportCount} entries if you continue?", "analytic_query_export_title": "Exceeds the maximum number that can be exported at one time", + "workflow_export": "Export Workflow", + "workflow_export_success": "Workflow exported successfully", + "workflow_export_failed": "Workflow export failed", + "workflow_export_format_title": "Choose Export Format", + "workflow_export_format_json_desc": "JSON Format", + "workflow_export_format_yaml_desc": "YAML Format", + "workflow_export_format_json_badge": "Recommended", + "workflow_export_format_yaml_badge": "Readable", + "workflow_export_confirm": "Confirm Export", + "workflow_export_error_empty_data": "Export data is empty", + "workflow_export_error_invalid_format": "Unsupported export format", + "workflow_export_error_network": "Network error, please check connection", + "workflow_export_error_permission": "Insufficient permissions to export this workflow", + "workflow_export_error_not_found": "The specified workflow was not found", + "recommended": "Recommended", + "human_readable": "Human Readable", + "fast_processing": "Fast processing", + "wide_compatibility": "Wide compatibility", + "easy_to_read": "Easy to read", + "configuration_friendly": "Config friendly", + "workflow_import": "Import Workflow", + "workflow_import_success": "Workflow imported successfully", + "workflow_import_failed": "Workflow import failed", + "workflow_import_error_invalid_file": "Invalid file format", + "workflow_import_error_parse_failed": "Failed to parse file, please check the file format", + "workflow_import_error_invalid_structure": "Invalid workflow file structure", + "workflow_import_error_missing_name": "Missing workflow name or ID", + "workflow_import_error_network": "Network error, please check connection", + "workflow_import_error_permission": "Insufficient permissions to import workflow", + "workflow_import_select_file": "Select File", + "workflow_import_click_upload": "Click to Upload", + "workflow_import_batch_description": "You can import multiple workflow files at once (JSON, YAML, ZIP formats), ZIP files will be parsed automatically.", + "workflow_import_drag_and_drop": "Drag and drop files here or click to browse", + "workflow_import_file_list": "File List", + "workflow_import_clear_all": "Clear All", + "workflow_import_delete": "Delete", + "workflow_import_cancel": "Cancel", + "workflow_import_result": "Import Result", + "workflow_import_partial_complete": "Import Partially Complete", + "workflow_import_partial_message": "Imported {total} files in total, {success} successful, {failed} failed", + "workflow_import_success_message": "Successfully imported {count} workflows", + "workflow_import_failed_message": "Import failed, {count} files could not be imported", + "workflow_import_close": "Close", + "workflow_import_complete": "Complete", + "workflow_import_view_workflow": "View Workflow", + "workflow_import_failed_files_details": "Failed Files Details", + "workflow_import_error_reason": "Error Reason", + "workflow_import_unknown_error": "Unknown error", + "workflow_import_importing": "Importing...", + "workflow_import_button_import": "Import Workflows ({count} files)", + "workflow_import_error_missing_name": "Missing workspace ID", + "workflow_import_select_file_tip": "Please select files first", + "workflow_import_preview": "Workflow Preview", + "workflow_import_name": "Name", + "workflow_import_description": "Description", + "workflow_import_nodes": "Nodes", + "workflow_import_edges": "Edges", + "workflow_import_workflow_name": "Workflow Name", + "workflow_import_workflow_name_placeholder": "Please enter workflow name", + "workflow_import_workflow_name_required": "Please enter workflow name", + "workflow_import_workflow_name_max_length": "Workflow name cannot exceed 50 characters", + "workflow_import_workflow_name_min_length": "Workflow name must be at least 2 characters", + "workflow_name_invalid_chars": "Workflow name must start with a letter and can only contain letters, numbers and underscores", + "workflow_import_tip": "After import, a new workflow will be created, the original workflow will not be affected", + "workflow_import_select_file_tip": "Select a file to display workflow preview information", + "workflow_import_back_to_library": "Back to Library", + "workflow_import_description": "Select a previously exported workflow JSON or YAML file to import it into the current workspace.", + "workflow_import_select_workflow_file": "Select Workflow File", + "workflow_import_usage_guide": "Usage Guide", + "workflow_import_supported_formats": "Supported File Formats", + "workflow_import_format_json": "JSON format workflow export files", + "workflow_import_format_yaml": "YAML format workflow export files", + "workflow_import_format_size": "File size not exceeding 10MB", + "workflow_import_format_complete": "Must contain complete workflow architecture information", + "workflow_import_process": "Import Process", + "workflow_import_process_step1": "Select the JSON or YAML file to import", + "workflow_import_process_step2": "System automatically parses and previews workflow information", + "workflow_import_process_step3": "Confirm or modify workflow name", + "workflow_import_process_step4": "Click 'Start Import' to complete import", + "workflow_import_drag_drop": "Drag and drop files here, or click to upload", + "workflow_import_file_selected": "File selected", + "workflow_import_upload_area": "Upload area", + "workflow_import_preview_loading": "Parsing file...", + "import": "Import", + "export": "Export", + "export_success": "Export successful", + "export_failed": "Export failed", "analytic_query_filters_key_botversion": "Agent Vers.", "analytic_query_filters_key_input": "Input", "analytic_query_filters_key_output": "Output", @@ -3765,6 +3852,33 @@ "worklfow_start_basic_setting": "Basic settings", "worklfow_trigger_bind_delete": "The bound workflow is invalid", "worklfow_without_run": "Workflow is not running", + "workflow_import_description": "Click import button to navigate to workflow import page", + "import_partial_complete": "Import Partially Complete", + "import_success": "Import Successful", + "import_failed": "Import Failed", + "import_partial_message": "Imported {total} files in total, {success} successful, {failed} failed", + "import_success_message": "Successfully imported {count} workflows", + "import_failed_message": "Import failed, {count} files could not be imported", + "file_status_pending": "Pending", + "file_status_validating": "Validating...", + "file_status_valid": "✅ Valid", + "file_status_invalid": "❌ Invalid", + "file_status_importing": "Importing...", + "file_status_success": "✅ Import Successful", + "file_status_failed": "❌ Import Failed", + "file_status_needs_check": "Needs Check", + "file_error_import_failed": "Import Failed", + "file_error_invalid_file": "Invalid File", + "file_error_unknown": "Unknown error, please check file format and content", + "file_error_suggestion": "💡 Suggestion: Please check file content format, or view backend logs for detailed information", + "file_preview_name": "Name", + "file_preview_nodes": "Nodes", + "file_preview_connections": "Connections", + "file_preview_version": "Version", + "file_preview_description": "Description", + "import_button_cancel": "❌ Cancel", + "import_button_importing": "Importing...", + "import_button_import": "📦 Import Workflows ({count} files)", "workspace_create": "Create", "workspace_develop": "Development", "workspace_develop_search_project": "Search for projects", diff --git a/frontend/packages/arch/resources/studio-i18n-resource/src/locales/zh-CN.json b/frontend/packages/arch/resources/studio-i18n-resource/src/locales/zh-CN.json index 9ee69987fa..115b9b6862 100644 --- a/frontend/packages/arch/resources/studio-i18n-resource/src/locales/zh-CN.json +++ b/frontend/packages/arch/resources/studio-i18n-resource/src/locales/zh-CN.json @@ -3,7 +3,7 @@ "About_Plugins_tip": "关于插件", "Actions": "操作", "AddFailedToast": "Workflow 中所引用的插件请求失败,Workflow添加失败。", - "AddSuccessToast": "“{name}”添加成功。", + "AddSuccessToast": "\"{name}\"添加成功。", "Add_1": "添加", "Add_2": "添加", "Added": "已添加", @@ -31,7 +31,7 @@ "Create_newtool_s1_title_empty": "请输入工具名称,确保名称含义清晰且符合平台规范", "Create_newtool_s1_title_error1": "只能包含字母、数字、下划线", "Create_newtool_s1_url": "工具路径", - "Create_newtool_s1_url_empty": "输入工具具体路径,以/开头,如“/search”", + "Create_newtool_s1_url_empty": "输入工具具体路径,以/开头,如\"/search\"", "Create_newtool_s1_url_error1": "路径需要以 / 开头", "Create_newtool_s1_url_error2": "请输入工具具体路径", "Create_newtool_s2": "配置输入参数", @@ -217,6 +217,93 @@ "analytic_query_export": "导出为 Excel", "analytic_query_export_content": "单次最大可导出 {maxExportCount} 条,目前选择 {selectedExportCount} 条。确定继续导出则导出前 {maxExportCount} 条吗?", "analytic_query_export_title": "超过单次可导出最大数量", + "workflow_export": "导出工作流", + "workflow_export_success": "工作流导出成功", + "workflow_export_failed": "工作流导出失败", + "workflow_export_format_title": "选择导出格式", + "workflow_export_format_json_desc": "JSON格式", + "workflow_export_format_yaml_desc": "YAML格式", + "workflow_export_format_json_badge": "推荐", + "workflow_export_format_yaml_badge": "易读", + "workflow_export_confirm": "确定导出", + "workflow_export_error_empty_data": "导出数据为空", + "workflow_export_error_invalid_format": "不支持的导出格式", + "workflow_export_error_network": "网络错误,请检查连接", + "workflow_export_error_permission": "权限不足,无法导出此工作流", + "workflow_export_error_not_found": "未找到指定的工作流", + "recommended": "推荐", + "human_readable": "人类可读", + "fast_processing": "处理快速", + "wide_compatibility": "兼容性广", + "easy_to_read": "易于阅读", + "configuration_friendly": "配置友好", + "workflow_import": "导入工作流", + "workflow_import_success": "工作流导入成功", + "workflow_import_failed": "工作流导入失败", + "workflow_import_error_invalid_file": "无效的文件格式", + "workflow_import_error_parse_failed": "文件解析失败,请检查文件格式", + "workflow_import_error_invalid_structure": "无效的工作流文件结构", + "workflow_import_error_missing_name": "缺少工作流名称或ID", + "workflow_import_error_network": "网络错误,请检查连接", + "workflow_import_error_permission": "权限不足,无法导入工作流", + "workflow_import_select_file": "选择文件", + "workflow_import_click_upload": "点击上传", + "workflow_import_batch_description": "您可以一次导入多个工作流文件(JSON、YAML、ZIP格式), ZIP文件将自动解析.", + "workflow_import_drag_and_drop": "拖拽文件到此处或点击浏览", + "workflow_import_file_list": "文件列表", + "workflow_import_clear_all": "清空全部", + "workflow_import_delete": "删除", + "workflow_import_cancel": "取消", + "workflow_import_result": "导入结果", + "workflow_import_partial_complete": "导入部分完成", + "workflow_import_partial_message": "共导入 {total} 个文件,成功 {success} 个,失败 {failed} 个", + "workflow_import_success_message": "成功导入 {count} 个工作流", + "workflow_import_failed_message": "导入失败,共 {count} 个文件未能成功导入", + "workflow_import_close": "关闭", + "workflow_import_complete": "完成", + "workflow_import_view_workflow": "查看工作流", + "workflow_import_failed_files_details": "失败文件详情", + "workflow_import_error_reason": "失败原因", + "workflow_import_unknown_error": "未知错误", + "workflow_import_importing": "导入中...", + "workflow_import_button_import": "导入工作流 ({count}个文件)", + "workflow_import_error_missing_name": "缺少工作空间ID", + "workflow_import_select_file_tip": "请先选择文件", + "workflow_import_preview": "工作流预览", + "workflow_import_name": "名称", + "workflow_import_description": "描述", + "workflow_import_nodes": "节点", + "workflow_import_edges": "连线", + "workflow_import_workflow_name": "工作流名称", + "workflow_import_workflow_name_placeholder": "请输入工作流名称", + "workflow_import_workflow_name_required": "请输入工作流名称", + "workflow_import_workflow_name_max_length": "工作流名称最多50个字符", + "workflow_import_workflow_name_min_length": "工作流名称至少2个字符", + "workflow_name_invalid_chars": "工作流名称必须以字母开头,只能包含字母、数字和下划线", + "workflow_import_tip": "导入后将创建一个新的工作流,原有工作流不会被影响", + "workflow_import_select_file_tip": "选择文件后将显示工作流预览信息", + "workflow_import_back_to_library": "返回资源库", + "workflow_import_description": "选择之前导出的工作流JSON或YAML文件, 将其导入到当前工作空间中.", + "workflow_import_select_workflow_file": "选择工作流文件", + "workflow_import_usage_guide": "使用说明", + "workflow_import_supported_formats": "支持的文件格式", + "workflow_import_format_json": "JSON格式的工作流导出文件", + "workflow_import_format_yaml": "YAML格式的工作流导出文件", + "workflow_import_format_size": "文件大小不超过10MB", + "workflow_import_format_complete": "必须包含完整的工作流架构信息", + "workflow_import_process": "导入流程", + "workflow_import_process_step1": "选择要导入的JSON或YAML文件", + "workflow_import_process_step2": "系统自动解析并预览工作流信息", + "workflow_import_process_step3": "确认或修改工作流名称", + "workflow_import_process_step4": "点击\\\"开始导入\\\"完成导入", + "workflow_import_drag_drop": "拖拽文件到此处,或点击上传", + "workflow_import_file_selected": "已选择文件", + "workflow_import_upload_area": "上传区域", + "workflow_import_preview_loading": "正在解析文件...", + "import": "导入", + "export": "导出", + "export_success": "导出成功", + "export_failed": "导出失败", "analytic_query_filters_key_botversion": "智能体版本", "analytic_query_filters_key_input": "输入", "analytic_query_filters_key_output": "输出", @@ -3813,6 +3900,33 @@ "worklfow_start_basic_setting": "基础设置", "worklfow_trigger_bind_delete": "绑定的工作流已失效", "worklfow_without_run": "工作流未运行", + "workflow_import_description": "点击导入按钮跳转到工作流导入页面", + "import_partial_complete": "导入部分完成", + "import_success": "导入成功", + "import_failed": "导入失败", + "import_partial_message": "共导入 {total} 个文件,成功 {success} 个,失败 {failed} 个", + "import_success_message": "成功导入 {count} 个工作流", + "import_failed_message": "导入失败,共 {count} 个文件未能成功导入", + "file_status_pending": "等待中", + "file_status_validating": "验证中...", + "file_status_valid": "✅ 有效", + "file_status_invalid": "❌ 无效", + "file_status_importing": "导入中...", + "file_status_success": "✅ 导入成功", + "file_status_failed": "❌ 导入失败", + "file_status_needs_check": "需要检查", + "file_error_import_failed": "导入失败", + "file_error_invalid_file": "文件无效", + "file_error_unknown": "未知错误,请检查文件格式和内容", + "file_error_suggestion": "💡 建议:请检查文件内容格式,或查看后端日志获取详细信息", + "file_preview_name": "名称", + "file_preview_nodes": "节点", + "file_preview_connections": "连接", + "file_preview_version": "版本", + "file_preview_description": "描述", + "import_button_cancel": "❌ 取消", + "import_button_importing": "导入中...", + "import_button_import": "📦 导入工作流 ({count}个文件)", "workspace_create": "创建", "workspace_develop": "项目开发", "workspace_develop_search_project": "搜索项目", diff --git a/frontend/packages/components/bot-semi/tsconfig.misc.json b/frontend/packages/components/bot-semi/tsconfig.misc.json index 3802c36081..03c409c772 100644 --- a/frontend/packages/components/bot-semi/tsconfig.misc.json +++ b/frontend/packages/components/bot-semi/tsconfig.misc.json @@ -1,7 +1,7 @@ { "extends": "@coze-arch/ts-config/tsconfig.web.json", "$schema": "https://json.schemastore.org/tsconfig", - "include": ["__tests__", "stories", "vitest.config.ts"], + "include": ["__tests__", "stories", "vitest.config.ts", "vitest.setup.ts"], "exclude": ["./dist"], "references": [ { diff --git a/frontend/packages/components/bot-semi/vitest.config.ts b/frontend/packages/components/bot-semi/vitest.config.ts index 1b1724bf97..e6ca881110 100644 --- a/frontend/packages/components/bot-semi/vitest.config.ts +++ b/frontend/packages/components/bot-semi/vitest.config.ts @@ -18,6 +18,13 @@ import { defineConfig } from '@coze-arch/vitest-config'; export default defineConfig({ dirname: __dirname, - preset: 'node', - test: {}, + preset: 'web', + test: { + environment: 'happy-dom', + css: { + modules: { + classNameStrategy: 'stable', + }, + }, + }, }); diff --git a/frontend/packages/components/bot-semi/vitest.setup.ts b/frontend/packages/components/bot-semi/vitest.setup.ts new file mode 100644 index 0000000000..601a3664eb --- /dev/null +++ b/frontend/packages/components/bot-semi/vitest.setup.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Mock CSS modules for Node environment +const mockCssModules = new Proxy( + {}, + { + get: (target, prop) => { + if (typeof prop === 'string') { + return `mock-${prop}`; + } + return target[prop]; + }, + }, +); + +// Mock all .module.less imports +interface GlobalWithCSSModules { + // eslint-disable-next-line @typescript-eslint/naming-convention + __CSS_MODULES__: typeof mockCssModules; +} + +(global as unknown as GlobalWithCSSModules).__CSS_MODULES__ = mockCssModules; diff --git a/frontend/packages/studio/workspace/entry-base/src/components/workflow-import-i18n.ts b/frontend/packages/studio/workspace/entry-base/src/components/workflow-import-i18n.ts new file mode 100644 index 0000000000..fbcdc4fb93 --- /dev/null +++ b/frontend/packages/studio/workspace/entry-base/src/components/workflow-import-i18n.ts @@ -0,0 +1,127 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +type Locale = 'zh-CN' | 'en-US'; + +const messages = { + 'zh-CN': { + drag_and_drop_or_click: '拖拽文件到此处或点击选择文件', + supported_formats: '支持 JSON、YAML、ZIP 格式', + batch_select_description: + '支持同时选择多个工作流文件(JSON、YAML、ZIP格式),最多50个文件。ZIP文件将自动解析。', + select_files: '选择文件', + file_list: '文件列表', + clear_all: '清空全部', + workflow_name_placeholder: '工作流名称', + import_workflow: '导入工作流', + import_result: '导入结果', + cancel: '取消', + import_button_importing: '导入中...', + import_button_import: '导入工作流 ({count}个文件)', + delete: '删除', + import_partial_complete: '导入部分完成', + import_success: '导入成功', + import_failed: '导入失败', + import_partial_message: + '共导入 {total} 个文件,成功 {success} 个,失败 {failed} 个', + import_success_message: '成功导入 {count} 个工作流', + import_failed_message: '导入失败,共 {count} 个文件未能成功导入', + close: '关闭', + view_workflow: '查看工作流', + missing_workspace_id: '缺少工作空间ID', + please_select_files: '请先选择文件', + batch_import_failed_retry: '批量导入失败,请重试', + importing_files_progress: '正在导入 {count} 个工作流文件...', + failed_files_details: '失败文件详情', + error_reason: '失败原因', + complete: '完成', + workflow: '工作流', + unknown_error: '未知错误', + }, + 'en-US': { + drag_and_drop_or_click: 'Drag files here or click to select', + supported_formats: 'Supports JSON, YAML, ZIP formats', + batch_select_description: + 'Support selecting multiple workflow files (JSON, YAML, ZIP formats), up to 50 files. ZIP files will be parsed automatically.', + select_files: 'Select Files', + file_list: 'File List', + clear_all: 'Clear All', + workflow_name_placeholder: 'Workflow Name', + import_workflow: 'Import Workflow', + import_result: 'Import Result', + cancel: 'Cancel', + import_button_importing: 'Importing...', + import_button_import: 'Import Workflows ({count} files)', + delete: 'Delete', + import_partial_complete: 'Import Partially Complete', + import_success: 'Import Successful', + import_failed: 'Import Failed', + import_partial_message: + 'Imported {total} files in total, {success} successful, {failed} failed', + import_success_message: 'Successfully imported {count} workflows', + import_failed_message: 'Import failed, {count} files could not be imported', + close: 'Close', + view_workflow: 'View Workflow', + missing_workspace_id: 'Missing workspace ID', + please_select_files: 'Please select files first', + batch_import_failed_retry: 'Batch import failed, please retry', + importing_files_progress: 'Importing {count} workflow files...', + failed_files_details: 'Failed Files Details', + error_reason: 'Error Reason', + complete: 'Complete', + workflow: 'Workflow', + unknown_error: 'Unknown error', + }, +}; + +function getCurrentLocale(): Locale { + const savedLocale = localStorage.getItem('coze-locale'); + if (savedLocale && (savedLocale === 'zh-CN' || savedLocale === 'en-US')) { + return savedLocale as Locale; + } + + const browserLang = navigator.language || 'en-US'; + if (browserLang.startsWith('zh')) { + return 'zh-CN'; + } + return 'en-US'; +} + +export function t( + key: string, + params?: Record, +): string { + const locale = getCurrentLocale(); + const messageMap = messages[locale]; + + let message = messageMap[key as keyof typeof messageMap]; + if (!message) { + message = messages['en-US'][key as keyof (typeof messages)['en-US']]; + } + + if (!message) { + return key; + } + + if (params) { + return message.replace( + /\{(\w+)\}/g, + (match, paramKey) => params[paramKey]?.toString() || match, + ); + } + + return message; +} diff --git a/frontend/packages/studio/workspace/entry-base/src/components/workflow-import-modal.tsx b/frontend/packages/studio/workspace/entry-base/src/components/workflow-import-modal.tsx new file mode 100644 index 0000000000..3ab6c2c992 --- /dev/null +++ b/frontend/packages/studio/workspace/entry-base/src/components/workflow-import-modal.tsx @@ -0,0 +1,212 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; + +import { I18n } from '@coze-arch/i18n'; +import { Modal, Button, LoadingButton } from '@coze-arch/coze-design'; + +import { useWorkflowImportModal } from './workflow-import/use-workflow-import-modal'; +import ImportResultModalSection from './workflow-import/ImportResultModalSection'; +import FileUploadSection from './workflow-import/FileUploadSection'; +import FileListSection from './workflow-import/FileListSection'; + +interface WorkflowImportModalProps { + visible: boolean; + onCancel: () => void; +} + +const MODAL_WIDTHS = { FORM: 800, RESULT: 600 }; + +// Modal footer component +const ImportModalFooter = ({ + showImportForm, + validFileCount, + isImporting, + handleModalCancel, + handleImport, + hasProcessingFiles, + hasInvalidNames, +}: { + showImportForm: boolean; + validFileCount: number; + isImporting: boolean; + handleModalCancel: () => void; + handleImport: () => void; + hasProcessingFiles: boolean; + hasInvalidNames: boolean; +}) => { + if (!showImportForm) { + return null; + } + return ( +
+ + + {isImporting + ? I18n.t('workflow_import_importing') + : I18n.t('workflow_import_button_import', { + count: validFileCount.toString(), + })} + +
+ ); +}; + +const WorkflowImportModal: React.FC = ({ + visible, + onCancel, +}) => { + const { + selectedFiles, + setSelectedFiles, + dragActive, + isImporting, + showResultModal, + showImportForm, + resultModalData, + validFileCount, + hasProcessingFiles, + hasInvalidNames, + handleFilesSelected, + handleDragEnter, + handleDragLeave, + handleDragOver, + handleDrop, + handleImport, + handleClose, + handleViewWorkflow, + handleResultClose, + } = useWorkflowImportModal(); + + const handleModalCancel = () => { + handleClose(); + onCancel(); + }; + + return ( + <> + + } + > + {showImportForm ? ( + <> + + {selectedFiles.length > 0 && ( + + )} + + ) : ( +
+
+

+ {I18n.t('workflow_import_importing')} +

+

+ {I18n.t('workflow_import_importing')} +

+
+ )} +
+ + + + ); +}; + +export default WorkflowImportModal; diff --git a/frontend/packages/studio/workspace/entry-base/src/components/workflow-import/FileListSection.tsx b/frontend/packages/studio/workspace/entry-base/src/components/workflow-import/FileListSection.tsx new file mode 100644 index 0000000000..7b402e9951 --- /dev/null +++ b/frontend/packages/studio/workspace/entry-base/src/components/workflow-import/FileListSection.tsx @@ -0,0 +1,273 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, useCallback } from 'react'; + +import { I18n } from '@coze-arch/i18n'; +import { Button } from '@coze-arch/coze-design'; + +interface WorkflowFile { + id: string; + fileName: string; + workflowName: string; + originalContent: string; + workflowData: string; + status: + | 'pending' + | 'validating' + | 'valid' + | 'invalid' + | 'importing' + | 'success' + | 'failed'; + error?: string; +} + +interface FileListSectionProps { + selectedFiles: WorkflowFile[]; + isImporting: boolean; + onFilesChange: (files: WorkflowFile[]) => void; +} + +const validateWorkflowName = ( + name: string, +): { isValid: boolean; message?: string } => { + const MIN_NAME_LENGTH = 2; + const MAX_NAME_LENGTH = 50; + + if (!name || name.trim().length === 0) { + return { + isValid: false, + message: + I18n.t('workflow_import_workflow_name_required') || + 'Workflow name cannot be empty', + }; + } + + const trimmedName = name.trim(); + if (trimmedName.length < MIN_NAME_LENGTH) { + return { + isValid: false, + message: + I18n.t('workflow_import_workflow_name_min_length') || + `Workflow name must be at least ${MIN_NAME_LENGTH} characters`, + }; + } + + if (trimmedName.length > MAX_NAME_LENGTH) { + return { + isValid: false, + message: + I18n.t('workflow_import_workflow_name_max_length') || + `Workflow name cannot exceed ${MAX_NAME_LENGTH} characters`, + }; + } + + if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(trimmedName)) { + return { + isValid: false, + message: + I18n.t('workflow_name_invalid_chars') || + 'Workflow name must start with a letter and can only contain letters, numbers and underscores', + }; + } + + return { isValid: true }; +}; + +const FileItem: React.FC<{ + file: WorkflowFile; + isImporting: boolean; + selectedFiles: WorkflowFile[]; + onFilesChange: (files: WorkflowFile[]) => void; +}> = ({ file, isImporting, selectedFiles, onFilesChange }) => { + const [nameError, setNameError] = useState(null); + const [isFocused, setIsFocused] = useState(false); + + const handleNameChange = useCallback( + (value: string) => { + onFilesChange( + selectedFiles.map(f => + f.id === file.id ? { ...f, workflowName: value } : f, + ), + ); + + // 总是执行验证,显示相应的错误消息 + const validation = validateWorkflowName(value); + setNameError(validation.isValid ? null : validation.message || null); + }, + [file.id, selectedFiles, onFilesChange], + ); + + const handleFocus = useCallback(() => { + setIsFocused(true); + }, []); + + const handleBlur = useCallback(() => { + setIsFocused(false); + // 总是执行验证,确保错误提示正确显示 + const validation = validateWorkflowName(file.workflowName); + setNameError(validation.isValid ? null : validation.message || null); + }, [file.workflowName]); + + return ( +
+
+
+
+ {file.fileName} +
+
+ +
+ handleNameChange(e.target.value)} + onFocus={handleFocus} + onBlur={handleBlur} + placeholder={I18n.t( + 'workflow_import_workflow_name_placeholder', + )} + disabled={isImporting} + style={{ + width: '200px', + padding: '6px 8px', + border: `1px solid ${ + nameError ? '#ef4444' : isFocused ? '#3b82f6' : '#d1d5db' + }`, + borderRadius: '4px', + fontSize: '14px', + outline: 'none', + transition: 'border-color 0.2s ease', + }} + /> +
+
+ {nameError ? ( +
+ ⚠️ + {nameError} +
+ ) : null} +
+
+
+ +
+
+ ); +}; + +const FileListSection: React.FC = ({ + selectedFiles, + isImporting, + onFilesChange, +}) => ( +
+
+

+ {I18n.t('workflow_import_file_list')} ({selectedFiles.length}) +

+ +
+ +
+ {selectedFiles.map(file => ( + + ))} +
+
+); + +export default FileListSection; diff --git a/frontend/packages/studio/workspace/entry-base/src/components/workflow-import/FileProcessor.tsx b/frontend/packages/studio/workspace/entry-base/src/components/workflow-import/FileProcessor.tsx new file mode 100644 index 0000000000..378909cf27 --- /dev/null +++ b/frontend/packages/studio/workspace/entry-base/src/components/workflow-import/FileProcessor.tsx @@ -0,0 +1,191 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +interface WorkflowFile { + id: string; + fileName: string; + workflowName: string; + originalContent: string; + workflowData: string; + status: + | 'pending' + | 'validating' + | 'valid' + | 'invalid' + | 'importing' + | 'success' + | 'failed'; + error?: string; +} + +const RANDOM_ID_BASE = 36; +const RANDOM_ID_START = 2; +const RANDOM_ID_LENGTH = 9; +const MIN_FILENAME_LENGTH = 2; +const FALLBACK_ID_LENGTH = 6; + +export const generateRandomId = (): string => + Math.random() + .toString(RANDOM_ID_BASE) + .substr(RANDOM_ID_START, RANDOM_ID_LENGTH); + +export const sanitizeWorkflowName = (fileName: string): string => { + let workflowName = fileName; + const lowerName = fileName.toLowerCase(); + + if (lowerName.endsWith('.json')) { + workflowName = fileName.replace('.json', ''); + } else if (lowerName.endsWith('.yml')) { + workflowName = fileName.replace('.yml', ''); + } else if (lowerName.endsWith('.yaml')) { + workflowName = fileName.replace('.yaml', ''); + } else if (lowerName.endsWith('.zip')) { + workflowName = fileName.replace('.zip', ''); + } + + workflowName = workflowName.replace(/[^a-zA-Z0-9_]/g, '_'); + if (!/^[a-zA-Z]/.test(workflowName)) { + workflowName = `Workflow_${workflowName}`; + } + if (workflowName.length < MIN_FILENAME_LENGTH) { + workflowName = `Workflow_${Math.random().toString(RANDOM_ID_BASE).substr(RANDOM_ID_START, FALLBACK_ID_LENGTH)}`; + } + + return workflowName; +}; + +const tryAlternativeEncoding = ( + file: File, + setSelectedFiles: (fn: (prev: WorkflowFile[]) => WorkflowFile[]) => void, +) => { + const alternativeReader = new FileReader(); + alternativeReader.onload = e => { + const arrayBuffer = e.target?.result as ArrayBuffer; + if (arrayBuffer) { + let content = ''; + try { + const utf8Decoder = new TextDecoder('utf-8'); + content = utf8Decoder.decode(arrayBuffer); + + if (/�/.test(content)) { + try { + const gbkDecoder = new TextDecoder('gbk'); + content = gbkDecoder.decode(arrayBuffer); + } catch (gbkError) { + console.warn(`GBK decoding failed for "${file.name}":`, gbkError); + try { + const gb2312Decoder = new TextDecoder('gb2312'); + content = gb2312Decoder.decode(arrayBuffer); + } catch (gb2312Error) { + console.warn( + `GB2312 decoding failed for "${file.name}":`, + gb2312Error, + ); + console.warn( + `Unable to properly decode file "${file.name}", using UTF-8`, + ); + } + } + } + + const newFile: WorkflowFile = { + id: generateRandomId(), + fileName: file.name, + workflowName: sanitizeWorkflowName(file.name), + originalContent: content, + workflowData: content, + status: 'valid', + }; + setSelectedFiles(prev => [...prev, newFile]); + } catch (error) { + console.error(`Error decoding file "${file.name}":`, error); + const reader = new FileReader(); + reader.onload = fallbackEvent => { + const result = fallbackEvent.target?.result as string; + if (result) { + const newFile: WorkflowFile = { + id: generateRandomId(), + fileName: file.name, + workflowName: sanitizeWorkflowName(file.name), + originalContent: result, + workflowData: result, + status: 'valid', + }; + setSelectedFiles(prev => [...prev, newFile]); + } + }; + reader.readAsText(file, 'UTF-8'); + } + } + }; + alternativeReader.readAsArrayBuffer(file); +}; + +export const processFiles = ( + files: FileList, + setSelectedFiles: (fn: (prev: WorkflowFile[]) => WorkflowFile[]) => void, +) => { + Array.from(files).forEach(file => { + const reader = new FileReader(); + + if (file.name.toLowerCase().endsWith('.zip')) { + reader.onload = e => { + const result = e.target?.result as string; + if (result) { + const base64Content = result.split(',')[1]; + if (base64Content) { + const newFile: WorkflowFile = { + id: generateRandomId(), + fileName: file.name, + workflowName: sanitizeWorkflowName(file.name), + originalContent: base64Content, + workflowData: base64Content, + status: 'valid', + }; + setSelectedFiles(prev => [...prev, newFile]); + } + } + }; + reader.readAsDataURL(file); + } else { + reader.onload = e => { + const content = e.target?.result as string; + if (content) { + const hasGarbledText = /�/.test(content); + + if (hasGarbledText) { + console.warn( + `File "${file.name}" may have encoding issues, trying alternative encoding`, + ); + tryAlternativeEncoding(file, setSelectedFiles); + return; + } + + const newFile: WorkflowFile = { + id: generateRandomId(), + fileName: file.name, + workflowName: sanitizeWorkflowName(file.name), + originalContent: content, + workflowData: content, + status: 'valid', + }; + setSelectedFiles(prev => [...prev, newFile]); + } + }; + reader.readAsText(file, 'UTF-8'); + } + }); +}; diff --git a/frontend/packages/studio/workspace/entry-base/src/components/workflow-import/FileUploadSection.tsx b/frontend/packages/studio/workspace/entry-base/src/components/workflow-import/FileUploadSection.tsx new file mode 100644 index 0000000000..a7f5cbcd79 --- /dev/null +++ b/frontend/packages/studio/workspace/entry-base/src/components/workflow-import/FileUploadSection.tsx @@ -0,0 +1,107 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { I18n } from '@coze-arch/i18n'; + +interface FileUploadSectionProps { + dragActive: boolean; + isImporting: boolean; + onFilesSelected: (files: FileList) => void; + onDragEnter: (e: React.DragEvent) => void; + onDragLeave: (e: React.DragEvent) => void; + onDragOver: (e: React.DragEvent) => void; + onDrop: (e: React.DragEvent) => void; +} + +const DISABLED_OPACITY = 0.6; + +const FileUploadSection: React.FC = ({ + dragActive, + isImporting, + onFilesSelected, + onDragEnter, + onDragLeave, + onDragOver, + onDrop, +}) => ( +
+ !isImporting && document.getElementById('file-input')?.click() + } + > +
📁
+

+ {I18n.t('workflow_import_drag_and_drop')} +

+

+ {I18n.t('workflow_import_batch_description')} +

+ { + const { files } = e.target; + if (files && files.length > 0) { + onFilesSelected(files); + } + }} + style={{ display: 'none' }} + disabled={isImporting} + /> +
+ {I18n.t('workflow_import_select_file')} +
+
+); + +export default FileUploadSection; diff --git a/frontend/packages/studio/workspace/entry-base/src/components/workflow-import/ImportHandler.tsx b/frontend/packages/studio/workspace/entry-base/src/components/workflow-import/ImportHandler.tsx new file mode 100644 index 0000000000..0aad4e51bb --- /dev/null +++ b/frontend/packages/studio/workspace/entry-base/src/components/workflow-import/ImportHandler.tsx @@ -0,0 +1,321 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { UserInfo } from '@coze-foundation/account-adapter'; + +interface WorkflowFile { + id: string; + fileName: string; + workflowName: string; + originalContent: string; + workflowData: string; + status: + | 'pending' + | 'validating' + | 'valid' + | 'invalid' + | 'importing' + | 'success' + | 'failed'; + error?: string; +} + +interface ResultData { + successCount: number; + failedCount: number; + firstWorkflowId?: string; + failedFiles?: Array<{ + file_name: string; + workflow_name: string; + error_code: string; + error_message: string; + fail_reason?: string; + }>; +} + +interface ImportParams { + selectedFiles: WorkflowFile[]; + spaceId: string; + userInfo: UserInfo; + setShowImportForm: (show: boolean) => void; + setIsImporting: (importing: boolean) => void; + setResultModalData: (data: ResultData) => void; + setShowResultModal: (show: boolean) => void; +} + +const PREVIEW_LENGTH = 50; + +// 处理导入响应 +const processImportResponse = (responseData: { + success_count?: number; + failed_count?: number; + first_workflow_id?: string; + failed_list?: Array<{ + file_name: string; + workflow_name: string; + error_code?: number; + error_message?: string; + fail_reason?: string; + }>; +}) => { + const successCount = responseData.success_count || 0; + const failedCount = responseData.failed_count || 0; + const firstWorkflowId = responseData.first_workflow_id; + + return { successCount, failedCount, firstWorkflowId }; +}; + +// 转换失败文件数据格式 +const formatFailedFiles = ( + failedList: Array<{ + file_name: string; + workflow_name: string; + error_code?: number; + error_message?: string; + fail_reason?: string; + }>, +) => + failedList.map(item => ({ + file_name: item.file_name, + workflow_name: item.workflow_name, + error_code: item.error_code?.toString() || 'unknown_error', + error_message: item.error_message || '', + fail_reason: item.fail_reason || '', + })); + +// 生成成功结果数据 +const createSuccessResultData = (params: { + successCount: number; + failedCount: number; + firstWorkflowId: string | undefined; + failedFiles: Array<{ + file_name: string; + workflow_name: string; + error_code: string; + error_message: string; + fail_reason: string; + }>; +}) => params; + +// 生成错误情况下的结果数据 +const createErrorResultData = ( + selectedFiles: Array<{ fileName: string; workflowName: string }>, + error: unknown, +) => ({ + successCount: 0, + failedCount: selectedFiles.length, + firstWorkflowId: undefined, + failedFiles: selectedFiles.map(file => ({ + file_name: file.fileName, + workflow_name: file.workflowName, + error_code: 'network_error', + error_message: error instanceof Error ? error.message : 'Import failed', + fail_reason: 'network_error', + })), +}); + +// API请求处理辅助函数 +const processImportRequest = async ( + workflowFiles: Array<{ + file_name: string; + workflow_data: string; + workflow_name: string; + }>, + spaceId: string, + userInfo: { uid?: string }, +) => { + console.log('发送批量导入请求:', { + workflow_files: workflowFiles, + space_id: spaceId, + import_mode: 'batch', + import_format: 'mixed', + }); + + console.log(`开始批量导入 ${workflowFiles.length} 个文件...`); + + const response = await fetch('/api/workflow_api/batch_import', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + workflow_files: workflowFiles, + space_id: spaceId, + creator_id: userInfo?.uid || spaceId, + import_mode: 'batch', + import_format: 'mixed', + }), + }); + + console.log('Response status:', response.status); + console.log('Response headers:', response.headers); + + if (!response.ok) { + let errorMessage = `导入失败,HTTP状态码: ${response.status}`; + try { + const errorData = await response.json(); + console.log('Error response data:', errorData); + if (errorData.message) { + errorMessage = errorData.message; + } + } catch (parseError) { + console.log('Failed to parse error response:', parseError); + } + throw new Error(errorMessage); + } + + const result = await response.json(); + console.log('Success response:', result); + + const responseData = result.data || result || {}; + + const { successCount, failedCount, firstWorkflowId } = + processImportResponse(responseData); + const actualFirstWorkflowId = responseData.success_list?.length + ? responseData.success_list[0].workflow_id + : firstWorkflowId; + + console.log('Import results:', { + successCount, + failedCount, + firstWorkflowId: actualFirstWorkflowId, + }); + + const failedFiles = formatFailedFiles(responseData.failed_list || []); + + return { + successCount, + failedCount, + firstWorkflowId: actualFirstWorkflowId, + failedFiles, + }; +}; + +// 文件验证辅助函数 +const validateSelectedFiles = ( + selectedFiles: Array<{ + fileName: string; + workflowName: string; + originalContent: string; + }>, +) => { + const workflowFiles: Array<{ + file_name: string; + workflow_data: string; + workflow_name: string; + }> = []; + const validationErrors: string[] = []; + + for (let i = 0; i < selectedFiles.length; i++) { + const file = selectedFiles[i]; + + if (!file.fileName) { + validationErrors.push(`文件 ${i + 1} 缺少文件名`); + continue; + } + if (!file.workflowName) { + validationErrors.push(`文件 "${file.fileName}" 缺少工作流名称`); + continue; + } + if (!file.originalContent) { + validationErrors.push(`文件 "${file.fileName}" 缺少工作流数据`); + continue; + } + + if (file.fileName.toLowerCase().endsWith('.zip')) { + const isValidBase64 = /^[A-Za-z0-9+/]*={0,2}$/.test(file.originalContent); + console.log(`ZIP file validation for ${file.fileName}:`, { + dataLength: file.originalContent.length, + isValidBase64, + dataPreview: file.originalContent.substring(0, PREVIEW_LENGTH), + }); + + if (!isValidBase64) { + validationErrors.push( + `ZIP文件 "${file.fileName}" 包含无效的base64数据`, + ); + continue; + } + } + + workflowFiles.push({ + file_name: file.fileName, + workflow_data: file.originalContent, + workflow_name: file.workflowName, + }); + } + + if (validationErrors.length > 0) { + console.warn('文件验证警告:', validationErrors); + } + + if (workflowFiles.length === 0) { + throw new Error('没有有效的文件可以导入'); + } + + return workflowFiles; +}; + +export const handleBatchImport = async (params: ImportParams) => { + const { + selectedFiles, + spaceId, + userInfo, + setShowImportForm, + setIsImporting, + setResultModalData, + setShowResultModal, + } = params; + + if (!spaceId) { + alert('Missing space ID for import'); + return; + } + + if (selectedFiles.length === 0) { + alert('Please select files to import'); + return; + } + + setShowImportForm(false); + setIsImporting(true); + + try { + const workflowFiles = validateSelectedFiles(selectedFiles); + + const { successCount, failedCount, firstWorkflowId, failedFiles } = + await processImportRequest(workflowFiles, spaceId, { + uid: userInfo?.user_id_str, + }); + setResultModalData( + createSuccessResultData({ + successCount, + failedCount, + firstWorkflowId, + failedFiles, + }), + ); + setShowResultModal(true); + } catch (error) { + console.error('批量导入失败:', error); + + // 即使出错也要显示结果弹窗 + setResultModalData(createErrorResultData(selectedFiles, error)); + setShowResultModal(true); + } finally { + setIsImporting(false); + } +}; diff --git a/frontend/packages/studio/workspace/entry-base/src/components/workflow-import/ImportResultModalSection.tsx b/frontend/packages/studio/workspace/entry-base/src/components/workflow-import/ImportResultModalSection.tsx new file mode 100644 index 0000000000..f697992985 --- /dev/null +++ b/frontend/packages/studio/workspace/entry-base/src/components/workflow-import/ImportResultModalSection.tsx @@ -0,0 +1,417 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { I18n } from '@coze-arch/i18n'; + +interface FailedFile { + file_name: string; + workflow_name: string; + error_code: string; + error_message: string; + fail_reason?: string; +} + +interface ResultModalData { + successCount: number; + failedCount: number; + firstWorkflowId?: string; + failedFiles?: FailedFile[]; +} + +interface ImportResultModalSectionProps { + visible: boolean; + resultModalData: ResultModalData; + onClose: () => void; + onViewWorkflow: () => void; +} + +const ResultIcon: React.FC<{ successCount: number; failedCount: number }> = ({ + successCount, + failedCount, +}) => { + const getIcon = () => { + if (successCount > 0 && failedCount > 0) { + return '⚠️'; + } + if (successCount > 0) { + return '✅'; + } + return '❌'; + }; + + return ( +
+ {getIcon()} +
+ ); +}; + +const ResultTitle: React.FC<{ successCount: number; failedCount: number }> = ({ + successCount, + failedCount, +}) => { + const getTitle = () => { + if (successCount > 0 && failedCount > 0) { + return I18n.t('workflow_import_partial_complete'); + } + if (successCount > 0) { + return I18n.t('workflow_import_success'); + } + return I18n.t('workflow_import_failed'); + }; + + return ( +

+ {getTitle()} +

+ ); +}; + +const ResultMessage: React.FC<{ + successCount: number; + failedCount: number; + totalCount: number; +}> = ({ successCount, failedCount, totalCount }) => { + const getMessage = () => { + if (successCount > 0 && failedCount > 0) { + return I18n.t('workflow_import_partial_message', { + total: totalCount.toString(), + success: successCount.toString(), + failed: failedCount.toString(), + }); + } + if (successCount > 0) { + return I18n.t('workflow_import_success_message', { + count: successCount.toString(), + }); + } + return I18n.t('workflow_import_failed_message', { + count: failedCount.toString(), + }); + }; + + return ( +

0 ? '16px' : '0', + lineHeight: '1.6', + textAlign: 'center', + }} + > + {getMessage()} +

+ ); +}; + +const FailedFilesSection: React.FC<{ + failedCount: number; + failedFiles?: FailedFile[]; +}> = ({ failedCount, failedFiles }) => { + if (failedCount === 0 || !failedFiles || failedFiles.length === 0) { + return null; + } + + return ( +
+
+
+ {I18n.t('workflow_import_failed_files_details')} ({failedCount}) +
+
+ {failedFiles.map((file, index) => ( +
+
+ {file.file_name} +
+
+ {I18n.t('workflow_import_workflow_name')}: {file.workflow_name} +
+
+ {I18n.t('workflow_import_error_reason')}:{' '} + {file.error_message || + file.fail_reason || + I18n.t('workflow_import_unknown_error')} +
+
+ ))} +
+
+
+ ); +}; + +const ActionButtons: React.FC<{ + hasSuccess: boolean; + firstWorkflowId?: string; + onClose: () => void; + onViewWorkflow: () => void; +}> = ({ hasSuccess, firstWorkflowId, onClose, onViewWorkflow }) => ( +
+ + + {hasSuccess && firstWorkflowId ? ( + + ) : null} +
+); + +const ImportResultModalSection: React.FC = ({ + visible, + resultModalData, + onClose, + onViewWorkflow, +}) => { + if (!visible) { + return null; + } + + const { successCount, failedCount, firstWorkflowId, failedFiles } = + resultModalData; + const totalCount = successCount + failedCount; + const hasSuccess = successCount > 0; + + return ( +
{ + if (e.target === e.currentTarget) { + onClose(); + } + }} + > +
e.stopPropagation()} + > +
+ {I18n.t('workflow_import_result')} +
+ +
+ + + + +
+ + +
+
+ ); +}; + +export default ImportResultModalSection; diff --git a/frontend/packages/studio/workspace/entry-base/src/components/workflow-import/use-workflow-import-modal.tsx b/frontend/packages/studio/workspace/entry-base/src/components/workflow-import/use-workflow-import-modal.tsx new file mode 100644 index 0000000000..9446a23b6e --- /dev/null +++ b/frontend/packages/studio/workspace/entry-base/src/components/workflow-import/use-workflow-import-modal.tsx @@ -0,0 +1,201 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useParams, useNavigate } from 'react-router-dom'; +import type React from 'react'; +import { useState, useCallback } from 'react'; + +import { useUserInfo } from '@coze-foundation/account-adapter'; + +import { handleBatchImport } from './ImportHandler'; +import { processFiles } from './FileProcessor'; + +interface WorkflowFile { + id: string; + fileName: string; + workflowName: string; + originalContent: string; + workflowData: string; + status: + | 'pending' + | 'validating' + | 'valid' + | 'invalid' + | 'importing' + | 'success' + | 'failed'; + error?: string; +} + +// 校验工作流名称 - 纯函数,可以安全提取 +const validateWorkflowName = (name: string): boolean => { + const MIN_NAME_LENGTH = 2; + const MAX_NAME_LENGTH = 50; + + if (!name || name.trim().length === 0) { + return false; + } + + const trimmedName = name.trim(); + if (trimmedName.length < MIN_NAME_LENGTH) { + return false; + } + if (trimmedName.length > MAX_NAME_LENGTH) { + return false; + } + if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(trimmedName)) { + return false; + } + + return true; +}; + +// 计算文件统计信息 - 纯函数,可以安全提取 +const calculateFileStats = (selectedFiles: WorkflowFile[]) => { + const validFileCount = selectedFiles.filter( + f => f.status === 'valid' && validateWorkflowName(f.workflowName), + ).length; + + const hasProcessingFiles = selectedFiles.some( + f => f.status === 'pending' || f.status === 'validating', + ); + + const hasInvalidNames = selectedFiles.some( + f => f.status === 'valid' && !validateWorkflowName(f.workflowName), + ); + + return { validFileCount, hasProcessingFiles, hasInvalidNames }; +}; + +export const useWorkflowImportModal = () => { + const { space_id } = useParams<{ space_id: string }>(); + const navigate = useNavigate(); + const userInfo = useUserInfo(); + + const [selectedFiles, setSelectedFiles] = useState([]); + const [dragActive, setDragActive] = useState(false); + const [isImporting, setIsImporting] = useState(false); + const [showResultModal, setShowResultModal] = useState(false); + const [showImportForm, setShowImportForm] = useState(true); + const [resultModalData, setResultModalData] = useState<{ + successCount: number; + failedCount: number; + firstWorkflowId?: string; + failedFiles?: Array<{ + file_name: string; + workflow_name: string; + error_code: string; + error_message: string; + fail_reason?: string; + }>; + }>({ successCount: 0, failedCount: 0 }); + + const handleFilesSelected = useCallback((files: FileList) => { + processFiles(files, setSelectedFiles); + }, []); + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + + const { files } = e.dataTransfer; + if (files && files.length > 0) { + handleFilesSelected(files); + } + }; + + const handleImport = async () => { + if (!space_id || !userInfo) { + return; + } + + await handleBatchImport({ + selectedFiles, + spaceId: space_id, + userInfo, + setShowImportForm, + setIsImporting, + setResultModalData, + setShowResultModal, + }); + }; + + const handleClose = () => { + if (!isImporting) { + setSelectedFiles([]); + setShowImportForm(true); + setShowResultModal(false); + } + }; + + const handleViewWorkflow = () => { + if (resultModalData.firstWorkflowId && space_id) { + navigate( + `/work_flow?workflow_id=${resultModalData.firstWorkflowId}&space_id=${space_id}`, + ); + setShowResultModal(false); + handleClose(); + } + }; + + const handleResultClose = () => { + setShowResultModal(false); + handleClose(); + }; + + const { validFileCount, hasProcessingFiles, hasInvalidNames } = + calculateFileStats(selectedFiles); + + return { + selectedFiles, + setSelectedFiles, + dragActive, + isImporting, + showResultModal, + showImportForm, + resultModalData, + validFileCount, + hasProcessingFiles, + hasInvalidNames, + handleFilesSelected, + handleDragEnter, + handleDragLeave, + handleDragOver, + handleDrop, + handleImport, + handleClose, + handleViewWorkflow, + handleResultClose, + }; +}; diff --git a/frontend/packages/studio/workspace/entry-base/src/pages/library/components/LibraryFilters.tsx b/frontend/packages/studio/workspace/entry-base/src/pages/library/components/LibraryFilters.tsx new file mode 100644 index 0000000000..052b882a88 --- /dev/null +++ b/frontend/packages/studio/workspace/entry-base/src/pages/library/components/LibraryFilters.tsx @@ -0,0 +1,172 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import classNames from 'classnames'; +import { I18n } from '@coze-arch/i18n'; +import { Select, Search, Cascader, Space } from '@coze-arch/coze-design'; +import { EVENT_NAMES, sendTeaEvent } from '@coze-arch/bot-tea'; + +import { highlightFilterStyle } from '@/constants/filter-style'; + +import { getScopeOptions, getStatusOptions } from '../consts'; +import { type BaseLibraryPageProps } from '../types'; + +import s from '../index.module.less'; + +interface LibraryFiltersParams { + res_type_filter?: number[]; + user_filter?: number; + publish_status_filter?: number; + name?: string; +} + +interface LibraryFiltersProps { + spaceId: string; + isPersonalSpace: boolean; + entityConfigs: BaseLibraryPageProps['entityConfigs']; + params: LibraryFiltersParams; + setParams: (updater: (prev: LibraryFiltersParams) => LibraryFiltersParams) => void; +} + +export const LibraryFilters: React.FC = ({ + spaceId, + isPersonalSpace, + entityConfigs, + params, + setParams, +}) => { + const typeFilterData = [ + { label: I18n.t('library_filter_tags_all_types'), value: -1 }, + ...entityConfigs.map(item => item.typeFilter).filter(filter => !!filter), + ]; + const scopeOptions = getScopeOptions(); + const statusOptions = getStatusOptions(); + + return ( +
+ + { + const typeFilter = typeFilterData.find( + item => + item.value === ((v as Array)?.[0] as number), + ); + sendTeaEvent(EVENT_NAMES.workspace_action_front, { + space_id: spaceId, + space_type: isPersonalSpace ? 'personal' : 'teamspace', + tab_name: 'library', + action: 'filter', + filter_type: 'types', + filter_name: typeFilter?.filterName ?? typeFilter?.label, + }); + + setParams(prev => ({ + ...prev, + res_type_filter: v as Array, + })); + }} + /> + {!isPersonalSpace ? ( + { + sendTeaEvent(EVENT_NAMES.workspace_action_front, { + space_id: spaceId, + space_type: isPersonalSpace ? 'personal' : 'teamspace', + tab_name: 'library', + action: 'filter', + filter_type: 'status', + filter_name: statusOptions.find( + item => + item.value === + ((v as Array)?.[0] as number), + )?.label, + }); + setParams(prev => ({ + ...prev, + publish_status_filter: v as number, + })); + }} + /> + + { + setParams(prev => ({ + ...prev, + name: v, + })); + }} + /> +
+ ); +}; \ No newline at end of file diff --git a/frontend/packages/studio/workspace/entry-base/src/pages/library/components/LibraryTable.tsx b/frontend/packages/studio/workspace/entry-base/src/pages/library/components/LibraryTable.tsx new file mode 100644 index 0000000000..f33cf558e9 --- /dev/null +++ b/frontend/packages/studio/workspace/entry-base/src/pages/library/components/LibraryTable.tsx @@ -0,0 +1,91 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { Table, type ColumnProps } from '@coze-arch/coze-design'; +import { EVENT_NAMES, sendTeaEvent } from '@coze-arch/bot-tea'; +import { + type ResType, + type ResourceInfo, +} from '@coze-arch/bot-api/plugin_develop'; + +import { WorkspaceEmpty } from '@/components/workspace-empty'; + +import { type BaseLibraryPageProps, type ListData } from '../types'; +import { eventLibraryType } from '../consts'; + +interface LibraryTableProps { + spaceId: string; + isPersonalSpace: boolean; + entityConfigs: BaseLibraryPageProps['entityConfigs']; + listResp: { + loading: boolean; + data?: ListData; + loadMore: () => void; + }; + columns: ColumnProps[]; + hasFilter: boolean; + resetParams: () => void; +} + +export const LibraryTable: React.FC = ({ + spaceId, + isPersonalSpace, + entityConfigs, + listResp, + columns, + hasFilter, + resetParams, +}) => ( + { + if (!record || record.res_type === undefined || record.detail_disable) { + return {}; + } + return { + onClick: () => { + sendTeaEvent(EVENT_NAMES.workspace_action_front, { + space_id: spaceId, + space_type: isPersonalSpace ? 'personal' : 'teamspace', + tab_name: 'library', + action: 'click', + id: record.res_id, + name: record.name, + type: record.res_type && eventLibraryType[record.res_type], + }); + entityConfigs + .find(c => c.target.includes(record.res_type as ResType)) + ?.onItemClick(record); + }, + }; + }, + }} + empty={} + enableLoad + loadMode="cursor" + strictDataSourceProp + hasMore={listResp.data?.hasMore} + onLoad={listResp.loadMore} + /> +); diff --git a/frontend/packages/studio/workspace/entry-base/src/pages/library/components/library-header.tsx b/frontend/packages/studio/workspace/entry-base/src/pages/library/components/library-header.tsx index fac27bdd87..6631e5bfdb 100644 --- a/frontend/packages/studio/workspace/entry-base/src/pages/library/components/library-header.tsx +++ b/frontend/packages/studio/workspace/entry-base/src/pages/library/components/library-header.tsx @@ -14,38 +14,71 @@ * limitations under the License. */ -import React from 'react'; +import React, { useState } from 'react'; import { I18n } from '@coze-arch/i18n'; import { IconCozPlus } from '@coze-arch/coze-design/icons'; import { Button, Menu } from '@coze-arch/coze-design'; import { type LibraryEntityConfig } from '../types'; +import WorkflowImportModal from '../../../components/workflow-import-modal'; export const LibraryHeader: React.FC<{ entityConfigs: LibraryEntityConfig[]; -}> = ({ entityConfigs }) => ( -
-
- {I18n.t('navigation_workspace_library')} +}> = ({ entityConfigs }) => { + const [showImportModal, setShowImportModal] = useState(false); + + const handleImportWorkflow = () => { + setShowImportModal(true); + }; + + const handleCloseImportModal = () => { + setShowImportModal(false); + }; + + return ( +
+
+ {I18n.t('navigation_workspace_library')} +
+ +
+ {/* 导入工作流按钮 */} + + + {/* 创建资源按钮 */} + + {entityConfigs.map(config => config.renderCreateMenu?.() ?? null)} + + } + > + + +
+ + {/* 工作流导入弹窗 */} +
- - {entityConfigs.map(config => config.renderCreateMenu?.() ?? null)} - - } - > - - -
-); + ); +}; diff --git a/frontend/packages/studio/workspace/entry-base/src/pages/library/hooks/use-library-data.ts b/frontend/packages/studio/workspace/entry-base/src/pages/library/hooks/use-library-data.ts new file mode 100644 index 0000000000..d5492eb0ea --- /dev/null +++ b/frontend/packages/studio/workspace/entry-base/src/pages/library/hooks/use-library-data.ts @@ -0,0 +1,105 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useInfiniteScroll } from 'ahooks'; +import { + type LibraryResourceListRequest, + type ResourceInfo, +} from '@coze-arch/bot-api/plugin_develop'; +import { PluginDevelopApi } from '@coze-arch/bot-api'; + +import { type ListData, type BaseLibraryPageProps } from '../types'; +import { LIBRARY_PAGE_SIZE } from '../consts'; + +// 数据转换函数 +const transformResourceList = (resourceList: ResourceInfo[]): ResourceInfo[] => + resourceList.map(item => { + if (!item) { + return item; + } + + const transformedItem: ResourceInfo = { + ...item, + res_id: String( + (item as Record).ResID || + (item as Record).res_id || + '', + ), + creator_id: String( + (item as Record).CreatorID || + (item as Record).creator_id || + '', + ), + edit_time: + ((item as Record).EditTime as number) || + ((item as Record).edit_time as number), + space_id: String( + (item as Record).SpaceID || + (item as Record).space_id || + '', + ), + }; + return transformedItem; + }); + +interface LibraryDataParams { + res_type_filter?: number[]; + user_filter?: number; + publish_status_filter?: number; + name?: string; +} + +export const useLibraryData = ( + spaceId: string, + entityConfigs: BaseLibraryPageProps['entityConfigs'], + params: LibraryDataParams, +) => + useInfiniteScroll( + async prev => { + const resp = await PluginDevelopApi.LibraryResourceList( + entityConfigs.reduce( + (res, config) => config.parseParams?.(res) ?? res, + { + ...params, + cursor: prev?.nextCursorId, + space_id: spaceId, + size: LIBRARY_PAGE_SIZE, + }, + ), + ); + + const resourceList = resp?.resource_list || []; + const transformedList = transformResourceList(resourceList); + + return { + list: transformedList, + nextCursorId: resp?.cursor, + hasMore: !!resp?.has_more, + }; + }, + { + target: () => + document.querySelector('[data-testid="workspace.library.table"]'), + isNoMore: d => !d?.hasMore, + reloadDeps: [ + spaceId, + params.res_type_filter, + params.publish_status_filter, + params.user_filter, + params.name, + ], + }, + ); diff --git a/frontend/packages/studio/workspace/entry-base/src/pages/library/index.tsx b/frontend/packages/studio/workspace/entry-base/src/pages/library/index.tsx index 8980ad696b..9c10922b52 100644 --- a/frontend/packages/studio/workspace/entry-base/src/pages/library/index.tsx +++ b/frontend/packages/studio/workspace/entry-base/src/pages/library/index.tsx @@ -17,38 +17,17 @@ import { forwardRef, useImperativeHandle } from 'react'; import classNames from 'classnames'; -import { useInfiniteScroll } from 'ahooks'; import { I18n } from '@coze-arch/i18n'; -import { - Table, - Select, - Search, - Layout, - Cascader, - Space, -} from '@coze-arch/coze-design'; +import { Layout } from '@coze-arch/coze-design'; import { renderHtmlTitle } from '@coze-arch/bot-utils'; -import { EVENT_NAMES, sendTeaEvent } from '@coze-arch/bot-tea'; -import { - type ResType, - type LibraryResourceListRequest, - type ResourceInfo, -} from '@coze-arch/bot-api/plugin_develop'; -import { PluginDevelopApi } from '@coze-arch/bot-api'; -import { highlightFilterStyle } from '@/constants/filter-style'; -import { WorkspaceEmpty } from '@/components/workspace-empty'; - -import { type ListData, type BaseLibraryPageProps } from './types'; +import { type BaseLibraryPageProps } from './types'; import { useGetColumns } from './hooks/use-columns'; import { useCachedQueryParams } from './hooks/use-cached-query-params'; -import { - eventLibraryType, - getScopeOptions, - getStatusOptions, - LIBRARY_PAGE_SIZE, -} from './consts'; +import { useLibraryData } from './hooks/use-library-data'; import { LibraryHeader } from './components/library-header'; +import { LibraryFilters } from './components/LibraryFilters'; +import { LibraryTable } from './components/LibraryTable'; import s from './index.module.less'; @@ -64,236 +43,54 @@ export { BaseLibraryItem } from './components/base-library-item'; export const BaseLibraryPage = forwardRef< { reloadList: () => void }, BaseLibraryPageProps ->( - // eslint-disable-next-line @coze-arch/max-line-per-function - ({ spaceId, isPersonalSpace = true, entityConfigs }, ref) => { - const { params, setParams, resetParams, hasFilter, ready } = - useCachedQueryParams({ - spaceId, - }); - - const listResp = useInfiniteScroll( - async prev => { - if (!ready) { - return { - list: [], - nextCursorId: undefined, - hasMore: false, - }; - } - // Allow business to customize request parameters - const resp = await PluginDevelopApi.LibraryResourceList( - entityConfigs.reduce( - (res, config) => config.parseParams?.(res) ?? res, - { - ...params, - cursor: prev?.nextCursorId, - space_id: spaceId, - size: LIBRARY_PAGE_SIZE, - }, - ), - ); - return { - list: resp?.resource_list || [], - nextCursorId: resp?.cursor, - hasMore: !!resp?.has_more, - }; - }, - { - reloadDeps: [params, spaceId], - }, - ); - - useImperativeHandle(ref, () => ({ - reloadList: listResp.reload, - })); - - const columns = useGetColumns({ - entityConfigs, - reloadList: listResp.reload, - isPersonalSpace, +>(({ spaceId, isPersonalSpace = true, entityConfigs }, ref) => { + const { params, setParams, resetParams, hasFilter } = + useCachedQueryParams({ + spaceId, }); - const typeFilterData = [ - { label: I18n.t('library_filter_tags_all_types'), value: -1 }, - ...entityConfigs.map(item => item.typeFilter).filter(filter => !!filter), - ]; - const scopeOptions = getScopeOptions(); - const statusOptions = getStatusOptions(); + const listResp = useLibraryData(spaceId, entityConfigs, params); + + const columns = useGetColumns({ + entityConfigs, + reloadList: listResp.reload, + isPersonalSpace, + }); - return ( - - -
- -
- - { - const typeFilter = typeFilterData.find( - item => - item.value === ((v as Array)?.[0] as number), - ); - sendTeaEvent(EVENT_NAMES.workspace_action_front, { - space_id: spaceId, - space_type: isPersonalSpace ? 'personal' : 'teamspace', - tab_name: 'library', - action: 'filter', - filter_type: 'types', - filter_name: typeFilter?.filterName ?? typeFilter?.label, - }); + useImperativeHandle(ref, () => ({ + reloadList: () => { + listResp.reload(); + }, + })); - setParams(prev => ({ - ...prev, - res_type_filter: v as Array, - })); - }} - /> - {!isPersonalSpace ? ( - { - sendTeaEvent(EVENT_NAMES.workspace_action_front, { - space_id: spaceId, - space_type: isPersonalSpace ? 'personal' : 'teamspace', - tab_name: 'library', - action: 'filter', - filter_type: 'status', - filter_name: statusOptions.find( - item => - item.value === ((v as Array)?.[0] as number), - )?.label, - }); - setParams(prev => ({ - ...prev, - publish_status_filter: v as number, - })); - }} - /> - - { - sendTeaEvent(EVENT_NAMES.search_front, { - full_url: window.location.href, - source: 'library', - search_word: v, - }); - setParams(prev => ({ - ...prev, - name: v, - })); - }} - /> -
-
-
- -
{ - if ( - !record || - record.res_type === undefined || - record.detail_disable - ) { - return {}; - } - return { - onClick: () => { - sendTeaEvent(EVENT_NAMES.workspace_action_front, { - space_id: spaceId, - space_type: isPersonalSpace ? 'personal' : 'teamspace', - tab_name: 'library', - action: 'click', - id: record.res_id, - name: record.name, - type: - record.res_type && eventLibraryType[record.res_type], - }); - entityConfigs - .find(c => c.target.includes(record.res_type as ResType)) - ?.onItemClick(record); - }, - }; - }, - }} - empty={ - - } - enableLoad - loadMode="cursor" - strictDataSourceProp - hasMore={listResp.data?.hasMore} - onLoad={listResp.loadMore} + return ( + + +
+ + - - - ); - }, -); +
+
+ + + +
+ ); +}); diff --git a/frontend/packages/workflow/components/package.json b/frontend/packages/workflow/components/package.json index fe214c2286..d9c345b091 100644 --- a/frontend/packages/workflow/components/package.json +++ b/frontend/packages/workflow/components/package.json @@ -63,6 +63,7 @@ "dequal": "^2.0.3", "eventemitter3": "^5.0.1", "immer": "^10.0.3", + "js-yaml": "^4.1.0", "lodash-es": "^4.17.21", "nanoid": "^4.0.2", "reflect-metadata": "^0.1.13", @@ -84,6 +85,7 @@ "@testing-library/jest-dom": "^6.1.5", "@testing-library/react": "^14.1.2", "@testing-library/react-hooks": "^8.0.1", + "@types/js-yaml": "^4.0.9", "@types/lodash-es": "^4.17.10", "@types/node": "^18", "@types/react": "18.2.37", diff --git a/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/components/ExportFormatModal.tsx b/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/components/ExportFormatModal.tsx new file mode 100644 index 0000000000..acc9d426c1 --- /dev/null +++ b/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/components/ExportFormatModal.tsx @@ -0,0 +1,295 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { I18n } from '@coze-arch/i18n'; +import { Modal } from '@coze-arch/coze-design'; + +import { FormatOption } from './FormatOption'; + +type ExportFormat = 'json' | 'yml' | 'yaml'; + +interface ExportFormatModalProps { + visible: boolean; + onCancel: () => void; + onConfirm: () => void; + selectedFormat: ExportFormat; + setSelectedFormat: (format: ExportFormat) => void; +} + +const ModalHeader = () => ( +
+
+ + + + +
+ + + +
+
+

+ {I18n.t('workflow_export_format_title')} +

+
+); + +const ModalFooter = ({ + onCancel, + onConfirm, + selectedFormat, +}: { + onCancel: () => void; + onConfirm: () => void; + selectedFormat: ExportFormat; +}) => ( +
+ + +
+); + +export const ExportFormatModal: React.FC = ({ + visible, + onCancel, + onConfirm, + selectedFormat, + setSelectedFormat, +}) => ( + +
+ + +
+
+ + + + } + /> + + + + + + } + /> +
+
+ + +
+
+); diff --git a/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/components/FormatOption.tsx b/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/components/FormatOption.tsx new file mode 100644 index 0000000000..b3dcae860b --- /dev/null +++ b/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/components/FormatOption.tsx @@ -0,0 +1,144 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +type ExportFormat = 'json' | 'yml' | 'yaml'; + +interface FormatOptionProps { + format: ExportFormat; + selectedFormat: ExportFormat; + onSelect: (format: ExportFormat) => void; + title: string; + description: string; + badge: string; + icon: React.ReactNode; +} + +export const FormatOption: React.FC = ({ + format, + selectedFormat, + onSelect, + title, + description, + badge, + icon, +}) => ( +
onSelect(format)} + style={{ + flex: 1, + padding: '20px 16px', + border: + selectedFormat === format ? '2px solid #3b82f6' : '2px solid #e2e8f0', + borderRadius: '12px', + cursor: 'pointer', + background: + selectedFormat === format + ? 'linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%)' + : 'white', + transition: 'all 0.3s ease', + position: 'relative', + boxShadow: + selectedFormat === format + ? '0 4px 16px rgba(59, 130, 246, 0.15)' + : '0 2px 8px rgba(0, 0, 0, 0.04)', + transform: + selectedFormat === format ? 'translateY(-2px)' : 'translateY(0)', + }} + onMouseEnter={e => { + if (selectedFormat !== format) { + e.currentTarget.style.borderColor = '#94a3b8'; + e.currentTarget.style.background = '#f8fafc'; + e.currentTarget.style.transform = 'translateY(-1px)'; + e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.08)'; + } + }} + onMouseLeave={e => { + if (selectedFormat !== format) { + e.currentTarget.style.borderColor = '#e2e8f0'; + e.currentTarget.style.background = 'white'; + e.currentTarget.style.transform = 'translateY(0)'; + e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.04)'; + } + }} + > +
+
+ {icon} +
+
+ {title} +
+
+ {description} +
+
+ {badge} +
+
+
+); diff --git a/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/import-utils.ts b/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/import-utils.ts new file mode 100644 index 0000000000..75d6a2daf6 --- /dev/null +++ b/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/import-utils.ts @@ -0,0 +1,120 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { load } from 'js-yaml'; +import { Toast } from '@coze-arch/coze-design'; + +export const HTTP_STATUS = { + BAD_REQUEST: 400, + FORBIDDEN: 403, + INTERNAL_SERVER_ERROR: 500, + BAD_GATEWAY: 502, + SERVICE_UNAVAILABLE: 503, + GATEWAY_TIMEOUT: 504, +} as const; + +export interface ImportResponse { + data?: ImportResponseData; + msg?: string; +} + +export interface ImportResponseData { + success_count?: number; + failed_count?: number; + success_list?: Array<{ workflow_id: string }>; + failed_list?: Array<{ error_message: string }>; +} + +export const parseFileContent = ( + fileContent: string, + isYamlFile: boolean, +): Record => { + if (isYamlFile) { + return load(fileContent) as Record; + } + return JSON.parse(fileContent); +}; + +export const validateWorkflowData = ( + workflowData: Record, +): void => { + if (!workflowData || typeof workflowData !== 'object') { + throw new Error('Invalid workflow structure'); + } + + if (!workflowData.name && !workflowData.workflow_id) { + throw new Error('Workflow name is missing'); + } +}; + +export const getErrorKey = (status: number): string => { + switch (status) { + case HTTP_STATUS.FORBIDDEN: + return 'workflow_import_error_permission'; + case HTTP_STATUS.BAD_REQUEST: + return 'workflow_import_error_invalid_file'; + case HTTP_STATUS.INTERNAL_SERVER_ERROR: + case HTTP_STATUS.BAD_GATEWAY: + case HTTP_STATUS.SERVICE_UNAVAILABLE: + case HTTP_STATUS.GATEWAY_TIMEOUT: + return 'workflow_import_error_network'; + default: + return 'workflow_import_failed'; + } +}; + +export interface HandleSuccessResultParams { + responseData: ImportResponseData; + goWorkflowDetail?: (workflowId: string, spaceId?: string) => void; + refreshPage?: () => void; + spaceId?: string; +} + +export const handleSuccessResult = ( + params: HandleSuccessResultParams, +): void => { + Toast.success('Workflow imported successfully'); + + if (params.refreshPage) { + params.refreshPage(); + } + + if ( + params.goWorkflowDetail && + params.responseData.success_list?.[0]?.workflow_id + ) { + params.goWorkflowDetail( + params.responseData.success_list[0].workflow_id, + params.spaceId, + ); + } +}; + +export const handleFailureResult = ( + responseData: ImportResponseData, + result: ImportResponse, +): never => { + const failedCount = + responseData.failed_count || responseData.failed_list?.length || 0; + + if (failedCount > 0) { + const errorMessage = + responseData.failed_list?.[0]?.error_message || 'Import failed'; + throw new Error(errorMessage); + } + + throw new Error(result.msg || 'Import failed'); +}; diff --git a/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/index.tsx b/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/index.tsx index da65b12b30..dfa6a72e85 100644 --- a/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/index.tsx +++ b/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/index.tsx @@ -17,6 +17,8 @@ import { useWorkflowResourceMenuActions } from './use-workflow-resource-menu-actions'; import { useWorkflowResourceClick } from './use-workflow-resource-click'; import { useCreateWorkflowModal } from './use-create-workflow-modal'; +import { useImportAction } from './use-import-action'; +import { useImportWorkflowModal } from './use-import-workflow-modal'; import { type UseWorkflowResourceAction, type WorkflowResourceActionProps, @@ -55,4 +57,6 @@ export { useCreateWorkflowModal, useWorkflowResourceClick, useWorkflowResourceMenuActions, + useImportAction, + useImportWorkflowModal, }; diff --git a/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/use-export-action.tsx b/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/use-export-action.tsx new file mode 100644 index 0000000000..a094d71bb0 --- /dev/null +++ b/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/use-export-action.tsx @@ -0,0 +1,213 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useState } from 'react'; + +import { I18n } from '@coze-arch/i18n'; +import { Toast } from '@coze-arch/coze-design'; +import { type ResourceInfo } from '@coze-arch/bot-api/plugin_develop'; + +import { type WorkflowResourceActionProps } from './type'; +import { ExportFormatModal } from './components/ExportFormatModal'; + +type ExportFormat = 'json' | 'yml' | 'yaml'; + +const HTTP_STATUS = { + FORBIDDEN: 403, + NOT_FOUND: 404, + BAD_REQUEST: 400, + SERVER_ERROR: 500, + BAD_GATEWAY: 502, + SERVICE_UNAVAILABLE: 503, + GATEWAY_TIMEOUT: 504, + OK: 200, +} as const; + +const createFileContent = ( + exportData: Record, + format: ExportFormat, + recordName: string, +): { content: string; fileName: string; mimeType: string } => { + const JSON_INDENT = 2; + + if (format === 'yml' || format === 'yaml') { + // 对于YAML格式, 使用后端返回的序列化数据 + let fileContent: string; + if ( + exportData.serialized_data && + typeof exportData.serialized_data === 'string' + ) { + fileContent = exportData.serialized_data; + } else { + console.warn( + 'YAML serialized_data is invalid, fallback to JSON stringify', + ); + console.log('serialized_data value:', exportData.serialized_data); + fileContent = JSON.stringify(exportData, null, JSON_INDENT); + } + return { + content: fileContent, + fileName: `${recordName || 'workflow'}_export.${format}`, + mimeType: 'text/yaml', + }; + } else { + // 对于JSON格式, 使用原有逻辑 + return { + content: JSON.stringify(exportData, null, JSON_INDENT), + fileName: `${recordName || 'workflow'}_export.json`, + mimeType: 'application/json', + }; + } +}; + +export const useExportAction = (props: WorkflowResourceActionProps) => { + const [exporting, setExporting] = useState(false); + const [showFormatModal, setShowFormatModal] = useState(false); + const [selectedRecord, setSelectedRecord] = useState( + null, + ); + const [selectedFormat, setSelectedFormat] = useState('json'); + + const callExportAPI = async (record: ResourceInfo, format: ExportFormat) => { + const response = await fetch('/api/workflow_api/export', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + workflow_id: String(record.res_id), + include_dependencies: true, + export_format: format, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('API Error:', errorText); + throw new Error('Export request failed'); + } + + return response.json(); + }; + + const downloadFile = ( + content: string, + fileName: string, + mimeType: string, + ) => { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + + const performExport = async (record: ResourceInfo, format: ExportFormat) => { + if (exporting) { + return; + } + + try { + setExporting(true); + console.log('Starting export:', { recordId: record.res_id, format }); + + const result = await callExportAPI(record, format); + console.log('API Result:', result); + + if (result.code === HTTP_STATUS.OK && result.data?.workflow_export) { + const exportData = result.data.workflow_export; + const { content, fileName, mimeType } = createFileContent( + exportData, + format, + record.name || 'workflow', + ); + + console.log('File content length:', content.length); + console.log('File name:', fileName); + + if (!content || content.trim() === '') { + throw new Error('Export data is empty'); + } + + downloadFile(content, fileName, mimeType); + Toast.success(I18n.t('workflow_export_success') || 'Export successful'); + console.log('Export completed successfully'); + } else { + console.error('Invalid API response:', result); + throw new Error(result.msg || 'Invalid response from server'); + } + } catch (error) { + console.error('Export workflow failed:', error); + let errorMessage = 'Export failed'; + if (error instanceof Error) { + errorMessage = error.message; + } + Toast.error(errorMessage); + } finally { + setExporting(false); + } + }; + + const handleExport = (record: ResourceInfo) => { + console.log('handleExport called with record:', record); + setSelectedRecord(record); + setShowFormatModal(true); + }; + + const handleConfirmExport = () => { + if (!selectedRecord) { + Toast.error('No record selected'); + return; + } + + if (!selectedFormat) { + Toast.error('Please select an export format'); + return; + } + + setShowFormatModal(false); + performExport(selectedRecord, selectedFormat); + setSelectedRecord(null); + setSelectedFormat('json'); + }; + + const handleCancelExport = () => { + setShowFormatModal(false); + setSelectedRecord(null); + setSelectedFormat('json'); // 重置为默认格式 + }; + + const exportModal = ( + + ); + + return { + actionHandler: handleExport, + exporting, + exportModal, + }; +}; diff --git a/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/use-import-action.tsx b/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/use-import-action.tsx new file mode 100644 index 0000000000..e6fd87b4ac --- /dev/null +++ b/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/use-import-action.tsx @@ -0,0 +1,146 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useState } from 'react'; + +import { Toast } from '@coze-arch/coze-design'; + +import { type WorkflowResourceActionProps } from './type'; +import { + parseFileContent, + validateWorkflowData, + handleSuccessResult, + handleFailureResult, + type ImportResponse, +} from './import-utils'; + +export interface ExtendedWorkflowResourceActionProps + extends WorkflowResourceActionProps { + goWorkflowDetail?: (workflowId: string, spaceId?: string) => void; +} + +export const useImportAction = (props: ExtendedWorkflowResourceActionProps) => { + const [importing, setImporting] = useState(false); + + const callImportAPI = async ( + file: File, + workflowData: Record, + fileContent: string, + ): Promise => { + const response = await fetch('/api/workflow_api/batch_import', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + workflow_files: [ + { + file_name: file.name, + workflow_data: fileContent, + workflow_name: workflowData.name || `Imported_${Date.now()}`, + }, + ], + space_id: props.spaceId || '', + creator_id: props.userId || '', + import_format: 'mixed', + import_mode: 'batch', + }), + }); + + if (!response.ok) { + throw new Error('Import request failed'); + } + + return response.json(); + }; + + const processImportResult = (result: ImportResponse): void => { + const responseData = result.data || {}; + const successCount = + responseData.success_count || responseData.success_list?.length || 0; + + if (successCount > 0) { + handleSuccessResult({ + responseData, + goWorkflowDetail: props.goWorkflowDetail, + refreshPage: props.refreshPage, + spaceId: props.spaceId, + }); + } else { + handleFailureResult(responseData, result); + } + }; + + const handleImport = async (fileOrRecord: File | unknown) => { + if (importing) { + return; + } + + try { + setImporting(true); + + // 如果是从菜单触发的, 需要创建文件输入 + if (!(fileOrRecord instanceof File)) { + // 创建隐藏的文件输入元素 + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json,.yml,.yaml'; + input.onchange = async e => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) { + await processFileImport(file); + } + }; + input.click(); + return; + } + + await processFileImport(fileOrRecord); + } catch (error) { + console.error('Import workflow failed:', error); + Toast.error( + error instanceof Error ? error.message : 'Workflow import failed', + ); + } finally { + setImporting(false); + } + }; + + const processFileImport = async (file: File) => { + const fileContent = await file.text(); + const fileName = file.name.toLowerCase(); + const isYamlFile = fileName.endsWith('.yml') || fileName.endsWith('.yaml'); + + let workflowData: Record; + try { + workflowData = parseFileContent(fileContent, isYamlFile); + } catch (error) { + console.error('Failed to parse workflow file:', error); + throw new Error('Failed to parse workflow file'); + } + + validateWorkflowData(workflowData); + + const result = await callImportAPI(file, workflowData, fileContent); + processImportResult(result); + }; + + return { + actionHandler: handleImport, + importing, + importModal: null, // 如果需要模态框, 这里应该返回相应的组件 + }; +}; diff --git a/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/use-import-workflow-modal.tsx b/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/use-import-workflow-modal.tsx new file mode 100644 index 0000000000..dcdcef199b --- /dev/null +++ b/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/use-import-workflow-modal.tsx @@ -0,0 +1,461 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useState, useCallback, useEffect } from 'react'; + +import { load } from 'js-yaml'; +import { useBoolean } from 'ahooks'; +import { I18n } from '@coze-arch/i18n'; +import { + IconCozUpload, + IconCozFilter, + IconCozCheckMarkCircle, + IconCozInfoCircle, +} from '@coze-arch/coze-design/icons'; +import { + Modal, + Upload, + Button, + Input, + Typography, + Tag, + Progress, + Toast, +} from '@coze-arch/coze-design'; + +import { + useImportAction, + type ExtendedWorkflowResourceActionProps, +} from './use-import-action'; + +const { Text } = Typography; + +// Constants +const MAX_FILE_SIZE = 10 * 1024 * 1024; + +// Helper functions +const validateFile = (file: File) => { + const fileName = file.name.toLowerCase(); + const isValidFile = + fileName.endsWith('.json') || + fileName.endsWith('.yml') || + fileName.endsWith('.yaml'); + + if (!isValidFile) { + Toast.error('Invalid file type'); + return false; + } + + if (file.size > MAX_FILE_SIZE) { + Toast.error('File too large'); + return false; + } + + return true; +}; + +const parseFileContent = async ( + file: File, +): Promise | null> => { + const fileName = file.name.toLowerCase(); + const fileContent = await file.text(); + + try { + let workflowData: Record; + + if (fileName.endsWith('.yml') || fileName.endsWith('.yaml')) { + workflowData = load(fileContent) as Record; + } else { + workflowData = JSON.parse(fileContent); + } + + if (workflowData && typeof workflowData === 'object') { + return workflowData; + } else { + Toast.error('Invalid workflow structure'); + return null; + } + } catch (error) { + console.error('File parsing error:', error); + Toast.error('Failed to parse file'); + return null; + } +}; + +const formatFileSize = (bytes: number) => { + if (bytes === 0) { + return '0 Bytes'; + } + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; +}; + +interface FileUploadAreaProps { + selectedFile: File | null; + onFileSelect: (file: File) => Promise; +} + +const FileUploadArea = ({ + selectedFile, + onFileSelect, +}: FileUploadAreaProps) => ( + { + if (object.file.fileInstance) { + await onFileSelect(object.file.fileInstance); + } + return { shouldUpload: false }; + }} + showUploadList={false} + > +
+ {selectedFile ? ( +
+ +
+ File Selected +
+
{selectedFile.name}
+
+ + {formatFileSize(selectedFile.size)} + + + + {selectedFile.name.toLowerCase().endsWith('.json') + ? 'JSON' + : 'YAML'} + +
+
+ ) : ( +
+ +
+ Drag and drop or click to upload +
+
Supports JSON, YAML files
+
+ )} +
+
+); + +interface WorkflowPreviewProps { + workflowPreview: Record; + selectedFile: File | null; +} + +const WorkflowPreview = ({ + workflowPreview, + selectedFile, +}: WorkflowPreviewProps) => ( +
+
+ Preview + {selectedFile ? ( + + {selectedFile.name.toLowerCase().endsWith('.json') + ? 'JSON Format' + : 'YAML Format'} + + ) : null} +
+
+
+
+
+ {(workflowPreview.nodes as unknown[])?.length || 0} +
+
Nodes
+ + 节点 + +
+ +
+
+ {(workflowPreview.edges as unknown[])?.length || 0} +
+
Edges
+ + 连接 + +
+
+ +
+
+ + Name: + +
+ {String(workflowPreview.name || '')} +
+
+ + {Boolean(workflowPreview.description) && ( +
+ + Description: + +
+ {String(workflowPreview.description)} +
+
+ )} +
+
+
+); + +// Custom hook for file handling +const useFileHandler = () => { + const [selectedFile, setSelectedFile] = useState(null); + const [workflowPreview, setWorkflowPreview] = useState | null>(null); + const [parsing, setParsing] = useState(false); + + const handleFileSelect = useCallback(async (file: File) => { + try { + if (!validateFile(file)) { + return false; + } + + setSelectedFile(file); + setParsing(true); + + const workflowData = await parseFileContent(file); + + if (workflowData) { + setWorkflowPreview(workflowData); + } else { + return false; + } + + return false; // 阻止自动上传 + } catch (error) { + console.error('File selection error:', error); + Toast.error('Invalid file'); + return false; + } finally { + setParsing(false); + } + }, []); + + const resetFileState = useCallback(() => { + setSelectedFile(null); + setWorkflowPreview(null); + setParsing(false); + }, []); + + return { + selectedFile, + workflowPreview, + parsing, + handleFileSelect, + resetFileState, + }; +}; + +// Custom hook for form state +const useImportFormState = ( + workflowPreview: Record | null, +) => { + const [workflowName, setWorkflowName] = useState(''); + + // Update workflow name when preview changes + useEffect(() => { + if (workflowPreview) { + const importedWorkflowName = + workflowPreview.name || + workflowPreview.workflow_id || + `Imported_${Date.now()}`; + setWorkflowName(String(importedWorkflowName)); + } + }, [workflowPreview]); + + const resetFormState = useCallback(() => { + setWorkflowName(''); + }, []); + + return { + workflowName, + setWorkflowName, + resetFormState, + }; +}; + +export const useImportWorkflowModal = ( + props: ExtendedWorkflowResourceActionProps, +) => { + const [ + importModalVisible, + { setTrue: openImportModal, setFalse: closeImportModal }, + ] = useBoolean(false); + const { actionHandler: importAction, importing } = useImportAction(props); + + const { + selectedFile, + workflowPreview, + parsing, + handleFileSelect, + resetFileState, + } = useFileHandler(); + + const { workflowName, setWorkflowName, resetFormState } = + useImportFormState(workflowPreview); + + const handleImport = useCallback(async () => { + if (!selectedFile) { + Toast.error('Import failed'); + return; + } + + try { + if (!workflowName.trim()) { + Toast.error('Workflow name is required'); + return; + } + await importAction(selectedFile); + closeImportModal(); + resetFileState(); + resetFormState(); + } catch (error) { + console.error('Import error:', error); + } + }, [ + selectedFile, + workflowName, + importAction, + closeImportModal, + resetFileState, + resetFormState, + ]); + + const handleCancel = useCallback(() => { + closeImportModal(); + resetFileState(); + resetFormState(); + }, [closeImportModal, resetFileState, resetFormState]); + + const importModal = ( + + + {I18n.t('workflow_import')} + + } + visible={importModalVisible} + onCancel={handleCancel} + footer={[ + , + , + ]} + width={700} + className="workflow-import-modal" + > +
+
+ + +
+ + {parsing ? ( +
+
+ + Loading preview... +
+ +
+ ) : null} + + {workflowPreview ? ( + + ) : null} + +
+ + setWorkflowName(value)} + className="text-base" + /> +
+
+
+ ); + + return { + openImportModal, + closeImportModal, + importModal, + }; +}; diff --git a/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/use-workflow-resource-menu-actions.tsx b/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/use-workflow-resource-menu-actions.tsx index aa06858dff..426dab346f 100644 --- a/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/use-workflow-resource-menu-actions.tsx +++ b/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/use-workflow-resource-menu-actions.tsx @@ -35,6 +35,7 @@ import { } from './utils'; import { useWorkflowPublishEntry } from './use-workflow-publish-entry'; import { usePublishAction } from './use-publish-action'; +import { useExportAction } from './use-export-action'; import { useDeleteAction } from './use-delete-action'; import { useCopyAction } from './use-copy-action'; import { useChatflowSwitch } from './use-chatflow-switch'; @@ -61,6 +62,11 @@ export const useWorkflowResourceMenuActions = ( const { actionHandler: copyAction } = useCopyAction(props); const { actionHandler: publishAction, publishModal } = usePublishAction(props); + const { + actionHandler: exportAction, + exporting, + exportModal, + } = useExportAction(props); const { switchToChatflow, switchToWorkflow } = useChatflowSwitch({ spaceId: props.spaceId ?? '', refreshPage: props.refreshPage, @@ -139,6 +145,19 @@ export const useWorkflowResourceMenuActions = ( }, }, ]; + + // 添加导出操作 + extraActions.push({ + hide: false, + disabled: exporting, + actionKey: 'export', + actionText: I18n.t('export'), + handler: () => { + console.log('Export action handler called for record:', record); + exportAction(record); + }, + }); + return ( ); }; - return { renderWorkflowResourceActions, modals: [deleteModal, publishModal] }; + return { + renderWorkflowResourceActions, + modals: [deleteModal, publishModal, exportModal], + }; }; diff --git a/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/utils.ts b/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/utils.ts index 6d0164e4ee..015816afdf 100644 --- a/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/utils.ts +++ b/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/utils.ts @@ -70,6 +70,6 @@ export const transformResourceToWorkflowEditInfo = ( desc: resource.desc, schema_type: bizExtend?.schema_type, external_flow_info: bizExtend?.external_flow_info, - space_id: resource.space_id, + space_id: resource.space_id || '', // 确保 space_id 不为 undefined }; }; diff --git a/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/workflow-modal-styles.css b/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/workflow-modal-styles.css new file mode 100644 index 0000000000..6531a31d99 --- /dev/null +++ b/frontend/packages/workflow/components/src/hooks/use-workflow-resource-action/workflow-modal-styles.css @@ -0,0 +1,112 @@ +/* Workflow Export Modal Styles */ +.workflow-export-modal .ant-modal-content { + overflow: hidden; + border-radius: 12px; +} + +.workflow-export-modal .ant-modal-body { + padding: 0; +} + +/* Workflow Import Modal Styles */ +.workflow-import-modal .ant-modal-header { + padding: 16px 24px; + border-bottom: 1px solid #f0f0f0; +} + +.workflow-import-modal .ant-modal-body { + padding: 24px; +} + +/* Custom upload area hover effects */ +.workflow-upload-area { + transition: all 0.3s ease; +} + +.workflow-upload-area:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 10%); +} + +/* Format selection buttons */ +.format-selection-button { + position: relative; + overflow: hidden; +} + +.format-selection-button::before { + content: ''; + + position: absolute; + top: 0; + left: -100%; + + width: 100%; + height: 100%; + + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 20%), transparent); + + transition: left 0.5s; +} + +.format-selection-button:hover::before { + left: 100%; +} + +/* Preview cards animation */ +.preview-card { + transform: translateY(0); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.preview-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 12%); +} + +/* Progress animation */ +@keyframes shimmer { + 0% { + background-position: -468px 0; + } + + 100% { + background-position: 468px 0; + } +} + +.loading-shimmer { + background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); + background-size: 400% 100%; + animation: shimmer 1.2s ease-in-out infinite; +} + +/* File type indicators */ +.file-type-json { + color: white; + background: linear-gradient(135deg, #3b82f6, #1d4ed8); +} + +.file-type-yaml { + color: white; + background: linear-gradient(135deg, #8b5cf6, #7c3aed); +} + +/* Success states */ +.success-pulse { + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { + transform: scale(1); + } + + 50% { + transform: scale(1.05); + } + + 100% { + transform: scale(1); + } +} \ No newline at end of file diff --git a/frontend/packages/workflow/components/src/workflow-modal/sider/create-workflow-btn.tsx b/frontend/packages/workflow/components/src/workflow-modal/sider/create-workflow-btn.tsx index 8c6c6bb4f2..ab44bca9df 100644 --- a/frontend/packages/workflow/components/src/workflow-modal/sider/create-workflow-btn.tsx +++ b/frontend/packages/workflow/components/src/workflow-modal/sider/create-workflow-btn.tsx @@ -22,6 +22,7 @@ import { IconCozWorkflow, IconCozChat, IconCozArrowDown, + IconCozUpload, } from '@coze-arch/coze-design/icons'; import { Menu, Button } from '@coze-arch/coze-design'; import { CustomError } from '@coze-arch/bot-error'; @@ -31,7 +32,9 @@ import { WorkflowModalFrom, type WorkFlowModalModeProps } from '../type'; import { useI18nText } from '../hooks/use-i18n-text'; import { CreateWorkflowModal } from '../../workflow-edit'; import { wait } from '../../utils'; +import { useImportWorkflowModal } from '../../hooks/use-workflow-resource-action/use-import-workflow-modal'; import { useOpenWorkflowDetail } from '../../hooks/use-open-workflow-detail'; + export const CreateWorkflowBtn: FC< Pick< WorkFlowModalModeProps, @@ -43,7 +46,18 @@ export const CreateWorkflowBtn: FC< const context = useContext(WorkflowModalContext); const { i18nText, ModalI18nKey } = useI18nText(); const openWorkflowDetailPage = useOpenWorkflowDetail(); - + const { openImportModal, importModal } = useImportWorkflowModal({ + spaceId: context?.spaceId, + userId: context?.userId || '', + refreshPage: onCreateSuccess + ? () => + onCreateSuccess({ + spaceId: context?.spaceId || '', + workflowId: '', + flowMode: WorkflowMode.Workflow, + }) + : undefined, + }); const [createFlowMode, setCreateFlowMode] = useState( context?.flowMode ?? WorkflowMode.Workflow, ); @@ -76,6 +90,13 @@ export const CreateWorkflowBtn: FC< }, icon: , }, + { + label: I18n.t('workflow_import'), + handler: () => { + openImportModal(); + }, + icon: , + }, ]; return ( @@ -106,7 +127,7 @@ export const CreateWorkflowBtn: FC< {menuConfig.map(item => ( { + onClick={(_value, event) => { event.stopPropagation(); item.handler(); }} @@ -147,7 +168,9 @@ export const CreateWorkflowBtn: FC< 'create workflow failed, no workflow id', ); } - // Due to the delay in the synchronization of the main and standby data of the workflow created by the server level, if you jump directly after the creation, the workflowId may not be found, so the front-end delay reduces the probability of the problem triggering + // Due to the delay in the synchronization of the main and standby data of the workflow created by the + // server level, if you jump directly after the creation, the workflowId may not be found, so the + // front-end delay reduces the probability of the problem triggering await wait(500); if (onCreateSuccess) { @@ -165,6 +188,7 @@ export const CreateWorkflowBtn: FC< }} nameValidators={nameValidators} /> + {importModal} ); }; diff --git a/frontend/packages/workflow/components/src/workflow-modal/workflow-modal-context.tsx b/frontend/packages/workflow/components/src/workflow-modal/workflow-modal-context.tsx index 20982dfa87..e616c4f2b7 100644 --- a/frontend/packages/workflow/components/src/workflow-modal/workflow-modal-context.tsx +++ b/frontend/packages/workflow/components/src/workflow-modal/workflow-modal-context.tsx @@ -29,11 +29,15 @@ import { type I18nKey, type ModalI18nKey } from './hooks/use-i18n-text'; export interface WorkflowModalContextValue { spaceId: string; spaceType: SpaceType; + userId?: string; bindBizId?: string; bindBizType?: BindBizType; /** The current project id, only the workflow within the project has this field */ projectId?: string; - /** Workflow type, this parameter is passed in by props when created by WorkflowModal pop-up window, possible values are Workflow, Imageflow. Used to distinguish which workflow to add */ + /** + * Workflow type, this parameter is passed in by props when created by WorkflowModal pop-up window, + * possible values are Workflow, Imageflow. Used to distinguish which workflow to add + */ flowMode: WorkflowMode; modalState: WorkflowModalState; /** Update popup status, merge mode */ diff --git a/idl/resource/resource_common.thrift b/idl/resource/resource_common.thrift index 54bbf1b18d..da6b949467 100644 --- a/idl/resource/resource_common.thrift +++ b/idl/resource/resource_common.thrift @@ -22,6 +22,7 @@ enum ActionKey{ Delete = 2, // delete EnableSwitch = 3, // enable/disable Edit = 4, // edit + Export = 5, // export SwitchToFuncflow = 8, // Switch to funcflow SwitchToChatflow = 9, // Switch to chatflow CrossSpaceCopy = 10, // Cross-space copy diff --git a/idl/workflow/workflow.thrift b/idl/workflow/workflow.thrift index 2f207d402c..c8202a8790 100644 --- a/idl/workflow/workflow.thrift +++ b/idl/workflow/workflow.thrift @@ -2201,6 +2201,7 @@ struct OpenAPIGetWorkflowInfoResponse{ 255: required base.BaseResp BaseResp } +// Conversation related structures struct CreateConversationRequest { 1: optional map MetaData (api.body = "meta_data") //自定义透传字段 3: optional i64 BotId (api.body = "bot_id", api.js_conv="true") @@ -2214,14 +2215,12 @@ struct CreateConversationRequest { 255: optional base.Base Base } - struct CreateConversationResponse { 1: i64 code 2: string msg 3: optional ConversationData ConversationData (api.body = "data") } - struct ConversationData { 1: i64 Id (api.body = "id", agw.key = "id", api.js_conv="true") 2: i64 CreatedAt (api.body = "created_at", agw.key = "created_at") @@ -2231,3 +2230,49 @@ struct ConversationData { 6: optional i64 LastSectionID (api.body="last_section_id", api.js_conv="true") 7: optional i64 AccountID (api.body = "account_id") } + +// Export workflow related structures +struct ExportWorkflowRequest { + 1: required string workflow_id, // 工作流ID + 2: optional bool include_dependencies, // 是否包含依赖资源 + 3: required string export_format, // 导出格式,目前支持 "json" + + 255: optional base.Base Base, +} + +struct ExportWorkflowResponse { + 1: required ExportWorkflowData data, + + 253: required i64 code, + 254: required string msg, + 255: required base.BaseResp BaseResp, +} + +struct ExportWorkflowData { + 1: required string workflow_id, // 工作流ID + 2: required string name, // 工作流名称 + 3: optional string description, // 工作流描述 + 4: optional string version, // 版本 + 5: optional i64 create_time, // 创建时间 + 6: optional i64 update_time, // 更新时间 + 7: optional string schema_json, // 工作流schema JSON + 8: optional list nodes, // 节点列表 + 9: optional list connections, // 连接列表 + 10: optional map metadata, // 元数据 + 11: optional list dependencies, // 依赖资源 +} + +struct DependencyResource { + 1: required string resource_id, // 资源ID + 2: required string resource_type, // 资源类型 + 3: required string resource_name, // 资源名称 + 4: optional string resource_version, // 资源版本 + 5: optional map metadata, // 资源元数据 +} + +struct Connection { + 1: required string from_node, // 起始节点 + 2: required string to_node, // 目标节点 + 3: optional string from_port, // 起始端口 + 4: optional string to_port, // 目标端口 +} diff --git a/idl/workflow/workflow_svc.thrift b/idl/workflow/workflow_svc.thrift index 2e99596c6c..2a3b8f48ef 100644 --- a/idl/workflow/workflow_svc.thrift +++ b/idl/workflow/workflow_svc.thrift @@ -68,6 +68,9 @@ service WorkflowService { // App Release Management workflow.ListPublishWorkflowResponse ListPublishWorkflow(1: workflow.ListPublishWorkflowRequest request) (api.post='/api/workflow_api/list_publish_workflow', api.category="workflow_api", api.gen_path="workflow_api", agw.preserve_base = "true") + // Export workflow + workflow.ExportWorkflowResponse ExportWorkflow(1: workflow.ExportWorkflowRequest request) (api.post='/api/workflow_api/export', api.category="workflow_api", api.gen_path="workflow_api", agw.preserve_base = "true") + // Open API workflow.OpenAPIRunFlowResponse OpenAPIRunFlow(1: workflow.OpenAPIRunFlowRequest request)(api.post = "/v1/workflow/run", api.category="workflow_open_api", api.tag="openapi", api.gen_path="workflow_open_api" ) workflow.OpenAPIStreamRunFlowResponse OpenAPIStreamRunFlow(1: workflow.OpenAPIRunFlowRequest request)(api.post = "/v1/workflow/stream_run", api.category="workflow_open_api", api.tag="openapi", api.gen_path="workflow_open_api") diff --git a/scripts/volcengine/go.mod b/scripts/volcengine/go.mod index 16f0f12b69..a91926d75b 100644 --- a/scripts/volcengine/go.mod +++ b/scripts/volcengine/go.mod @@ -4,12 +4,19 @@ go 1.24.1 require ( github.com/joho/godotenv v1.5.1 - github.com/volcengine/volcengine-go-sdk v1.1.18 + github.com/volcengine/volcengine-go-sdk v1.1.20 ) require ( - github.com/google/uuid v1.3.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/google/uuid v1.6.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect - github.com/volcengine/volc-sdk-golang v1.0.23 // indirect - gopkg.in/yaml.v2 v2.2.8 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/volcengine/volc-sdk-golang v1.0.211 // indirect + golang.org/x/net v0.41.0 // indirect + golang.org/x/text v0.26.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/scripts/volcengine/go.sum b/scripts/volcengine/go.sum index 64e7cb90a1..ac6f1ced49 100644 --- a/scripts/volcengine/go.sum +++ b/scripts/volcengine/go.sum @@ -1,11 +1,12 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -24,29 +25,29 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/volcengine/volc-sdk-golang v1.0.23 h1:anOslb2Qp6ywnsbyq9jqR0ljuO63kg9PY+4OehIk5R8= github.com/volcengine/volc-sdk-golang v1.0.23/go.mod h1:AfG/PZRUkHJ9inETvbjNifTDgut25Wbkm2QoYBTbvyU= -github.com/volcengine/volcengine-go-sdk v1.1.18 h1:tVcp1m6R8fgwZFb/MaltrpZY7b2+vYNbmO1MHlDSXIs= -github.com/volcengine/volcengine-go-sdk v1.1.18/go.mod h1:EyKoi6t6eZxoPNGr2GdFCZti2Skd7MO3eUzx7TtSvNo= +github.com/volcengine/volc-sdk-golang v1.0.211 h1:FgwD+1phyy+un4Qk2YqooYtp6XpvNDQ4a/fpsCAtDHo= +github.com/volcengine/volcengine-go-sdk v1.1.20 h1:+ifZdF7IIIagqF8yVNfk9CmNUl5wgRfU/8orlH+JQhA= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -56,6 +57,7 @@ golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -63,6 +65,7 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= @@ -86,10 +89,10 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=