From f06025a3425b9580bfc72b7b6aa4938e615ce335 Mon Sep 17 00:00:00 2001 From: KVOJJJin Date: Mon, 27 Oct 2025 13:35:54 +0800 Subject: [PATCH 001/394] Fix: upload limit in knowledge (#27480) Co-authored-by: jyong <718720800@qq.com> --- api/controllers/console/files.py | 1 + .../datasets/create/file-uploader/index.tsx | 23 +++++++++++-------- web/i18n/en-US/dataset-creation.ts | 2 +- web/i18n/zh-Hans/dataset-creation.ts | 2 +- web/models/common.ts | 1 + 5 files changed, 17 insertions(+), 12 deletions(-) diff --git a/api/controllers/console/files.py b/api/controllers/console/files.py index 1cd193f7ad..36fcd460bb 100644 --- a/api/controllers/console/files.py +++ b/api/controllers/console/files.py @@ -39,6 +39,7 @@ class FileApi(Resource): return { "file_size_limit": dify_config.UPLOAD_FILE_SIZE_LIMIT, "batch_count_limit": dify_config.UPLOAD_FILE_BATCH_LIMIT, + "file_upload_limit": dify_config.BATCH_UPLOAD_LIMIT, "image_file_size_limit": dify_config.UPLOAD_IMAGE_FILE_SIZE_LIMIT, "video_file_size_limit": dify_config.UPLOAD_VIDEO_FILE_SIZE_LIMIT, "audio_file_size_limit": dify_config.UPLOAD_AUDIO_FILE_SIZE_LIMIT, diff --git a/web/app/components/datasets/create/file-uploader/index.tsx b/web/app/components/datasets/create/file-uploader/index.tsx index 43d69d1889..463715bb62 100644 --- a/web/app/components/datasets/create/file-uploader/index.tsx +++ b/web/app/components/datasets/create/file-uploader/index.tsx @@ -19,8 +19,6 @@ import { IS_CE_EDITION } from '@/config' import { Theme } from '@/types/app' import useTheme from '@/hooks/use-theme' -const FILES_NUMBER_LIMIT = 20 - type IFileUploaderProps = { fileList: FileItem[] titleClassName?: string @@ -72,6 +70,7 @@ const FileUploader = ({ const fileUploadConfig = useMemo(() => fileUploadConfigResponse ?? { file_size_limit: 15, batch_count_limit: 5, + file_upload_limit: 5, }, [fileUploadConfigResponse]) const fileListRef = useRef([]) @@ -121,10 +120,10 @@ const FileUploader = ({ data: formData, onprogress: onProgress, }, false, undefined, '?source=datasets') - .then((res: File) => { + .then((res) => { const completeFile = { fileID: fileItem.fileID, - file: res, + file: res as unknown as File, progress: -1, } const index = fileListRef.current.findIndex(item => item.fileID === fileItem.fileID) @@ -163,11 +162,12 @@ const FileUploader = ({ }, [fileUploadConfig, uploadBatchFiles]) const initialUpload = useCallback((files: File[]) => { + const filesCountLimit = fileUploadConfig.file_upload_limit if (!files.length) return false - if (files.length + fileList.length > FILES_NUMBER_LIMIT && !IS_CE_EDITION) { - notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.filesNumber', { filesNumber: FILES_NUMBER_LIMIT }) }) + if (files.length + fileList.length > filesCountLimit && !IS_CE_EDITION) { + notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.filesNumber', { filesNumber: filesCountLimit }) }) return false } @@ -180,7 +180,7 @@ const FileUploader = ({ prepareFileList(newFiles) fileListRef.current = newFiles uploadMultipleFiles(preparedFiles) - }, [prepareFileList, uploadMultipleFiles, notify, t, fileList]) + }, [prepareFileList, uploadMultipleFiles, notify, t, fileList, fileUploadConfig]) const handleDragEnter = (e: DragEvent) => { e.preventDefault() @@ -255,10 +255,11 @@ const FileUploader = ({ ) let files = nested.flat() if (notSupportBatchUpload) files = files.slice(0, 1) + files = files.slice(0, fileUploadConfig.batch_count_limit) const valid = files.filter(isValid) initialUpload(valid) }, - [initialUpload, isValid, notSupportBatchUpload, traverseFileEntry], + [initialUpload, isValid, notSupportBatchUpload, traverseFileEntry, fileUploadConfig], ) const selectHandle = () => { if (fileUploader.current) @@ -273,9 +274,10 @@ const FileUploader = ({ onFileListUpdate?.([...fileListRef.current]) } const fileChangeHandle = useCallback((e: React.ChangeEvent) => { - const files = [...(e.target.files ?? [])] as File[] + let files = [...(e.target.files ?? [])] as File[] + files = files.slice(0, fileUploadConfig.batch_count_limit) initialUpload(files.filter(isValid)) - }, [isValid, initialUpload]) + }, [isValid, initialUpload, fileUploadConfig]) const { theme } = useTheme() const chartColor = useMemo(() => theme === Theme.dark ? '#5289ff' : '#296dff', [theme]) @@ -325,6 +327,7 @@ const FileUploader = ({ size: fileUploadConfig.file_size_limit, supportTypes: supportTypesShowNames, batchCount: notSupportBatchUpload ? 1 : fileUploadConfig.batch_count_limit, + totalCount: fileUploadConfig.file_upload_limit, })} {dragging &&
}
diff --git a/web/i18n/en-US/dataset-creation.ts b/web/i18n/en-US/dataset-creation.ts index 54d5a54fb4..f32639a6b4 100644 --- a/web/i18n/en-US/dataset-creation.ts +++ b/web/i18n/en-US/dataset-creation.ts @@ -38,7 +38,7 @@ const translation = { button: 'Drag and drop file or folder, or', buttonSingleFile: 'Drag and drop file, or', browse: 'Browse', - tip: 'Supports {{supportTypes}}. Max {{batchCount}} in a batch and {{size}} MB each.', + tip: 'Supports {{supportTypes}}. Max {{batchCount}} in a batch and {{size}} MB each. Max total {{totalCount}} files.', validation: { typeError: 'File type not supported', size: 'File too large. Maximum is {{size}}MB', diff --git a/web/i18n/zh-Hans/dataset-creation.ts b/web/i18n/zh-Hans/dataset-creation.ts index 5b1ff2435c..f780269914 100644 --- a/web/i18n/zh-Hans/dataset-creation.ts +++ b/web/i18n/zh-Hans/dataset-creation.ts @@ -38,7 +38,7 @@ const translation = { button: '拖拽文件或文件夹至此,或者', buttonSingleFile: '拖拽文件至此,或者', browse: '选择文件', - tip: '已支持 {{supportTypes}},每批最多 {{batchCount}} 个文件,每个文件不超过 {{size}} MB。', + tip: '已支持 {{supportTypes}},每批最多 {{batchCount}} 个文件,每个文件不超过 {{size}} MB ,总数不超过 {{totalCount}} 个文件。', validation: { typeError: '文件类型不支持', size: '文件太大了,不能超过 {{size}}MB', diff --git a/web/models/common.ts b/web/models/common.ts index aa6372e36f..d83ae5fb98 100644 --- a/web/models/common.ts +++ b/web/models/common.ts @@ -236,6 +236,7 @@ export type FileUploadConfigResponse = { audio_file_size_limit?: number // default is 50MB video_file_size_limit?: number // default is 100MB workflow_file_upload_limit?: number // default is 10 + file_upload_limit: number // default is 5 } export type InvitationResult = { From 43bcf40f809d1d546fd3f396418514d0a63afc9c Mon Sep 17 00:00:00 2001 From: GuanMu Date: Mon, 27 Oct 2025 14:38:58 +0800 Subject: [PATCH 002/394] refactor: update installed app component to handle missing params and improve type safety (#27331) --- .../explore/installed/[appId]/page.tsx | 6 +- web/app/account/oauth/authorize/constants.ts | 3 + web/app/account/oauth/authorize/page.tsx | 10 +- .../components/app/app-publisher/index.tsx | 13 +- .../text-generation-item.tsx | 8 +- .../debug/debug-with-single-model/index.tsx | 4 +- .../app/configuration/debug/hooks.tsx | 18 +- .../app/configuration/debug/index.tsx | 15 +- .../components/app/configuration/index.tsx | 311 +++++++++--------- .../chat/chat-with-history/chat-wrapper.tsx | 3 +- .../chat/embedded-chatbot/chat-wrapper.tsx | 3 +- web/app/components/base/chat/types.ts | 2 +- .../base/content-dialog/index.stories.tsx | 5 + .../time-picker/index.spec.tsx | 4 +- .../base/date-and-time-picker/utils/dayjs.ts | 4 +- .../components/base/dialog/index.stories.tsx | 4 + web/app/components/base/form/types.ts | 2 +- .../base/markdown-blocks/think-block.tsx | 19 +- .../base/modal-like-wrap/index.stories.tsx | 6 + web/app/components/base/popover/index.tsx | 22 +- .../base/portal-to-follow-elem/index.tsx | 7 +- .../components/base/prompt-editor/hooks.ts | 4 +- .../prompt-editor/plugins/placeholder.tsx | 3 +- web/app/components/base/voice-input/utils.ts | 12 +- web/app/components/billing/pricing/index.tsx | 1 - .../billing/pricing/plans/index.tsx | 9 +- .../create/embedding-process/index.tsx | 18 +- .../datasets/create/file-uploader/index.tsx | 2 + .../data-source/local-file/index.tsx | 12 +- .../detail/batch-modal/csv-uploader.tsx | 12 +- .../datasets/documents/detail/index.tsx | 16 +- .../documents/detail/metadata/index.tsx | 36 +- .../detail/settings/document-settings.tsx | 109 ++++-- .../model-selector/index.tsx | 3 +- .../model-selector/popup.tsx | 2 +- .../plugins/install-plugin/utils.ts | 13 +- .../plugin-detail-panel/endpoint-modal.tsx | 2 +- .../model-selector/index.tsx | 5 +- .../tools/add-tool-modal/category.tsx | 25 +- .../components/tools/add-tool-modal/index.tsx | 3 +- .../components/tools/add-tool-modal/tools.tsx | 18 +- .../panel/debug-and-preview/chat-wrapper.tsx | 4 +- .../utils/{layout.ts => elk-layout.ts} | 12 +- web/app/components/workflow/utils/index.ts | 2 +- web/app/signin/utils/post-login-redirect.ts | 2 +- web/context/debug-configuration.ts | 10 + web/models/datasets.ts | 2 + web/models/debug.ts | 11 + web/types/app.ts | 16 +- 49 files changed, 531 insertions(+), 302 deletions(-) create mode 100644 web/app/account/oauth/authorize/constants.ts rename web/app/components/workflow/utils/{layout.ts => elk-layout.ts} (97%) diff --git a/web/app/(commonLayout)/explore/installed/[appId]/page.tsx b/web/app/(commonLayout)/explore/installed/[appId]/page.tsx index e288c62b5d..983fdb9d23 100644 --- a/web/app/(commonLayout)/explore/installed/[appId]/page.tsx +++ b/web/app/(commonLayout)/explore/installed/[appId]/page.tsx @@ -2,14 +2,14 @@ import React from 'react' import Main from '@/app/components/explore/installed-app' export type IInstalledAppProps = { - params: { + params?: Promise<{ appId: string - } + }> } // Using Next.js page convention for async server components async function InstalledApp({ params }: IInstalledAppProps) { - const appId = (await params).appId + const { appId } = await (params ?? Promise.reject(new Error('Missing params'))) return (
) diff --git a/web/app/account/oauth/authorize/constants.ts b/web/app/account/oauth/authorize/constants.ts new file mode 100644 index 0000000000..f1d8b98ef4 --- /dev/null +++ b/web/app/account/oauth/authorize/constants.ts @@ -0,0 +1,3 @@ +export const OAUTH_AUTHORIZE_PENDING_KEY = 'oauth_authorize_pending' +export const REDIRECT_URL_KEY = 'oauth_redirect_url' +export const OAUTH_AUTHORIZE_PENDING_TTL = 60 * 3 diff --git a/web/app/account/oauth/authorize/page.tsx b/web/app/account/oauth/authorize/page.tsx index 4aa5fa0b8e..c9b26b97c1 100644 --- a/web/app/account/oauth/authorize/page.tsx +++ b/web/app/account/oauth/authorize/page.tsx @@ -19,11 +19,11 @@ import { } from '@remixicon/react' import dayjs from 'dayjs' import { useIsLogin } from '@/service/use-common' - -export const OAUTH_AUTHORIZE_PENDING_KEY = 'oauth_authorize_pending' -export const REDIRECT_URL_KEY = 'oauth_redirect_url' - -const OAUTH_AUTHORIZE_PENDING_TTL = 60 * 3 +import { + OAUTH_AUTHORIZE_PENDING_KEY, + OAUTH_AUTHORIZE_PENDING_TTL, + REDIRECT_URL_KEY, +} from './constants' function setItemWithExpiry(key: string, value: string, ttl: number) { const item = { diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index df2618b49c..d3306ac141 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -44,7 +44,7 @@ import { appDefaultIconBackground } from '@/config' import type { PublishWorkflowParams } from '@/types/workflow' import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control' import { AccessMode } from '@/models/access-control' -import { fetchAppDetail } from '@/service/apps' +import { fetchAppDetailDirect } from '@/service/apps' import { useGlobalPublicStore } from '@/context/global-public-context' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' @@ -162,11 +162,16 @@ const AppPublisher = ({ } }, [appDetail?.id]) - const handleAccessControlUpdate = useCallback(() => { - fetchAppDetail({ url: '/apps', id: appDetail!.id }).then((res) => { + const handleAccessControlUpdate = useCallback(async () => { + if (!appDetail) + return + try { + const res = await fetchAppDetailDirect({ url: '/apps', id: appDetail.id }) setAppDetail(res) + } + finally { setShowAppAccessControl(false) - }) + } }, [appDetail, setAppDetail]) const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false) diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx index 8f8555efa4..670e5a1467 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx @@ -14,7 +14,8 @@ import { TransferMethod } from '@/app/components/base/chat/types' import { useEventEmitterContextContext } from '@/context/event-emitter' import { useProviderContext } from '@/context/provider-context' import { useFeatures } from '@/app/components/base/features/hooks' -import { noop } from 'lodash-es' +import { cloneDeep, noop } from 'lodash-es' +import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config' type TextGenerationItemProps = { modelAndParameter: ModelAndParameter @@ -50,8 +51,8 @@ const TextGenerationItem: FC = ({ const config: TextGenerationConfig = { pre_prompt: !isAdvancedMode ? modelConfig.configs.prompt_template : '', prompt_type: promptMode, - chat_prompt_config: isAdvancedMode ? chatPromptConfig : {}, - completion_prompt_config: isAdvancedMode ? completionPromptConfig : {}, + chat_prompt_config: isAdvancedMode ? chatPromptConfig : cloneDeep(DEFAULT_CHAT_PROMPT_CONFIG), + completion_prompt_config: isAdvancedMode ? completionPromptConfig : cloneDeep(DEFAULT_COMPLETION_PROMPT_CONFIG), user_input_form: promptVariablesToUserInputsForm(modelConfig.configs.prompt_variables), dataset_query_variable: contextVar || '', // features @@ -74,6 +75,7 @@ const TextGenerationItem: FC = ({ datasets: [...postDatasets], } as any, }, + system_parameters: modelConfig.system_parameters, } const { completion, diff --git a/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx b/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx index d439b00939..506e18cc62 100644 --- a/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx +++ b/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx @@ -6,7 +6,7 @@ import { import Chat from '@/app/components/base/chat/chat' import { useChat } from '@/app/components/base/chat/chat/hooks' import { useDebugConfigurationContext } from '@/context/debug-configuration' -import type { ChatConfig, ChatItem, ChatItemInTree, OnSend } from '@/app/components/base/chat/types' +import type { ChatConfig, ChatItem, OnSend } from '@/app/components/base/chat/types' import { useProviderContext } from '@/context/provider-context' import { fetchConversationMessages, @@ -126,7 +126,7 @@ const DebugWithSingleModel = ( ) }, [appId, chatList, checkCanSend, completionParams, config, handleSend, inputs, modelConfig.mode, modelConfig.model_id, modelConfig.provider, textGenerationModelList]) - const doRegenerate = useCallback((chatItem: ChatItemInTree, editedQuestion?: { message: string, files?: FileEntity[] }) => { + const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => { const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)! const parentAnswer = chatList.find(item => item.id === question.parentMessageId) doSend(editedQuestion ? editedQuestion.message : question.content, diff --git a/web/app/components/app/configuration/debug/hooks.tsx b/web/app/components/app/configuration/debug/hooks.tsx index 12022e706a..9f628c46af 100644 --- a/web/app/components/app/configuration/debug/hooks.tsx +++ b/web/app/components/app/configuration/debug/hooks.tsx @@ -12,12 +12,15 @@ import type { ChatConfig, ChatItem, } from '@/app/components/base/chat/types' +import cloneDeep from 'lodash-es/cloneDeep' import { AgentStrategy, } from '@/types/app' +import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { promptVariablesToUserInputsForm } from '@/utils/model-config' import { useDebugConfigurationContext } from '@/context/debug-configuration' import { useEventEmitterContextContext } from '@/context/event-emitter' +import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config' export const useDebugWithSingleOrMultipleModel = (appId: string) => { const localeDebugWithSingleOrMultipleModelConfigs = localStorage.getItem('app-debug-with-single-or-multiple-models') @@ -95,16 +98,14 @@ export const useConfigFromDebugContext = () => { const config: ChatConfig = { pre_prompt: !isAdvancedMode ? modelConfig.configs.prompt_template : '', prompt_type: promptMode, - chat_prompt_config: isAdvancedMode ? chatPromptConfig : {}, - completion_prompt_config: isAdvancedMode ? completionPromptConfig : {}, + chat_prompt_config: isAdvancedMode ? chatPromptConfig : cloneDeep(DEFAULT_CHAT_PROMPT_CONFIG), + completion_prompt_config: isAdvancedMode ? completionPromptConfig : cloneDeep(DEFAULT_COMPLETION_PROMPT_CONFIG), user_input_form: promptVariablesToUserInputsForm(modelConfig.configs.prompt_variables), dataset_query_variable: contextVar || '', opening_statement: introduction, - more_like_this: { - enabled: false, - }, + more_like_this: modelConfig.more_like_this ?? { enabled: false }, suggested_questions: openingSuggestedQuestions, - suggested_questions_after_answer: suggestedQuestionsAfterAnswerConfig, + suggested_questions_after_answer: suggestedQuestionsAfterAnswerConfig ?? { enabled: false }, text_to_speech: textToSpeechConfig, speech_to_text: speechToTextConfig, retriever_resource: citationConfig, @@ -121,8 +122,13 @@ export const useConfigFromDebugContext = () => { }, file_upload: { image: visionConfig, + allowed_file_upload_methods: visionConfig.transfer_methods ?? [], + allowed_file_types: [SupportUploadFileTypes.image], + max_length: visionConfig.number_limits ?? 0, + number_limits: visionConfig.number_limits, }, annotation_reply: annotationConfig, + system_parameters: modelConfig.system_parameters, supportAnnotation: true, appId, diff --git a/web/app/components/app/configuration/debug/index.tsx b/web/app/components/app/configuration/debug/index.tsx index 23e1fdf9c4..ef3b9355b9 100644 --- a/web/app/components/app/configuration/debug/index.tsx +++ b/web/app/components/app/configuration/debug/index.tsx @@ -3,6 +3,7 @@ import type { FC } from 'react' import { useTranslation } from 'react-i18next' import React, { useCallback, useEffect, useRef, useState } from 'react' import { produce, setAutoFreeze } from 'immer' +import cloneDeep from 'lodash-es/cloneDeep' import { useBoolean } from 'ahooks' import { RiAddLine, @@ -36,7 +37,7 @@ import ActionButton, { ActionButtonState } from '@/app/components/base/action-bu import type { ModelConfig as BackendModelConfig, VisionFile, VisionSettings } from '@/types/app' import { formatBooleanInputs, promptVariablesToUserInputsForm } from '@/utils/model-config' import TextGeneration from '@/app/components/app/text-generate/item' -import { IS_CE_EDITION } from '@/config' +import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG, IS_CE_EDITION } from '@/config' import type { Inputs } from '@/models/debug' import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import { ModelFeatureEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' @@ -90,6 +91,7 @@ const Debug: FC = ({ completionParams, hasSetContextVar, datasetConfigs, + externalDataToolsConfig, } = useContext(ConfigContext) const { eventEmitter } = useEventEmitterContextContext() const { data: text2speechDefaultModel } = useDefaultModel(ModelTypeEnum.textEmbedding) @@ -223,8 +225,8 @@ const Debug: FC = ({ const postModelConfig: BackendModelConfig = { pre_prompt: !isAdvancedMode ? modelConfig.configs.prompt_template : '', prompt_type: promptMode, - chat_prompt_config: {}, - completion_prompt_config: {}, + chat_prompt_config: isAdvancedMode ? chatPromptConfig : cloneDeep(DEFAULT_CHAT_PROMPT_CONFIG), + completion_prompt_config: isAdvancedMode ? completionPromptConfig : cloneDeep(DEFAULT_COMPLETION_PROMPT_CONFIG), user_input_form: promptVariablesToUserInputsForm(modelConfig.configs.prompt_variables), dataset_query_variable: contextVar || '', dataset_configs: { @@ -251,11 +253,8 @@ const Debug: FC = ({ suggested_questions_after_answer: suggestedQuestionsAfterAnswerConfig, speech_to_text: speechToTextConfig, retriever_resource: citationConfig, - } - - if (isAdvancedMode) { - postModelConfig.chat_prompt_config = chatPromptConfig - postModelConfig.completion_prompt_config = completionPromptConfig + system_parameters: modelConfig.system_parameters, + external_data_tools: externalDataToolsConfig, } const data: Record = { diff --git a/web/app/components/app/configuration/index.tsx b/web/app/components/app/configuration/index.tsx index a1710c8f39..4f47bfd883 100644 --- a/web/app/components/app/configuration/index.tsx +++ b/web/app/components/app/configuration/index.tsx @@ -36,14 +36,14 @@ import type { } from '@/models/debug' import type { ExternalDataTool } from '@/models/common' import type { DataSet } from '@/models/datasets' -import type { ModelConfig as BackendModelConfig, VisionSettings } from '@/types/app' +import type { ModelConfig as BackendModelConfig, UserInputFormItem, VisionSettings } from '@/types/app' import ConfigContext from '@/context/debug-configuration' import Config from '@/app/components/app/configuration/config' import Debug from '@/app/components/app/configuration/debug' import Confirm from '@/app/components/base/confirm' import { ModelFeatureEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { ToastContext } from '@/app/components/base/toast' -import { fetchAppDetail, updateAppModelConfig } from '@/service/apps' +import { fetchAppDetailDirect, updateAppModelConfig } from '@/service/apps' import { promptVariablesToUserInputsForm, userInputsFormToPromptVariables } from '@/utils/model-config' import { fetchDatasets } from '@/service/datasets' import { useProviderContext } from '@/context/provider-context' @@ -186,6 +186,8 @@ const Configuration: FC = () => { prompt_template: '', prompt_variables: [] as PromptVariable[], }, + chat_prompt_config: clone(DEFAULT_CHAT_PROMPT_CONFIG), + completion_prompt_config: clone(DEFAULT_COMPLETION_PROMPT_CONFIG), more_like_this: null, opening_statement: '', suggested_questions: [], @@ -196,6 +198,14 @@ const Configuration: FC = () => { suggested_questions_after_answer: null, retriever_resource: null, annotation_reply: null, + external_data_tools: [], + system_parameters: { + audio_file_size_limit: 0, + file_size_limit: 0, + image_file_size_limit: 0, + video_file_size_limit: 0, + workflow_file_upload_limit: 0, + }, dataSets: [], agentConfig: DEFAULT_AGENT_SETTING, }) @@ -543,169 +553,169 @@ const Configuration: FC = () => { }) } setCollectionList(collectionList) - fetchAppDetail({ url: '/apps', id: appId }).then(async (res: any) => { - setMode(res.mode) - const modelConfig = res.model_config - const promptMode = modelConfig.prompt_type === PromptMode.advanced ? PromptMode.advanced : PromptMode.simple - doSetPromptMode(promptMode) - if (promptMode === PromptMode.advanced) { - if (modelConfig.chat_prompt_config && modelConfig.chat_prompt_config.prompt.length > 0) - setChatPromptConfig(modelConfig.chat_prompt_config) - else - setChatPromptConfig(clone(DEFAULT_CHAT_PROMPT_CONFIG)) - setCompletionPromptConfig(modelConfig.completion_prompt_config || clone(DEFAULT_COMPLETION_PROMPT_CONFIG) as any) - setCanReturnToSimpleMode(false) - } + const res = await fetchAppDetailDirect({ url: '/apps', id: appId }) + setMode(res.mode) + const modelConfig = res.model_config as BackendModelConfig + const promptMode = modelConfig.prompt_type === PromptMode.advanced ? PromptMode.advanced : PromptMode.simple + doSetPromptMode(promptMode) + if (promptMode === PromptMode.advanced) { + if (modelConfig.chat_prompt_config && modelConfig.chat_prompt_config.prompt.length > 0) + setChatPromptConfig(modelConfig.chat_prompt_config) + else + setChatPromptConfig(clone(DEFAULT_CHAT_PROMPT_CONFIG)) + setCompletionPromptConfig(modelConfig.completion_prompt_config || clone(DEFAULT_COMPLETION_PROMPT_CONFIG) as any) + setCanReturnToSimpleMode(false) + } - const model = res.model_config.model + const model = modelConfig.model - let datasets: any = null + let datasets: any = null // old dataset struct - if (modelConfig.agent_mode?.tools?.find(({ dataset }: any) => dataset?.enabled)) - datasets = modelConfig.agent_mode?.tools.filter(({ dataset }: any) => dataset?.enabled) + if (modelConfig.agent_mode?.tools?.find(({ dataset }: any) => dataset?.enabled)) + datasets = modelConfig.agent_mode?.tools.filter(({ dataset }: any) => dataset?.enabled) // new dataset struct - else if (modelConfig.dataset_configs.datasets?.datasets?.length > 0) - datasets = modelConfig.dataset_configs?.datasets?.datasets + else if (modelConfig.dataset_configs.datasets?.datasets?.length > 0) + datasets = modelConfig.dataset_configs?.datasets?.datasets - if (dataSets && datasets?.length && datasets?.length > 0) { - const { data: dataSetsWithDetail } = await fetchDatasets({ url: '/datasets', params: { page: 1, ids: datasets.map(({ dataset }: any) => dataset.id) } }) - datasets = dataSetsWithDetail - setDataSets(datasets) - } + if (dataSets && datasets?.length && datasets?.length > 0) { + const { data: dataSetsWithDetail } = await fetchDatasets({ url: '/datasets', params: { page: 1, ids: datasets.map(({ dataset }: any) => dataset.id) } }) + datasets = dataSetsWithDetail + setDataSets(datasets) + } - setIntroduction(modelConfig.opening_statement) - setSuggestedQuestions(modelConfig.suggested_questions || []) - if (modelConfig.more_like_this) - setMoreLikeThisConfig(modelConfig.more_like_this) + setIntroduction(modelConfig.opening_statement) + setSuggestedQuestions(modelConfig.suggested_questions || []) + if (modelConfig.more_like_this) + setMoreLikeThisConfig(modelConfig.more_like_this) - if (modelConfig.suggested_questions_after_answer) - setSuggestedQuestionsAfterAnswerConfig(modelConfig.suggested_questions_after_answer) + if (modelConfig.suggested_questions_after_answer) + setSuggestedQuestionsAfterAnswerConfig(modelConfig.suggested_questions_after_answer) - if (modelConfig.speech_to_text) - setSpeechToTextConfig(modelConfig.speech_to_text) + if (modelConfig.speech_to_text) + setSpeechToTextConfig(modelConfig.speech_to_text) - if (modelConfig.text_to_speech) - setTextToSpeechConfig(modelConfig.text_to_speech) + if (modelConfig.text_to_speech) + setTextToSpeechConfig(modelConfig.text_to_speech) - if (modelConfig.retriever_resource) - setCitationConfig(modelConfig.retriever_resource) + if (modelConfig.retriever_resource) + setCitationConfig(modelConfig.retriever_resource) - if (modelConfig.annotation_reply) { - let annotationConfig = modelConfig.annotation_reply - if (modelConfig.annotation_reply.enabled) { - annotationConfig = { - ...modelConfig.annotation_reply, - embedding_model: { - ...modelConfig.annotation_reply.embedding_model, - embedding_provider_name: correctModelProvider(modelConfig.annotation_reply.embedding_model.embedding_provider_name), - }, - } + if (modelConfig.annotation_reply) { + let annotationConfig = modelConfig.annotation_reply + if (modelConfig.annotation_reply.enabled) { + annotationConfig = { + ...modelConfig.annotation_reply, + embedding_model: { + ...modelConfig.annotation_reply.embedding_model, + embedding_provider_name: correctModelProvider(modelConfig.annotation_reply.embedding_model.embedding_provider_name), + }, } - setAnnotationConfig(annotationConfig, true) } + setAnnotationConfig(annotationConfig, true) + } - if (modelConfig.sensitive_word_avoidance) - setModerationConfig(modelConfig.sensitive_word_avoidance) + if (modelConfig.sensitive_word_avoidance) + setModerationConfig(modelConfig.sensitive_word_avoidance) - if (modelConfig.external_data_tools) - setExternalDataToolsConfig(modelConfig.external_data_tools) + if (modelConfig.external_data_tools) + setExternalDataToolsConfig(modelConfig.external_data_tools) - const config = { - modelConfig: { - provider: correctModelProvider(model.provider), - model_id: model.name, - mode: model.mode, - configs: { - prompt_template: modelConfig.pre_prompt || '', - prompt_variables: userInputsFormToPromptVariables( - [ - ...modelConfig.user_input_form, - ...( - modelConfig.external_data_tools?.length - ? modelConfig.external_data_tools.map((item: any) => { - return { - external_data_tool: { - variable: item.variable as string, - label: item.label as string, - enabled: item.enabled, - type: item.type as string, - config: item.config, - required: true, - icon: item.icon, - icon_background: item.icon_background, - }, - } - }) - : [] - ), - ], - modelConfig.dataset_query_variable, - ), - }, - more_like_this: modelConfig.more_like_this, - opening_statement: modelConfig.opening_statement, - suggested_questions: modelConfig.suggested_questions, - sensitive_word_avoidance: modelConfig.sensitive_word_avoidance, - speech_to_text: modelConfig.speech_to_text, - text_to_speech: modelConfig.text_to_speech, - file_upload: modelConfig.file_upload, - suggested_questions_after_answer: modelConfig.suggested_questions_after_answer, - retriever_resource: modelConfig.retriever_resource, - annotation_reply: modelConfig.annotation_reply, - external_data_tools: modelConfig.external_data_tools, - dataSets: datasets || [], - agentConfig: res.mode === 'agent-chat' ? { - max_iteration: DEFAULT_AGENT_SETTING.max_iteration, - ...modelConfig.agent_mode, + const config: PublishConfig = { + modelConfig: { + provider: correctModelProvider(model.provider), + model_id: model.name, + mode: model.mode, + configs: { + prompt_template: modelConfig.pre_prompt || '', + prompt_variables: userInputsFormToPromptVariables( + ([ + ...modelConfig.user_input_form, + ...( + modelConfig.external_data_tools?.length + ? modelConfig.external_data_tools.map((item: any) => { + return { + external_data_tool: { + variable: item.variable as string, + label: item.label as string, + enabled: item.enabled, + type: item.type as string, + config: item.config, + required: true, + icon: item.icon, + icon_background: item.icon_background, + }, + } + }) + : [] + ), + ]) as unknown as UserInputFormItem[], + modelConfig.dataset_query_variable, + ), + }, + more_like_this: modelConfig.more_like_this ?? { enabled: false }, + opening_statement: modelConfig.opening_statement, + suggested_questions: modelConfig.suggested_questions ?? [], + sensitive_word_avoidance: modelConfig.sensitive_word_avoidance, + speech_to_text: modelConfig.speech_to_text, + text_to_speech: modelConfig.text_to_speech, + file_upload: modelConfig.file_upload ?? null, + suggested_questions_after_answer: modelConfig.suggested_questions_after_answer ?? { enabled: false }, + retriever_resource: modelConfig.retriever_resource, + annotation_reply: modelConfig.annotation_reply ?? null, + external_data_tools: modelConfig.external_data_tools ?? [], + system_parameters: modelConfig.system_parameters, + dataSets: datasets || [], + agentConfig: res.mode === 'agent-chat' ? { + max_iteration: DEFAULT_AGENT_SETTING.max_iteration, + ...modelConfig.agent_mode, // remove dataset - enabled: true, // modelConfig.agent_mode?.enabled is not correct. old app: the value of app with dataset's is always true - tools: modelConfig.agent_mode?.tools.filter((tool: any) => { - return !tool.dataset - }).map((tool: any) => { - const toolInCollectionList = collectionList.find(c => tool.provider_id === c.id) - return { - ...tool, - isDeleted: res.deleted_tools?.some((deletedTool: any) => deletedTool.id === tool.id && deletedTool.tool_name === tool.tool_name), - notAuthor: toolInCollectionList?.is_team_authorization === false, - ...(tool.provider_type === 'builtin' ? { - provider_id: correctToolProvider(tool.provider_name, !!toolInCollectionList), - provider_name: correctToolProvider(tool.provider_name, !!toolInCollectionList), - } : {}), - } - }), - } : DEFAULT_AGENT_SETTING, - }, - completionParams: model.completion_params, - } + enabled: true, // modelConfig.agent_mode?.enabled is not correct. old app: the value of app with dataset's is always true + tools: (modelConfig.agent_mode?.tools ?? []).filter((tool: any) => { + return !tool.dataset + }).map((tool: any) => { + const toolInCollectionList = collectionList.find(c => tool.provider_id === c.id) + return { + ...tool, + isDeleted: res.deleted_tools?.some((deletedTool: any) => deletedTool.id === tool.id && deletedTool.tool_name === tool.tool_name) ?? false, + notAuthor: toolInCollectionList?.is_team_authorization === false, + ...(tool.provider_type === 'builtin' ? { + provider_id: correctToolProvider(tool.provider_name, !!toolInCollectionList), + provider_name: correctToolProvider(tool.provider_name, !!toolInCollectionList), + } : {}), + } + }), + strategy: modelConfig.agent_mode?.strategy ?? AgentStrategy.react, + } : DEFAULT_AGENT_SETTING, + }, + completionParams: model.completion_params, + } - if (modelConfig.file_upload) - handleSetVisionConfig(modelConfig.file_upload.image, true) + if (modelConfig.file_upload) + handleSetVisionConfig(modelConfig.file_upload.image, true) - syncToPublishedConfig(config) - setPublishedConfig(config) - const retrievalConfig = getMultipleRetrievalConfig({ - ...modelConfig.dataset_configs, - reranking_model: modelConfig.dataset_configs.reranking_model && { - provider: modelConfig.dataset_configs.reranking_model.reranking_provider_name, - model: modelConfig.dataset_configs.reranking_model.reranking_model_name, - }, - }, datasets, datasets, { - provider: currentRerankProvider?.provider, - model: currentRerankModel?.model, - }) - setDatasetConfigs({ - retrieval_model: RETRIEVE_TYPE.multiWay, - ...modelConfig.dataset_configs, - ...retrievalConfig, - ...(retrievalConfig.reranking_model ? { - reranking_model: { - reranking_model_name: retrievalConfig.reranking_model.model, - reranking_provider_name: correctModelProvider(retrievalConfig.reranking_model.provider), - }, - } : {}), - }) - setHasFetchedDetail(true) + syncToPublishedConfig(config) + setPublishedConfig(config) + const retrievalConfig = getMultipleRetrievalConfig({ + ...modelConfig.dataset_configs, + reranking_model: modelConfig.dataset_configs.reranking_model && { + provider: modelConfig.dataset_configs.reranking_model.reranking_provider_name, + model: modelConfig.dataset_configs.reranking_model.reranking_model_name, + }, + }, datasets, datasets, { + provider: currentRerankProvider?.provider, + model: currentRerankModel?.model, }) + setDatasetConfigs({ + ...modelConfig.dataset_configs, + ...retrievalConfig, + ...(retrievalConfig.reranking_model ? { + reranking_model: { + reranking_model_name: retrievalConfig.reranking_model.model, + reranking_provider_name: correctModelProvider(retrievalConfig.reranking_model.provider), + }, + } : {}), + } as DatasetConfigs) + setHasFetchedDetail(true) })() }, [appId]) @@ -780,8 +790,8 @@ const Configuration: FC = () => { // Simple Mode prompt pre_prompt: !isAdvancedMode ? promptTemplate : '', prompt_type: promptMode, - chat_prompt_config: {}, - completion_prompt_config: {}, + chat_prompt_config: isAdvancedMode ? chatPromptConfig : clone(DEFAULT_CHAT_PROMPT_CONFIG), + completion_prompt_config: isAdvancedMode ? completionPromptConfig : clone(DEFAULT_COMPLETION_PROMPT_CONFIG), user_input_form: promptVariablesToUserInputsForm(promptVariables), dataset_query_variable: contextVar || '', // features @@ -798,6 +808,7 @@ const Configuration: FC = () => { ...modelConfig.agentConfig, strategy: isFunctionCall ? AgentStrategy.functionCall : AgentStrategy.react, }, + external_data_tools: externalDataToolsConfig, model: { provider: modelAndParameter?.provider || modelConfig.provider, name: modelId, @@ -810,11 +821,7 @@ const Configuration: FC = () => { datasets: [...postDatasets], } as any, }, - } - - if (isAdvancedMode) { - data.chat_prompt_config = chatPromptConfig - data.completion_prompt_config = completionPromptConfig + system_parameters: modelConfig.system_parameters, } await updateAppModelConfig({ url: `/apps/${appId}/model-config`, body: data }) diff --git a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx index 29b27a60ad..302fb9a3c7 100644 --- a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx +++ b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx @@ -3,7 +3,6 @@ import Chat from '../chat' import type { ChatConfig, ChatItem, - ChatItemInTree, OnSend, } from '../types' import { useChat } from '../chat/hooks' @@ -149,7 +148,7 @@ const ChatWrapper = () => { ) }, [chatList, handleNewConversationCompleted, handleSend, currentConversationId, currentConversationInputs, newConversationInputs, isInstalledApp, appId]) - const doRegenerate = useCallback((chatItem: ChatItemInTree, editedQuestion?: { message: string, files?: FileEntity[] }) => { + const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => { const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)! const parentAnswer = chatList.find(item => item.id === question.parentMessageId) doSend(editedQuestion ? editedQuestion.message : question.content, diff --git a/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx b/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx index 1bb3dbf56f..5fba104d35 100644 --- a/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx +++ b/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx @@ -3,7 +3,6 @@ import Chat from '../chat' import type { ChatConfig, ChatItem, - ChatItemInTree, OnSend, } from '../types' import { useChat } from '../chat/hooks' @@ -147,7 +146,7 @@ const ChatWrapper = () => { ) }, [currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, isInstalledApp, appId, handleNewConversationCompleted]) - const doRegenerate = useCallback((chatItem: ChatItemInTree, editedQuestion?: { message: string, files?: FileEntity[] }) => { + const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => { const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)! const parentAnswer = chatList.find(item => item.id === question.parentMessageId) doSend(editedQuestion ? editedQuestion.message : question.content, diff --git a/web/app/components/base/chat/types.ts b/web/app/components/base/chat/types.ts index f7f7aa4dce..5b0fe1f248 100644 --- a/web/app/components/base/chat/types.ts +++ b/web/app/components/base/chat/types.ts @@ -85,7 +85,7 @@ export type OnSend = { (message: string, files: FileEntity[] | undefined, isRegenerate: boolean, lastAnswer?: ChatItem | null): void } -export type OnRegenerate = (chatItem: ChatItem) => void +export type OnRegenerate = (chatItem: ChatItem, editedQuestion?: { message: string; files?: FileEntity[] }) => void export type Callback = { onSuccess: () => void diff --git a/web/app/components/base/content-dialog/index.stories.tsx b/web/app/components/base/content-dialog/index.stories.tsx index 29b3914704..67781a17a0 100644 --- a/web/app/components/base/content-dialog/index.stories.tsx +++ b/web/app/components/base/content-dialog/index.stories.tsx @@ -32,6 +32,7 @@ const meta = { }, args: { show: false, + children: null, }, } satisfies Meta @@ -92,6 +93,9 @@ const DemoWrapper = (props: Props) => { } export const Default: Story = { + args: { + children: null, + }, render: args => , } @@ -99,6 +103,7 @@ export const NarrowPanel: Story = { render: args => , args: { className: 'max-w-[420px]', + children: null, }, parameters: { docs: { diff --git a/web/app/components/base/date-and-time-picker/time-picker/index.spec.tsx b/web/app/components/base/date-and-time-picker/time-picker/index.spec.tsx index 40bc2928c8..bd4468e82d 100644 --- a/web/app/components/base/date-and-time-picker/time-picker/index.spec.tsx +++ b/web/app/components/base/date-and-time-picker/time-picker/index.spec.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import TimePicker from './index' import dayjs from '../utils/dayjs' import { isDayjsObject } from '../utils/dayjs' +import type { TimePickerProps } from '../types' jest.mock('react-i18next', () => ({ useTranslation: () => ({ @@ -30,9 +31,10 @@ jest.mock('./options', () => () =>
) jest.mock('./header', () => () =>
) describe('TimePicker', () => { - const baseProps = { + const baseProps: Pick = { onChange: jest.fn(), onClear: jest.fn(), + value: undefined, } beforeEach(() => { diff --git a/web/app/components/base/date-and-time-picker/utils/dayjs.ts b/web/app/components/base/date-and-time-picker/utils/dayjs.ts index 808b50247a..4f53c766ea 100644 --- a/web/app/components/base/date-and-time-picker/utils/dayjs.ts +++ b/web/app/components/base/date-and-time-picker/utils/dayjs.ts @@ -150,7 +150,7 @@ export const toDayjs = (value: string | Dayjs | undefined, options: ToDayjsOptio if (format) { const parsedWithFormat = tzName - ? dayjs.tz(trimmed, format, tzName, true) + ? dayjs(trimmed, format, true).tz(tzName, true) : dayjs(trimmed, format, true) if (parsedWithFormat.isValid()) return parsedWithFormat @@ -191,7 +191,7 @@ export const toDayjs = (value: string | Dayjs | undefined, options: ToDayjsOptio const candidateFormats = formats ?? COMMON_PARSE_FORMATS for (const fmt of candidateFormats) { const parsed = tzName - ? dayjs.tz(trimmed, fmt, tzName, true) + ? dayjs(trimmed, fmt, true).tz(tzName, true) : dayjs(trimmed, fmt, true) if (parsed.isValid()) return parsed diff --git a/web/app/components/base/dialog/index.stories.tsx b/web/app/components/base/dialog/index.stories.tsx index 62ae7c00ce..94998c6d21 100644 --- a/web/app/components/base/dialog/index.stories.tsx +++ b/web/app/components/base/dialog/index.stories.tsx @@ -47,6 +47,7 @@ const meta = { args: { title: 'Manage API Keys', show: false, + children: null, }, } satisfies Meta @@ -102,6 +103,7 @@ export const Default: Story = { ), + children: null, }, } @@ -110,6 +112,7 @@ export const WithoutFooter: Story = { args: { footer: undefined, title: 'Read-only summary', + children: null, }, parameters: { docs: { @@ -140,6 +143,7 @@ export const CustomStyling: Story = {
), + children: null, }, parameters: { docs: { diff --git a/web/app/components/base/form/types.ts b/web/app/components/base/form/types.ts index d18c166186..ce3b5ec965 100644 --- a/web/app/components/base/form/types.ts +++ b/web/app/components/base/form/types.ts @@ -42,7 +42,7 @@ export type FormOption = { icon?: string } -export type AnyValidators = FieldValidators +export type AnyValidators = FieldValidators export type FormSchema = { type: FormTypeEnum diff --git a/web/app/components/base/markdown-blocks/think-block.tsx b/web/app/components/base/markdown-blocks/think-block.tsx index a3b0561677..9c43578e4c 100644 --- a/web/app/components/base/markdown-blocks/think-block.tsx +++ b/web/app/components/base/markdown-blocks/think-block.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useChatContext } from '../chat/chat/context' +import cn from '@/utils/classnames' const hasEndThink = (children: any): boolean => { if (typeof children === 'string') @@ -40,7 +41,7 @@ const useThinkTimer = (children: any) => { const [startTime] = useState(() => Date.now()) const [elapsedTime, setElapsedTime] = useState(0) const [isComplete, setIsComplete] = useState(false) - const timerRef = useRef() + const timerRef = useRef(null) useEffect(() => { if (isComplete) return @@ -63,16 +64,26 @@ const useThinkTimer = (children: any) => { return { elapsedTime, isComplete } } -const ThinkBlock = ({ children, ...props }: React.ComponentProps<'details'>) => { +type ThinkBlockProps = React.ComponentProps<'details'> & { + 'data-think'?: boolean +} + +const ThinkBlock = ({ children, ...props }: ThinkBlockProps) => { const { elapsedTime, isComplete } = useThinkTimer(children) const displayContent = removeEndThink(children) const { t } = useTranslation() + const { 'data-think': isThink = false, className, open, ...rest } = props - if (!(props['data-think'] ?? false)) + if (!isThink) return (
{children}
) return ( -
+
console.log('close'), onConfirm: () => console.log('confirm'), + children: null, }, } satisfies Meta @@ -68,6 +69,9 @@ export const Default: Story = { ), + args: { + children: null, + }, } export const WithBackLink: Story = { @@ -90,6 +94,7 @@ export const WithBackLink: Story = { ), args: { title: 'Select metadata type', + children: null, }, parameters: { docs: { @@ -114,6 +119,7 @@ export const CustomWidth: Story = { ), args: { title: 'Advanced configuration', + children: null, }, parameters: { docs: { diff --git a/web/app/components/base/popover/index.tsx b/web/app/components/base/popover/index.tsx index 41df06f43a..2387737d02 100644 --- a/web/app/components/base/popover/index.tsx +++ b/web/app/components/base/popover/index.tsx @@ -1,5 +1,5 @@ import { Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react' -import { Fragment, cloneElement, useRef } from 'react' +import { Fragment, cloneElement, isValidElement, useRef } from 'react' import cn from '@/utils/classnames' export type HtmlContentProps = { @@ -103,15 +103,17 @@ export default function CustomPopover({ }) } > - {cloneElement(htmlContent as React.ReactElement, { - open, - onClose: close, - ...(manualClose - ? { - onClick: close, - } - : {}), - })} + {isValidElement(htmlContent) + ? cloneElement(htmlContent as React.ReactElement, { + open, + onClose: close, + ...(manualClose + ? { + onClick: close, + } + : {}), + }) + : htmlContent}
)} diff --git a/web/app/components/base/portal-to-follow-elem/index.tsx b/web/app/components/base/portal-to-follow-elem/index.tsx index 71ee251edd..e1192fe73b 100644 --- a/web/app/components/base/portal-to-follow-elem/index.tsx +++ b/web/app/components/base/portal-to-follow-elem/index.tsx @@ -125,7 +125,7 @@ export const PortalToFollowElemTrigger = ( children, asChild = false, ...props - }: React.HTMLProps & { ref?: React.RefObject, asChild?: boolean }, + }: React.HTMLProps & { ref?: React.RefObject, asChild?: boolean }, ) => { const context = usePortalToFollowElemContext() const childrenRef = (children as any).props?.ref @@ -133,12 +133,13 @@ export const PortalToFollowElemTrigger = ( // `asChild` allows the user to pass any element as the anchor if (asChild && React.isValidElement(children)) { + const childProps = (children.props ?? {}) as Record return React.cloneElement( children, context.getReferenceProps({ ref, ...props, - ...children.props, + ...childProps, 'data-state': context.open ? 'open' : 'closed', } as React.HTMLProps), ) @@ -164,7 +165,7 @@ export const PortalToFollowElemContent = ( style, ...props }: React.HTMLProps & { - ref?: React.RefObject; + ref?: React.RefObject; }, ) => { const context = usePortalToFollowElemContext() diff --git a/web/app/components/base/prompt-editor/hooks.ts b/web/app/components/base/prompt-editor/hooks.ts index 87119f8b49..b3d2b22236 100644 --- a/web/app/components/base/prompt-editor/hooks.ts +++ b/web/app/components/base/prompt-editor/hooks.ts @@ -35,7 +35,7 @@ import { DELETE_QUERY_BLOCK_COMMAND } from './plugins/query-block' import type { CustomTextNode } from './plugins/custom-text/node' import { registerLexicalTextEntity } from './utils' -export type UseSelectOrDeleteHandler = (nodeKey: string, command?: LexicalCommand) => [RefObject, boolean] +export type UseSelectOrDeleteHandler = (nodeKey: string, command?: LexicalCommand) => [RefObject, boolean] export const useSelectOrDelete: UseSelectOrDeleteHandler = (nodeKey: string, command?: LexicalCommand) => { const ref = useRef(null) const [editor] = useLexicalComposerContext() @@ -110,7 +110,7 @@ export const useSelectOrDelete: UseSelectOrDeleteHandler = (nodeKey: string, com return [ref, isSelected] } -export type UseTriggerHandler = () => [RefObject, boolean, Dispatch>] +export type UseTriggerHandler = () => [RefObject, boolean, Dispatch>] export const useTrigger: UseTriggerHandler = () => { const triggerRef = useRef(null) const [open, setOpen] = useState(false) diff --git a/web/app/components/base/prompt-editor/plugins/placeholder.tsx b/web/app/components/base/prompt-editor/plugins/placeholder.tsx index c2c2623992..187b574cea 100644 --- a/web/app/components/base/prompt-editor/plugins/placeholder.tsx +++ b/web/app/components/base/prompt-editor/plugins/placeholder.tsx @@ -1,4 +1,5 @@ import { memo } from 'react' +import type { ReactNode } from 'react' import { useTranslation } from 'react-i18next' import cn from '@/utils/classnames' @@ -8,7 +9,7 @@ const Placeholder = ({ className, }: { compact?: boolean - value?: string | JSX.Element + value?: ReactNode className?: string }) => { const { t } = useTranslation() diff --git a/web/app/components/base/voice-input/utils.ts b/web/app/components/base/voice-input/utils.ts index 70133f459f..a8ac9eba03 100644 --- a/web/app/components/base/voice-input/utils.ts +++ b/web/app/components/base/voice-input/utils.ts @@ -14,13 +14,19 @@ export const convertToMp3 = (recorder: any) => { const { channels, sampleRate } = wav const mp3enc = new lamejs.Mp3Encoder(channels, sampleRate, 128) const result = recorder.getChannelData() - const buffer = [] + const buffer: BlobPart[] = [] const leftData = result.left && new Int16Array(result.left.buffer, 0, result.left.byteLength / 2) const rightData = result.right && new Int16Array(result.right.buffer, 0, result.right.byteLength / 2) const remaining = leftData.length + (rightData ? rightData.length : 0) const maxSamples = 1152 + const toArrayBuffer = (bytes: Int8Array) => { + const arrayBuffer = new ArrayBuffer(bytes.length) + new Uint8Array(arrayBuffer).set(bytes) + return arrayBuffer + } + for (let i = 0; i < remaining; i += maxSamples) { const left = leftData.subarray(i, i + maxSamples) let right = null @@ -35,13 +41,13 @@ export const convertToMp3 = (recorder: any) => { } if (mp3buf.length > 0) - buffer.push(mp3buf) + buffer.push(toArrayBuffer(mp3buf)) } const enc = mp3enc.flush() if (enc.length > 0) - buffer.push(enc) + buffer.push(toArrayBuffer(enc)) return new Blob(buffer, { type: 'audio/mp3' }) } diff --git a/web/app/components/billing/pricing/index.tsx b/web/app/components/billing/pricing/index.tsx index 8b678ab272..ae8cb2056f 100644 --- a/web/app/components/billing/pricing/index.tsx +++ b/web/app/components/billing/pricing/index.tsx @@ -32,7 +32,6 @@ const Pricing: FC = ({ const [planRange, setPlanRange] = React.useState(PlanRange.monthly) const [currentCategory, setCurrentCategory] = useState(CategoryEnum.CLOUD) const canPay = isCurrentWorkspaceManager - useKeyPress(['esc'], onCancel) const pricingPageLanguage = useGetPricingPageLanguage() diff --git a/web/app/components/billing/pricing/plans/index.tsx b/web/app/components/billing/pricing/plans/index.tsx index 0d6d61b690..d648613c8f 100644 --- a/web/app/components/billing/pricing/plans/index.tsx +++ b/web/app/components/billing/pricing/plans/index.tsx @@ -6,7 +6,7 @@ import SelfHostedPlanItem from './self-hosted-plan-item' type PlansProps = { plan: { - type: BasicPlan + type: Plan usage: UsagePlanInfo total: UsagePlanInfo } @@ -21,6 +21,7 @@ const Plans = ({ planRange, canPay, }: PlansProps) => { + const currentPlanType: BasicPlan = plan.type === Plan.enterprise ? Plan.team : plan.type return (
@@ -28,21 +29,21 @@ const Plans = ({ currentPlan === 'cloud' && ( <> = ({ datasetId, batchId, documents = [], index return doc?.data_source_type as DataSourceType } + const isLegacyDataSourceInfo = (info: DataSourceInfo): info is LegacyDataSourceInfo => { + return info != null && typeof (info as LegacyDataSourceInfo).upload_file === 'object' + } + const getIcon = (id: string) => { const doc = documents.find(document => document.id === id) - - return doc?.data_source_info.notion_page_icon + const info = doc?.data_source_info + if (info && isLegacyDataSourceInfo(info)) + return info.notion_page_icon + return undefined } const isSourceEmbedding = (detail: IndexingStatusResponse) => ['indexing', 'splitting', 'parsing', 'cleaning', 'waiting'].includes(detail.indexing_status || '') diff --git a/web/app/components/datasets/create/file-uploader/index.tsx b/web/app/components/datasets/create/file-uploader/index.tsx index 463715bb62..75557b37c9 100644 --- a/web/app/components/datasets/create/file-uploader/index.tsx +++ b/web/app/components/datasets/create/file-uploader/index.tsx @@ -105,6 +105,8 @@ const FileUploader = ({ return isValidType && isValidSize }, [fileUploadConfig, notify, t, ACCEPTS]) + type UploadResult = Awaited> + const fileUpload = useCallback(async (fileItem: FileItem): Promise => { const formData = new FormData() formData.append('file', fileItem.file) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.tsx index 47da96c2de..361378362e 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.tsx @@ -121,6 +121,8 @@ const LocalFile = ({ return isValidType && isValidSize }, [fileUploadConfig, notify, t, ACCEPTS]) + type UploadResult = Awaited> + const fileUpload = useCallback(async (fileItem: FileItem): Promise => { const formData = new FormData() formData.append('file', fileItem.file) @@ -136,10 +138,14 @@ const LocalFile = ({ data: formData, onprogress: onProgress, }, false, undefined, '?source=datasets') - .then((res: File) => { - const completeFile = { + .then((res: UploadResult) => { + const updatedFile = Object.assign({}, fileItem.file, { + id: res.id, + ...(res as Partial), + }) as File + const completeFile: FileItem = { fileID: fileItem.fileID, - file: res, + file: updatedFile, progress: -1, } const index = fileListRef.current.findIndex(item => item.fileID === fileItem.fileID) diff --git a/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.tsx b/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.tsx index 7e8749f0bf..96cab11c9c 100644 --- a/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.tsx +++ b/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.tsx @@ -38,6 +38,8 @@ const CSVUploader: FC = ({ file_size_limit: 15, }, [fileUploadConfigResponse]) + type UploadResult = Awaited> + const fileUpload = useCallback(async (fileItem: FileItem): Promise => { fileItem.progress = 0 @@ -58,10 +60,14 @@ const CSVUploader: FC = ({ data: formData, onprogress: onProgress, }, false, undefined, '?source=datasets') - .then((res: File) => { - const completeFile = { + .then((res: UploadResult) => { + const updatedFile = Object.assign({}, fileItem.file, { + id: res.id, + ...(res as Partial), + }) as File + const completeFile: FileItem = { fileID: fileItem.fileID, - file: res, + file: updatedFile, progress: 100, } updateFile(completeFile) diff --git a/web/app/components/datasets/documents/detail/index.tsx b/web/app/components/datasets/documents/detail/index.tsx index b4f47253fb..ddec9b6dbe 100644 --- a/web/app/components/datasets/documents/detail/index.tsx +++ b/web/app/components/datasets/documents/detail/index.tsx @@ -17,7 +17,7 @@ import Divider from '@/app/components/base/divider' import Loading from '@/app/components/base/loading' import Toast from '@/app/components/base/toast' import { ChunkingMode } from '@/models/datasets' -import type { FileItem } from '@/models/datasets' +import type { DataSourceInfo, FileItem, LegacyDataSourceInfo } from '@/models/datasets' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import FloatRightContainer from '@/app/components/base/float-right-container' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' @@ -109,6 +109,18 @@ const DocumentDetail: FC = ({ datasetId, documentId }) => { const embedding = ['queuing', 'indexing', 'paused'].includes((documentDetail?.display_status || '').toLowerCase()) + const isLegacyDataSourceInfo = (info?: DataSourceInfo): info is LegacyDataSourceInfo => { + return !!info && 'upload_file' in info + } + + const documentUploadFile = useMemo(() => { + if (!documentDetail?.data_source_info) + return undefined + if (isLegacyDataSourceInfo(documentDetail.data_source_info)) + return documentDetail.data_source_info.upload_file + return undefined + }, [documentDetail?.data_source_info]) + const invalidChunkList = useInvalid(useSegmentListKey) const invalidChildChunkList = useInvalid(useChildSegmentListKey) const invalidDocumentList = useInvalidDocumentList(datasetId) @@ -153,7 +165,7 @@ const DocumentDetail: FC = ({ datasetId, documentId }) => {
void } +type MetadataState = { + documentType?: DocType | '' + metadata: Record +} + const Metadata: FC = ({ docDetail, loading, onUpdate }) => { const { doc_metadata = {} } = docDetail || {} - const doc_type = docDetail?.doc_type || '' + const rawDocType = docDetail?.doc_type ?? '' + const doc_type = rawDocType === 'others' ? '' : rawDocType const { t } = useTranslation() const metadataMap = useMetadataMap() @@ -143,18 +149,16 @@ const Metadata: FC = ({ docDetail, loading, onUpdate }) => { const businessDocCategoryMap = useBusinessDocCategories() const [editStatus, setEditStatus] = useState(!doc_type) // if no documentType, in editing status by default // the initial values are according to the documentType - const [metadataParams, setMetadataParams] = useState<{ - documentType?: DocType | '' - metadata: { [key: string]: string } - }>( + const [metadataParams, setMetadataParams] = useState( doc_type ? { - documentType: doc_type, - metadata: doc_metadata || {}, + documentType: doc_type as DocType, + metadata: (doc_metadata || {}) as Record, } - : { metadata: {} }) + : { metadata: {} }, + ) const [showDocTypes, setShowDocTypes] = useState(!doc_type) // whether show doc types - const [tempDocType, setTempDocType] = useState('') // for remember icon click + const [tempDocType, setTempDocType] = useState('') // for remember icon click const [saveLoading, setSaveLoading] = useState(false) const { notify } = useContext(ToastContext) @@ -165,13 +169,13 @@ const Metadata: FC = ({ docDetail, loading, onUpdate }) => { if (docDetail?.doc_type) { setEditStatus(false) setShowDocTypes(false) - setTempDocType(docDetail?.doc_type) + setTempDocType(doc_type as DocType | '') setMetadataParams({ - documentType: docDetail?.doc_type, - metadata: docDetail?.doc_metadata || {}, + documentType: doc_type as DocType | '', + metadata: (docDetail?.doc_metadata || {}) as Record, }) } - }, [docDetail?.doc_type]) + }, [docDetail?.doc_type, docDetail?.doc_metadata, doc_type]) // confirm doc type const confirmDocType = () => { @@ -179,7 +183,7 @@ const Metadata: FC = ({ docDetail, loading, onUpdate }) => { return setMetadataParams({ documentType: tempDocType, - metadata: tempDocType === metadataParams.documentType ? metadataParams.metadata : {}, // change doc type, clear metadata + metadata: tempDocType === metadataParams.documentType ? metadataParams.metadata : {} as Record, // change doc type, clear metadata }) setEditStatus(true) setShowDocTypes(false) @@ -187,7 +191,7 @@ const Metadata: FC = ({ docDetail, loading, onUpdate }) => { // cancel doc type const cancelDocType = () => { - setTempDocType(metadataParams.documentType) + setTempDocType(metadataParams.documentType ?? '') setEditStatus(true) setShowDocTypes(false) } @@ -209,7 +213,7 @@ const Metadata: FC = ({ docDetail, loading, onUpdate }) => { {t('datasetDocuments.metadata.docTypeChangeTitle')} {t('datasetDocuments.metadata.docTypeSelectWarning')} } - + {CUSTOMIZABLE_DOC_TYPES.map((type, index) => { const currValue = tempDocType ?? documentType return diff --git a/web/app/components/datasets/documents/detail/settings/document-settings.tsx b/web/app/components/datasets/documents/detail/settings/document-settings.tsx index 048645c9cf..3bcb8ef3aa 100644 --- a/web/app/components/datasets/documents/detail/settings/document-settings.tsx +++ b/web/app/components/datasets/documents/detail/settings/document-settings.tsx @@ -4,7 +4,17 @@ import { useBoolean } from 'ahooks' import { useContext } from 'use-context-selector' import { useRouter } from 'next/navigation' import DatasetDetailContext from '@/context/dataset-detail' -import type { CrawlOptions, CustomFile, DataSourceType } from '@/models/datasets' +import type { + CrawlOptions, + CustomFile, + DataSourceInfo, + DataSourceType, + LegacyDataSourceInfo, + LocalFileInfo, + OnlineDocumentInfo, + WebsiteCrawlInfo, +} from '@/models/datasets' +import type { DataSourceProvider } from '@/models/common' import Loading from '@/app/components/base/loading' import StepTwo from '@/app/components/datasets/create/step-two' import AccountSetting from '@/app/components/header/account-setting' @@ -42,15 +52,78 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => { params: { metadata: 'without' }, }) + const dataSourceInfo = documentDetail?.data_source_info + + const isLegacyDataSourceInfo = (info: DataSourceInfo | undefined): info is LegacyDataSourceInfo => { + return !!info && 'upload_file' in info + } + const isWebsiteCrawlInfo = (info: DataSourceInfo | undefined): info is WebsiteCrawlInfo => { + return !!info && 'source_url' in info && 'title' in info + } + const isOnlineDocumentInfo = (info: DataSourceInfo | undefined): info is OnlineDocumentInfo => { + return !!info && 'page' in info + } + const isLocalFileInfo = (info: DataSourceInfo | undefined): info is LocalFileInfo => { + return !!info && 'related_id' in info && 'transfer_method' in info + } + const legacyInfo = isLegacyDataSourceInfo(dataSourceInfo) ? dataSourceInfo : undefined + const websiteInfo = isWebsiteCrawlInfo(dataSourceInfo) ? dataSourceInfo : undefined + const onlineDocumentInfo = isOnlineDocumentInfo(dataSourceInfo) ? dataSourceInfo : undefined + const localFileInfo = isLocalFileInfo(dataSourceInfo) ? dataSourceInfo : undefined + const currentPage = useMemo(() => { - return { - workspace_id: documentDetail?.data_source_info.notion_workspace_id, - page_id: documentDetail?.data_source_info.notion_page_id, - page_name: documentDetail?.name, - page_icon: documentDetail?.data_source_info.notion_page_icon, - type: documentDetail?.data_source_type, + if (legacyInfo) { + return { + workspace_id: legacyInfo.notion_workspace_id ?? '', + page_id: legacyInfo.notion_page_id ?? '', + page_name: documentDetail?.name, + page_icon: legacyInfo.notion_page_icon, + type: documentDetail?.data_source_type, + } } - }, [documentDetail]) + if (onlineDocumentInfo) { + return { + workspace_id: onlineDocumentInfo.workspace_id, + page_id: onlineDocumentInfo.page.page_id, + page_name: onlineDocumentInfo.page.page_name, + page_icon: onlineDocumentInfo.page.page_icon, + type: onlineDocumentInfo.page.type, + } + } + return undefined + }, [documentDetail?.data_source_type, documentDetail?.name, legacyInfo, onlineDocumentInfo]) + + const files = useMemo(() => { + if (legacyInfo?.upload_file) + return [legacyInfo.upload_file as CustomFile] + if (localFileInfo) { + const { related_id, name, extension } = localFileInfo + return [{ + id: related_id, + name, + extension, + } as unknown as CustomFile] + } + return [] + }, [legacyInfo?.upload_file, localFileInfo]) + + const websitePages = useMemo(() => { + if (!websiteInfo) + return [] + return [{ + title: websiteInfo.title, + source_url: websiteInfo.source_url, + content: websiteInfo.content, + description: websiteInfo.description, + }] + }, [websiteInfo]) + + const crawlOptions = (dataSourceInfo && typeof dataSourceInfo === 'object' && 'includes' in dataSourceInfo && 'excludes' in dataSourceInfo) + ? dataSourceInfo as unknown as CrawlOptions + : undefined + + const websiteCrawlProvider = (websiteInfo?.provider ?? legacyInfo?.provider) as DataSourceProvider | undefined + const websiteCrawlJobId = websiteInfo?.job_id ?? legacyInfo?.job_id if (error) return @@ -65,22 +138,16 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => { onSetting={showSetAPIKey} datasetId={datasetId} dataSourceType={documentDetail.data_source_type as DataSourceType} - notionPages={[currentPage as unknown as NotionPage]} - websitePages={[ - { - title: documentDetail.name, - source_url: documentDetail.data_source_info?.url, - content: '', - description: '', - }, - ]} - websiteCrawlProvider={documentDetail.data_source_info?.provider} - websiteCrawlJobId={documentDetail.data_source_info?.job_id} - crawlOptions={documentDetail.data_source_info as unknown as CrawlOptions} + notionPages={currentPage ? [currentPage as unknown as NotionPage] : []} + notionCredentialId={legacyInfo?.credential_id || onlineDocumentInfo?.credential_id || ''} + websitePages={websitePages} + websiteCrawlProvider={websiteCrawlProvider} + websiteCrawlJobId={websiteCrawlJobId || ''} + crawlOptions={crawlOptions} indexingType={indexingTechnique} isSetting documentDetail={documentDetail} - files={[documentDetail.data_source_info.upload_file as CustomFile]} + files={files} onSave={saveHandler} onCancel={cancelHandler} /> diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx index d28959a509..58e96fde69 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx @@ -5,6 +5,7 @@ import type { Model, ModelItem, } from '../declarations' +import type { ModelFeatureEnum } from '../declarations' import { useCurrentProviderAndModel } from '../hooks' import ModelTrigger from './model-trigger' import EmptyTrigger from './empty-trigger' @@ -24,7 +25,7 @@ type ModelSelectorProps = { popupClassName?: string onSelect?: (model: DefaultModel) => void readonly?: boolean - scopeFeatures?: string[] + scopeFeatures?: ModelFeatureEnum[] deprecatedClassName?: string showDeprecatedWarnIcon?: boolean } diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx index ff32b438ed..b43fcd6301 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx @@ -22,7 +22,7 @@ type PopupProps = { defaultModel?: DefaultModel modelList: Model[] onSelect: (provider: string, model: ModelItem) => void - scopeFeatures?: string[] + scopeFeatures?: ModelFeatureEnum[] onHide: () => void } const Popup: FC = ({ diff --git a/web/app/components/plugins/install-plugin/utils.ts b/web/app/components/plugins/install-plugin/utils.ts index f19a7fd287..79c6d7b031 100644 --- a/web/app/components/plugins/install-plugin/utils.ts +++ b/web/app/components/plugins/install-plugin/utils.ts @@ -5,15 +5,17 @@ import { isEmpty } from 'lodash-es' export const pluginManifestToCardPluginProps = (pluginManifest: PluginDeclaration): Plugin => { return { plugin_id: pluginManifest.plugin_unique_identifier, - type: pluginManifest.category, + type: pluginManifest.category as Plugin['type'], category: pluginManifest.category, name: pluginManifest.name, version: pluginManifest.version, latest_version: '', latest_package_identifier: '', org: pluginManifest.author, + author: pluginManifest.author, label: pluginManifest.label, brief: pluginManifest.description, + description: pluginManifest.description, icon: pluginManifest.icon, verified: pluginManifest.verified, introduction: '', @@ -22,14 +24,17 @@ export const pluginManifestToCardPluginProps = (pluginManifest: PluginDeclaratio endpoint: { settings: [], }, - tags: [], + tags: pluginManifest.tags.map(tag => ({ name: tag })), + badges: [], + verification: { authorized_category: 'langgenius' }, + from: 'package', } } export const pluginManifestInMarketToPluginProps = (pluginManifest: PluginManifestInMarket): Plugin => { return { plugin_id: pluginManifest.plugin_unique_identifier, - type: pluginManifest.category, + type: pluginManifest.category as Plugin['type'], category: pluginManifest.category, name: pluginManifest.name, version: pluginManifest.latest_version, @@ -38,6 +43,7 @@ export const pluginManifestInMarketToPluginProps = (pluginManifest: PluginManife org: pluginManifest.org, label: pluginManifest.label, brief: pluginManifest.brief, + description: pluginManifest.brief, icon: pluginManifest.icon, verified: true, introduction: pluginManifest.introduction, @@ -49,6 +55,7 @@ export const pluginManifestInMarketToPluginProps = (pluginManifest: PluginManife tags: [], badges: pluginManifest.badges, verification: isEmpty(pluginManifest.verification) ? { authorized_category: 'langgenius' } : pluginManifest.verification, + from: pluginManifest.from, } } diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx index 3041f13f2f..d4c0bc2d92 100644 --- a/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx @@ -50,7 +50,7 @@ const EndpointModal: FC = ({ // Fix: Process boolean fields to ensure they are sent as proper boolean values const processedCredential = { ...tempCredential } - formSchemas.forEach((field) => { + formSchemas.forEach((field: any) => { if (field.type === 'boolean' && processedCredential[field.name] !== undefined) { const value = processedCredential[field.name] if (typeof value === 'string') diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/index.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/index.tsx index 873f187e8f..1393a1844f 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-selector/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/index.tsx @@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next' import type { DefaultModel, FormValue, + ModelFeatureEnum, } from '@/app/components/header/account-setting/model-provider-page/declarations' import { ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' @@ -57,7 +58,7 @@ const ModelParameterModal: FC = ({ const { isAPIKeySet } = useProviderContext() const [open, setOpen] = useState(false) const scopeArray = scope.split('&') - const scopeFeatures = useMemo(() => { + const scopeFeatures = useMemo((): ModelFeatureEnum[] => { if (scopeArray.includes('all')) return [] return scopeArray.filter(item => ![ @@ -67,7 +68,7 @@ const ModelParameterModal: FC = ({ ModelTypeEnum.moderation, ModelTypeEnum.speech2text, ModelTypeEnum.tts, - ].includes(item as ModelTypeEnum)) + ].includes(item as ModelTypeEnum)).map(item => item as ModelFeatureEnum) }, [scopeArray]) const { data: textGenerationList } = useModelList(ModelTypeEnum.textGeneration) diff --git a/web/app/components/tools/add-tool-modal/category.tsx b/web/app/components/tools/add-tool-modal/category.tsx index 270b4fc2bf..c1467a0ff4 100644 --- a/web/app/components/tools/add-tool-modal/category.tsx +++ b/web/app/components/tools/add-tool-modal/category.tsx @@ -9,6 +9,7 @@ import I18n from '@/context/i18n' import { getLanguage } from '@/i18n-config/language' import { useStore as useLabelStore } from '@/app/components/tools/labels/store' import { fetchLabelList } from '@/service/tools' +import { renderI18nObject } from '@/i18n-config' type Props = { value: string @@ -55,14 +56,24 @@ const Category = ({ {t('tools.type.all')}
- {labelList.map(label => ( -
onSelect(label.name)}> -
- + {labelList.map((label) => { + const labelText = typeof label.label === 'string' + ? label.label + : (label.label ? renderI18nObject(label.label, language) : '') + return ( +
onSelect(label.name)} + > +
+ +
+ {labelText}
- {label.label[language]} -
- ))} + ) + })}
) } diff --git a/web/app/components/tools/add-tool-modal/index.tsx b/web/app/components/tools/add-tool-modal/index.tsx index e12ba3e334..392fa02f3a 100644 --- a/web/app/components/tools/add-tool-modal/index.tsx +++ b/web/app/components/tools/add-tool-modal/index.tsx @@ -10,6 +10,7 @@ import { } from '@remixicon/react' import { useMount } from 'ahooks' import type { Collection, CustomCollectionBackend, Tool } from '../types' +import type { CollectionType } from '../types' import Type from './type' import Category from './category' import Tools from './tools' @@ -129,7 +130,7 @@ const AddToolModal: FC = ({ const nexModelConfig = produce(modelConfig, (draft: ModelConfig) => { draft.agentConfig.tools.push({ provider_id: collection.id || collection.name, - provider_type: collection.type, + provider_type: collection.type as CollectionType, provider_name: collection.name, tool_name: tool.name, tool_label: tool.label[locale] || tool.label[locale.replaceAll('-', '_')], diff --git a/web/app/components/tools/add-tool-modal/tools.tsx b/web/app/components/tools/add-tool-modal/tools.tsx index 17a3df8357..20f7e6b0da 100644 --- a/web/app/components/tools/add-tool-modal/tools.tsx +++ b/web/app/components/tools/add-tool-modal/tools.tsx @@ -23,6 +23,14 @@ import type { Tool } from '@/app/components/tools/types' import { CollectionType } from '@/app/components/tools/types' import type { AgentTool } from '@/types/app' import { MAX_TOOLS_NUM } from '@/config' +import type { TypeWithI18N } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { renderI18nObject } from '@/i18n-config' + +const resolveI18nText = (value: TypeWithI18N | string | undefined, language: string): string => { + if (!value) + return '' + return typeof value === 'string' ? value : renderI18nObject(value, language) +} type ToolsProps = { showWorkflowEmpty: boolean @@ -53,7 +61,7 @@ const Blocks = ({ className='group mb-1 last-of-type:mb-0' >
- {toolWithProvider.label[language]} + {resolveI18nText(toolWithProvider.label, language)} {t('tools.addToolModal.manageInTools')}
{list.map((tool) => { @@ -62,7 +70,7 @@ const Blocks = ({ return '' return tool.labels.map((name) => { const label = labelList.find(item => item.name === name) - return label?.label[language] + return resolveI18nText(label?.label, language) }).filter(Boolean).join(', ') })() const added = !!addedTools?.find(v => v.provider_id === toolWithProvider.id && v.provider_type === toolWithProvider.type && v.tool_name === tool.name) @@ -79,8 +87,8 @@ const Blocks = ({ type={BlockEnum.Tool} toolIcon={toolWithProvider.icon} /> -
{tool.label[language]}
-
{tool.description[language]}
+
{resolveI18nText(tool.label, language)}
+
{resolveI18nText(tool.description, language)}
{tool.labels?.length > 0 && (
@@ -98,7 +106,7 @@ const Blocks = ({ type={BlockEnum.Tool} toolIcon={toolWithProvider.icon} /> -
{tool.label[language]}
+
{resolveI18nText(tool.label, language)}
{!needAuth && added && (
diff --git a/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx b/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx index 1a97357da5..6fba10bf81 100644 --- a/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx +++ b/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx @@ -12,7 +12,7 @@ import ConversationVariableModal from './conversation-variable-modal' import { useChat } from './hooks' import type { ChatWrapperRefType } from './index' import Chat from '@/app/components/base/chat/chat' -import type { ChatItem, ChatItemInTree, OnSend } from '@/app/components/base/chat/types' +import type { ChatItem, OnSend } from '@/app/components/base/chat/types' import { useFeatures } from '@/app/components/base/features/hooks' import { fetchSuggestedQuestions, @@ -117,7 +117,7 @@ const ChatWrapper = ( ) }, [handleSend, workflowStore, conversationId, chatList, appDetail]) - const doRegenerate = useCallback((chatItem: ChatItemInTree, editedQuestion?: { message: string, files?: FileEntity[] }) => { + const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => { const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)! const parentAnswer = chatList.find(item => item.id === question.parentMessageId) doSend(editedQuestion ? editedQuestion.message : question.content, diff --git a/web/app/components/workflow/utils/layout.ts b/web/app/components/workflow/utils/elk-layout.ts similarity index 97% rename from web/app/components/workflow/utils/layout.ts rename to web/app/components/workflow/utils/elk-layout.ts index b3cf3b0d88..69acbf9aff 100644 --- a/web/app/components/workflow/utils/layout.ts +++ b/web/app/components/workflow/utils/elk-layout.ts @@ -4,18 +4,18 @@ import { cloneDeep } from 'lodash-es' import type { Edge, Node, -} from '../types' +} from '@/app/components/workflow/types' import { BlockEnum, -} from '../types' +} from '@/app/components/workflow/types' import { CUSTOM_NODE, NODE_LAYOUT_HORIZONTAL_PADDING, NODE_LAYOUT_VERTICAL_PADDING, -} from '../constants' -import { CUSTOM_ITERATION_START_NODE } from '../nodes/iteration-start/constants' -import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants' -import type { CaseItem, IfElseNodeType } from '../nodes/if-else/types' +} from '@/app/components/workflow/constants' +import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants' +import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants' +import type { CaseItem, IfElseNodeType } from '@/app/components/workflow/nodes/if-else/types' // Although the file name refers to Dagre, the implementation now relies on ELK's layered algorithm. // Keep the export signatures unchanged to minimise the blast radius while we migrate the layout stack. diff --git a/web/app/components/workflow/utils/index.ts b/web/app/components/workflow/utils/index.ts index e9ae2d1ef0..53a423de34 100644 --- a/web/app/components/workflow/utils/index.ts +++ b/web/app/components/workflow/utils/index.ts @@ -1,7 +1,7 @@ export * from './node' export * from './edge' export * from './workflow-init' -export * from './layout' +export * from './elk-layout' export * from './common' export * from './tool' export * from './workflow' diff --git a/web/app/signin/utils/post-login-redirect.ts b/web/app/signin/utils/post-login-redirect.ts index 37ab122dfa..45e2c55941 100644 --- a/web/app/signin/utils/post-login-redirect.ts +++ b/web/app/signin/utils/post-login-redirect.ts @@ -1,4 +1,4 @@ -import { OAUTH_AUTHORIZE_PENDING_KEY, REDIRECT_URL_KEY } from '@/app/account/oauth/authorize/page' +import { OAUTH_AUTHORIZE_PENDING_KEY, REDIRECT_URL_KEY } from '@/app/account/oauth/authorize/constants' import dayjs from 'dayjs' import type { ReadonlyURLSearchParams } from 'next/navigation' diff --git a/web/context/debug-configuration.ts b/web/context/debug-configuration.ts index dba2e7a231..1358940e39 100644 --- a/web/context/debug-configuration.ts +++ b/web/context/debug-configuration.ts @@ -210,6 +210,8 @@ const DebugConfigurationContext = createContext({ prompt_template: '', prompt_variables: [], }, + chat_prompt_config: DEFAULT_CHAT_PROMPT_CONFIG, + completion_prompt_config: DEFAULT_COMPLETION_PROMPT_CONFIG, more_like_this: null, opening_statement: '', suggested_questions: [], @@ -220,6 +222,14 @@ const DebugConfigurationContext = createContext({ suggested_questions_after_answer: null, retriever_resource: null, annotation_reply: null, + external_data_tools: [], + system_parameters: { + audio_file_size_limit: 0, + file_size_limit: 0, + image_file_size_limit: 0, + video_file_size_limit: 0, + workflow_file_upload_limit: 0, + }, dataSets: [], agentConfig: DEFAULT_AGENT_SETTING, }, diff --git a/web/models/datasets.ts b/web/models/datasets.ts index aeeb5c161a..39313d68a3 100644 --- a/web/models/datasets.ts +++ b/web/models/datasets.ts @@ -344,6 +344,8 @@ export type WebsiteCrawlInfo = { description: string source_url: string title: string + provider?: string + job_id?: string } export type OnlineDocumentInfo = { diff --git a/web/models/debug.ts b/web/models/debug.ts index 630c48a970..90f79cbf8d 100644 --- a/web/models/debug.ts +++ b/web/models/debug.ts @@ -9,6 +9,7 @@ import type { MetadataFilteringModeEnum, } from '@/app/components/workflow/nodes/knowledge-retrieval/types' import type { ModelConfig as NodeModelConfig } from '@/app/components/workflow/types' +import type { ExternalDataTool } from '@/models/common' export type Inputs = Record export enum PromptMode { @@ -133,6 +134,8 @@ export type ModelConfig = { model_id: string mode: ModelModeType configs: PromptConfig + chat_prompt_config?: ChatPromptConfig | null + completion_prompt_config?: CompletionPromptConfig | null opening_statement: string | null more_like_this: MoreLikeThisConfig | null suggested_questions: string[] | null @@ -143,6 +146,14 @@ export type ModelConfig = { retriever_resource: RetrieverResourceConfig | null sensitive_word_avoidance: ModerationConfig | null annotation_reply: AnnotationReplyConfig | null + external_data_tools?: ExternalDataTool[] | null + system_parameters: { + audio_file_size_limit: number + file_size_limit: number + image_file_size_limit: number + video_file_size_limit: number + workflow_file_upload_limit: number + } dataSets: any[] agentConfig: AgentConfig } diff --git a/web/types/app.ts b/web/types/app.ts index abc5b34ca5..591bbf5e31 100644 --- a/web/types/app.ts +++ b/web/types/app.ts @@ -8,6 +8,7 @@ import type { } from '@/models/datasets' import type { UploadFileSetting } from '@/app/components/workflow/types' import type { AccessMode } from '@/models/access-control' +import type { ExternalDataTool } from '@/models/common' export enum Theme { light = 'light', @@ -206,12 +207,12 @@ export type ModelConfig = { suggested_questions?: string[] pre_prompt: string prompt_type: PromptMode - chat_prompt_config: ChatPromptConfig | {} - completion_prompt_config: CompletionPromptConfig | {} + chat_prompt_config?: ChatPromptConfig | null + completion_prompt_config?: CompletionPromptConfig | null user_input_form: UserInputFormItem[] dataset_query_variable?: string more_like_this: { - enabled?: boolean + enabled: boolean } suggested_questions_after_answer: { enabled: boolean @@ -237,12 +238,20 @@ export type ModelConfig = { strategy?: AgentStrategy tools: ToolItem[] } + external_data_tools?: ExternalDataTool[] model: Model dataset_configs: DatasetConfigs file_upload?: { image: VisionSettings } & UploadFileSetting files?: VisionFile[] + system_parameters: { + audio_file_size_limit: number + file_size_limit: number + image_file_size_limit: number + video_file_size_limit: number + workflow_file_upload_limit: number + } created_at?: number updated_at?: number } @@ -360,6 +369,7 @@ export type App = { updated_at: number updated_by?: string } + deleted_tools?: Array<{ id: string; tool_name: string }> /** access control */ access_mode: AccessMode max_active_requests?: number | null From b6e0abadabf7cb84b852d0afdf3911b48046f9c4 Mon Sep 17 00:00:00 2001 From: Novice Date: Mon, 27 Oct 2025 16:04:24 +0800 Subject: [PATCH 003/394] feat: add flatten_output configuration to iteration node (#27502) --- api/core/workflow/nodes/iteration/entities.py | 1 + .../nodes/iteration/iteration_node.py | 8 + ...ation_flatten_output_disabled_workflow.yml | 258 ++++++++++++++++++ ...ration_flatten_output_enabled_workflow.yml | 258 ++++++++++++++++++ .../test_iteration_flatten_output.py | 96 +++++++ .../workflow/nodes/iteration/default.ts | 1 + .../workflow/nodes/iteration/panel.tsx | 13 + .../workflow/nodes/iteration/types.ts | 1 + .../workflow/nodes/iteration/use-config.ts | 9 + web/i18n/en-US/workflow.ts | 2 + web/i18n/zh-Hans/workflow.ts | 2 + 11 files changed, 649 insertions(+) create mode 100644 api/tests/fixtures/workflow/iteration_flatten_output_disabled_workflow.yml create mode 100644 api/tests/fixtures/workflow/iteration_flatten_output_enabled_workflow.yml create mode 100644 api/tests/unit_tests/core/workflow/graph_engine/test_iteration_flatten_output.py diff --git a/api/core/workflow/nodes/iteration/entities.py b/api/core/workflow/nodes/iteration/entities.py index ed4ab2c11c..63a41ec755 100644 --- a/api/core/workflow/nodes/iteration/entities.py +++ b/api/core/workflow/nodes/iteration/entities.py @@ -23,6 +23,7 @@ class IterationNodeData(BaseIterationNodeData): is_parallel: bool = False # open the parallel mode or not parallel_nums: int = 10 # the numbers of parallel error_handle_mode: ErrorHandleMode = ErrorHandleMode.TERMINATED # how to handle the error + flatten_output: bool = True # whether to flatten the output array if all elements are lists class IterationStartNodeData(BaseNodeData): diff --git a/api/core/workflow/nodes/iteration/iteration_node.py b/api/core/workflow/nodes/iteration/iteration_node.py index 3a3a2290be..ce83352dcb 100644 --- a/api/core/workflow/nodes/iteration/iteration_node.py +++ b/api/core/workflow/nodes/iteration/iteration_node.py @@ -98,6 +98,7 @@ class IterationNode(LLMUsageTrackingMixin, Node): "is_parallel": False, "parallel_nums": 10, "error_handle_mode": ErrorHandleMode.TERMINATED, + "flatten_output": True, }, } @@ -411,7 +412,14 @@ class IterationNode(LLMUsageTrackingMixin, Node): """ Flatten the outputs list if all elements are lists. This maintains backward compatibility with version 1.8.1 behavior. + + If flatten_output is False, returns outputs as-is (nested structure). + If flatten_output is True (default), flattens the list if all elements are lists. """ + # If flatten_output is disabled, return outputs as-is + if not self._node_data.flatten_output: + return outputs + if not outputs: return outputs diff --git a/api/tests/fixtures/workflow/iteration_flatten_output_disabled_workflow.yml b/api/tests/fixtures/workflow/iteration_flatten_output_disabled_workflow.yml new file mode 100644 index 0000000000..9cae6385c8 --- /dev/null +++ b/api/tests/fixtures/workflow/iteration_flatten_output_disabled_workflow.yml @@ -0,0 +1,258 @@ +app: + description: 'This workflow tests the iteration node with flatten_output=False. + + + It processes [1, 2, 3], outputs [item, item*2] for each iteration. + + + With flatten_output=False, it should output nested arrays: + + + ``` + + {"output": [[1, 2], [2, 4], [3, 6]]} + + ```' + icon: 🤖 + icon_background: '#FFEAD5' + mode: workflow + name: test_iteration_flatten_disabled + use_icon_as_answer_icon: false +dependencies: [] +kind: app +version: 0.3.1 +workflow: + conversation_variables: [] + environment_variables: [] + features: + file_upload: + enabled: false + opening_statement: '' + retriever_resource: + enabled: true + sensitive_word_avoidance: + enabled: false + speech_to_text: + enabled: false + suggested_questions: [] + suggested_questions_after_answer: + enabled: false + text_to_speech: + enabled: false + graph: + edges: + - data: + isInIteration: false + isInLoop: false + sourceType: start + targetType: code + id: start-source-code-target + source: start_node + sourceHandle: source + target: code_node + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: code + targetType: iteration + id: code-source-iteration-target + source: code_node + sourceHandle: source + target: iteration_node + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: true + isInLoop: false + iteration_id: iteration_node + sourceType: iteration-start + targetType: code + id: iteration-start-source-code-inner-target + source: iteration_nodestart + sourceHandle: source + target: code_inner_node + targetHandle: target + type: custom + zIndex: 1002 + - data: + isInIteration: false + isInLoop: false + sourceType: iteration + targetType: end + id: iteration-source-end-target + source: iteration_node + sourceHandle: source + target: end_node + targetHandle: target + type: custom + zIndex: 0 + nodes: + - data: + desc: '' + selected: false + title: Start + type: start + variables: [] + height: 54 + id: start_node + position: + x: 80 + y: 282 + positionAbsolute: + x: 80 + y: 282 + sourcePosition: right + targetPosition: left + type: custom + width: 244 + - data: + code: "\ndef main() -> dict:\n return {\n \"result\": [1, 2, 3],\n\ + \ }\n" + code_language: python3 + desc: '' + outputs: + result: + children: null + type: array[number] + selected: false + title: Generate Array + type: code + variables: [] + height: 54 + id: code_node + position: + x: 384 + y: 282 + positionAbsolute: + x: 384 + y: 282 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 244 + - data: + desc: '' + error_handle_mode: terminated + flatten_output: false + height: 178 + is_parallel: false + iterator_input_type: array[number] + iterator_selector: + - code_node + - result + output_selector: + - code_inner_node + - result + output_type: array[array[number]] + parallel_nums: 10 + selected: false + start_node_id: iteration_nodestart + title: Iteration with Flatten Disabled + type: iteration + width: 388 + height: 178 + id: iteration_node + position: + x: 684 + y: 282 + positionAbsolute: + x: 684 + y: 282 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 388 + zIndex: 1 + - data: + desc: '' + isInIteration: true + selected: false + title: '' + type: iteration-start + draggable: false + height: 48 + id: iteration_nodestart + parentId: iteration_node + position: + x: 24 + y: 68 + positionAbsolute: + x: 708 + y: 350 + selectable: false + sourcePosition: right + targetPosition: left + type: custom-iteration-start + width: 44 + zIndex: 1002 + - data: + code: "\ndef main(arg1: int) -> dict:\n return {\n \"result\": [arg1,\ + \ arg1 * 2],\n }\n" + code_language: python3 + desc: '' + isInIteration: true + isInLoop: false + iteration_id: iteration_node + outputs: + result: + children: null + type: array[number] + selected: false + title: Generate Pair + type: code + variables: + - value_selector: + - iteration_node + - item + value_type: number + variable: arg1 + height: 54 + id: code_inner_node + parentId: iteration_node + position: + x: 128 + y: 68 + positionAbsolute: + x: 812 + y: 350 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 244 + zIndex: 1002 + - data: + desc: '' + outputs: + - value_selector: + - iteration_node + - output + value_type: array[array[number]] + variable: output + selected: false + title: End + type: end + height: 90 + id: end_node + position: + x: 1132 + y: 282 + positionAbsolute: + x: 1132 + y: 282 + selected: true + sourcePosition: right + targetPosition: left + type: custom + width: 244 + viewport: + x: -476 + y: 3 + zoom: 1 + diff --git a/api/tests/fixtures/workflow/iteration_flatten_output_enabled_workflow.yml b/api/tests/fixtures/workflow/iteration_flatten_output_enabled_workflow.yml new file mode 100644 index 0000000000..0fc76df768 --- /dev/null +++ b/api/tests/fixtures/workflow/iteration_flatten_output_enabled_workflow.yml @@ -0,0 +1,258 @@ +app: + description: 'This workflow tests the iteration node with flatten_output=True. + + + It processes [1, 2, 3], outputs [item, item*2] for each iteration. + + + With flatten_output=True (default), it should output: + + + ``` + + {"output": [1, 2, 2, 4, 3, 6]} + + ```' + icon: 🤖 + icon_background: '#FFEAD5' + mode: workflow + name: test_iteration_flatten_enabled + use_icon_as_answer_icon: false +dependencies: [] +kind: app +version: 0.3.1 +workflow: + conversation_variables: [] + environment_variables: [] + features: + file_upload: + enabled: false + opening_statement: '' + retriever_resource: + enabled: true + sensitive_word_avoidance: + enabled: false + speech_to_text: + enabled: false + suggested_questions: [] + suggested_questions_after_answer: + enabled: false + text_to_speech: + enabled: false + graph: + edges: + - data: + isInIteration: false + isInLoop: false + sourceType: start + targetType: code + id: start-source-code-target + source: start_node + sourceHandle: source + target: code_node + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: code + targetType: iteration + id: code-source-iteration-target + source: code_node + sourceHandle: source + target: iteration_node + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: true + isInLoop: false + iteration_id: iteration_node + sourceType: iteration-start + targetType: code + id: iteration-start-source-code-inner-target + source: iteration_nodestart + sourceHandle: source + target: code_inner_node + targetHandle: target + type: custom + zIndex: 1002 + - data: + isInIteration: false + isInLoop: false + sourceType: iteration + targetType: end + id: iteration-source-end-target + source: iteration_node + sourceHandle: source + target: end_node + targetHandle: target + type: custom + zIndex: 0 + nodes: + - data: + desc: '' + selected: false + title: Start + type: start + variables: [] + height: 54 + id: start_node + position: + x: 80 + y: 282 + positionAbsolute: + x: 80 + y: 282 + sourcePosition: right + targetPosition: left + type: custom + width: 244 + - data: + code: "\ndef main() -> dict:\n return {\n \"result\": [1, 2, 3],\n\ + \ }\n" + code_language: python3 + desc: '' + outputs: + result: + children: null + type: array[number] + selected: false + title: Generate Array + type: code + variables: [] + height: 54 + id: code_node + position: + x: 384 + y: 282 + positionAbsolute: + x: 384 + y: 282 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 244 + - data: + desc: '' + error_handle_mode: terminated + flatten_output: true + height: 178 + is_parallel: false + iterator_input_type: array[number] + iterator_selector: + - code_node + - result + output_selector: + - code_inner_node + - result + output_type: array[array[number]] + parallel_nums: 10 + selected: false + start_node_id: iteration_nodestart + title: Iteration with Flatten Enabled + type: iteration + width: 388 + height: 178 + id: iteration_node + position: + x: 684 + y: 282 + positionAbsolute: + x: 684 + y: 282 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 388 + zIndex: 1 + - data: + desc: '' + isInIteration: true + selected: false + title: '' + type: iteration-start + draggable: false + height: 48 + id: iteration_nodestart + parentId: iteration_node + position: + x: 24 + y: 68 + positionAbsolute: + x: 708 + y: 350 + selectable: false + sourcePosition: right + targetPosition: left + type: custom-iteration-start + width: 44 + zIndex: 1002 + - data: + code: "\ndef main(arg1: int) -> dict:\n return {\n \"result\": [arg1,\ + \ arg1 * 2],\n }\n" + code_language: python3 + desc: '' + isInIteration: true + isInLoop: false + iteration_id: iteration_node + outputs: + result: + children: null + type: array[number] + selected: false + title: Generate Pair + type: code + variables: + - value_selector: + - iteration_node + - item + value_type: number + variable: arg1 + height: 54 + id: code_inner_node + parentId: iteration_node + position: + x: 128 + y: 68 + positionAbsolute: + x: 812 + y: 350 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 244 + zIndex: 1002 + - data: + desc: '' + outputs: + - value_selector: + - iteration_node + - output + value_type: array[number] + variable: output + selected: false + title: End + type: end + height: 90 + id: end_node + position: + x: 1132 + y: 282 + positionAbsolute: + x: 1132 + y: 282 + selected: true + sourcePosition: right + targetPosition: left + type: custom + width: 244 + viewport: + x: -476 + y: 3 + zoom: 1 + diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_iteration_flatten_output.py b/api/tests/unit_tests/core/workflow/graph_engine/test_iteration_flatten_output.py new file mode 100644 index 0000000000..f2095a8a70 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_iteration_flatten_output.py @@ -0,0 +1,96 @@ +""" +Test cases for the Iteration node's flatten_output functionality. + +This module tests the iteration node's ability to: +1. Flatten array outputs when flatten_output=True (default) +2. Preserve nested array structure when flatten_output=False +""" + +from .test_table_runner import TableTestRunner, WorkflowTestCase + + +def test_iteration_with_flatten_output_enabled(): + """ + Test iteration node with flatten_output=True (default behavior). + + The fixture implements an iteration that: + 1. Iterates over [1, 2, 3] + 2. For each item, outputs [item, item*2] + 3. With flatten_output=True, should output [1, 2, 2, 4, 3, 6] + """ + runner = TableTestRunner() + + test_case = WorkflowTestCase( + fixture_path="iteration_flatten_output_enabled_workflow", + inputs={}, + expected_outputs={"output": [1, 2, 2, 4, 3, 6]}, + description="Iteration with flatten_output=True flattens nested arrays", + use_auto_mock=False, # Run code nodes directly + ) + + result = runner.run_test_case(test_case) + + assert result.success, f"Test failed: {result.error}" + assert result.actual_outputs is not None, "Should have outputs" + assert result.actual_outputs == {"output": [1, 2, 2, 4, 3, 6]}, ( + f"Expected flattened output [1, 2, 2, 4, 3, 6], got {result.actual_outputs}" + ) + + +def test_iteration_with_flatten_output_disabled(): + """ + Test iteration node with flatten_output=False. + + The fixture implements an iteration that: + 1. Iterates over [1, 2, 3] + 2. For each item, outputs [item, item*2] + 3. With flatten_output=False, should output [[1, 2], [2, 4], [3, 6]] + """ + runner = TableTestRunner() + + test_case = WorkflowTestCase( + fixture_path="iteration_flatten_output_disabled_workflow", + inputs={}, + expected_outputs={"output": [[1, 2], [2, 4], [3, 6]]}, + description="Iteration with flatten_output=False preserves nested structure", + use_auto_mock=False, # Run code nodes directly + ) + + result = runner.run_test_case(test_case) + + assert result.success, f"Test failed: {result.error}" + assert result.actual_outputs is not None, "Should have outputs" + assert result.actual_outputs == {"output": [[1, 2], [2, 4], [3, 6]]}, ( + f"Expected nested output [[1, 2], [2, 4], [3, 6]], got {result.actual_outputs}" + ) + + +def test_iteration_flatten_output_comparison(): + """ + Run both flatten_output configurations in parallel to verify the difference. + """ + runner = TableTestRunner() + + test_cases = [ + WorkflowTestCase( + fixture_path="iteration_flatten_output_enabled_workflow", + inputs={}, + expected_outputs={"output": [1, 2, 2, 4, 3, 6]}, + description="flatten_output=True: Flattened output", + use_auto_mock=False, # Run code nodes directly + ), + WorkflowTestCase( + fixture_path="iteration_flatten_output_disabled_workflow", + inputs={}, + expected_outputs={"output": [[1, 2], [2, 4], [3, 6]]}, + description="flatten_output=False: Nested output", + use_auto_mock=False, # Run code nodes directly + ), + ] + + suite_result = runner.run_table_tests(test_cases, parallel=True) + + # Assert all tests passed + assert suite_result.passed_tests == 2, f"Expected 2 passed tests, got {suite_result.passed_tests}" + assert suite_result.failed_tests == 0, f"Expected 0 failed tests, got {suite_result.failed_tests}" + assert suite_result.success_rate == 100.0, f"Expected 100% success rate, got {suite_result.success_rate}" diff --git a/web/app/components/workflow/nodes/iteration/default.ts b/web/app/components/workflow/nodes/iteration/default.ts index 450379ec6b..c375dbdcbf 100644 --- a/web/app/components/workflow/nodes/iteration/default.ts +++ b/web/app/components/workflow/nodes/iteration/default.ts @@ -22,6 +22,7 @@ const nodeDefault: NodeDefault = { is_parallel: false, parallel_nums: 10, error_handle_mode: ErrorHandleMode.Terminated, + flatten_output: true, }, checkValid(payload: IterationNodeType, t: any) { let errorMessages = '' diff --git a/web/app/components/workflow/nodes/iteration/panel.tsx b/web/app/components/workflow/nodes/iteration/panel.tsx index 23e93b0dd5..63e0d5f8cd 100644 --- a/web/app/components/workflow/nodes/iteration/panel.tsx +++ b/web/app/components/workflow/nodes/iteration/panel.tsx @@ -46,6 +46,7 @@ const Panel: FC> = ({ changeParallel, changeErrorResponseMode, changeParallelNums, + changeFlattenOutput, } = useConfig(id, data) return ( @@ -117,6 +118,18 @@ const Panel: FC> = ({ - {!readonly && headerItems.length > 1 && ( + {!readonly && !!headersItems.length && ( handleRemoveItem(index)} className='mr-2' diff --git a/web/app/components/tools/mcp/modal.tsx b/web/app/components/tools/mcp/modal.tsx index 1d888c57e8..987a517ef5 100644 --- a/web/app/components/tools/mcp/modal.tsx +++ b/web/app/components/tools/mcp/modal.tsx @@ -1,6 +1,7 @@ 'use client' -import React, { useRef, useState } from 'react' +import React, { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { v4 as uuid } from 'uuid' import { getDomain } from 'tldts' import { RiCloseLine, RiEditLine } from '@remixicon/react' import { Mcp } from '@/app/components/base/icons/src/vender/other' @@ -11,6 +12,7 @@ import Modal from '@/app/components/base/modal' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import HeadersInput from './headers-input' +import type { HeaderItem } from './headers-input' import type { AppIconType } from '@/types/app' import type { ToolWithProvider } from '@/app/components/workflow/types' import { noop } from 'lodash-es' @@ -19,6 +21,9 @@ import { uploadRemoteFileInfo } from '@/service/common' import cn from '@/utils/classnames' import { useHover } from 'ahooks' import { shouldUseMcpIconForAppIcon } from '@/utils/mcp' +import TabSlider from '@/app/components/base/tab-slider' +import { MCPAuthMethod } from '@/app/components/tools/types' +import Switch from '@/app/components/base/switch' export type DuplicateAppModalProps = { data?: ToolWithProvider @@ -30,9 +35,17 @@ export type DuplicateAppModalProps = { icon: string icon_background?: string | null server_identifier: string - timeout: number - sse_read_timeout: number headers?: Record + is_dynamic_registration?: boolean + authentication?: { + client_id?: string + client_secret?: string + grant_type?: string + } + configuration: { + timeout: number + sse_read_timeout: number + } }) => void onHide: () => void } @@ -63,6 +76,20 @@ const MCPModal = ({ const { t } = useTranslation() const isCreate = !data + const authMethods = [ + { + text: t('tools.mcp.modal.authentication'), + value: MCPAuthMethod.authentication, + }, + { + text: t('tools.mcp.modal.headers'), + value: MCPAuthMethod.headers, + }, + { + text: t('tools.mcp.modal.configurations'), + value: MCPAuthMethod.configurations, + }, + ] const originalServerUrl = data?.server_url const originalServerID = data?.server_identifier const [url, setUrl] = React.useState(data?.server_url || '') @@ -72,12 +99,16 @@ const MCPModal = ({ const [serverIdentifier, setServerIdentifier] = React.useState(data?.server_identifier || '') const [timeout, setMcpTimeout] = React.useState(data?.timeout || 30) const [sseReadTimeout, setSseReadTimeout] = React.useState(data?.sse_read_timeout || 300) - const [headers, setHeaders] = React.useState>( - data?.masked_headers || {}, + const [headers, setHeaders] = React.useState( + Object.entries(data?.masked_headers || {}).map(([key, value]) => ({ id: uuid(), key, value })), ) const [isFetchingIcon, setIsFetchingIcon] = useState(false) const appIconRef = useRef(null) const isHovering = useHover(appIconRef) + const [authMethod, setAuthMethod] = useState(MCPAuthMethod.authentication) + const [isDynamicRegistration, setIsDynamicRegistration] = useState(isCreate ? true : data?.is_dynamic_registration) + const [clientID, setClientID] = useState(data?.authentication?.client_id || '') + const [credentials, setCredentials] = useState(data?.authentication?.client_secret || '') // Update states when data changes (for edit mode) React.useEffect(() => { @@ -87,8 +118,11 @@ const MCPModal = ({ setServerIdentifier(data.server_identifier || '') setMcpTimeout(data.timeout || 30) setSseReadTimeout(data.sse_read_timeout || 300) - setHeaders(data.masked_headers || {}) + setHeaders(Object.entries(data.masked_headers || {}).map(([key, value]) => ({ id: uuid(), key, value }))) setAppIcon(getIcon(data)) + setIsDynamicRegistration(data.is_dynamic_registration) + setClientID(data.authentication?.client_id || '') + setCredentials(data.authentication?.client_secret || '') } else { // Reset for create mode @@ -97,8 +131,11 @@ const MCPModal = ({ setServerIdentifier('') setMcpTimeout(30) setSseReadTimeout(300) - setHeaders({}) + setHeaders([]) setAppIcon(DEFAULT_ICON as AppIconSelection) + setIsDynamicRegistration(true) + setClientID('') + setCredentials('') } }, [data]) @@ -150,6 +187,11 @@ const MCPModal = ({ Toast.notify({ type: 'error', message: 'invalid server identifier' }) return } + const formattedHeaders = headers.reduce((acc, item) => { + if (item.key.trim()) + acc[item.key.trim()] = item.value + return acc + }, {} as Record) await onConfirm({ server_url: originalServerUrl === url ? '[__HIDDEN__]' : url.trim(), name, @@ -157,14 +199,25 @@ const MCPModal = ({ icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId, icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined, server_identifier: serverIdentifier.trim(), - timeout: timeout || 30, - sse_read_timeout: sseReadTimeout || 300, - headers: Object.keys(headers).length > 0 ? headers : undefined, + headers: Object.keys(formattedHeaders).length > 0 ? formattedHeaders : undefined, + is_dynamic_registration: isDynamicRegistration, + authentication: { + client_id: clientID, + client_secret: credentials, + }, + configuration: { + timeout: timeout || 30, + sse_read_timeout: sseReadTimeout || 300, + }, }) if(isCreate) onHide() } + const handleAuthMethodChange = useCallback((value: string) => { + setAuthMethod(value as MCPAuthMethod) + }, []) + return ( <> )}
-
-
- {t('tools.mcp.modal.timeout')} -
- setMcpTimeout(Number(e.target.value))} - onBlur={e => handleBlur(e.target.value.trim())} - placeholder={t('tools.mcp.modal.timeoutPlaceholder')} - /> -
-
-
- {t('tools.mcp.modal.sseReadTimeout')} -
- setSseReadTimeout(Number(e.target.value))} - onBlur={e => handleBlur(e.target.value.trim())} - placeholder={t('tools.mcp.modal.timeoutPlaceholder')} - /> -
-
-
- {t('tools.mcp.modal.headers')} -
-
{t('tools.mcp.modal.headersTip')}
- 0} - /> -
+ { + return `flex-1 ${isActive && 'text-text-accent-light-mode-only'}` + }} + value={authMethod} + onChange={handleAuthMethodChange} + options={authMethods} + /> + { + authMethod === MCPAuthMethod.authentication && ( + <> +
+
+ + {t('tools.mcp.modal.useDynamicClientRegistration')} +
+
+
+
+ {t('tools.mcp.modal.clientID')} +
+ setClientID(e.target.value)} + onBlur={e => handleBlur(e.target.value.trim())} + placeholder={t('tools.mcp.modal.clientID')} + disabled={isDynamicRegistration} + /> +
+
+
+ {t('tools.mcp.modal.clientSecret')} +
+ setCredentials(e.target.value)} + onBlur={e => handleBlur(e.target.value.trim())} + placeholder={t('tools.mcp.modal.clientSecretPlaceholder')} + disabled={isDynamicRegistration} + /> +
+ + ) + } + { + authMethod === MCPAuthMethod.headers && ( +
+
+ {t('tools.mcp.modal.headers')} +
+
{t('tools.mcp.modal.headersTip')}
+ item.key.trim()).length > 0} + /> +
+ ) + } + { + authMethod === MCPAuthMethod.configurations && ( + <> +
+
+ {t('tools.mcp.modal.timeout')} +
+ setMcpTimeout(Number(e.target.value))} + onBlur={e => handleBlur(e.target.value.trim())} + placeholder={t('tools.mcp.modal.timeoutPlaceholder')} + /> +
+
+
+ {t('tools.mcp.modal.sseReadTimeout')} +
+ setSseReadTimeout(Number(e.target.value))} + onBlur={e => handleBlur(e.target.value.trim())} + placeholder={t('tools.mcp.modal.timeoutPlaceholder')} + /> +
+ + ) + }
diff --git a/web/app/components/tools/types.ts b/web/app/components/tools/types.ts index 623a7b6d8a..1bfccc04e5 100644 --- a/web/app/components/tools/types.ts +++ b/web/app/components/tools/types.ts @@ -65,6 +65,15 @@ export type Collection = { masked_headers?: Record is_authorized?: boolean provider?: string + is_dynamic_registration?: boolean + authentication?: { + client_id?: string + client_secret?: string + } + configuration?: { + timeout?: number + sse_read_timeout?: number + } } export type ToolParameter = { @@ -192,3 +201,9 @@ export type MCPServerDetail = { parameters?: Record headers?: Record } + +export enum MCPAuthMethod { + authentication = 'authentication', + headers = 'headers', + configurations = 'configurations', +} diff --git a/web/i18n/en-US/tools.ts b/web/i18n/en-US/tools.ts index 3fba10447f..ec78aa2084 100644 --- a/web/i18n/en-US/tools.ts +++ b/web/i18n/en-US/tools.ts @@ -203,6 +203,12 @@ const translation = { timeout: 'Timeout', sseReadTimeout: 'SSE Read Timeout', timeoutPlaceholder: '30', + authentication: 'Authentication', + useDynamicClientRegistration: 'Use Dynamic Client Registration', + clientID: 'Client ID', + clientSecret: 'Client Secret', + clientSecretPlaceholder: 'Client Secret', + configurations: 'Configurations', }, delete: 'Remove MCP Server', deleteConfirmTitle: 'Would you like to remove {{mcp}}?', diff --git a/web/i18n/zh-Hans/tools.ts b/web/i18n/zh-Hans/tools.ts index 15b1c7f592..8382d192f6 100644 --- a/web/i18n/zh-Hans/tools.ts +++ b/web/i18n/zh-Hans/tools.ts @@ -203,6 +203,12 @@ const translation = { timeout: '超时时间', sseReadTimeout: 'SSE 读取超时时间', timeoutPlaceholder: '30', + authentication: '认证', + useDynamicClientRegistration: '使用动态客户端注册', + clientID: '客户端 ID', + clientSecret: '客户端密钥', + clientSecretPlaceholder: '客户端密钥', + configurations: '配置', }, delete: '删除 MCP 服务', deleteConfirmTitle: '你想要删除 {{mcp}} 吗?', From d6bd2a9bdb5439565689c5fc4168559d330fd7a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Oct 2025 17:39:43 +0800 Subject: [PATCH 006/394] chore: translate i18n files and update type definitions (#27503) Co-authored-by: Nov1c444 <66365942+Nov1c444@users.noreply.github.com> --- web/i18n/de-DE/tools.ts | 6 ++++++ web/i18n/es-ES/tools.ts | 6 ++++++ web/i18n/fa-IR/tools.ts | 6 ++++++ web/i18n/fr-FR/tools.ts | 6 ++++++ web/i18n/hi-IN/tools.ts | 6 ++++++ web/i18n/id-ID/tools.ts | 6 ++++++ web/i18n/it-IT/tools.ts | 6 ++++++ web/i18n/ja-JP/tools.ts | 6 ++++++ web/i18n/ko-KR/tools.ts | 6 ++++++ web/i18n/pl-PL/tools.ts | 6 ++++++ web/i18n/pt-BR/tools.ts | 6 ++++++ web/i18n/ro-RO/tools.ts | 6 ++++++ web/i18n/ru-RU/tools.ts | 6 ++++++ web/i18n/sl-SI/tools.ts | 6 ++++++ web/i18n/th-TH/tools.ts | 6 ++++++ web/i18n/tr-TR/tools.ts | 6 ++++++ web/i18n/uk-UA/tools.ts | 6 ++++++ web/i18n/vi-VN/tools.ts | 6 ++++++ web/i18n/zh-Hant/tools.ts | 6 ++++++ 19 files changed, 114 insertions(+) diff --git a/web/i18n/de-DE/tools.ts b/web/i18n/de-DE/tools.ts index 8cef76b732..8aef8e87f9 100644 --- a/web/i18n/de-DE/tools.ts +++ b/web/i18n/de-DE/tools.ts @@ -203,6 +203,12 @@ const translation = { noHeaders: 'Keine benutzerdefinierten Header konfiguriert', maskedHeadersTip: 'Headerwerte sind zum Schutz maskiert. Änderungen werden die tatsächlichen Werte aktualisieren.', headersTip: 'Zusätzliche HTTP-Header, die mit MCP-Serveranfragen gesendet werden sollen', + clientSecret: 'Client-Geheimnis', + clientSecretPlaceholder: 'Client-Geheimnis', + clientID: 'Kunden-ID', + authentication: 'Authentifizierung', + useDynamicClientRegistration: 'Dynamische Client-Registrierung verwenden', + configurations: 'Konfigurationen', }, delete: 'MCP-Server entfernen', deleteConfirmTitle: 'Möchten Sie {{mcp}} entfernen?', diff --git a/web/i18n/es-ES/tools.ts b/web/i18n/es-ES/tools.ts index 10584c41ca..304247aee9 100644 --- a/web/i18n/es-ES/tools.ts +++ b/web/i18n/es-ES/tools.ts @@ -203,6 +203,12 @@ const translation = { headerValue: 'Valor del encabezado', noHeaders: 'No se han configurado encabezados personalizados', headerKey: 'Nombre del encabezado', + authentication: 'Autenticación', + clientID: 'ID del Cliente', + clientSecretPlaceholder: 'Secreto del Cliente', + useDynamicClientRegistration: 'Usar registro dinámico de clientes', + clientSecret: 'Secreto del Cliente', + configurations: 'Configuraciones', }, delete: 'Eliminar servidor MCP', deleteConfirmTitle: '¿Eliminar {{mcp}}?', diff --git a/web/i18n/fa-IR/tools.ts b/web/i18n/fa-IR/tools.ts index 587c16d960..a6be1d0d42 100644 --- a/web/i18n/fa-IR/tools.ts +++ b/web/i18n/fa-IR/tools.ts @@ -203,6 +203,12 @@ const translation = { noHeaders: 'هیچ هدر سفارشی پیکربندی نشده است', headersTip: 'هدرهای HTTP اضافی برای ارسال با درخواست‌های سرور MCP', maskedHeadersTip: 'مقدارهای هدر به خاطر امنیت مخفی شده‌اند. تغییرات مقادیر واقعی را به‌روزرسانی خواهد کرد.', + authentication: 'احراز هویت', + configurations: 'تنظیمات', + clientSecretPlaceholder: 'رمز مشتری', + clientID: 'شناسه مشتری', + clientSecret: 'رمز مشتری', + useDynamicClientRegistration: 'استفاده از ثبت‌نام پویا برای مشتری', }, delete: 'حذف سرور MCP', deleteConfirmTitle: 'آیا مایل به حذف {mcp} هستید؟', diff --git a/web/i18n/fr-FR/tools.ts b/web/i18n/fr-FR/tools.ts index c91952d6c5..7c0e4db020 100644 --- a/web/i18n/fr-FR/tools.ts +++ b/web/i18n/fr-FR/tools.ts @@ -203,6 +203,12 @@ const translation = { headersTip: 'En-têtes HTTP supplémentaires à envoyer avec les requêtes au serveur MCP', addHeader: 'Ajouter un en-tête', maskedHeadersTip: 'Les valeurs d\'en-tête sont masquées pour des raisons de sécurité. Les modifications mettront à jour les valeurs réelles.', + clientSecretPlaceholder: 'Secret client', + configurations: 'Configurations', + clientID: 'ID client', + authentication: 'Authentification', + useDynamicClientRegistration: 'Utiliser l\'enregistrement dynamique des clients', + clientSecret: 'Secret client', }, delete: 'Supprimer le Serveur MCP', deleteConfirmTitle: 'Souhaitez-vous supprimer {mcp}?', diff --git a/web/i18n/hi-IN/tools.ts b/web/i18n/hi-IN/tools.ts index 7279d3bcbe..6e6dcf0ff6 100644 --- a/web/i18n/hi-IN/tools.ts +++ b/web/i18n/hi-IN/tools.ts @@ -208,6 +208,12 @@ const translation = { noHeaders: 'कोई कस्टम हेडर कॉन्फ़िगर नहीं किए गए हैं', maskedHeadersTip: 'सुरक्षा के लिए हेडर मानों को छिपाया गया है। परिवर्तन वास्तविक मानों को अपडेट करेगा।', headersTip: 'MCP सर्वर अनुरोधों के साथ भेजने के लिए अतिरिक्त HTTP हेडर्स', + clientSecretPlaceholder: 'क्लाइंट सीक्रेट', + clientSecret: 'क्लाइंट सीक्रेट', + clientID: 'क्लाइंट आईडी', + configurations: 'संरचनाएँ', + authentication: 'प्रमाणीकरण', + useDynamicClientRegistration: 'डायनामिक क्लाइंट पंजीकरण का उपयोग करें', }, delete: 'MCP सर्वर हटाएँ', deleteConfirmTitle: '{mcp} हटाना चाहते हैं?', diff --git a/web/i18n/id-ID/tools.ts b/web/i18n/id-ID/tools.ts index e3817e0111..707594446d 100644 --- a/web/i18n/id-ID/tools.ts +++ b/web/i18n/id-ID/tools.ts @@ -185,6 +185,12 @@ const translation = { headerValuePlaceholder: 'Bearer 123', noHeaders: 'Tidak ada header kustom yang dikonfigurasi', maskedHeadersTip: 'Nilai header disembunyikan untuk keamanan. Perubahan akan memperbarui nilai yang sebenarnya.', + clientSecretPlaceholder: 'Rahasia Klien', + authentication: 'Otentikasi', + useDynamicClientRegistration: 'Gunakan Pendaftaran Klien Dinamis', + configurations: 'Konfigurasi', + clientSecret: 'Rahasia Klien', + clientID: 'ID Klien', }, operation: { edit: 'Mengedit', diff --git a/web/i18n/it-IT/tools.ts b/web/i18n/it-IT/tools.ts index 5e54b8f837..0b8b122518 100644 --- a/web/i18n/it-IT/tools.ts +++ b/web/i18n/it-IT/tools.ts @@ -213,6 +213,12 @@ const translation = { headerValuePlaceholder: 'ad esempio, Token di accesso123', headersTip: 'Intestazioni HTTP aggiuntive da inviare con le richieste al server MCP', maskedHeadersTip: 'I valori dell\'intestazione sono mascherati per motivi di sicurezza. Le modifiche aggiorneranno i valori effettivi.', + clientID: 'ID cliente', + clientSecret: 'Segreto del Cliente', + useDynamicClientRegistration: 'Usa la Registrazione Dinamica del Client', + clientSecretPlaceholder: 'Segreto del Cliente', + authentication: 'Autenticazione', + configurations: 'Configurazioni', }, delete: 'Rimuovi Server MCP', deleteConfirmTitle: 'Vuoi rimuovere {mcp}?', diff --git a/web/i18n/ja-JP/tools.ts b/web/i18n/ja-JP/tools.ts index 2fed3768c0..812b5f3c92 100644 --- a/web/i18n/ja-JP/tools.ts +++ b/web/i18n/ja-JP/tools.ts @@ -203,6 +203,12 @@ const translation = { noHeaders: 'カスタムヘッダーは設定されていません', headersTip: 'MCPサーバーへのリクエストに送信する追加のHTTPヘッダー', maskedHeadersTip: 'ヘッダー値はセキュリティのためマスクされています。変更は実際の値を更新します。', + configurations: '設定', + authentication: '認証', + clientID: 'クライアントID', + useDynamicClientRegistration: '動的クライアント登録を使用する', + clientSecretPlaceholder: 'クライアントシークレット', + clientSecret: 'クライアントシークレット', }, delete: 'MCP サーバーを削除', deleteConfirmTitle: '{{mcp}} を削除しますか?', diff --git a/web/i18n/ko-KR/tools.ts b/web/i18n/ko-KR/tools.ts index d8e975e61c..ddcefe4bd4 100644 --- a/web/i18n/ko-KR/tools.ts +++ b/web/i18n/ko-KR/tools.ts @@ -203,6 +203,12 @@ const translation = { noHeaders: '사용자 정의 헤더가 구성되어 있지 않습니다.', headersTip: 'MCP 서버 요청과 함께 보낼 추가 HTTP 헤더', maskedHeadersTip: '헤더 값은 보안상 마스킹 처리되어 있습니다. 변경 사항은 실제 값에 업데이트됩니다.', + authentication: '인증', + configurations: '구성', + useDynamicClientRegistration: '동적 클라이언트 등록 사용', + clientSecret: '클라이언트 시크릿', + clientID: '클라이언트 ID', + clientSecretPlaceholder: '클라이언트 시크릿', }, delete: 'MCP 서버 제거', deleteConfirmTitle: '{mcp}를 제거하시겠습니까?', diff --git a/web/i18n/pl-PL/tools.ts b/web/i18n/pl-PL/tools.ts index dfa83d1231..16fe5037df 100644 --- a/web/i18n/pl-PL/tools.ts +++ b/web/i18n/pl-PL/tools.ts @@ -207,6 +207,12 @@ const translation = { headerValue: 'Wartość nagłówka', noHeaders: 'Brak skonfigurowanych nagłówków niestandardowych', maskedHeadersTip: 'Wartości nagłówków są ukryte dla bezpieczeństwa. Zmiany zaktualizują rzeczywiste wartości.', + configurations: 'Konfiguracje', + authentication: 'Uwierzytelnianie', + clientSecretPlaceholder: 'Tajny klucz klienta', + clientSecret: 'Tajny klucz klienta', + useDynamicClientRegistration: 'Użyj dynamicznej rejestracji klienta', + clientID: 'ID klienta', }, delete: 'Usuń serwer MCP', deleteConfirmTitle: 'Usunąć {mcp}?', diff --git a/web/i18n/pt-BR/tools.ts b/web/i18n/pt-BR/tools.ts index 401a81f615..66b040275d 100644 --- a/web/i18n/pt-BR/tools.ts +++ b/web/i18n/pt-BR/tools.ts @@ -203,6 +203,12 @@ const translation = { headerKey: 'Nome do Cabeçalho', noHeaders: 'Nenhum cabeçalho personalizado configurado', headerValuePlaceholder: 'ex: Token de portador 123', + useDynamicClientRegistration: 'Usar Registro Dinâmico de Cliente', + configurations: 'Configurações', + clientSecret: 'Segredo do Cliente', + authentication: 'Autenticação', + clientID: 'ID do Cliente', + clientSecretPlaceholder: 'Segredo do Cliente', }, delete: 'Remover Servidor MCP', deleteConfirmTitle: 'Você gostaria de remover {{mcp}}?', diff --git a/web/i18n/ro-RO/tools.ts b/web/i18n/ro-RO/tools.ts index b732128684..72c3954c97 100644 --- a/web/i18n/ro-RO/tools.ts +++ b/web/i18n/ro-RO/tools.ts @@ -203,6 +203,12 @@ const translation = { maskedHeadersTip: 'Valorile de antet sunt mascate pentru securitate. Modificările vor actualiza valorile reale.', headersTip: 'Header-uri HTTP suplimentare de trimis cu cererile către serverul MCP', noHeaders: 'Nu sunt configurate antete personalizate.', + authentication: 'Autentificare', + configurations: 'Configurații', + clientSecretPlaceholder: 'Secretul Clientului', + clientID: 'ID client', + useDynamicClientRegistration: 'Utilizați înregistrarea dinamică a clientului', + clientSecret: 'Secretul Clientului', }, delete: 'Eliminare Server MCP', deleteConfirmTitle: 'Ștergeți {mcp}?', diff --git a/web/i18n/ru-RU/tools.ts b/web/i18n/ru-RU/tools.ts index 36d48affc2..7ee263657d 100644 --- a/web/i18n/ru-RU/tools.ts +++ b/web/i18n/ru-RU/tools.ts @@ -203,6 +203,12 @@ const translation = { noHeaders: 'Нет настроенных пользовательских заголовков', maskedHeadersTip: 'Значения заголовков скрыты для безопасности. Изменения обновят фактические значения.', headersTip: 'Дополнительные HTTP заголовки для отправки с запросами к серверу MCP', + configurations: 'Конфигурации', + clientID: 'Идентификатор клиента', + clientSecretPlaceholder: 'Секрет клиента', + useDynamicClientRegistration: 'Использовать динамическую регистрацию клиентов', + clientSecret: 'Секрет клиента', + authentication: 'Аутентификация', }, delete: 'Удалить MCP сервер', deleteConfirmTitle: 'Вы действительно хотите удалить {mcp}?', diff --git a/web/i18n/sl-SI/tools.ts b/web/i18n/sl-SI/tools.ts index 8eb28c21bf..dccf8b9178 100644 --- a/web/i18n/sl-SI/tools.ts +++ b/web/i18n/sl-SI/tools.ts @@ -203,6 +203,12 @@ const translation = { headerValuePlaceholder: 'npr., Bearer žeton123', noHeaders: 'Nobena prilagojena glava ni konfigurirana', maskedHeadersTip: 'Vrednosti glave so zakrite zaradi varnosti. Spremembe bodo posodobile dejanske vrednosti.', + authentication: 'Avtentikacija', + configurations: 'Konfiguracije', + clientSecret: 'Skrivnost stranke', + useDynamicClientRegistration: 'Uporabi dinamično registracijo odjemalca', + clientID: 'ID stranke', + clientSecretPlaceholder: 'Skrivnost stranke', }, delete: 'Odstrani strežnik MCP', deleteConfirmTitle: 'Odstraniti {mcp}?', diff --git a/web/i18n/th-TH/tools.ts b/web/i18n/th-TH/tools.ts index 71175ff26c..848a3f51b6 100644 --- a/web/i18n/th-TH/tools.ts +++ b/web/i18n/th-TH/tools.ts @@ -203,6 +203,12 @@ const translation = { noHeaders: 'ไม่มีการกำหนดหัวข้อที่กำหนดเอง', headersTip: 'HTTP header เพิ่มเติมที่จะส่งไปกับคำขอ MCP server', maskedHeadersTip: 'ค่าหัวถูกปกปิดเพื่อความปลอดภัย การเปลี่ยนแปลงจะปรับปรุงค่าที่แท้จริง', + clientSecret: 'รหัสลับของลูกค้า', + configurations: 'การตั้งค่า', + authentication: 'การตรวจสอบตัวตน', + clientSecretPlaceholder: 'รหัสลับของลูกค้า', + useDynamicClientRegistration: 'ใช้การลงทะเบียนลูกค้าแบบไดนามิก', + clientID: 'รหัสลูกค้า', }, delete: 'ลบเซิร์ฟเวอร์ MCP', deleteConfirmTitle: 'คุณต้องการลบ {mcp} หรือไม่?', diff --git a/web/i18n/tr-TR/tools.ts b/web/i18n/tr-TR/tools.ts index d309b78689..ccc97fef10 100644 --- a/web/i18n/tr-TR/tools.ts +++ b/web/i18n/tr-TR/tools.ts @@ -203,6 +203,12 @@ const translation = { headersTip: 'MCP sunucu istekleri ile gönderilecek ek HTTP başlıkları', headerValuePlaceholder: 'örneğin, Taşıyıcı jeton123', maskedHeadersTip: 'Başlık değerleri güvenlik amacıyla gizlenmiştir. Değişiklikler gerçek değerleri güncelleyecektir.', + clientID: 'Müşteri Kimliği', + configurations: 'Yapılandırmalar', + clientSecretPlaceholder: 'İstemci Sırrı', + clientSecret: 'İstemci Sırrı', + authentication: 'Kimlik Doğrulama', + useDynamicClientRegistration: 'Dinamik İstemci Kaydını Kullan', }, delete: 'MCP Sunucusunu Kaldır', deleteConfirmTitle: '{mcp} kaldırılsın mı?', diff --git a/web/i18n/uk-UA/tools.ts b/web/i18n/uk-UA/tools.ts index 596153974f..40e35a1236 100644 --- a/web/i18n/uk-UA/tools.ts +++ b/web/i18n/uk-UA/tools.ts @@ -203,6 +203,12 @@ const translation = { headerKeyPlaceholder: 'наприклад, Авторизація', maskedHeadersTip: 'Значення заголовків маскуються для безпеки. Зміни оновлять фактичні значення.', headersTip: 'Додаткові HTTP заголовки для відправлення з запитами до сервера MCP', + clientSecret: 'Секрет клієнта', + clientSecretPlaceholder: 'Секрет клієнта', + clientID: 'Ідентифікатор клієнта', + authentication: 'Аутентифікація', + configurations: 'Конфігурації', + useDynamicClientRegistration: 'Використовувати динамічну реєстрацію клієнтів', }, delete: 'Видалити сервер MCP', deleteConfirmTitle: 'Видалити {mcp}?', diff --git a/web/i18n/vi-VN/tools.ts b/web/i18n/vi-VN/tools.ts index 7c0826890e..08041ce400 100644 --- a/web/i18n/vi-VN/tools.ts +++ b/web/i18n/vi-VN/tools.ts @@ -203,6 +203,12 @@ const translation = { headerValue: 'Giá trị tiêu đề', maskedHeadersTip: 'Các giá trị tiêu đề được mã hóa để đảm bảo an ninh. Các thay đổi sẽ cập nhật các giá trị thực tế.', headersTip: 'Các tiêu đề HTTP bổ sung để gửi cùng với các yêu cầu máy chủ MCP', + authentication: 'Xác thực', + clientSecret: 'Bí mật của khách hàng', + clientID: 'ID khách hàng', + configurations: 'Cấu hình', + useDynamicClientRegistration: 'Sử dụng Đăng ký Khách hàng Động', + clientSecretPlaceholder: 'Bí mật của khách hàng', }, delete: 'Xóa Máy chủ MCP', deleteConfirmTitle: 'Xóa {mcp}?', diff --git a/web/i18n/zh-Hant/tools.ts b/web/i18n/zh-Hant/tools.ts index 3c53b87c72..0e8e937419 100644 --- a/web/i18n/zh-Hant/tools.ts +++ b/web/i18n/zh-Hant/tools.ts @@ -203,6 +203,12 @@ const translation = { headersTip: '與 MCP 伺服器請求一同發送的附加 HTTP 標頭', maskedHeadersTip: '標頭值已被遮罩以保障安全。更改將更新實際值。', headers: '標題', + authentication: '身份驗證', + clientID: '客戶編號', + clientSecretPlaceholder: '客戶端密鑰', + configurations: '設定', + useDynamicClientRegistration: '使用動態客戶端註冊', + clientSecret: '客戶端密鑰', }, delete: '刪除 MCP 伺服器', deleteConfirmTitle: '您確定要刪除 {{mcp}} 嗎?', From dc1ae57dc669f4dbce1daa08b37f2e78068d5074 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Mon, 27 Oct 2025 18:39:52 +0900 Subject: [PATCH 007/394] example for 24421 doc (#27511) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../rag_pipeline/datasource_content_preview.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/api/controllers/console/datasets/rag_pipeline/datasource_content_preview.py b/api/controllers/console/datasets/rag_pipeline/datasource_content_preview.py index 856e4a1c70..d413def27f 100644 --- a/api/controllers/console/datasets/rag_pipeline/datasource_content_preview.py +++ b/api/controllers/console/datasets/rag_pipeline/datasource_content_preview.py @@ -4,7 +4,7 @@ from flask_restx import ( # type: ignore ) from werkzeug.exceptions import Forbidden -from controllers.console import console_ns +from controllers.console import api, console_ns from controllers.console.datasets.wraps import get_rag_pipeline from controllers.console.wraps import account_initialization_required, setup_required from libs.login import current_user, login_required @@ -12,9 +12,17 @@ from models import Account from models.dataset import Pipeline from services.rag_pipeline.rag_pipeline import RagPipelineService +parser = ( + reqparse.RequestParser() + .add_argument("inputs", type=dict, required=True, nullable=False, location="json") + .add_argument("datasource_type", type=str, required=True, location="json") + .add_argument("credential_id", type=str, required=False, location="json") +) + @console_ns.route("/rag/pipelines//workflows/published/datasource/nodes//preview") class DataSourceContentPreviewApi(Resource): + @api.expect(parser) @setup_required @login_required @account_initialization_required @@ -26,12 +34,6 @@ class DataSourceContentPreviewApi(Resource): if not isinstance(current_user, Account): raise Forbidden() - parser = ( - reqparse.RequestParser() - .add_argument("inputs", type=dict, required=True, nullable=False, location="json") - .add_argument("datasource_type", type=str, required=True, location="json") - .add_argument("credential_id", type=str, required=False, location="json") - ) args = parser.parse_args() inputs = args.get("inputs") From d9860b8907f5f2593facdd2b1487911edfb9f840 Mon Sep 17 00:00:00 2001 From: QuantumGhost Date: Mon, 27 Oct 2025 21:15:44 +0800 Subject: [PATCH 008/394] fix(api): Disable SSE events truncation for service api (#27484) Disable SSE events truncation for service api invocations to ensure backward compatibility. Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../common/workflow_response_converter.py | 14 +- api/services/variable_truncator.py | 48 +- ...orkflow_response_converter_process_data.py | 324 ------- ..._workflow_response_converter_truncation.py | 810 ++++++++++++++++++ .../services/test_variable_truncator.py | 31 + 5 files changed, 899 insertions(+), 328 deletions(-) delete mode 100644 api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_process_data.py create mode 100644 api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_truncation.py diff --git a/api/core/app/apps/common/workflow_response_converter.py b/api/core/app/apps/common/workflow_response_converter.py index 2c9ce5b56d..eebaaaff80 100644 --- a/api/core/app/apps/common/workflow_response_converter.py +++ b/api/core/app/apps/common/workflow_response_converter.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from datetime import datetime from typing import Any, NewType, Union -from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, WorkflowAppGenerateEntity +from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom, WorkflowAppGenerateEntity from core.app.entities.queue_entities import ( QueueAgentLogEvent, QueueIterationCompletedEvent, @@ -51,7 +51,7 @@ from core.workflow.workflow_entry import WorkflowEntry from core.workflow.workflow_type_encoder import WorkflowRuntimeTypeConverter from libs.datetime_utils import naive_utc_now from models import Account, EndUser -from services.variable_truncator import VariableTruncator +from services.variable_truncator import BaseTruncator, DummyVariableTruncator, VariableTruncator NodeExecutionId = NewType("NodeExecutionId", str) @@ -70,6 +70,8 @@ class _NodeSnapshot: class WorkflowResponseConverter: + _truncator: BaseTruncator + def __init__( self, *, @@ -81,7 +83,13 @@ class WorkflowResponseConverter: self._user = user self._system_variables = system_variables self._workflow_inputs = self._prepare_workflow_inputs() - self._truncator = VariableTruncator.default() + + # Disable truncation for SERVICE_API calls to keep backward compatibility. + if application_generate_entity.invoke_from == InvokeFrom.SERVICE_API: + self._truncator = DummyVariableTruncator() + else: + self._truncator = VariableTruncator.default() + self._node_snapshots: dict[NodeExecutionId, _NodeSnapshot] = {} self._workflow_execution_id: str | None = None self._workflow_started_at: datetime | None = None diff --git a/api/services/variable_truncator.py b/api/services/variable_truncator.py index 6f8adb7536..6eb8d0031d 100644 --- a/api/services/variable_truncator.py +++ b/api/services/variable_truncator.py @@ -1,4 +1,5 @@ import dataclasses +from abc import ABC, abstractmethod from collections.abc import Mapping from typing import Any, Generic, TypeAlias, TypeVar, overload @@ -66,7 +67,17 @@ class TruncationResult: truncated: bool -class VariableTruncator: +class BaseTruncator(ABC): + @abstractmethod + def truncate(self, segment: Segment) -> TruncationResult: + pass + + @abstractmethod + def truncate_variable_mapping(self, v: Mapping[str, Any]) -> tuple[Mapping[str, Any], bool]: + pass + + +class VariableTruncator(BaseTruncator): """ Handles variable truncation with structure-preserving strategies. @@ -418,3 +429,38 @@ class VariableTruncator: return _PartResult(val, self.calculate_json_size(val), False) else: raise AssertionError("this statement should be unreachable.") + + +class DummyVariableTruncator(BaseTruncator): + """ + A no-op variable truncator that doesn't truncate any data. + + This is used for Service API calls where truncation should be disabled + to maintain backward compatibility and provide complete data. + """ + + def truncate_variable_mapping(self, v: Mapping[str, Any]) -> tuple[Mapping[str, Any], bool]: + """ + Return original mapping without truncation. + + Args: + v: The variable mapping to process + + Returns: + Tuple of (original_mapping, False) where False indicates no truncation occurred + """ + return v, False + + def truncate(self, segment: Segment) -> TruncationResult: + """ + Return original segment without truncation. + + Args: + segment: The segment to process + + Returns: + The original segment unchanged + """ + # For Service API, we want to preserve the original segment + # without any truncation, so just return it as-is + return TruncationResult(result=segment, truncated=False) diff --git a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_process_data.py b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_process_data.py deleted file mode 100644 index abe09fb8a4..0000000000 --- a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_process_data.py +++ /dev/null @@ -1,324 +0,0 @@ -""" -Unit tests for WorkflowResponseConverter focusing on process_data truncation functionality. -""" - -import uuid -from collections.abc import Mapping -from typing import Any -from unittest.mock import Mock - -import pytest - -from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter -from core.app.entities.app_invoke_entities import WorkflowAppGenerateEntity -from core.app.entities.queue_entities import ( - QueueNodeRetryEvent, - QueueNodeStartedEvent, - QueueNodeSucceededEvent, -) -from core.workflow.enums import NodeType -from core.workflow.system_variable import SystemVariable -from libs.datetime_utils import naive_utc_now -from models import Account - - -class TestWorkflowResponseConverterCenarios: - """Test process_data truncation in WorkflowResponseConverter.""" - - def create_mock_generate_entity(self) -> WorkflowAppGenerateEntity: - """Create a mock WorkflowAppGenerateEntity.""" - mock_entity = Mock(spec=WorkflowAppGenerateEntity) - mock_app_config = Mock() - mock_app_config.tenant_id = "test-tenant-id" - mock_entity.app_config = mock_app_config - mock_entity.inputs = {} - return mock_entity - - def create_workflow_response_converter(self) -> WorkflowResponseConverter: - """Create a WorkflowResponseConverter for testing.""" - - mock_entity = self.create_mock_generate_entity() - mock_user = Mock(spec=Account) - mock_user.id = "test-user-id" - mock_user.name = "Test User" - mock_user.email = "test@example.com" - - system_variables = SystemVariable(workflow_id="wf-id", workflow_execution_id="initial-run-id") - return WorkflowResponseConverter( - application_generate_entity=mock_entity, - user=mock_user, - system_variables=system_variables, - ) - - def create_node_started_event(self, *, node_execution_id: str | None = None) -> QueueNodeStartedEvent: - """Create a QueueNodeStartedEvent for testing.""" - return QueueNodeStartedEvent( - node_execution_id=node_execution_id or str(uuid.uuid4()), - node_id="test-node-id", - node_title="Test Node", - node_type=NodeType.CODE, - start_at=naive_utc_now(), - predecessor_node_id=None, - in_iteration_id=None, - in_loop_id=None, - provider_type="built-in", - provider_id="code", - ) - - def create_node_succeeded_event( - self, - *, - node_execution_id: str, - process_data: Mapping[str, Any] | None = None, - ) -> QueueNodeSucceededEvent: - """Create a QueueNodeSucceededEvent for testing.""" - return QueueNodeSucceededEvent( - node_id="test-node-id", - node_type=NodeType.CODE, - node_execution_id=node_execution_id, - start_at=naive_utc_now(), - in_iteration_id=None, - in_loop_id=None, - inputs={}, - process_data=process_data or {}, - outputs={}, - execution_metadata={}, - ) - - def create_node_retry_event( - self, - *, - node_execution_id: str, - process_data: Mapping[str, Any] | None = None, - ) -> QueueNodeRetryEvent: - """Create a QueueNodeRetryEvent for testing.""" - return QueueNodeRetryEvent( - inputs={"data": "inputs"}, - outputs={"data": "outputs"}, - process_data=process_data or {}, - error="oops", - retry_index=1, - node_id="test-node-id", - node_type=NodeType.CODE, - node_title="test code", - provider_type="built-in", - provider_id="code", - node_execution_id=node_execution_id, - start_at=naive_utc_now(), - in_iteration_id=None, - in_loop_id=None, - ) - - def test_workflow_node_finish_response_uses_truncated_process_data(self): - """Test that node finish response uses get_response_process_data().""" - converter = self.create_workflow_response_converter() - - original_data = {"large_field": "x" * 10000, "metadata": "info"} - truncated_data = {"large_field": "[TRUNCATED]", "metadata": "info"} - - converter.workflow_start_to_stream_response(task_id="bootstrap", workflow_run_id="run-id", workflow_id="wf-id") - start_event = self.create_node_started_event() - converter.workflow_node_start_to_stream_response( - event=start_event, - task_id="test-task-id", - ) - - event = self.create_node_succeeded_event( - node_execution_id=start_event.node_execution_id, - process_data=original_data, - ) - - def fake_truncate(mapping): - if mapping == dict(original_data): - return truncated_data, True - return mapping, False - - converter._truncator.truncate_variable_mapping = fake_truncate # type: ignore[assignment] - - response = converter.workflow_node_finish_to_stream_response( - event=event, - task_id="test-task-id", - ) - - # Response should use truncated data, not original - assert response is not None - assert response.data.process_data == truncated_data - assert response.data.process_data != original_data - assert response.data.process_data_truncated is True - - def test_workflow_node_finish_response_without_truncation(self): - """Test node finish response when no truncation is applied.""" - converter = self.create_workflow_response_converter() - - original_data = {"small": "data"} - - converter.workflow_start_to_stream_response(task_id="bootstrap", workflow_run_id="run-id", workflow_id="wf-id") - start_event = self.create_node_started_event() - converter.workflow_node_start_to_stream_response( - event=start_event, - task_id="test-task-id", - ) - - event = self.create_node_succeeded_event( - node_execution_id=start_event.node_execution_id, - process_data=original_data, - ) - - def fake_truncate(mapping): - return mapping, False - - converter._truncator.truncate_variable_mapping = fake_truncate # type: ignore[assignment] - - response = converter.workflow_node_finish_to_stream_response( - event=event, - task_id="test-task-id", - ) - - # Response should use original data - assert response is not None - assert response.data.process_data == original_data - assert response.data.process_data_truncated is False - - def test_workflow_node_finish_response_with_none_process_data(self): - """Test node finish response when process_data is None.""" - converter = self.create_workflow_response_converter() - - converter.workflow_start_to_stream_response(task_id="bootstrap", workflow_run_id="run-id", workflow_id="wf-id") - start_event = self.create_node_started_event() - converter.workflow_node_start_to_stream_response( - event=start_event, - task_id="test-task-id", - ) - - event = self.create_node_succeeded_event( - node_execution_id=start_event.node_execution_id, - process_data=None, - ) - - def fake_truncate(mapping): - return mapping, False - - converter._truncator.truncate_variable_mapping = fake_truncate # type: ignore[assignment] - - response = converter.workflow_node_finish_to_stream_response( - event=event, - task_id="test-task-id", - ) - - # Response should normalize missing process_data to an empty mapping - assert response is not None - assert response.data.process_data == {} - assert response.data.process_data_truncated is False - - def test_workflow_node_retry_response_uses_truncated_process_data(self): - """Test that node retry response uses get_response_process_data().""" - converter = self.create_workflow_response_converter() - - original_data = {"large_field": "x" * 10000, "metadata": "info"} - truncated_data = {"large_field": "[TRUNCATED]", "metadata": "info"} - - converter.workflow_start_to_stream_response(task_id="bootstrap", workflow_run_id="run-id", workflow_id="wf-id") - start_event = self.create_node_started_event() - converter.workflow_node_start_to_stream_response( - event=start_event, - task_id="test-task-id", - ) - - event = self.create_node_retry_event( - node_execution_id=start_event.node_execution_id, - process_data=original_data, - ) - - def fake_truncate(mapping): - if mapping == dict(original_data): - return truncated_data, True - return mapping, False - - converter._truncator.truncate_variable_mapping = fake_truncate # type: ignore[assignment] - - response = converter.workflow_node_retry_to_stream_response( - event=event, - task_id="test-task-id", - ) - - # Response should use truncated data, not original - assert response is not None - assert response.data.process_data == truncated_data - assert response.data.process_data != original_data - assert response.data.process_data_truncated is True - - def test_workflow_node_retry_response_without_truncation(self): - """Test node retry response when no truncation is applied.""" - converter = self.create_workflow_response_converter() - - original_data = {"small": "data"} - - converter.workflow_start_to_stream_response(task_id="bootstrap", workflow_run_id="run-id", workflow_id="wf-id") - start_event = self.create_node_started_event() - converter.workflow_node_start_to_stream_response( - event=start_event, - task_id="test-task-id", - ) - - event = self.create_node_retry_event( - node_execution_id=start_event.node_execution_id, - process_data=original_data, - ) - - def fake_truncate(mapping): - return mapping, False - - converter._truncator.truncate_variable_mapping = fake_truncate # type: ignore[assignment] - - response = converter.workflow_node_retry_to_stream_response( - event=event, - task_id="test-task-id", - ) - - assert response is not None - assert response.data.process_data == original_data - assert response.data.process_data_truncated is False - - def test_iteration_and_loop_nodes_return_none(self): - """Test that iteration and loop nodes return None (no streaming events).""" - converter = self.create_workflow_response_converter() - - iteration_event = QueueNodeSucceededEvent( - node_id="iteration-node", - node_type=NodeType.ITERATION, - node_execution_id=str(uuid.uuid4()), - start_at=naive_utc_now(), - in_iteration_id=None, - in_loop_id=None, - inputs={}, - process_data={}, - outputs={}, - execution_metadata={}, - ) - - response = converter.workflow_node_finish_to_stream_response( - event=iteration_event, - task_id="test-task-id", - ) - assert response is None - - loop_event = iteration_event.model_copy(update={"node_type": NodeType.LOOP}) - response = converter.workflow_node_finish_to_stream_response( - event=loop_event, - task_id="test-task-id", - ) - assert response is None - - def test_finish_without_start_raises(self): - """Ensure finish responses require a prior workflow start.""" - converter = self.create_workflow_response_converter() - event = self.create_node_succeeded_event( - node_execution_id=str(uuid.uuid4()), - process_data={}, - ) - - with pytest.raises(ValueError): - converter.workflow_node_finish_to_stream_response( - event=event, - task_id="test-task-id", - ) diff --git a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_truncation.py b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_truncation.py new file mode 100644 index 0000000000..964d62be1f --- /dev/null +++ b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_truncation.py @@ -0,0 +1,810 @@ +""" +Unit tests for WorkflowResponseConverter focusing on process_data truncation functionality. +""" + +import uuid +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any +from unittest.mock import Mock + +import pytest + +from core.app.app_config.entities import WorkflowUIBasedAppConfig +from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter +from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity +from core.app.entities.queue_entities import ( + QueueEvent, + QueueIterationStartEvent, + QueueLoopStartEvent, + QueueNodeExceptionEvent, + QueueNodeFailedEvent, + QueueNodeRetryEvent, + QueueNodeStartedEvent, + QueueNodeSucceededEvent, +) +from core.workflow.enums import NodeType +from core.workflow.system_variable import SystemVariable +from libs.datetime_utils import naive_utc_now +from models import Account +from models.model import AppMode + + +class TestWorkflowResponseConverter: + """Test truncation in WorkflowResponseConverter.""" + + def create_mock_generate_entity(self) -> WorkflowAppGenerateEntity: + """Create a mock WorkflowAppGenerateEntity.""" + mock_entity = Mock(spec=WorkflowAppGenerateEntity) + mock_app_config = Mock() + mock_app_config.tenant_id = "test-tenant-id" + mock_entity.invoke_from = InvokeFrom.WEB_APP + mock_entity.app_config = mock_app_config + mock_entity.inputs = {} + return mock_entity + + def create_workflow_response_converter(self) -> WorkflowResponseConverter: + """Create a WorkflowResponseConverter for testing.""" + + mock_entity = self.create_mock_generate_entity() + mock_user = Mock(spec=Account) + mock_user.id = "test-user-id" + mock_user.name = "Test User" + mock_user.email = "test@example.com" + + system_variables = SystemVariable(workflow_id="wf-id", workflow_execution_id="initial-run-id") + return WorkflowResponseConverter( + application_generate_entity=mock_entity, + user=mock_user, + system_variables=system_variables, + ) + + def create_node_started_event(self, *, node_execution_id: str | None = None) -> QueueNodeStartedEvent: + """Create a QueueNodeStartedEvent for testing.""" + return QueueNodeStartedEvent( + node_execution_id=node_execution_id or str(uuid.uuid4()), + node_id="test-node-id", + node_title="Test Node", + node_type=NodeType.CODE, + start_at=naive_utc_now(), + in_iteration_id=None, + in_loop_id=None, + provider_type="built-in", + provider_id="code", + ) + + def create_node_succeeded_event( + self, + *, + node_execution_id: str, + process_data: Mapping[str, Any] | None = None, + ) -> QueueNodeSucceededEvent: + """Create a QueueNodeSucceededEvent for testing.""" + return QueueNodeSucceededEvent( + node_id="test-node-id", + node_type=NodeType.CODE, + node_execution_id=node_execution_id, + start_at=naive_utc_now(), + in_iteration_id=None, + in_loop_id=None, + inputs={}, + process_data=process_data or {}, + outputs={}, + execution_metadata={}, + ) + + def create_node_retry_event( + self, + *, + node_execution_id: str, + process_data: Mapping[str, Any] | None = None, + ) -> QueueNodeRetryEvent: + """Create a QueueNodeRetryEvent for testing.""" + return QueueNodeRetryEvent( + inputs={"data": "inputs"}, + outputs={"data": "outputs"}, + process_data=process_data or {}, + error="oops", + retry_index=1, + node_id="test-node-id", + node_type=NodeType.CODE, + node_title="test code", + provider_type="built-in", + provider_id="code", + node_execution_id=node_execution_id, + start_at=naive_utc_now(), + in_iteration_id=None, + in_loop_id=None, + ) + + def test_workflow_node_finish_response_uses_truncated_process_data(self): + """Test that node finish response uses get_response_process_data().""" + converter = self.create_workflow_response_converter() + + original_data = {"large_field": "x" * 10000, "metadata": "info"} + truncated_data = {"large_field": "[TRUNCATED]", "metadata": "info"} + + converter.workflow_start_to_stream_response(task_id="bootstrap", workflow_run_id="run-id", workflow_id="wf-id") + start_event = self.create_node_started_event() + converter.workflow_node_start_to_stream_response( + event=start_event, + task_id="test-task-id", + ) + + event = self.create_node_succeeded_event( + node_execution_id=start_event.node_execution_id, + process_data=original_data, + ) + + def fake_truncate(mapping): + if mapping == dict(original_data): + return truncated_data, True + return mapping, False + + converter._truncator.truncate_variable_mapping = fake_truncate # type: ignore[assignment] + + response = converter.workflow_node_finish_to_stream_response( + event=event, + task_id="test-task-id", + ) + + # Response should use truncated data, not original + assert response is not None + assert response.data.process_data == truncated_data + assert response.data.process_data != original_data + assert response.data.process_data_truncated is True + + def test_workflow_node_finish_response_without_truncation(self): + """Test node finish response when no truncation is applied.""" + converter = self.create_workflow_response_converter() + + original_data = {"small": "data"} + + converter.workflow_start_to_stream_response(task_id="bootstrap", workflow_run_id="run-id", workflow_id="wf-id") + start_event = self.create_node_started_event() + converter.workflow_node_start_to_stream_response( + event=start_event, + task_id="test-task-id", + ) + + event = self.create_node_succeeded_event( + node_execution_id=start_event.node_execution_id, + process_data=original_data, + ) + + def fake_truncate(mapping): + return mapping, False + + converter._truncator.truncate_variable_mapping = fake_truncate # type: ignore[assignment] + + response = converter.workflow_node_finish_to_stream_response( + event=event, + task_id="test-task-id", + ) + + # Response should use original data + assert response is not None + assert response.data.process_data == original_data + assert response.data.process_data_truncated is False + + def test_workflow_node_finish_response_with_none_process_data(self): + """Test node finish response when process_data is None.""" + converter = self.create_workflow_response_converter() + + converter.workflow_start_to_stream_response(task_id="bootstrap", workflow_run_id="run-id", workflow_id="wf-id") + start_event = self.create_node_started_event() + converter.workflow_node_start_to_stream_response( + event=start_event, + task_id="test-task-id", + ) + + event = self.create_node_succeeded_event( + node_execution_id=start_event.node_execution_id, + process_data=None, + ) + + def fake_truncate(mapping): + return mapping, False + + converter._truncator.truncate_variable_mapping = fake_truncate # type: ignore[assignment] + + response = converter.workflow_node_finish_to_stream_response( + event=event, + task_id="test-task-id", + ) + + # Response should normalize missing process_data to an empty mapping + assert response is not None + assert response.data.process_data == {} + assert response.data.process_data_truncated is False + + def test_workflow_node_retry_response_uses_truncated_process_data(self): + """Test that node retry response uses get_response_process_data().""" + converter = self.create_workflow_response_converter() + + original_data = {"large_field": "x" * 10000, "metadata": "info"} + truncated_data = {"large_field": "[TRUNCATED]", "metadata": "info"} + + converter.workflow_start_to_stream_response(task_id="bootstrap", workflow_run_id="run-id", workflow_id="wf-id") + start_event = self.create_node_started_event() + converter.workflow_node_start_to_stream_response( + event=start_event, + task_id="test-task-id", + ) + + event = self.create_node_retry_event( + node_execution_id=start_event.node_execution_id, + process_data=original_data, + ) + + def fake_truncate(mapping): + if mapping == dict(original_data): + return truncated_data, True + return mapping, False + + converter._truncator.truncate_variable_mapping = fake_truncate # type: ignore[assignment] + + response = converter.workflow_node_retry_to_stream_response( + event=event, + task_id="test-task-id", + ) + + # Response should use truncated data, not original + assert response is not None + assert response.data.process_data == truncated_data + assert response.data.process_data != original_data + assert response.data.process_data_truncated is True + + def test_workflow_node_retry_response_without_truncation(self): + """Test node retry response when no truncation is applied.""" + converter = self.create_workflow_response_converter() + + original_data = {"small": "data"} + + converter.workflow_start_to_stream_response(task_id="bootstrap", workflow_run_id="run-id", workflow_id="wf-id") + start_event = self.create_node_started_event() + converter.workflow_node_start_to_stream_response( + event=start_event, + task_id="test-task-id", + ) + + event = self.create_node_retry_event( + node_execution_id=start_event.node_execution_id, + process_data=original_data, + ) + + def fake_truncate(mapping): + return mapping, False + + converter._truncator.truncate_variable_mapping = fake_truncate # type: ignore[assignment] + + response = converter.workflow_node_retry_to_stream_response( + event=event, + task_id="test-task-id", + ) + + assert response is not None + assert response.data.process_data == original_data + assert response.data.process_data_truncated is False + + def test_iteration_and_loop_nodes_return_none(self): + """Test that iteration and loop nodes return None (no streaming events).""" + converter = self.create_workflow_response_converter() + + iteration_event = QueueNodeSucceededEvent( + node_id="iteration-node", + node_type=NodeType.ITERATION, + node_execution_id=str(uuid.uuid4()), + start_at=naive_utc_now(), + in_iteration_id=None, + in_loop_id=None, + inputs={}, + process_data={}, + outputs={}, + execution_metadata={}, + ) + + response = converter.workflow_node_finish_to_stream_response( + event=iteration_event, + task_id="test-task-id", + ) + assert response is None + + loop_event = iteration_event.model_copy(update={"node_type": NodeType.LOOP}) + response = converter.workflow_node_finish_to_stream_response( + event=loop_event, + task_id="test-task-id", + ) + assert response is None + + def test_finish_without_start_raises(self): + """Ensure finish responses require a prior workflow start.""" + converter = self.create_workflow_response_converter() + event = self.create_node_succeeded_event( + node_execution_id=str(uuid.uuid4()), + process_data={}, + ) + + with pytest.raises(ValueError): + converter.workflow_node_finish_to_stream_response( + event=event, + task_id="test-task-id", + ) + + +@dataclass +class TestCase: + """Test case data for table-driven tests.""" + + name: str + invoke_from: InvokeFrom + expected_truncation_enabled: bool + description: str + + +class TestWorkflowResponseConverterServiceApiTruncation: + """Test class for Service API truncation functionality in WorkflowResponseConverter.""" + + def create_test_app_generate_entity(self, invoke_from: InvokeFrom) -> WorkflowAppGenerateEntity: + """Create a test WorkflowAppGenerateEntity with specified invoke_from.""" + # Create a minimal WorkflowUIBasedAppConfig for testing + app_config = WorkflowUIBasedAppConfig( + tenant_id="test_tenant", + app_id="test_app", + app_mode=AppMode.WORKFLOW, + workflow_id="test_workflow_id", + ) + + entity = WorkflowAppGenerateEntity( + task_id="test_task_id", + app_id="test_app_id", + app_config=app_config, + tenant_id="test_tenant", + app_mode="workflow", + invoke_from=invoke_from, + inputs={"test_input": "test_value"}, + user_id="test_user_id", + stream=True, + files=[], + workflow_execution_id="test_workflow_exec_id", + ) + return entity + + def create_test_user(self) -> Account: + """Create a test user account.""" + account = Account( + name="Test User", + email="test@example.com", + ) + # Manually set the ID for testing purposes + account.id = "test_user_id" + return account + + def create_test_system_variables(self) -> SystemVariable: + """Create test system variables.""" + return SystemVariable() + + def create_test_converter(self, invoke_from: InvokeFrom) -> WorkflowResponseConverter: + """Create WorkflowResponseConverter with specified invoke_from.""" + entity = self.create_test_app_generate_entity(invoke_from) + user = self.create_test_user() + system_variables = self.create_test_system_variables() + + converter = WorkflowResponseConverter( + application_generate_entity=entity, + user=user, + system_variables=system_variables, + ) + # ensure `workflow_run_id` is set. + converter.workflow_start_to_stream_response( + task_id="test-task-id", + workflow_run_id="test-workflow-run-id", + workflow_id="test-workflow-id", + ) + return converter + + @pytest.mark.parametrize( + "test_case", + [ + TestCase( + name="service_api_truncation_disabled", + invoke_from=InvokeFrom.SERVICE_API, + expected_truncation_enabled=False, + description="Service API calls should have truncation disabled", + ), + TestCase( + name="web_app_truncation_enabled", + invoke_from=InvokeFrom.WEB_APP, + expected_truncation_enabled=True, + description="Web app calls should have truncation enabled", + ), + TestCase( + name="debugger_truncation_enabled", + invoke_from=InvokeFrom.DEBUGGER, + expected_truncation_enabled=True, + description="Debugger calls should have truncation enabled", + ), + TestCase( + name="explore_truncation_enabled", + invoke_from=InvokeFrom.EXPLORE, + expected_truncation_enabled=True, + description="Explore calls should have truncation enabled", + ), + TestCase( + name="published_truncation_enabled", + invoke_from=InvokeFrom.PUBLISHED, + expected_truncation_enabled=True, + description="Published app calls should have truncation enabled", + ), + ], + ids=lambda x: x.name, + ) + def test_truncator_selection_based_on_invoke_from(self, test_case: TestCase): + """Test that the correct truncator is selected based on invoke_from.""" + converter = self.create_test_converter(test_case.invoke_from) + + # Test truncation behavior instead of checking private attribute + + # Create a test event with large data + large_value = {"key": ["x"] * 2000} # Large data that would be truncated + + event = QueueNodeSucceededEvent( + node_execution_id="test_node_exec_id", + node_id="test_node", + node_type=NodeType.LLM, + start_at=naive_utc_now(), + inputs=large_value, + process_data=large_value, + outputs=large_value, + error=None, + execution_metadata=None, + in_iteration_id=None, + in_loop_id=None, + ) + + response = converter.workflow_node_finish_to_stream_response( + event=event, + task_id="test_task", + ) + + # Verify response is not None + assert response is not None + + # Verify truncation behavior matches expectations + if test_case.expected_truncation_enabled: + # Truncation should be enabled for non-service-api calls + assert response.data.inputs_truncated + assert response.data.process_data_truncated + assert response.data.outputs_truncated + else: + # SERVICE_API should not truncate + assert not response.data.inputs_truncated + assert not response.data.process_data_truncated + assert not response.data.outputs_truncated + + def test_service_api_truncator_no_op_mapping(self): + """Test that Service API truncator doesn't truncate variable mappings.""" + converter = self.create_test_converter(InvokeFrom.SERVICE_API) + + # Create a test event with large data + large_value: dict[str, Any] = { + "large_string": "x" * 10000, # Large string + "large_list": list(range(2000)), # Large array + "nested_data": {"deep_nested": {"very_deep": {"value": "x" * 5000}}}, + } + + event = QueueNodeSucceededEvent( + node_execution_id="test_node_exec_id", + node_id="test_node", + node_type=NodeType.LLM, + start_at=naive_utc_now(), + inputs=large_value, + process_data=large_value, + outputs=large_value, + error=None, + execution_metadata=None, + in_iteration_id=None, + in_loop_id=None, + ) + + response = converter.workflow_node_finish_to_stream_response( + event=event, + task_id="test_task", + ) + + # Verify response is not None + data = response.data + assert data.inputs == large_value + assert data.process_data == large_value + assert data.outputs == large_value + # Service API should not truncate + assert data.inputs_truncated is False + assert data.process_data_truncated is False + assert data.outputs_truncated is False + + def test_web_app_truncator_works_normally(self): + """Test that web app truncator still works normally.""" + converter = self.create_test_converter(InvokeFrom.WEB_APP) + + # Create a test event with large data + large_value = { + "large_string": "x" * 10000, # Large string + "large_list": list(range(2000)), # Large array + } + + event = QueueNodeSucceededEvent( + node_execution_id="test_node_exec_id", + node_id="test_node", + node_type=NodeType.LLM, + start_at=naive_utc_now(), + inputs=large_value, + process_data=large_value, + outputs=large_value, + error=None, + execution_metadata=None, + in_iteration_id=None, + in_loop_id=None, + ) + + response = converter.workflow_node_finish_to_stream_response( + event=event, + task_id="test_task", + ) + + # Verify response is not None + assert response is not None + + # Web app should truncate + data = response.data + assert data.inputs != large_value + assert data.process_data != large_value + assert data.outputs != large_value + # The exact behavior depends on VariableTruncator implementation + # Just verify that truncation flags are present + assert data.inputs_truncated is True + assert data.process_data_truncated is True + assert data.outputs_truncated is True + + @staticmethod + def _create_event_by_type( + type_: QueueEvent, inputs: Mapping[str, Any], process_data: Mapping[str, Any], outputs: Mapping[str, Any] + ) -> QueueNodeSucceededEvent | QueueNodeFailedEvent | QueueNodeExceptionEvent: + if type_ == QueueEvent.NODE_SUCCEEDED: + return QueueNodeSucceededEvent( + node_execution_id="test_node_exec_id", + node_id="test_node", + node_type=NodeType.LLM, + start_at=naive_utc_now(), + inputs=inputs, + process_data=process_data, + outputs=outputs, + error=None, + execution_metadata=None, + in_iteration_id=None, + in_loop_id=None, + ) + elif type_ == QueueEvent.NODE_FAILED: + return QueueNodeFailedEvent( + node_execution_id="test_node_exec_id", + node_id="test_node", + node_type=NodeType.LLM, + start_at=naive_utc_now(), + inputs=inputs, + process_data=process_data, + outputs=outputs, + error="oops", + execution_metadata=None, + in_iteration_id=None, + in_loop_id=None, + ) + elif type_ == QueueEvent.NODE_EXCEPTION: + return QueueNodeExceptionEvent( + node_execution_id="test_node_exec_id", + node_id="test_node", + node_type=NodeType.LLM, + start_at=naive_utc_now(), + inputs=inputs, + process_data=process_data, + outputs=outputs, + error="oops", + execution_metadata=None, + in_iteration_id=None, + in_loop_id=None, + ) + else: + raise Exception("unknown type.") + + @pytest.mark.parametrize( + "event_type", + [ + QueueEvent.NODE_SUCCEEDED, + QueueEvent.NODE_FAILED, + QueueEvent.NODE_EXCEPTION, + ], + ) + def test_service_api_node_finish_event_no_truncation(self, event_type: QueueEvent): + """Test that Service API doesn't truncate node finish events.""" + converter = self.create_test_converter(InvokeFrom.SERVICE_API) + # Create test event with large data + large_inputs = {"input1": "x" * 5000, "input2": list(range(2000))} + large_process_data = {"process1": "y" * 5000, "process2": {"nested": ["z"] * 2000}} + large_outputs = {"output1": "result" * 1000, "output2": list(range(2000))} + + event = TestWorkflowResponseConverterServiceApiTruncation._create_event_by_type( + event_type, large_inputs, large_process_data, large_outputs + ) + + response = converter.workflow_node_finish_to_stream_response( + event=event, + task_id="test_task", + ) + + # Verify response is not None + assert response is not None + + # Verify response contains full data (not truncated) + assert response.data.inputs == large_inputs + assert response.data.process_data == large_process_data + assert response.data.outputs == large_outputs + assert not response.data.inputs_truncated + assert not response.data.process_data_truncated + assert not response.data.outputs_truncated + + def test_service_api_node_retry_event_no_truncation(self): + """Test that Service API doesn't truncate node retry events.""" + converter = self.create_test_converter(InvokeFrom.SERVICE_API) + + # Create test event with large data + large_inputs = {"retry_input": "x" * 5000} + large_process_data = {"retry_process": "y" * 5000} + large_outputs = {"retry_output": "z" * 5000} + + # First, we need to store a snapshot by simulating a start event + start_event = QueueNodeStartedEvent( + node_execution_id="test_node_exec_id", + node_id="test_node", + node_type=NodeType.LLM, + node_title="Test Node", + node_run_index=1, + start_at=naive_utc_now(), + in_iteration_id=None, + in_loop_id=None, + agent_strategy=None, + provider_type="plugin", + provider_id="test/test_plugin", + ) + converter.workflow_node_start_to_stream_response(event=start_event, task_id="test_task") + + # Now create retry event + event = QueueNodeRetryEvent( + node_execution_id="test_node_exec_id", + node_id="test_node", + node_type=NodeType.LLM, + node_title="Test Node", + node_run_index=1, + start_at=naive_utc_now(), + inputs=large_inputs, + process_data=large_process_data, + outputs=large_outputs, + error="Retry error", + execution_metadata=None, + in_iteration_id=None, + in_loop_id=None, + retry_index=1, + provider_type="plugin", + provider_id="test/test_plugin", + ) + + response = converter.workflow_node_retry_to_stream_response( + event=event, + task_id="test_task", + ) + + # Verify response is not None + assert response is not None + + # Verify response contains full data (not truncated) + assert response.data.inputs == large_inputs + assert response.data.process_data == large_process_data + assert response.data.outputs == large_outputs + assert not response.data.inputs_truncated + assert not response.data.process_data_truncated + assert not response.data.outputs_truncated + + def test_service_api_iteration_events_no_truncation(self): + """Test that Service API doesn't truncate iteration events.""" + converter = self.create_test_converter(InvokeFrom.SERVICE_API) + + # Test iteration start event + large_value = {"iteration_input": ["x"] * 2000} + + start_event = QueueIterationStartEvent( + node_execution_id="test_iter_exec_id", + node_id="test_iteration", + node_type=NodeType.ITERATION, + node_title="Test Iteration", + node_run_index=0, + start_at=naive_utc_now(), + inputs=large_value, + metadata={}, + ) + + response = converter.workflow_iteration_start_to_stream_response( + task_id="test_task", + workflow_execution_id="test_workflow_exec_id", + event=start_event, + ) + + assert response is not None + assert response.data.inputs == large_value + assert not response.data.inputs_truncated + + def test_service_api_loop_events_no_truncation(self): + """Test that Service API doesn't truncate loop events.""" + converter = self.create_test_converter(InvokeFrom.SERVICE_API) + + # Test loop start event + large_inputs = {"loop_input": ["x"] * 2000} + + start_event = QueueLoopStartEvent( + node_execution_id="test_loop_exec_id", + node_id="test_loop", + node_type=NodeType.LOOP, + node_title="Test Loop", + start_at=naive_utc_now(), + inputs=large_inputs, + metadata={}, + node_run_index=0, + ) + + response = converter.workflow_loop_start_to_stream_response( + task_id="test_task", + workflow_execution_id="test_workflow_exec_id", + event=start_event, + ) + + assert response is not None + assert response.data.inputs == large_inputs + assert not response.data.inputs_truncated + + def test_web_app_node_finish_event_truncation_works(self): + """Test that web app still truncates node finish events.""" + converter = self.create_test_converter(InvokeFrom.WEB_APP) + + # Create test event with large data that should be truncated + large_inputs = {"input1": ["x"] * 2000} + large_process_data = {"process1": ["y"] * 2000} + large_outputs = {"output1": ["z"] * 2000} + + event = QueueNodeSucceededEvent( + node_execution_id="test_node_exec_id", + node_id="test_node", + node_type=NodeType.LLM, + start_at=naive_utc_now(), + inputs=large_inputs, + process_data=large_process_data, + outputs=large_outputs, + error=None, + execution_metadata=None, + in_iteration_id=None, + in_loop_id=None, + ) + + response = converter.workflow_node_finish_to_stream_response( + event=event, + task_id="test_task", + ) + + # Verify response is not None + assert response is not None + + # Verify response contains truncated data + # The exact behavior depends on VariableTruncator implementation + # Just verify truncation flags are set correctly (may or may not be truncated depending on size) + # At minimum, the truncation mechanism should work + assert isinstance(response.data.inputs, dict) + assert response.data.inputs_truncated + assert isinstance(response.data.process_data, dict) + assert response.data.process_data_truncated + assert isinstance(response.data.outputs, dict) + assert response.data.outputs_truncated diff --git a/api/tests/unit_tests/services/test_variable_truncator.py b/api/tests/unit_tests/services/test_variable_truncator.py index 6761f939e3..cf6fb25c1c 100644 --- a/api/tests/unit_tests/services/test_variable_truncator.py +++ b/api/tests/unit_tests/services/test_variable_truncator.py @@ -21,6 +21,7 @@ from core.file.enums import FileTransferMethod, FileType from core.file.models import File from core.variables.segments import ( ArrayFileSegment, + ArrayNumberSegment, ArraySegment, FileSegment, FloatSegment, @@ -30,6 +31,7 @@ from core.variables.segments import ( StringSegment, ) from services.variable_truncator import ( + DummyVariableTruncator, MaxDepthExceededError, TruncationResult, UnknownTypeError, @@ -596,3 +598,32 @@ class TestIntegrationScenarios: truncated_mapping, truncated = truncator.truncate_variable_mapping(mapping) assert truncated is False assert truncated_mapping == mapping + + +def test_dummy_variable_truncator_methods(): + """Test DummyVariableTruncator methods work correctly.""" + truncator = DummyVariableTruncator() + + # Test truncate_variable_mapping + test_data: dict[str, Any] = { + "key1": "value1", + "key2": ["item1", "item2"], + "large_array": list(range(2000)), + } + result, is_truncated = truncator.truncate_variable_mapping(test_data) + + assert result == test_data + assert not is_truncated + + # Test truncate method + segment = StringSegment(value="test string") + result = truncator.truncate(segment) + assert isinstance(result, TruncationResult) + assert result.result == segment + assert result.truncated is False + + segment = ArrayNumberSegment(value=list(range(2000))) + result = truncator.truncate(segment) + assert isinstance(result, TruncationResult) + assert result.result == segment + assert result.truncated is False From 29afc0657db4f8eef2c07c83fbe0bbc170acc074 Mon Sep 17 00:00:00 2001 From: crazywoola <100913391+crazywoola@users.noreply.github.com> Date: Tue, 28 Oct 2025 09:19:54 +0800 Subject: [PATCH 009/394] Fix/27468 in dify 192 the iframe embed cannot pass the user id in system variable (#27524) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- web/__tests__/embedded-user-id-auth.test.tsx | 132 +++++++++++++++ web/__tests__/embedded-user-id-store.test.tsx | 155 ++++++++++++++++++ web/app/(shareLayout)/components/splash.tsx | 9 +- .../webapp-signin/check-code/page.tsx | 7 +- .../components/mail-and-password-auth.tsx | 7 +- .../base/chat/embedded-chatbot/hooks.tsx | 14 +- web/context/web-app-context.tsx | 36 ++++ 7 files changed, 351 insertions(+), 9 deletions(-) create mode 100644 web/__tests__/embedded-user-id-auth.test.tsx create mode 100644 web/__tests__/embedded-user-id-store.test.tsx diff --git a/web/__tests__/embedded-user-id-auth.test.tsx b/web/__tests__/embedded-user-id-auth.test.tsx new file mode 100644 index 0000000000..5c3c3c943f --- /dev/null +++ b/web/__tests__/embedded-user-id-auth.test.tsx @@ -0,0 +1,132 @@ +import React from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' + +import MailAndPasswordAuth from '@/app/(shareLayout)/webapp-signin/components/mail-and-password-auth' +import CheckCode from '@/app/(shareLayout)/webapp-signin/check-code/page' + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +const replaceMock = jest.fn() +const backMock = jest.fn() + +jest.mock('next/navigation', () => ({ + usePathname: jest.fn(() => '/chatbot/test-app'), + useRouter: jest.fn(() => ({ + replace: replaceMock, + back: backMock, + })), + useSearchParams: jest.fn(), +})) + +const mockStoreState = { + embeddedUserId: 'embedded-user-99', + shareCode: 'test-app', +} + +const useWebAppStoreMock = jest.fn((selector?: (state: typeof mockStoreState) => any) => { + return selector ? selector(mockStoreState) : mockStoreState +}) + +jest.mock('@/context/web-app-context', () => ({ + useWebAppStore: (selector?: (state: typeof mockStoreState) => any) => useWebAppStoreMock(selector), +})) + +const webAppLoginMock = jest.fn() +const webAppEmailLoginWithCodeMock = jest.fn() +const sendWebAppEMailLoginCodeMock = jest.fn() + +jest.mock('@/service/common', () => ({ + webAppLogin: (...args: any[]) => webAppLoginMock(...args), + webAppEmailLoginWithCode: (...args: any[]) => webAppEmailLoginWithCodeMock(...args), + sendWebAppEMailLoginCode: (...args: any[]) => sendWebAppEMailLoginCodeMock(...args), +})) + +const fetchAccessTokenMock = jest.fn() + +jest.mock('@/service/share', () => ({ + fetchAccessToken: (...args: any[]) => fetchAccessTokenMock(...args), +})) + +const setWebAppAccessTokenMock = jest.fn() +const setWebAppPassportMock = jest.fn() + +jest.mock('@/service/webapp-auth', () => ({ + setWebAppAccessToken: (...args: any[]) => setWebAppAccessTokenMock(...args), + setWebAppPassport: (...args: any[]) => setWebAppPassportMock(...args), + webAppLogout: jest.fn(), +})) + +jest.mock('@/app/components/signin/countdown', () => () =>
) + +jest.mock('@remixicon/react', () => ({ + RiMailSendFill: () =>
, + RiArrowLeftLine: () =>
, +})) + +const { useSearchParams } = jest.requireMock('next/navigation') as { + useSearchParams: jest.Mock +} + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('embedded user id propagation in authentication flows', () => { + it('passes embedded user id when logging in with email and password', async () => { + const params = new URLSearchParams() + params.set('redirect_url', encodeURIComponent('/chatbot/test-app')) + useSearchParams.mockReturnValue(params) + + webAppLoginMock.mockResolvedValue({ result: 'success', data: { access_token: 'login-token' } }) + fetchAccessTokenMock.mockResolvedValue({ access_token: 'passport-token' }) + + render() + + fireEvent.change(screen.getByLabelText('login.email'), { target: { value: 'user@example.com' } }) + fireEvent.change(screen.getByLabelText(/login\.password/), { target: { value: 'strong-password' } }) + fireEvent.click(screen.getByRole('button', { name: 'login.signBtn' })) + + await waitFor(() => { + expect(fetchAccessTokenMock).toHaveBeenCalledWith({ + appCode: 'test-app', + userId: 'embedded-user-99', + }) + }) + expect(setWebAppAccessTokenMock).toHaveBeenCalledWith('login-token') + expect(setWebAppPassportMock).toHaveBeenCalledWith('test-app', 'passport-token') + expect(replaceMock).toHaveBeenCalledWith('/chatbot/test-app') + }) + + it('passes embedded user id when verifying email code', async () => { + const params = new URLSearchParams() + params.set('redirect_url', encodeURIComponent('/chatbot/test-app')) + params.set('email', encodeURIComponent('user@example.com')) + params.set('token', encodeURIComponent('token-abc')) + useSearchParams.mockReturnValue(params) + + webAppEmailLoginWithCodeMock.mockResolvedValue({ result: 'success', data: { access_token: 'code-token' } }) + fetchAccessTokenMock.mockResolvedValue({ access_token: 'passport-token' }) + + render() + + fireEvent.change( + screen.getByPlaceholderText('login.checkCode.verificationCodePlaceholder'), + { target: { value: '123456' } }, + ) + fireEvent.click(screen.getByRole('button', { name: 'login.checkCode.verify' })) + + await waitFor(() => { + expect(fetchAccessTokenMock).toHaveBeenCalledWith({ + appCode: 'test-app', + userId: 'embedded-user-99', + }) + }) + expect(setWebAppAccessTokenMock).toHaveBeenCalledWith('code-token') + expect(setWebAppPassportMock).toHaveBeenCalledWith('test-app', 'passport-token') + expect(replaceMock).toHaveBeenCalledWith('/chatbot/test-app') + }) +}) diff --git a/web/__tests__/embedded-user-id-store.test.tsx b/web/__tests__/embedded-user-id-store.test.tsx new file mode 100644 index 0000000000..24a815222e --- /dev/null +++ b/web/__tests__/embedded-user-id-store.test.tsx @@ -0,0 +1,155 @@ +import React from 'react' +import { render, screen, waitFor } from '@testing-library/react' + +import WebAppStoreProvider, { useWebAppStore } from '@/context/web-app-context' + +jest.mock('next/navigation', () => ({ + usePathname: jest.fn(() => '/chatbot/sample-app'), + useSearchParams: jest.fn(() => { + const params = new URLSearchParams() + return params + }), +})) + +jest.mock('@/service/use-share', () => { + const { AccessMode } = jest.requireActual('@/models/access-control') + return { + useGetWebAppAccessModeByCode: jest.fn(() => ({ + isLoading: false, + data: { accessMode: AccessMode.PUBLIC }, + })), + } +}) + +jest.mock('@/app/components/base/chat/utils', () => ({ + getProcessedSystemVariablesFromUrlParams: jest.fn(), +})) + +const { getProcessedSystemVariablesFromUrlParams: mockGetProcessedSystemVariablesFromUrlParams } + = jest.requireMock('@/app/components/base/chat/utils') as { + getProcessedSystemVariablesFromUrlParams: jest.Mock + } + +jest.mock('@/context/global-public-context', () => { + const mockGlobalStoreState = { + isGlobalPending: false, + setIsGlobalPending: jest.fn(), + systemFeatures: {}, + setSystemFeatures: jest.fn(), + } + const useGlobalPublicStore = Object.assign( + (selector?: (state: typeof mockGlobalStoreState) => any) => + selector ? selector(mockGlobalStoreState) : mockGlobalStoreState, + { + setState: (updater: any) => { + if (typeof updater === 'function') + Object.assign(mockGlobalStoreState, updater(mockGlobalStoreState) ?? {}) + + else + Object.assign(mockGlobalStoreState, updater) + }, + __mockState: mockGlobalStoreState, + }, + ) + return { + useGlobalPublicStore, + } +}) + +const { + useGlobalPublicStore: useGlobalPublicStoreMock, +} = jest.requireMock('@/context/global-public-context') as { + useGlobalPublicStore: ((selector?: (state: any) => any) => any) & { + setState: (updater: any) => void + __mockState: { + isGlobalPending: boolean + setIsGlobalPending: jest.Mock + systemFeatures: Record + setSystemFeatures: jest.Mock + } + } +} +const mockGlobalStoreState = useGlobalPublicStoreMock.__mockState + +const TestConsumer = () => { + const embeddedUserId = useWebAppStore(state => state.embeddedUserId) + const embeddedConversationId = useWebAppStore(state => state.embeddedConversationId) + return ( + <> +
{embeddedUserId ?? 'null'}
+
{embeddedConversationId ?? 'null'}
+ + ) +} + +const initialWebAppStore = (() => { + const snapshot = useWebAppStore.getState() + return { + shareCode: null as string | null, + appInfo: null, + appParams: null, + webAppAccessMode: snapshot.webAppAccessMode, + appMeta: null, + userCanAccessApp: false, + embeddedUserId: null, + embeddedConversationId: null, + updateShareCode: snapshot.updateShareCode, + updateAppInfo: snapshot.updateAppInfo, + updateAppParams: snapshot.updateAppParams, + updateWebAppAccessMode: snapshot.updateWebAppAccessMode, + updateWebAppMeta: snapshot.updateWebAppMeta, + updateUserCanAccessApp: snapshot.updateUserCanAccessApp, + updateEmbeddedUserId: snapshot.updateEmbeddedUserId, + updateEmbeddedConversationId: snapshot.updateEmbeddedConversationId, + } +})() + +beforeEach(() => { + mockGlobalStoreState.isGlobalPending = false + mockGetProcessedSystemVariablesFromUrlParams.mockReset() + useWebAppStore.setState(initialWebAppStore, true) +}) + +describe('WebAppStoreProvider embedded user id handling', () => { + it('hydrates embedded user and conversation ids from system variables', async () => { + mockGetProcessedSystemVariablesFromUrlParams.mockResolvedValue({ + user_id: 'iframe-user-123', + conversation_id: 'conversation-456', + }) + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByTestId('embedded-user-id')).toHaveTextContent('iframe-user-123') + expect(screen.getByTestId('embedded-conversation-id')).toHaveTextContent('conversation-456') + }) + expect(useWebAppStore.getState().embeddedUserId).toBe('iframe-user-123') + expect(useWebAppStore.getState().embeddedConversationId).toBe('conversation-456') + }) + + it('clears embedded user id when system variable is absent', async () => { + useWebAppStore.setState(state => ({ + ...state, + embeddedUserId: 'previous-user', + embeddedConversationId: 'existing-conversation', + })) + mockGetProcessedSystemVariablesFromUrlParams.mockResolvedValue({}) + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByTestId('embedded-user-id')).toHaveTextContent('null') + expect(screen.getByTestId('embedded-conversation-id')).toHaveTextContent('null') + }) + expect(useWebAppStore.getState().embeddedUserId).toBeNull() + expect(useWebAppStore.getState().embeddedConversationId).toBeNull() + }) +}) diff --git a/web/app/(shareLayout)/components/splash.tsx b/web/app/(shareLayout)/components/splash.tsx index 16d291d4b4..c30ad68950 100644 --- a/web/app/(shareLayout)/components/splash.tsx +++ b/web/app/(shareLayout)/components/splash.tsx @@ -15,6 +15,7 @@ const Splash: FC = ({ children }) => { const { t } = useTranslation() const shareCode = useWebAppStore(s => s.shareCode) const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode) + const embeddedUserId = useWebAppStore(s => s.embeddedUserId) const searchParams = useSearchParams() const router = useRouter() const redirectUrl = searchParams.get('redirect_url') @@ -69,7 +70,10 @@ const Splash: FC = ({ children }) => { } else if (userLoggedIn && !appLoggedIn) { try { - const { access_token } = await fetchAccessToken({ appCode: shareCode! }) + const { access_token } = await fetchAccessToken({ + appCode: shareCode!, + userId: embeddedUserId || undefined, + }) setWebAppPassport(shareCode!, access_token) redirectOrFinish() } @@ -85,7 +89,8 @@ const Splash: FC = ({ children }) => { router, message, webAppAccessMode, - tokenFromUrl]) + tokenFromUrl, + embeddedUserId]) if (message) { return
diff --git a/web/app/(shareLayout)/webapp-signin/check-code/page.tsx b/web/app/(shareLayout)/webapp-signin/check-code/page.tsx index 4a1326fedf..69131cdabe 100644 --- a/web/app/(shareLayout)/webapp-signin/check-code/page.tsx +++ b/web/app/(shareLayout)/webapp-signin/check-code/page.tsx @@ -12,6 +12,7 @@ import { sendWebAppEMailLoginCode, webAppEmailLoginWithCode } from '@/service/co import I18NContext from '@/context/i18n' import { setWebAppAccessToken, setWebAppPassport } from '@/service/webapp-auth' import { fetchAccessToken } from '@/service/share' +import { useWebAppStore } from '@/context/web-app-context' export default function CheckCode() { const { t } = useTranslation() @@ -23,6 +24,7 @@ export default function CheckCode() { const [loading, setIsLoading] = useState(false) const { locale } = useContext(I18NContext) const redirectUrl = searchParams.get('redirect_url') + const embeddedUserId = useWebAppStore(s => s.embeddedUserId) const getAppCodeFromRedirectUrl = useCallback(() => { if (!redirectUrl) @@ -63,7 +65,10 @@ export default function CheckCode() { const ret = await webAppEmailLoginWithCode({ email, code, token }) if (ret.result === 'success') { setWebAppAccessToken(ret.data.access_token) - const { access_token } = await fetchAccessToken({ appCode: appCode! }) + const { access_token } = await fetchAccessToken({ + appCode: appCode!, + userId: embeddedUserId || undefined, + }) setWebAppPassport(appCode!, access_token) router.replace(decodeURIComponent(redirectUrl)) } diff --git a/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx index ce220b103e..0136445ac9 100644 --- a/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx @@ -10,6 +10,7 @@ import { emailRegex } from '@/config' import { webAppLogin } from '@/service/common' import Input from '@/app/components/base/input' import I18NContext from '@/context/i18n' +import { useWebAppStore } from '@/context/web-app-context' import { noop } from 'lodash-es' import { fetchAccessToken } from '@/service/share' import { setWebAppAccessToken, setWebAppPassport } from '@/service/webapp-auth' @@ -30,6 +31,7 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut const [isLoading, setIsLoading] = useState(false) const redirectUrl = searchParams.get('redirect_url') + const embeddedUserId = useWebAppStore(s => s.embeddedUserId) const getAppCodeFromRedirectUrl = useCallback(() => { if (!redirectUrl) @@ -82,7 +84,10 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut if (res.result === 'success') { setWebAppAccessToken(res.data.access_token) - const { access_token } = await fetchAccessToken({ appCode: appCode! }) + const { access_token } = await fetchAccessToken({ + appCode: appCode!, + userId: embeddedUserId || undefined, + }) setWebAppPassport(appCode!, access_token) router.replace(decodeURIComponent(redirectUrl)) } diff --git a/web/app/components/base/chat/embedded-chatbot/hooks.tsx b/web/app/components/base/chat/embedded-chatbot/hooks.tsx index cfb221522c..9a9abfbd09 100644 --- a/web/app/components/base/chat/embedded-chatbot/hooks.tsx +++ b/web/app/components/base/chat/embedded-chatbot/hooks.tsx @@ -66,16 +66,20 @@ export const useEmbeddedChatbot = () => { const appInfo = useWebAppStore(s => s.appInfo) const appMeta = useWebAppStore(s => s.appMeta) const appParams = useWebAppStore(s => s.appParams) + const embeddedConversationId = useWebAppStore(s => s.embeddedConversationId) + const embeddedUserId = useWebAppStore(s => s.embeddedUserId) const appId = useMemo(() => appInfo?.app_id, [appInfo]) const [userId, setUserId] = useState() const [conversationId, setConversationId] = useState() + useEffect(() => { - getProcessedSystemVariablesFromUrlParams().then(({ user_id, conversation_id }) => { - setUserId(user_id) - setConversationId(conversation_id) - }) - }, []) + setUserId(embeddedUserId || undefined) + }, [embeddedUserId]) + + useEffect(() => { + setConversationId(embeddedConversationId || undefined) + }, [embeddedConversationId]) useEffect(() => { const setLanguageFromParams = async () => { diff --git a/web/context/web-app-context.tsx b/web/context/web-app-context.tsx index bcbd39b5fc..1b189cd452 100644 --- a/web/context/web-app-context.tsx +++ b/web/context/web-app-context.tsx @@ -9,6 +9,7 @@ import { usePathname, useSearchParams } from 'next/navigation' import type { FC, PropsWithChildren } from 'react' import { useEffect } from 'react' import { create } from 'zustand' +import { getProcessedSystemVariablesFromUrlParams } from '@/app/components/base/chat/utils' import { useGlobalPublicStore } from './global-public-context' type WebAppStore = { @@ -24,6 +25,10 @@ type WebAppStore = { updateWebAppMeta: (appMeta: AppMeta | null) => void userCanAccessApp: boolean updateUserCanAccessApp: (canAccess: boolean) => void + embeddedUserId: string | null + updateEmbeddedUserId: (userId: string | null) => void + embeddedConversationId: string | null + updateEmbeddedConversationId: (conversationId: string | null) => void } export const useWebAppStore = create(set => ({ @@ -39,6 +44,11 @@ export const useWebAppStore = create(set => ({ updateWebAppMeta: (appMeta: AppMeta | null) => set(() => ({ appMeta })), userCanAccessApp: false, updateUserCanAccessApp: (canAccess: boolean) => set(() => ({ userCanAccessApp: canAccess })), + embeddedUserId: null, + updateEmbeddedUserId: (userId: string | null) => set(() => ({ embeddedUserId: userId })), + embeddedConversationId: null, + updateEmbeddedConversationId: (conversationId: string | null) => + set(() => ({ embeddedConversationId: conversationId })), })) const getShareCodeFromRedirectUrl = (redirectUrl: string | null): string | null => { @@ -58,9 +68,12 @@ const WebAppStoreProvider: FC = ({ children }) => { const isGlobalPending = useGlobalPublicStore(s => s.isGlobalPending) const updateWebAppAccessMode = useWebAppStore(state => state.updateWebAppAccessMode) const updateShareCode = useWebAppStore(state => state.updateShareCode) + const updateEmbeddedUserId = useWebAppStore(state => state.updateEmbeddedUserId) + const updateEmbeddedConversationId = useWebAppStore(state => state.updateEmbeddedConversationId) const pathname = usePathname() const searchParams = useSearchParams() const redirectUrlParam = searchParams.get('redirect_url') + const searchParamsString = searchParams.toString() // Compute shareCode directly const shareCode = getShareCodeFromRedirectUrl(redirectUrlParam) || getShareCodeFromPathname(pathname) @@ -68,6 +81,29 @@ const WebAppStoreProvider: FC = ({ children }) => { updateShareCode(shareCode) }, [shareCode, updateShareCode]) + useEffect(() => { + let cancelled = false + const syncEmbeddedUserId = async () => { + try { + const { user_id, conversation_id } = await getProcessedSystemVariablesFromUrlParams() + if (!cancelled) { + updateEmbeddedUserId(user_id || null) + updateEmbeddedConversationId(conversation_id || null) + } + } + catch { + if (!cancelled) { + updateEmbeddedUserId(null) + updateEmbeddedConversationId(null) + } + } + } + syncEmbeddedUserId() + return () => { + cancelled = true + } + }, [searchParamsString, updateEmbeddedUserId, updateEmbeddedConversationId]) + const { isLoading, data: accessModeResult } = useGetWebAppAccessModeByCode(shareCode) useEffect(() => { From a7c855cab8d8f81a705da719100cc806780584f1 Mon Sep 17 00:00:00 2001 From: yangzheli <43645580+yangzheli@users.noreply.github.com> Date: Tue, 28 Oct 2025 09:26:12 +0800 Subject: [PATCH 010/394] fix(workflow): resolve note node copy/duplicate errors (#27528) Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> --- .../workflow/hooks/use-nodes-interactions.ts | 4 +++- web/app/components/workflow/note-node/index.tsx | 11 +++-------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index fa61cdeb8c..4de53c431c 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -1445,6 +1445,7 @@ export const useNodesInteractions = () => { // If no nodeId is provided, fall back to the current behavior const bundledNodes = nodes.filter((node) => { if (!node.data._isBundled) return false + if (node.type === CUSTOM_NOTE_NODE) return true const { metaData } = nodesMetaDataMap![node.data.type as BlockEnum] if (metaData.isSingleton) return false return !node.data.isInIteration && !node.data.isInLoop @@ -1457,6 +1458,7 @@ export const useNodesInteractions = () => { const selectedNode = nodes.find((node) => { if (!node.data.selected) return false + if (node.type === CUSTOM_NOTE_NODE) return true const { metaData } = nodesMetaDataMap![node.data.type as BlockEnum] return !metaData.isSingleton }) @@ -1495,7 +1497,7 @@ export const useNodesInteractions = () => { = generateNewNode({ type: nodeToPaste.type, data: { - ...nodesMetaDataMap![nodeType].defaultValue, + ...(nodeToPaste.type !== CUSTOM_NOTE_NODE && nodesMetaDataMap![nodeType].defaultValue), ...nodeToPaste.data, selected: false, _isBundled: false, diff --git a/web/app/components/workflow/note-node/index.tsx b/web/app/components/workflow/note-node/index.tsx index 7f2cde42d6..5a0b2677c1 100644 --- a/web/app/components/workflow/note-node/index.tsx +++ b/web/app/components/workflow/note-node/index.tsx @@ -1,6 +1,5 @@ import { memo, - useCallback, useRef, } from 'react' import { useTranslation } from 'react-i18next' @@ -51,10 +50,6 @@ const NoteNode = ({ } = useNodesInteractions() const { handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate() - const handleDeleteNode = useCallback(() => { - handleNodeDelete(id) - }, [id, handleNodeDelete]) - useClickAway(() => { handleNodeDataUpdateWithSyncDraft({ id, data: { selected: false } }) }, ref) @@ -102,9 +97,9 @@ const NoteNode = ({ handleNodesCopy(id)} + onDuplicate={() => handleNodesDuplicate(id)} + onDelete={() => handleNodeDelete(id)} showAuthor={data.showAuthor} onShowAuthorChange={handleShowAuthorChange} /> From f01907aac2f04b3d3a5ba23984dee77403263e4f Mon Sep 17 00:00:00 2001 From: quicksand Date: Tue, 28 Oct 2025 09:46:33 +0800 Subject: [PATCH 011/394] fix: knowledge sync from website error (#27534) --- api/services/dataset_service.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/services/dataset_service.py b/api/services/dataset_service.py index c97d419545..1e040abe3e 100644 --- a/api/services/dataset_service.py +++ b/api/services/dataset_service.py @@ -1417,7 +1417,7 @@ class DocumentService: assert isinstance(current_user, Account) assert current_user.current_tenant_id is not None assert knowledge_config.data_source - assert knowledge_config.data_source.info_list.file_info_list + assert knowledge_config.data_source.info_list features = FeatureService.get_features(current_user.current_tenant_id) @@ -1426,6 +1426,8 @@ class DocumentService: count = 0 if knowledge_config.data_source: if knowledge_config.data_source.info_list.data_source_type == "upload_file": + if not knowledge_config.data_source.info_list.file_info_list: + raise ValueError("File source info is required") upload_file_list = knowledge_config.data_source.info_list.file_info_list.file_ids count = len(upload_file_list) elif knowledge_config.data_source.info_list.data_source_type == "notion_import": @@ -1531,6 +1533,8 @@ class DocumentService: document_ids = [] duplicate_document_ids = [] if knowledge_config.data_source.info_list.data_source_type == "upload_file": + if not knowledge_config.data_source.info_list.file_info_list: + raise ValueError("File source info is required") upload_file_list = knowledge_config.data_source.info_list.file_info_list.file_ids for file_id in upload_file_list: file = ( From 341b3ae7c9209e35c2da008be5647f658c501a2e Mon Sep 17 00:00:00 2001 From: yalei <269870927@qq.com> Date: Tue, 28 Oct 2025 09:59:16 +0800 Subject: [PATCH 012/394] Sync log detail drawer with conversation_id query parameter, so that we can share a specific conversation (#27518) Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- web/app/components/app/log/list.tsx | 120 ++++++++++++++++++++++++---- 1 file changed, 103 insertions(+), 17 deletions(-) diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index 8b3370b678..d295784083 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -14,6 +14,7 @@ import timezone from 'dayjs/plugin/timezone' import { createContext, useContext } from 'use-context-selector' import { useShallow } from 'zustand/react/shallow' import { useTranslation } from 'react-i18next' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' import type { ChatItemInTree } from '../../base/chat/types' import Indicator from '../../header/indicator' import VarPanel from './var-panel' @@ -42,6 +43,10 @@ import cn from '@/utils/classnames' import { noop } from 'lodash-es' import PromptLogModal from '../../base/prompt-log-modal' +type AppStoreState = ReturnType +type ConversationListItem = ChatConversationGeneralDetail | CompletionConversationGeneralDetail +type ConversationSelection = ConversationListItem | { id: string; isPlaceholder?: true } + dayjs.extend(utc) dayjs.extend(timezone) @@ -201,7 +206,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { const { formatTime } = useTimestamp() const { onClose, appDetail } = useContext(DrawerContext) const { notify } = useContext(ToastContext) - const { currentLogItem, setCurrentLogItem, showMessageLogModal, setShowMessageLogModal, showPromptLogModal, setShowPromptLogModal, currentLogModalActiveTab } = useAppStore(useShallow(state => ({ + const { currentLogItem, setCurrentLogItem, showMessageLogModal, setShowMessageLogModal, showPromptLogModal, setShowPromptLogModal, currentLogModalActiveTab } = useAppStore(useShallow((state: AppStoreState) => ({ currentLogItem: state.currentLogItem, setCurrentLogItem: state.setCurrentLogItem, showMessageLogModal: state.showMessageLogModal, @@ -893,20 +898,113 @@ const ChatConversationDetailComp: FC<{ appId?: string; conversationId?: string } const ConversationList: FC = ({ logs, appDetail, onRefresh }) => { const { t } = useTranslation() const { formatTime } = useTimestamp() + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + const conversationIdInUrl = searchParams.get('conversation_id') ?? undefined const media = useBreakpoints() const isMobile = media === MediaType.mobile const [showDrawer, setShowDrawer] = useState(false) // Whether to display the chat details drawer - const [currentConversation, setCurrentConversation] = useState() // Currently selected conversation + const [currentConversation, setCurrentConversation] = useState() // Currently selected conversation + const closingConversationIdRef = useRef(null) + const pendingConversationIdRef = useRef(null) + const pendingConversationCacheRef = useRef(undefined) const isChatMode = appDetail.mode !== 'completion' // Whether the app is a chat app const isChatflow = appDetail.mode === 'advanced-chat' // Whether the app is a chatflow app - const { setShowPromptLogModal, setShowAgentLogModal, setShowMessageLogModal } = useAppStore(useShallow(state => ({ + const { setShowPromptLogModal, setShowAgentLogModal, setShowMessageLogModal } = useAppStore(useShallow((state: AppStoreState) => ({ setShowPromptLogModal: state.setShowPromptLogModal, setShowAgentLogModal: state.setShowAgentLogModal, setShowMessageLogModal: state.setShowMessageLogModal, }))) + const activeConversationId = conversationIdInUrl ?? pendingConversationIdRef.current ?? currentConversation?.id + + const buildUrlWithConversation = useCallback((conversationId?: string) => { + const params = new URLSearchParams(searchParams.toString()) + if (conversationId) + params.set('conversation_id', conversationId) + else + params.delete('conversation_id') + + const queryString = params.toString() + return queryString ? `${pathname}?${queryString}` : pathname + }, [pathname, searchParams]) + + const handleRowClick = useCallback((log: ConversationListItem) => { + if (conversationIdInUrl === log.id) { + if (!showDrawer) + setShowDrawer(true) + + if (!currentConversation || currentConversation.id !== log.id) + setCurrentConversation(log) + return + } + + pendingConversationIdRef.current = log.id + pendingConversationCacheRef.current = log + if (!showDrawer) + setShowDrawer(true) + + if (currentConversation?.id !== log.id) + setCurrentConversation(undefined) + + router.push(buildUrlWithConversation(log.id), { scroll: false }) + }, [buildUrlWithConversation, conversationIdInUrl, currentConversation, router, showDrawer]) + + const currentConversationId = currentConversation?.id + + useEffect(() => { + if (!conversationIdInUrl) { + if (pendingConversationIdRef.current) + return + + if (showDrawer || currentConversationId) { + setShowDrawer(false) + setCurrentConversation(undefined) + } + closingConversationIdRef.current = null + pendingConversationCacheRef.current = undefined + return + } + + if (closingConversationIdRef.current === conversationIdInUrl) + return + + if (pendingConversationIdRef.current === conversationIdInUrl) + pendingConversationIdRef.current = null + + const matchedConversation = logs?.data?.find((item: ConversationListItem) => item.id === conversationIdInUrl) + const nextConversation: ConversationSelection = matchedConversation + ?? pendingConversationCacheRef.current + ?? { id: conversationIdInUrl, isPlaceholder: true } + + if (!showDrawer) + setShowDrawer(true) + + if (!currentConversation || currentConversation.id !== conversationIdInUrl || (!('created_at' in currentConversation) && matchedConversation)) + setCurrentConversation(nextConversation) + + if (pendingConversationCacheRef.current?.id === conversationIdInUrl || matchedConversation) + pendingConversationCacheRef.current = undefined + }, [conversationIdInUrl, currentConversation, isChatMode, logs?.data, showDrawer]) + + const onCloseDrawer = useCallback(() => { + onRefresh() + setShowDrawer(false) + setCurrentConversation(undefined) + setShowPromptLogModal(false) + setShowAgentLogModal(false) + setShowMessageLogModal(false) + pendingConversationIdRef.current = null + pendingConversationCacheRef.current = undefined + closingConversationIdRef.current = conversationIdInUrl ?? null + + if (conversationIdInUrl) + router.replace(buildUrlWithConversation(), { scroll: false }) + }, [buildUrlWithConversation, conversationIdInUrl, onRefresh, router, setShowAgentLogModal, setShowMessageLogModal, setShowPromptLogModal]) + // Annotated data needs to be highlighted const renderTdValue = (value: string | number | null, isEmptyStyle: boolean, isHighlight = false, annotation?: LogAnnotation) => { return ( @@ -925,15 +1023,6 @@ const ConversationList: FC = ({ logs, appDetail, onRefresh }) ) } - const onCloseDrawer = () => { - onRefresh() - setShowDrawer(false) - setCurrentConversation(undefined) - setShowPromptLogModal(false) - setShowAgentLogModal(false) - setShowMessageLogModal(false) - } - if (!logs) return @@ -960,11 +1049,8 @@ const ConversationList: FC = ({ logs, appDetail, onRefresh }) const rightValue = get(log, isChatMode ? 'message_count' : 'message.answer') return { - setShowDrawer(true) - setCurrentConversation(log) - }}> + className={cn('cursor-pointer border-b border-divider-subtle hover:bg-background-default-hover', activeConversationId !== log.id ? '' : 'bg-background-default-hover')} + onClick={() => handleRowClick(log)}> {!log.read_at && (
From 543c5236e7735028013865cc66a1dae93b44697d Mon Sep 17 00:00:00 2001 From: heyszt <270985384@qq.com> Date: Tue, 28 Oct 2025 09:59:30 +0800 Subject: [PATCH 013/394] refactor:Decouple Domain Models from Direct Database Access (#27316) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../console/app/workflow_statistic.py | 222 ++++++---------- api/core/memory/token_buffer_memory.py | 24 +- api/core/ops/ops_trace_manager.py | 129 +++++----- .../api_workflow_run_repository.py | 154 ++++++++++- .../sqlalchemy_api_workflow_run_repository.py | 241 +++++++++++++++++- api/repositories/types.py | 21 ++ api/services/rag_pipeline/rag_pipeline.py | 68 ++--- 7 files changed, 595 insertions(+), 264 deletions(-) create mode 100644 api/repositories/types.py diff --git a/api/controllers/console/app/workflow_statistic.py b/api/controllers/console/app/workflow_statistic.py index bbea04640a..c246b3ffd5 100644 --- a/api/controllers/console/app/workflow_statistic.py +++ b/api/controllers/console/app/workflow_statistic.py @@ -1,10 +1,9 @@ from datetime import datetime -from decimal import Decimal import pytz -import sqlalchemy as sa from flask import jsonify from flask_restx import Resource, reqparse +from sqlalchemy.orm import sessionmaker from controllers.console import api, console_ns from controllers.console.app.wraps import get_app_model @@ -14,10 +13,16 @@ from libs.helper import DatetimeString from libs.login import current_account_with_tenant, login_required from models.enums import WorkflowRunTriggeredFrom from models.model import AppMode +from repositories.factory import DifyAPIRepositoryFactory @console_ns.route("/apps//workflow/statistics/daily-conversations") class WorkflowDailyRunsStatistic(Resource): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + session_maker = sessionmaker(bind=db.engine, expire_on_commit=False) + self._workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker) + @api.doc("get_workflow_daily_runs_statistic") @api.doc(description="Get workflow daily runs statistics") @api.doc(params={"app_id": "Application ID"}) @@ -37,57 +42,44 @@ class WorkflowDailyRunsStatistic(Resource): ) args = parser.parse_args() - sql_query = """SELECT - DATE(DATE_TRUNC('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz )) AS date, - COUNT(id) AS runs -FROM - workflow_runs -WHERE - app_id = :app_id - AND triggered_from = :triggered_from""" - arg_dict = { - "tz": account.timezone, - "app_id": app_model.id, - "triggered_from": WorkflowRunTriggeredFrom.APP_RUN, - } assert account.timezone is not None timezone = pytz.timezone(account.timezone) utc_timezone = pytz.utc + start_date = None + end_date = None + if args["start"]: start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M") start_datetime = start_datetime.replace(second=0) - start_datetime_timezone = timezone.localize(start_datetime) - start_datetime_utc = start_datetime_timezone.astimezone(utc_timezone) - - sql_query += " AND created_at >= :start" - arg_dict["start"] = start_datetime_utc + start_date = start_datetime_timezone.astimezone(utc_timezone) if args["end"]: end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M") end_datetime = end_datetime.replace(second=0) - end_datetime_timezone = timezone.localize(end_datetime) - end_datetime_utc = end_datetime_timezone.astimezone(utc_timezone) + end_date = end_datetime_timezone.astimezone(utc_timezone) - sql_query += " AND created_at < :end" - arg_dict["end"] = end_datetime_utc - - sql_query += " GROUP BY date ORDER BY date" - - response_data = [] - - with db.engine.begin() as conn: - rs = conn.execute(sa.text(sql_query), arg_dict) - for i in rs: - response_data.append({"date": str(i.date), "runs": i.runs}) + response_data = self._workflow_run_repo.get_daily_runs_statistics( + tenant_id=app_model.tenant_id, + app_id=app_model.id, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, + start_date=start_date, + end_date=end_date, + timezone=account.timezone, + ) return jsonify({"data": response_data}) @console_ns.route("/apps//workflow/statistics/daily-terminals") class WorkflowDailyTerminalsStatistic(Resource): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + session_maker = sessionmaker(bind=db.engine, expire_on_commit=False) + self._workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker) + @api.doc("get_workflow_daily_terminals_statistic") @api.doc(description="Get workflow daily terminals statistics") @api.doc(params={"app_id": "Application ID"}) @@ -107,57 +99,44 @@ class WorkflowDailyTerminalsStatistic(Resource): ) args = parser.parse_args() - sql_query = """SELECT - DATE(DATE_TRUNC('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz )) AS date, - COUNT(DISTINCT workflow_runs.created_by) AS terminal_count -FROM - workflow_runs -WHERE - app_id = :app_id - AND triggered_from = :triggered_from""" - arg_dict = { - "tz": account.timezone, - "app_id": app_model.id, - "triggered_from": WorkflowRunTriggeredFrom.APP_RUN, - } assert account.timezone is not None timezone = pytz.timezone(account.timezone) utc_timezone = pytz.utc + start_date = None + end_date = None + if args["start"]: start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M") start_datetime = start_datetime.replace(second=0) - start_datetime_timezone = timezone.localize(start_datetime) - start_datetime_utc = start_datetime_timezone.astimezone(utc_timezone) - - sql_query += " AND created_at >= :start" - arg_dict["start"] = start_datetime_utc + start_date = start_datetime_timezone.astimezone(utc_timezone) if args["end"]: end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M") end_datetime = end_datetime.replace(second=0) - end_datetime_timezone = timezone.localize(end_datetime) - end_datetime_utc = end_datetime_timezone.astimezone(utc_timezone) + end_date = end_datetime_timezone.astimezone(utc_timezone) - sql_query += " AND created_at < :end" - arg_dict["end"] = end_datetime_utc - - sql_query += " GROUP BY date ORDER BY date" - - response_data = [] - - with db.engine.begin() as conn: - rs = conn.execute(sa.text(sql_query), arg_dict) - for i in rs: - response_data.append({"date": str(i.date), "terminal_count": i.terminal_count}) + response_data = self._workflow_run_repo.get_daily_terminals_statistics( + tenant_id=app_model.tenant_id, + app_id=app_model.id, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, + start_date=start_date, + end_date=end_date, + timezone=account.timezone, + ) return jsonify({"data": response_data}) @console_ns.route("/apps//workflow/statistics/token-costs") class WorkflowDailyTokenCostStatistic(Resource): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + session_maker = sessionmaker(bind=db.engine, expire_on_commit=False) + self._workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker) + @api.doc("get_workflow_daily_token_cost_statistic") @api.doc(description="Get workflow daily token cost statistics") @api.doc(params={"app_id": "Application ID"}) @@ -177,62 +156,44 @@ class WorkflowDailyTokenCostStatistic(Resource): ) args = parser.parse_args() - sql_query = """SELECT - DATE(DATE_TRUNC('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz )) AS date, - SUM(workflow_runs.total_tokens) AS token_count -FROM - workflow_runs -WHERE - app_id = :app_id - AND triggered_from = :triggered_from""" - arg_dict = { - "tz": account.timezone, - "app_id": app_model.id, - "triggered_from": WorkflowRunTriggeredFrom.APP_RUN, - } assert account.timezone is not None timezone = pytz.timezone(account.timezone) utc_timezone = pytz.utc + start_date = None + end_date = None + if args["start"]: start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M") start_datetime = start_datetime.replace(second=0) - start_datetime_timezone = timezone.localize(start_datetime) - start_datetime_utc = start_datetime_timezone.astimezone(utc_timezone) - - sql_query += " AND created_at >= :start" - arg_dict["start"] = start_datetime_utc + start_date = start_datetime_timezone.astimezone(utc_timezone) if args["end"]: end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M") end_datetime = end_datetime.replace(second=0) - end_datetime_timezone = timezone.localize(end_datetime) - end_datetime_utc = end_datetime_timezone.astimezone(utc_timezone) + end_date = end_datetime_timezone.astimezone(utc_timezone) - sql_query += " AND created_at < :end" - arg_dict["end"] = end_datetime_utc - - sql_query += " GROUP BY date ORDER BY date" - - response_data = [] - - with db.engine.begin() as conn: - rs = conn.execute(sa.text(sql_query), arg_dict) - for i in rs: - response_data.append( - { - "date": str(i.date), - "token_count": i.token_count, - } - ) + response_data = self._workflow_run_repo.get_daily_token_cost_statistics( + tenant_id=app_model.tenant_id, + app_id=app_model.id, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, + start_date=start_date, + end_date=end_date, + timezone=account.timezone, + ) return jsonify({"data": response_data}) @console_ns.route("/apps//workflow/statistics/average-app-interactions") class WorkflowAverageAppInteractionStatistic(Resource): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + session_maker = sessionmaker(bind=db.engine, expire_on_commit=False) + self._workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker) + @api.doc("get_workflow_average_app_interaction_statistic") @api.doc(description="Get workflow average app interaction statistics") @api.doc(params={"app_id": "Application ID"}) @@ -252,67 +213,32 @@ class WorkflowAverageAppInteractionStatistic(Resource): ) args = parser.parse_args() - sql_query = """SELECT - AVG(sub.interactions) AS interactions, - sub.date -FROM - ( - SELECT - DATE(DATE_TRUNC('day', c.created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz )) AS date, - c.created_by, - COUNT(c.id) AS interactions - FROM - workflow_runs c - WHERE - c.app_id = :app_id - AND c.triggered_from = :triggered_from - {{start}} - {{end}} - GROUP BY - date, c.created_by - ) sub -GROUP BY - sub.date""" - arg_dict = { - "tz": account.timezone, - "app_id": app_model.id, - "triggered_from": WorkflowRunTriggeredFrom.APP_RUN, - } assert account.timezone is not None timezone = pytz.timezone(account.timezone) utc_timezone = pytz.utc + start_date = None + end_date = None + if args["start"]: start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M") start_datetime = start_datetime.replace(second=0) - start_datetime_timezone = timezone.localize(start_datetime) - start_datetime_utc = start_datetime_timezone.astimezone(utc_timezone) - - sql_query = sql_query.replace("{{start}}", " AND c.created_at >= :start") - arg_dict["start"] = start_datetime_utc - else: - sql_query = sql_query.replace("{{start}}", "") + start_date = start_datetime_timezone.astimezone(utc_timezone) if args["end"]: end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M") end_datetime = end_datetime.replace(second=0) - end_datetime_timezone = timezone.localize(end_datetime) - end_datetime_utc = end_datetime_timezone.astimezone(utc_timezone) + end_date = end_datetime_timezone.astimezone(utc_timezone) - sql_query = sql_query.replace("{{end}}", " AND c.created_at < :end") - arg_dict["end"] = end_datetime_utc - else: - sql_query = sql_query.replace("{{end}}", "") - - response_data = [] - - with db.engine.begin() as conn: - rs = conn.execute(sa.text(sql_query), arg_dict) - for i in rs: - response_data.append( - {"date": str(i.date), "interactions": float(i.interactions.quantize(Decimal("0.01")))} - ) + response_data = self._workflow_run_repo.get_average_app_interaction_statistics( + tenant_id=app_model.tenant_id, + app_id=app_model.id, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, + start_date=start_date, + end_date=end_date, + timezone=account.timezone, + ) return jsonify({"data": response_data}) diff --git a/api/core/memory/token_buffer_memory.py b/api/core/memory/token_buffer_memory.py index 35af742f2a..3ebbb60f85 100644 --- a/api/core/memory/token_buffer_memory.py +++ b/api/core/memory/token_buffer_memory.py @@ -1,6 +1,7 @@ from collections.abc import Sequence from sqlalchemy import select +from sqlalchemy.orm import sessionmaker from core.app.app_config.features.file_upload.manager import FileUploadConfigManager from core.file import file_manager @@ -18,7 +19,9 @@ from core.prompt.utils.extract_thread_messages import extract_thread_messages from extensions.ext_database import db from factories import file_factory from models.model import AppMode, Conversation, Message, MessageFile -from models.workflow import Workflow, WorkflowRun +from models.workflow import Workflow +from repositories.api_workflow_run_repository import APIWorkflowRunRepository +from repositories.factory import DifyAPIRepositoryFactory class TokenBufferMemory: @@ -29,6 +32,14 @@ class TokenBufferMemory: ): self.conversation = conversation self.model_instance = model_instance + self._workflow_run_repo: APIWorkflowRunRepository | None = None + + @property + def workflow_run_repo(self) -> APIWorkflowRunRepository: + if self._workflow_run_repo is None: + session_maker = sessionmaker(bind=db.engine, expire_on_commit=False) + self._workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker) + return self._workflow_run_repo def _build_prompt_message_with_files( self, @@ -50,7 +61,16 @@ class TokenBufferMemory: if self.conversation.mode in {AppMode.AGENT_CHAT, AppMode.COMPLETION, AppMode.CHAT}: file_extra_config = FileUploadConfigManager.convert(self.conversation.model_config) elif self.conversation.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: - workflow_run = db.session.scalar(select(WorkflowRun).where(WorkflowRun.id == message.workflow_run_id)) + app = self.conversation.app + if not app: + raise ValueError("App not found for conversation") + + if not message.workflow_run_id: + raise ValueError("Workflow run ID not found") + + workflow_run = self.workflow_run_repo.get_workflow_run_by_id( + tenant_id=app.tenant_id, app_id=app.id, run_id=message.workflow_run_id + ) if not workflow_run: raise ValueError(f"Workflow run not found: {message.workflow_run_id}") workflow = db.session.scalar(select(Workflow).where(Workflow.id == workflow_run.workflow_id)) diff --git a/api/core/ops/ops_trace_manager.py b/api/core/ops/ops_trace_manager.py index 7db9b076d2..de0d4560e3 100644 --- a/api/core/ops/ops_trace_manager.py +++ b/api/core/ops/ops_trace_manager.py @@ -12,7 +12,7 @@ from uuid import UUID, uuid4 from cachetools import LRUCache from flask import current_app from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker from core.helper.encrypter import decrypt_token, encrypt_token, obfuscated_token from core.ops.entities.config_entity import ( @@ -34,7 +34,8 @@ from core.ops.utils import get_message_data from extensions.ext_database import db from extensions.ext_storage import storage from models.model import App, AppModelConfig, Conversation, Message, MessageFile, TraceAppConfig -from models.workflow import WorkflowAppLog, WorkflowRun +from models.workflow import WorkflowAppLog +from repositories.factory import DifyAPIRepositoryFactory from tasks.ops_trace_task import process_trace_tasks if TYPE_CHECKING: @@ -419,6 +420,18 @@ class OpsTraceManager: class TraceTask: + _workflow_run_repo = None + _repo_lock = threading.Lock() + + @classmethod + def _get_workflow_run_repo(cls): + if cls._workflow_run_repo is None: + with cls._repo_lock: + if cls._workflow_run_repo is None: + session_maker = sessionmaker(bind=db.engine, expire_on_commit=False) + cls._workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker) + return cls._workflow_run_repo + def __init__( self, trace_type: Any, @@ -486,27 +499,27 @@ class TraceTask: if not workflow_run_id: return {} + workflow_run_repo = self._get_workflow_run_repo() + workflow_run = workflow_run_repo.get_workflow_run_by_id_without_tenant(run_id=workflow_run_id) + if not workflow_run: + raise ValueError("Workflow run not found") + + workflow_id = workflow_run.workflow_id + tenant_id = workflow_run.tenant_id + workflow_run_id = workflow_run.id + workflow_run_elapsed_time = workflow_run.elapsed_time + workflow_run_status = workflow_run.status + workflow_run_inputs = workflow_run.inputs_dict + workflow_run_outputs = workflow_run.outputs_dict + workflow_run_version = workflow_run.version + error = workflow_run.error or "" + + total_tokens = workflow_run.total_tokens + + file_list = workflow_run_inputs.get("sys.file") or [] + query = workflow_run_inputs.get("query") or workflow_run_inputs.get("sys.query") or "" + with Session(db.engine) as session: - workflow_run_stmt = select(WorkflowRun).where(WorkflowRun.id == workflow_run_id) - workflow_run = session.scalars(workflow_run_stmt).first() - if not workflow_run: - raise ValueError("Workflow run not found") - - workflow_id = workflow_run.workflow_id - tenant_id = workflow_run.tenant_id - workflow_run_id = workflow_run.id - workflow_run_elapsed_time = workflow_run.elapsed_time - workflow_run_status = workflow_run.status - workflow_run_inputs = workflow_run.inputs_dict - workflow_run_outputs = workflow_run.outputs_dict - workflow_run_version = workflow_run.version - error = workflow_run.error or "" - - total_tokens = workflow_run.total_tokens - - file_list = workflow_run_inputs.get("sys.file") or [] - query = workflow_run_inputs.get("query") or workflow_run_inputs.get("sys.query") or "" - # get workflow_app_log_id workflow_app_log_data_stmt = select(WorkflowAppLog.id).where( WorkflowAppLog.tenant_id == tenant_id, @@ -523,43 +536,43 @@ class TraceTask: ) message_id = session.scalar(message_data_stmt) - metadata = { - "workflow_id": workflow_id, - "conversation_id": conversation_id, - "workflow_run_id": workflow_run_id, - "tenant_id": tenant_id, - "elapsed_time": workflow_run_elapsed_time, - "status": workflow_run_status, - "version": workflow_run_version, - "total_tokens": total_tokens, - "file_list": file_list, - "triggered_from": workflow_run.triggered_from, - "user_id": user_id, - "app_id": workflow_run.app_id, - } + metadata = { + "workflow_id": workflow_id, + "conversation_id": conversation_id, + "workflow_run_id": workflow_run_id, + "tenant_id": tenant_id, + "elapsed_time": workflow_run_elapsed_time, + "status": workflow_run_status, + "version": workflow_run_version, + "total_tokens": total_tokens, + "file_list": file_list, + "triggered_from": workflow_run.triggered_from, + "user_id": user_id, + "app_id": workflow_run.app_id, + } - workflow_trace_info = WorkflowTraceInfo( - trace_id=self.trace_id, - workflow_data=workflow_run.to_dict(), - conversation_id=conversation_id, - workflow_id=workflow_id, - tenant_id=tenant_id, - workflow_run_id=workflow_run_id, - workflow_run_elapsed_time=workflow_run_elapsed_time, - workflow_run_status=workflow_run_status, - workflow_run_inputs=workflow_run_inputs, - workflow_run_outputs=workflow_run_outputs, - workflow_run_version=workflow_run_version, - error=error, - total_tokens=total_tokens, - file_list=file_list, - query=query, - metadata=metadata, - workflow_app_log_id=workflow_app_log_id, - message_id=message_id, - start_time=workflow_run.created_at, - end_time=workflow_run.finished_at, - ) + workflow_trace_info = WorkflowTraceInfo( + trace_id=self.trace_id, + workflow_data=workflow_run.to_dict(), + conversation_id=conversation_id, + workflow_id=workflow_id, + tenant_id=tenant_id, + workflow_run_id=workflow_run_id, + workflow_run_elapsed_time=workflow_run_elapsed_time, + workflow_run_status=workflow_run_status, + workflow_run_inputs=workflow_run_inputs, + workflow_run_outputs=workflow_run_outputs, + workflow_run_version=workflow_run_version, + error=error, + total_tokens=total_tokens, + file_list=file_list, + query=query, + metadata=metadata, + workflow_app_log_id=workflow_app_log_id, + message_id=message_id, + start_time=workflow_run.created_at, + end_time=workflow_run.finished_at, + ) return workflow_trace_info def message_trace(self, message_id: str | None): diff --git a/api/repositories/api_workflow_run_repository.py b/api/repositories/api_workflow_run_repository.py index 72de9fed31..eb6d599224 100644 --- a/api/repositories/api_workflow_run_repository.py +++ b/api/repositories/api_workflow_run_repository.py @@ -28,7 +28,7 @@ Example: runs = repo.get_paginated_workflow_runs( tenant_id="tenant-123", app_id="app-456", - triggered_from="debugging", + triggered_from=WorkflowRunTriggeredFrom.DEBUGGING, limit=20 ) ``` @@ -40,7 +40,14 @@ from typing import Protocol from core.workflow.repositories.workflow_execution_repository import WorkflowExecutionRepository from libs.infinite_scroll_pagination import InfiniteScrollPagination +from models.enums import WorkflowRunTriggeredFrom from models.workflow import WorkflowRun +from repositories.types import ( + AverageInteractionStats, + DailyRunsStats, + DailyTerminalsStats, + DailyTokenCostStats, +) class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol): @@ -56,7 +63,7 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol): self, tenant_id: str, app_id: str, - triggered_from: str, + triggered_from: WorkflowRunTriggeredFrom | Sequence[WorkflowRunTriggeredFrom], limit: int = 20, last_id: str | None = None, status: str | None = None, @@ -71,7 +78,7 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol): Args: tenant_id: Tenant identifier for multi-tenant isolation app_id: Application identifier - triggered_from: Filter by trigger source (e.g., "debugging", "app-run") + triggered_from: Filter by trigger source(s) (e.g., "debugging", "app-run", or list of values) limit: Maximum number of records to return (default: 20) last_id: Cursor for pagination - ID of the last record from previous page status: Optional filter by status (e.g., "running", "succeeded", "failed") @@ -109,6 +116,31 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol): """ ... + def get_workflow_run_by_id_without_tenant( + self, + run_id: str, + ) -> WorkflowRun | None: + """ + Get a specific workflow run by ID without tenant/app context. + + Retrieves a single workflow run using only the run ID, without + requiring tenant_id or app_id. This method is intended for internal + system operations like tracing and monitoring where the tenant context + is not available upfront. + + Args: + run_id: Workflow run identifier + + Returns: + WorkflowRun object if found, None otherwise + + Note: + This method bypasses tenant isolation checks and should only be used + in trusted system contexts like ops trace collection. For user-facing + operations, use get_workflow_run_by_id() with proper tenant isolation. + """ + ... + def get_workflow_runs_count( self, tenant_id: str, @@ -218,3 +250,119 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol): and ensure proper data retention policies are followed. """ ... + + def get_daily_runs_statistics( + self, + tenant_id: str, + app_id: str, + triggered_from: str, + start_date: datetime | None = None, + end_date: datetime | None = None, + timezone: str = "UTC", + ) -> list[DailyRunsStats]: + """ + Get daily runs statistics. + + Retrieves daily workflow runs count grouped by date for a specific app + and trigger source. Used for workflow statistics dashboard. + + Args: + tenant_id: Tenant identifier for multi-tenant isolation + app_id: Application identifier + triggered_from: Filter by trigger source (e.g., "app-run") + start_date: Optional start date filter + end_date: Optional end date filter + timezone: Timezone for date grouping (default: "UTC") + + Returns: + List of dictionaries containing date and runs count: + [{"date": "2024-01-01", "runs": 10}, ...] + """ + ... + + def get_daily_terminals_statistics( + self, + tenant_id: str, + app_id: str, + triggered_from: str, + start_date: datetime | None = None, + end_date: datetime | None = None, + timezone: str = "UTC", + ) -> list[DailyTerminalsStats]: + """ + Get daily terminals statistics. + + Retrieves daily unique terminal count grouped by date for a specific app + and trigger source. Used for workflow statistics dashboard. + + Args: + tenant_id: Tenant identifier for multi-tenant isolation + app_id: Application identifier + triggered_from: Filter by trigger source (e.g., "app-run") + start_date: Optional start date filter + end_date: Optional end date filter + timezone: Timezone for date grouping (default: "UTC") + + Returns: + List of dictionaries containing date and terminal count: + [{"date": "2024-01-01", "terminal_count": 5}, ...] + """ + ... + + def get_daily_token_cost_statistics( + self, + tenant_id: str, + app_id: str, + triggered_from: str, + start_date: datetime | None = None, + end_date: datetime | None = None, + timezone: str = "UTC", + ) -> list[DailyTokenCostStats]: + """ + Get daily token cost statistics. + + Retrieves daily total token count grouped by date for a specific app + and trigger source. Used for workflow statistics dashboard. + + Args: + tenant_id: Tenant identifier for multi-tenant isolation + app_id: Application identifier + triggered_from: Filter by trigger source (e.g., "app-run") + start_date: Optional start date filter + end_date: Optional end date filter + timezone: Timezone for date grouping (default: "UTC") + + Returns: + List of dictionaries containing date and token count: + [{"date": "2024-01-01", "token_count": 1000}, ...] + """ + ... + + def get_average_app_interaction_statistics( + self, + tenant_id: str, + app_id: str, + triggered_from: str, + start_date: datetime | None = None, + end_date: datetime | None = None, + timezone: str = "UTC", + ) -> list[AverageInteractionStats]: + """ + Get average app interaction statistics. + + Retrieves daily average interactions per user grouped by date for a specific app + and trigger source. Used for workflow statistics dashboard. + + Args: + tenant_id: Tenant identifier for multi-tenant isolation + app_id: Application identifier + triggered_from: Filter by trigger source (e.g., "app-run") + start_date: Optional start date filter + end_date: Optional end date filter + timezone: Timezone for date grouping (default: "UTC") + + Returns: + List of dictionaries containing date and average interactions: + [{"date": "2024-01-01", "interactions": 2.5}, ...] + """ + ... diff --git a/api/repositories/sqlalchemy_api_workflow_run_repository.py b/api/repositories/sqlalchemy_api_workflow_run_repository.py index 68affb59f3..f08eab0b01 100644 --- a/api/repositories/sqlalchemy_api_workflow_run_repository.py +++ b/api/repositories/sqlalchemy_api_workflow_run_repository.py @@ -22,16 +22,25 @@ Implementation Notes: import logging from collections.abc import Sequence from datetime import datetime -from typing import cast +from decimal import Decimal +from typing import Any, cast +import sqlalchemy as sa from sqlalchemy import delete, func, select from sqlalchemy.engine import CursorResult from sqlalchemy.orm import Session, sessionmaker from libs.infinite_scroll_pagination import InfiniteScrollPagination from libs.time_parser import get_time_threshold +from models.enums import WorkflowRunTriggeredFrom from models.workflow import WorkflowRun from repositories.api_workflow_run_repository import APIWorkflowRunRepository +from repositories.types import ( + AverageInteractionStats, + DailyRunsStats, + DailyTerminalsStats, + DailyTokenCostStats, +) logger = logging.getLogger(__name__) @@ -61,7 +70,7 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): self, tenant_id: str, app_id: str, - triggered_from: str, + triggered_from: WorkflowRunTriggeredFrom | Sequence[WorkflowRunTriggeredFrom], limit: int = 20, last_id: str | None = None, status: str | None = None, @@ -78,9 +87,14 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): base_stmt = select(WorkflowRun).where( WorkflowRun.tenant_id == tenant_id, WorkflowRun.app_id == app_id, - WorkflowRun.triggered_from == triggered_from, ) + # Handle triggered_from values + if isinstance(triggered_from, WorkflowRunTriggeredFrom): + triggered_from = [triggered_from] + if triggered_from: + base_stmt = base_stmt.where(WorkflowRun.triggered_from.in_(triggered_from)) + # Add optional status filter if status: base_stmt = base_stmt.where(WorkflowRun.status == status) @@ -126,6 +140,17 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): ) return session.scalar(stmt) + def get_workflow_run_by_id_without_tenant( + self, + run_id: str, + ) -> WorkflowRun | None: + """ + Get a specific workflow run by ID without tenant/app context. + """ + with self._session_maker() as session: + stmt = select(WorkflowRun).where(WorkflowRun.id == run_id) + return session.scalar(stmt) + def get_workflow_runs_count( self, tenant_id: str, @@ -275,3 +300,213 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): logger.info("Total deleted %s workflow runs for app %s", total_deleted, app_id) return total_deleted + + def get_daily_runs_statistics( + self, + tenant_id: str, + app_id: str, + triggered_from: str, + start_date: datetime | None = None, + end_date: datetime | None = None, + timezone: str = "UTC", + ) -> list[DailyRunsStats]: + """ + Get daily runs statistics using raw SQL for optimal performance. + """ + sql_query = """SELECT + DATE(DATE_TRUNC('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz )) AS date, + COUNT(id) AS runs +FROM + workflow_runs +WHERE + tenant_id = :tenant_id + AND app_id = :app_id + AND triggered_from = :triggered_from""" + + arg_dict: dict[str, Any] = { + "tz": timezone, + "tenant_id": tenant_id, + "app_id": app_id, + "triggered_from": triggered_from, + } + + if start_date: + sql_query += " AND created_at >= :start_date" + arg_dict["start_date"] = start_date + + if end_date: + sql_query += " AND created_at < :end_date" + arg_dict["end_date"] = end_date + + sql_query += " GROUP BY date ORDER BY date" + + response_data = [] + with self._session_maker() as session: + rs = session.execute(sa.text(sql_query), arg_dict) + for row in rs: + response_data.append({"date": str(row.date), "runs": row.runs}) + + return cast(list[DailyRunsStats], response_data) + + def get_daily_terminals_statistics( + self, + tenant_id: str, + app_id: str, + triggered_from: str, + start_date: datetime | None = None, + end_date: datetime | None = None, + timezone: str = "UTC", + ) -> list[DailyTerminalsStats]: + """ + Get daily terminals statistics using raw SQL for optimal performance. + """ + sql_query = """SELECT + DATE(DATE_TRUNC('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz )) AS date, + COUNT(DISTINCT created_by) AS terminal_count +FROM + workflow_runs +WHERE + tenant_id = :tenant_id + AND app_id = :app_id + AND triggered_from = :triggered_from""" + + arg_dict: dict[str, Any] = { + "tz": timezone, + "tenant_id": tenant_id, + "app_id": app_id, + "triggered_from": triggered_from, + } + + if start_date: + sql_query += " AND created_at >= :start_date" + arg_dict["start_date"] = start_date + + if end_date: + sql_query += " AND created_at < :end_date" + arg_dict["end_date"] = end_date + + sql_query += " GROUP BY date ORDER BY date" + + response_data = [] + with self._session_maker() as session: + rs = session.execute(sa.text(sql_query), arg_dict) + for row in rs: + response_data.append({"date": str(row.date), "terminal_count": row.terminal_count}) + + return cast(list[DailyTerminalsStats], response_data) + + def get_daily_token_cost_statistics( + self, + tenant_id: str, + app_id: str, + triggered_from: str, + start_date: datetime | None = None, + end_date: datetime | None = None, + timezone: str = "UTC", + ) -> list[DailyTokenCostStats]: + """ + Get daily token cost statistics using raw SQL for optimal performance. + """ + sql_query = """SELECT + DATE(DATE_TRUNC('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz )) AS date, + SUM(total_tokens) AS token_count +FROM + workflow_runs +WHERE + tenant_id = :tenant_id + AND app_id = :app_id + AND triggered_from = :triggered_from""" + + arg_dict: dict[str, Any] = { + "tz": timezone, + "tenant_id": tenant_id, + "app_id": app_id, + "triggered_from": triggered_from, + } + + if start_date: + sql_query += " AND created_at >= :start_date" + arg_dict["start_date"] = start_date + + if end_date: + sql_query += " AND created_at < :end_date" + arg_dict["end_date"] = end_date + + sql_query += " GROUP BY date ORDER BY date" + + response_data = [] + with self._session_maker() as session: + rs = session.execute(sa.text(sql_query), arg_dict) + for row in rs: + response_data.append( + { + "date": str(row.date), + "token_count": row.token_count, + } + ) + + return cast(list[DailyTokenCostStats], response_data) + + def get_average_app_interaction_statistics( + self, + tenant_id: str, + app_id: str, + triggered_from: str, + start_date: datetime | None = None, + end_date: datetime | None = None, + timezone: str = "UTC", + ) -> list[AverageInteractionStats]: + """ + Get average app interaction statistics using raw SQL for optimal performance. + """ + sql_query = """SELECT + AVG(sub.interactions) AS interactions, + sub.date +FROM + ( + SELECT + DATE(DATE_TRUNC('day', c.created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz )) AS date, + c.created_by, + COUNT(c.id) AS interactions + FROM + workflow_runs c + WHERE + c.tenant_id = :tenant_id + AND c.app_id = :app_id + AND c.triggered_from = :triggered_from + {{start}} + {{end}} + GROUP BY + date, c.created_by + ) sub +GROUP BY + sub.date""" + + arg_dict: dict[str, Any] = { + "tz": timezone, + "tenant_id": tenant_id, + "app_id": app_id, + "triggered_from": triggered_from, + } + + if start_date: + sql_query = sql_query.replace("{{start}}", " AND c.created_at >= :start_date") + arg_dict["start_date"] = start_date + else: + sql_query = sql_query.replace("{{start}}", "") + + if end_date: + sql_query = sql_query.replace("{{end}}", " AND c.created_at < :end_date") + arg_dict["end_date"] = end_date + else: + sql_query = sql_query.replace("{{end}}", "") + + response_data = [] + with self._session_maker() as session: + rs = session.execute(sa.text(sql_query), arg_dict) + for row in rs: + response_data.append( + {"date": str(row.date), "interactions": float(row.interactions.quantize(Decimal("0.01")))} + ) + + return cast(list[AverageInteractionStats], response_data) diff --git a/api/repositories/types.py b/api/repositories/types.py new file mode 100644 index 0000000000..3b3ef7f635 --- /dev/null +++ b/api/repositories/types.py @@ -0,0 +1,21 @@ +from typing import TypedDict + + +class DailyRunsStats(TypedDict): + date: str + runs: int + + +class DailyTerminalsStats(TypedDict): + date: str + terminal_count: int + + +class DailyTokenCostStats(TypedDict): + date: str + token_count: int + + +class AverageInteractionStats(TypedDict): + date: str + interactions: float diff --git a/api/services/rag_pipeline/rag_pipeline.py b/api/services/rag_pipeline/rag_pipeline.py index f6dddd75a3..50dec458a9 100644 --- a/api/services/rag_pipeline/rag_pipeline.py +++ b/api/services/rag_pipeline/rag_pipeline.py @@ -9,7 +9,7 @@ from typing import Any, Union, cast from uuid import uuid4 from flask_login import current_user -from sqlalchemy import func, or_, select +from sqlalchemy import func, select from sqlalchemy.orm import Session, sessionmaker import contexts @@ -94,6 +94,7 @@ class RagPipelineService: self._node_execution_service_repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository( session_maker ) + self._workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker) @classmethod def get_pipeline_templates(cls, type: str = "built-in", language: str = "en-US") -> dict: @@ -1015,48 +1016,21 @@ class RagPipelineService: :param args: request args """ limit = int(args.get("limit", 20)) + last_id = args.get("last_id") - base_query = db.session.query(WorkflowRun).where( - WorkflowRun.tenant_id == pipeline.tenant_id, - WorkflowRun.app_id == pipeline.id, - or_( - WorkflowRun.triggered_from == WorkflowRunTriggeredFrom.RAG_PIPELINE_RUN.value, - WorkflowRun.triggered_from == WorkflowRunTriggeredFrom.RAG_PIPELINE_DEBUGGING.value, - ), + triggered_from_values = [ + WorkflowRunTriggeredFrom.RAG_PIPELINE_RUN, + WorkflowRunTriggeredFrom.RAG_PIPELINE_DEBUGGING, + ] + + return self._workflow_run_repo.get_paginated_workflow_runs( + tenant_id=pipeline.tenant_id, + app_id=pipeline.id, + triggered_from=triggered_from_values, + limit=limit, + last_id=last_id, ) - if args.get("last_id"): - last_workflow_run = base_query.where( - WorkflowRun.id == args.get("last_id"), - ).first() - - if not last_workflow_run: - raise ValueError("Last workflow run not exists") - - workflow_runs = ( - base_query.where( - WorkflowRun.created_at < last_workflow_run.created_at, WorkflowRun.id != last_workflow_run.id - ) - .order_by(WorkflowRun.created_at.desc()) - .limit(limit) - .all() - ) - else: - workflow_runs = base_query.order_by(WorkflowRun.created_at.desc()).limit(limit).all() - - has_more = False - if len(workflow_runs) == limit: - current_page_first_workflow_run = workflow_runs[-1] - rest_count = base_query.where( - WorkflowRun.created_at < current_page_first_workflow_run.created_at, - WorkflowRun.id != current_page_first_workflow_run.id, - ).count() - - if rest_count > 0: - has_more = True - - return InfiniteScrollPagination(data=workflow_runs, limit=limit, has_more=has_more) - def get_rag_pipeline_workflow_run(self, pipeline: Pipeline, run_id: str) -> WorkflowRun | None: """ Get workflow run detail @@ -1064,18 +1038,12 @@ class RagPipelineService: :param app_model: app model :param run_id: workflow run id """ - workflow_run = ( - db.session.query(WorkflowRun) - .where( - WorkflowRun.tenant_id == pipeline.tenant_id, - WorkflowRun.app_id == pipeline.id, - WorkflowRun.id == run_id, - ) - .first() + return self._workflow_run_repo.get_workflow_run_by_id( + tenant_id=pipeline.tenant_id, + app_id=pipeline.id, + run_id=run_id, ) - return workflow_run - def get_rag_pipeline_workflow_run_node_executions( self, pipeline: Pipeline, From ff32dff1636e40d6c79dd9b25a5b90ffc1e3de96 Mon Sep 17 00:00:00 2001 From: Eric Guo Date: Tue, 28 Oct 2025 10:04:24 +0800 Subject: [PATCH 014/394] Enabled cross-subdomain console sessions by making the cookie domain configurable and aligning the frontend so it reads the shared CSRF cookie. (#27190) --- api/.env.example | 3 ++ api/configs/feature/__init__.py | 5 ++++ api/libs/external_api.py | 13 ++------- api/libs/token.py | 33 ++++++++++++++++++++- api/tests/unit_tests/libs/test_token.py | 39 ++++++++++++++++++++++++- docker/.env.example | 5 ++++ docker/docker-compose-template.yaml | 1 + docker/docker-compose.yaml | 3 ++ web/.env.example | 3 ++ web/config/index.ts | 2 ++ 10 files changed, 94 insertions(+), 13 deletions(-) diff --git a/api/.env.example b/api/.env.example index 4df6adf348..c59d3ea16f 100644 --- a/api/.env.example +++ b/api/.env.example @@ -156,6 +156,9 @@ SUPABASE_URL=your-server-url # CORS configuration WEB_API_CORS_ALLOW_ORIGINS=http://localhost:3000,* CONSOLE_CORS_ALLOW_ORIGINS=http://localhost:3000,* +# Set COOKIE_DOMAIN when the console frontend and API are on different subdomains. +# Provide the registrable domain (e.g. example.com); leading dots are optional. +COOKIE_DOMAIN= # Vector database configuration # Supported values are `weaviate`, `qdrant`, `milvus`, `myscale`, `relyt`, `pgvector`, `pgvecto-rs`, `chroma`, `opensearch`, `oracle`, `tencent`, `elasticsearch`, `elasticsearch-ja`, `analyticdb`, `couchbase`, `vikingdb`, `oceanbase`, `opengauss`, `tablestore`,`vastbase`,`tidb`,`tidb_on_qdrant`,`baidu`,`lindorm`,`huawei_cloud`,`upstash`, `matrixone`. diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index a02f8a4d49..b2a2f8d0fd 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -337,6 +337,11 @@ class HttpConfig(BaseSettings): HTTP-related configurations for the application """ + COOKIE_DOMAIN: str = Field( + description="Explicit cookie domain for console/service cookies when sharing across subdomains", + default="", + ) + API_COMPRESSION_ENABLED: bool = Field( description="Enable or disable gzip compression for HTTP responses", default=False, diff --git a/api/libs/external_api.py b/api/libs/external_api.py index 1a4fde960c..61a90ee4a9 100644 --- a/api/libs/external_api.py +++ b/api/libs/external_api.py @@ -9,9 +9,8 @@ from werkzeug.exceptions import HTTPException from werkzeug.http import HTTP_STATUS_CODES from configs import dify_config -from constants import COOKIE_NAME_ACCESS_TOKEN, COOKIE_NAME_CSRF_TOKEN, COOKIE_NAME_REFRESH_TOKEN from core.errors.error import AppInvokeQuotaExceededError -from libs.token import is_secure +from libs.token import build_force_logout_cookie_headers def http_status_message(code): @@ -73,15 +72,7 @@ def register_external_error_handlers(api: Api): error_code = getattr(e, "error_code", None) if error_code == "unauthorized_and_force_logout": # Add Set-Cookie headers to clear auth cookies - - secure = is_secure() - # response is not accessible, so we need to do it ugly - common_part = "Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly" - headers["Set-Cookie"] = [ - f'{COOKIE_NAME_ACCESS_TOKEN}=""; {common_part}{"; Secure" if secure else ""}; SameSite=Lax', - f'{COOKIE_NAME_CSRF_TOKEN}=""; {common_part}{"; Secure" if secure else ""}; SameSite=Lax', - f'{COOKIE_NAME_REFRESH_TOKEN}=""; {common_part}{"; Secure" if secure else ""}; SameSite=Lax', - ] + headers["Set-Cookie"] = build_force_logout_cookie_headers() return data, status_code, headers _ = handle_http_exception diff --git a/api/libs/token.py b/api/libs/token.py index b53663c89a..098ff958da 100644 --- a/api/libs/token.py +++ b/api/libs/token.py @@ -30,8 +30,22 @@ def is_secure() -> bool: return dify_config.CONSOLE_WEB_URL.startswith("https") and dify_config.CONSOLE_API_URL.startswith("https") +def _cookie_domain() -> str | None: + """ + Returns the normalized cookie domain. + + Leading dots are stripped from the configured domain. Historically, a leading dot + indicated that a cookie should be sent to all subdomains, but modern browsers treat + 'example.com' and '.example.com' identically. This normalization ensures consistent + behavior and avoids confusion. + """ + domain = dify_config.COOKIE_DOMAIN.strip() + domain = domain.removeprefix(".") + return domain or None + + def _real_cookie_name(cookie_name: str) -> str: - if is_secure(): + if is_secure() and _cookie_domain() is None: return "__Host-" + cookie_name else: return cookie_name @@ -91,6 +105,7 @@ def set_access_token_to_cookie(request: Request, response: Response, token: str, _real_cookie_name(COOKIE_NAME_ACCESS_TOKEN), value=token, httponly=True, + domain=_cookie_domain(), secure=is_secure(), samesite=samesite, max_age=int(dify_config.ACCESS_TOKEN_EXPIRE_MINUTES * 60), @@ -103,6 +118,7 @@ def set_refresh_token_to_cookie(request: Request, response: Response, token: str _real_cookie_name(COOKIE_NAME_REFRESH_TOKEN), value=token, httponly=True, + domain=_cookie_domain(), secure=is_secure(), samesite="Lax", max_age=int(60 * 60 * 24 * dify_config.REFRESH_TOKEN_EXPIRE_DAYS), @@ -115,6 +131,7 @@ def set_csrf_token_to_cookie(request: Request, response: Response, token: str): _real_cookie_name(COOKIE_NAME_CSRF_TOKEN), value=token, httponly=False, + domain=_cookie_domain(), secure=is_secure(), samesite="Lax", max_age=int(60 * dify_config.ACCESS_TOKEN_EXPIRE_MINUTES), @@ -133,6 +150,7 @@ def _clear_cookie( "", expires=0, path="/", + domain=_cookie_domain(), secure=is_secure(), httponly=http_only, samesite=samesite, @@ -155,6 +173,19 @@ def clear_csrf_token_from_cookie(response: Response): _clear_cookie(response, COOKIE_NAME_CSRF_TOKEN, http_only=False) +def build_force_logout_cookie_headers() -> list[str]: + """ + Generate Set-Cookie header values that clear all auth-related cookies. + This mirrors the behavior of the standard cookie clearing helpers while + allowing callers that do not have a Response instance to reuse the logic. + """ + response = Response() + clear_access_token_from_cookie(response) + clear_csrf_token_from_cookie(response) + clear_refresh_token_from_cookie(response) + return response.headers.getlist("Set-Cookie") + + def check_csrf_token(request: Request, user_id: str): # some apis are sent by beacon, so we need to bypass csrf token check # since these APIs are post, they are already protected by SameSite: Lax, so csrf is not required. diff --git a/api/tests/unit_tests/libs/test_token.py b/api/tests/unit_tests/libs/test_token.py index a611d3eb0e..6a65b5faa0 100644 --- a/api/tests/unit_tests/libs/test_token.py +++ b/api/tests/unit_tests/libs/test_token.py @@ -1,5 +1,10 @@ +from unittest.mock import MagicMock + +from werkzeug.wrappers import Response + from constants import COOKIE_NAME_ACCESS_TOKEN, COOKIE_NAME_WEBAPP_ACCESS_TOKEN -from libs.token import extract_access_token, extract_webapp_access_token +from libs import token +from libs.token import extract_access_token, extract_webapp_access_token, set_csrf_token_to_cookie class MockRequest: @@ -23,3 +28,35 @@ def test_extract_access_token(): for request, expected_console, expected_webapp in test_cases: assert extract_access_token(request) == expected_console # pyright: ignore[reportArgumentType] assert extract_webapp_access_token(request) == expected_webapp # pyright: ignore[reportArgumentType] + + +def test_real_cookie_name_uses_host_prefix_without_domain(monkeypatch): + monkeypatch.setattr(token.dify_config, "CONSOLE_WEB_URL", "https://console.example.com", raising=False) + monkeypatch.setattr(token.dify_config, "CONSOLE_API_URL", "https://api.example.com", raising=False) + monkeypatch.setattr(token.dify_config, "COOKIE_DOMAIN", "", raising=False) + + assert token._real_cookie_name("csrf_token") == "__Host-csrf_token" + + +def test_real_cookie_name_without_host_prefix_when_domain_present(monkeypatch): + monkeypatch.setattr(token.dify_config, "CONSOLE_WEB_URL", "https://console.example.com", raising=False) + monkeypatch.setattr(token.dify_config, "CONSOLE_API_URL", "https://api.example.com", raising=False) + monkeypatch.setattr(token.dify_config, "COOKIE_DOMAIN", ".example.com", raising=False) + + assert token._real_cookie_name("csrf_token") == "csrf_token" + + +def test_set_csrf_cookie_includes_domain_when_configured(monkeypatch): + monkeypatch.setattr(token.dify_config, "CONSOLE_WEB_URL", "https://console.example.com", raising=False) + monkeypatch.setattr(token.dify_config, "CONSOLE_API_URL", "https://api.example.com", raising=False) + monkeypatch.setattr(token.dify_config, "COOKIE_DOMAIN", ".example.com", raising=False) + + response = Response() + request = MagicMock() + + set_csrf_token_to_cookie(request, response, "abc123") + + cookies = response.headers.getlist("Set-Cookie") + assert any("csrf_token=abc123" in c for c in cookies) + assert any("Domain=example.com" in c for c in cookies) + assert all("__Host-" not in c for c in cookies) diff --git a/docker/.env.example b/docker/.env.example index e47bea2ff9..672d3d9836 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -348,6 +348,11 @@ WEB_API_CORS_ALLOW_ORIGINS=* # Specifies the allowed origins for cross-origin requests to the console API, # e.g. https://cloud.dify.ai or * for all origins. CONSOLE_CORS_ALLOW_ORIGINS=* +# Set COOKIE_DOMAIN when the console frontend and API are on different subdomains. +# Provide the registrable domain (e.g. example.com); leading dots are optional. +COOKIE_DOMAIN= +# The frontend reads NEXT_PUBLIC_COOKIE_DOMAIN to align cookie handling with the API. +NEXT_PUBLIC_COOKIE_DOMAIN= # ------------------------------ # File Storage Configuration diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index 886335a96b..fd63f5f00c 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -81,6 +81,7 @@ services: environment: CONSOLE_API_URL: ${CONSOLE_API_URL:-} APP_API_URL: ${APP_API_URL:-} + NEXT_PUBLIC_COOKIE_DOMAIN: ${NEXT_PUBLIC_COOKIE_DOMAIN:-} SENTRY_DSN: ${WEB_SENTRY_DSN:-} NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-0} TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 606d5ec58f..1b4012b446 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -99,6 +99,8 @@ x-shared-env: &shared-api-worker-env CELERY_SENTINEL_SOCKET_TIMEOUT: ${CELERY_SENTINEL_SOCKET_TIMEOUT:-0.1} WEB_API_CORS_ALLOW_ORIGINS: ${WEB_API_CORS_ALLOW_ORIGINS:-*} CONSOLE_CORS_ALLOW_ORIGINS: ${CONSOLE_CORS_ALLOW_ORIGINS:-*} + COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} + NEXT_PUBLIC_COOKIE_DOMAIN: ${NEXT_PUBLIC_COOKIE_DOMAIN:-} STORAGE_TYPE: ${STORAGE_TYPE:-opendal} OPENDAL_SCHEME: ${OPENDAL_SCHEME:-fs} OPENDAL_FS_ROOT: ${OPENDAL_FS_ROOT:-storage} @@ -691,6 +693,7 @@ services: environment: CONSOLE_API_URL: ${CONSOLE_API_URL:-} APP_API_URL: ${APP_API_URL:-} + NEXT_PUBLIC_COOKIE_DOMAIN: ${NEXT_PUBLIC_COOKIE_DOMAIN:-} SENTRY_DSN: ${WEB_SENTRY_DSN:-} NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-0} TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} diff --git a/web/.env.example b/web/.env.example index 4c5c8641e0..5bfcc9dac0 100644 --- a/web/.env.example +++ b/web/.env.example @@ -34,6 +34,9 @@ NEXT_PUBLIC_CSP_WHITELIST= # Default is not allow to embed into iframe to prevent Clickjacking: https://owasp.org/www-community/attacks/Clickjacking NEXT_PUBLIC_ALLOW_EMBED= +# Shared cookie domain when console UI and API use different subdomains (e.g. example.com) +NEXT_PUBLIC_COOKIE_DOMAIN= + # Allow rendering unsafe URLs which have "data:" scheme. NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME=false diff --git a/web/config/index.ts b/web/config/index.ts index 158d9976fc..4e98182c0e 100644 --- a/web/config/index.ts +++ b/web/config/index.ts @@ -144,7 +144,9 @@ export const getMaxToken = (modelId: string) => { export const LOCALE_COOKIE_NAME = 'locale' +const COOKIE_DOMAIN = (process.env.NEXT_PUBLIC_COOKIE_DOMAIN || '').trim() export const CSRF_COOKIE_NAME = () => { + if (COOKIE_DOMAIN) return 'csrf_token' const isSecure = API_PREFIX.startsWith('https://') return isSecure ? '__Host-csrf_token' : 'csrf_token' } From 0e62a66cc2f6ad4038f065de9ec7f743085f1f8f Mon Sep 17 00:00:00 2001 From: Wu Tianwei <30284043+WTW0313@users.noreply.github.com> Date: Tue, 28 Oct 2025 10:22:16 +0800 Subject: [PATCH 015/394] feat: Introduce RAG tool recommendations and refactor related components for improved plugin management (#27259) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../hooks/use-refresh-plugin-list.tsx | 5 +- .../plugin-auth/hooks/use-credential.ts | 9 +- .../components/plugins/plugin-auth/types.ts | 3 + .../workflow/block-selector/all-tools.tsx | 4 +- .../market-place-plugin/list.tsx | 2 +- .../index.tsx} | 41 +++---- .../rag-tool-recommendations/list.tsx | 102 ++++++++++++++++++ .../uninstalled-item.tsx | 63 +++++++++++ .../workflow/block-selector/tools.tsx | 4 +- .../workflow/hooks/use-checklist.ts | 25 +++-- .../hooks/use-fetch-workflow-inspect-vars.ts | 24 +++-- .../hooks/use-inspect-vars-crud-common.ts | 27 +++-- .../workflow/hooks/use-nodes-meta-data.ts | 23 ++-- .../workflow/hooks/use-tool-icon.ts | 38 ++++--- .../workflow/hooks/use-workflow-search.tsx | 23 ++-- .../workflow/hooks/use-workflow-variables.ts | 44 ++++---- .../components/workflow/hooks/use-workflow.ts | 53 +-------- web/app/components/workflow/index.tsx | 30 +++--- .../_base/components/workflow-panel/index.tsx | 7 +- .../nodes/_base/hooks/use-one-step-run.ts | 27 +++-- .../condition-list/condition-item.tsx | 35 +++--- .../workflow/nodes/iteration/use-config.ts | 24 +++-- .../workflow/nodes/loop/use-config.ts | 35 ++++-- .../extract-parameter/import-from-tool.tsx | 18 ++-- .../workflow/nodes/tool/use-config.ts | 50 +++++---- .../workflow/store/workflow/tool-slice.ts | 19 ---- web/app/components/workflow/types.ts | 11 +- web/i18n/en-US/pipeline.ts | 2 +- web/i18n/ja-JP/pipeline.ts | 2 +- web/i18n/zh-Hans/pipeline.ts | 2 +- web/service/use-plugins.ts | 14 ++- web/service/use-tools.ts | 12 +++ 32 files changed, 490 insertions(+), 288 deletions(-) rename web/app/components/workflow/block-selector/{rag-tool-suggestions.tsx => rag-tool-recommendations/index.tsx} (69%) create mode 100644 web/app/components/workflow/block-selector/rag-tool-recommendations/list.tsx create mode 100644 web/app/components/workflow/block-selector/rag-tool-recommendations/uninstalled-item.tsx diff --git a/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx b/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx index 024444cd6a..7c3ab29c49 100644 --- a/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx +++ b/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx @@ -2,7 +2,7 @@ import { useModelList } from '@/app/components/header/account-setting/model-prov import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useProviderContext } from '@/context/provider-context' import { useInvalidateInstalledPluginList } from '@/service/use-plugins' -import { useInvalidateAllBuiltInTools, useInvalidateAllToolProviders } from '@/service/use-tools' +import { useInvalidateAllBuiltInTools, useInvalidateAllToolProviders, useInvalidateRAGRecommendedPlugins } from '@/service/use-tools' import { useInvalidateStrategyProviders } from '@/service/use-strategy' import type { Plugin, PluginDeclaration, PluginManifestInMarket } from '../../types' import { PluginType } from '../../types' @@ -23,6 +23,8 @@ const useRefreshPluginList = () => { const invalidateDataSourceListAuth = useInvalidDataSourceListAuth() const invalidateStrategyProviders = useInvalidateStrategyProviders() + + const invalidateRAGRecommendedPlugins = useInvalidateRAGRecommendedPlugins() return { refreshPluginList: (manifest?: PluginManifestInMarket | Plugin | PluginDeclaration | null, refreshAllType?: boolean) => { // installed list @@ -32,6 +34,7 @@ const useRefreshPluginList = () => { if ((manifest && PluginType.tool.includes(manifest.category)) || refreshAllType) { invalidateAllToolProviders() invalidateAllBuiltInTools() + invalidateRAGRecommendedPlugins() // TODO: update suggested tools. It's a function in hook useMarketplacePlugins,handleUpdatePlugins } diff --git a/web/app/components/plugins/plugin-auth/hooks/use-credential.ts b/web/app/components/plugins/plugin-auth/hooks/use-credential.ts index 5a7a497ad9..9c342f2ced 100644 --- a/web/app/components/plugins/plugin-auth/hooks/use-credential.ts +++ b/web/app/components/plugins/plugin-auth/hooks/use-credential.ts @@ -15,6 +15,7 @@ import { import { useGetApi } from './use-get-api' import type { PluginPayload } from '../types' import type { CredentialTypeEnum } from '../types' +import { useInvalidToolsByType } from '@/service/use-tools' export const useGetPluginCredentialInfoHook = (pluginPayload: PluginPayload, enable?: boolean) => { const apiMap = useGetApi(pluginPayload) @@ -29,8 +30,14 @@ export const useDeletePluginCredentialHook = (pluginPayload: PluginPayload) => { export const useInvalidPluginCredentialInfoHook = (pluginPayload: PluginPayload) => { const apiMap = useGetApi(pluginPayload) + const invalidPluginCredentialInfo = useInvalidPluginCredentialInfo(apiMap.getCredentialInfo) + const providerType = pluginPayload.providerType + const invalidToolsByType = useInvalidToolsByType(providerType) - return useInvalidPluginCredentialInfo(apiMap.getCredentialInfo) + return () => { + invalidPluginCredentialInfo() + invalidToolsByType() + } } export const useSetPluginDefaultCredentialHook = (pluginPayload: PluginPayload) => { diff --git a/web/app/components/plugins/plugin-auth/types.ts b/web/app/components/plugins/plugin-auth/types.ts index 6366c80de3..fb23269b4b 100644 --- a/web/app/components/plugins/plugin-auth/types.ts +++ b/web/app/components/plugins/plugin-auth/types.ts @@ -1,3 +1,5 @@ +import type { CollectionType } from '../../tools/types' + export type { AddApiKeyButtonProps } from './authorize/add-api-key-button' export type { AddOAuthButtonProps } from './authorize/add-oauth-button' @@ -10,6 +12,7 @@ export enum AuthCategory { export type PluginPayload = { category: AuthCategory provider: string + providerType: CollectionType | string } export enum CredentialTypeEnum { diff --git a/web/app/components/workflow/block-selector/all-tools.tsx b/web/app/components/workflow/block-selector/all-tools.tsx index 6a2e07a411..7db8b9acf5 100644 --- a/web/app/components/workflow/block-selector/all-tools.tsx +++ b/web/app/components/workflow/block-selector/all-tools.tsx @@ -25,7 +25,7 @@ import PluginList, { type ListProps } from '@/app/components/workflow/block-sele import { PluginType } from '../../plugins/types' import { useMarketplacePlugins } from '../../plugins/marketplace/hooks' import { useGlobalPublicStore } from '@/context/global-public-context' -import RAGToolSuggestions from './rag-tool-suggestions' +import RAGToolRecommendations from './rag-tool-recommendations' type AllToolsProps = { className?: string @@ -148,7 +148,7 @@ const AllTools = ({ onScroll={pluginRef.current?.handleScroll} > {isShowRAGRecommendations && ( - > } -const RAGToolSuggestions: React.FC = ({ +const RAGToolRecommendations = ({ viewType, onSelect, onTagsChange, -}) => { +}: RAGToolRecommendationsProps) => { const { t } = useTranslation() const { data: ragRecommendedPlugins, + isLoading: isLoadingRAGRecommendedPlugins, isFetching: isFetchingRAGRecommendedPlugins, } = useRAGRecommendedPlugins() const recommendedPlugins = useMemo(() => { if (ragRecommendedPlugins) - return [...ragRecommendedPlugins.installed_recommended_plugins] + return ragRecommendedPlugins.installed_recommended_plugins + return [] + }, [ragRecommendedPlugins]) + + const unInstalledPlugins = useMemo(() => { + if (ragRecommendedPlugins) + return (ragRecommendedPlugins.uninstalled_recommended_plugins).map(getFormattedPlugin) return [] }, [ragRecommendedPlugins]) @@ -48,15 +55,16 @@ const RAGToolSuggestions: React.FC = ({
{t('pipeline.ragToolSuggestions.title')}
- {isFetchingRAGRecommendedPlugins && ( + {/* For first time loading, show loading */} + {isLoadingRAGRecommendedPlugins && (
)} - {!isFetchingRAGRecommendedPlugins && recommendedPlugins.length === 0 && ( + {!isFetchingRAGRecommendedPlugins && recommendedPlugins.length === 0 && unInstalledPlugins.length === 0 && (

= ({ />

)} - {!isFetchingRAGRecommendedPlugins && recommendedPlugins.length > 0 && ( + {(recommendedPlugins.length > 0 || unInstalledPlugins.length > 0) && ( <> -
= ({ ) } -export default React.memo(RAGToolSuggestions) +export default React.memo(RAGToolRecommendations) diff --git a/web/app/components/workflow/block-selector/rag-tool-recommendations/list.tsx b/web/app/components/workflow/block-selector/rag-tool-recommendations/list.tsx new file mode 100644 index 0000000000..19378caf48 --- /dev/null +++ b/web/app/components/workflow/block-selector/rag-tool-recommendations/list.tsx @@ -0,0 +1,102 @@ +import { + useMemo, + useRef, +} from 'react' +import type { BlockEnum, ToolWithProvider } from '../../types' +import type { ToolDefaultValue } from '../types' +import { ViewType } from '../view-type-select' +import { useGetLanguage } from '@/context/i18n' +import { groupItems } from '../index-bar' +import cn from '@/utils/classnames' +import ToolListTreeView from '../tool/tool-list-tree-view/list' +import ToolListFlatView from '../tool/tool-list-flat-view/list' +import UninstalledItem from './uninstalled-item' +import type { Plugin } from '@/app/components/plugins/types' + +type ListProps = { + onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + tools: ToolWithProvider[] + viewType: ViewType + unInstalledPlugins: Plugin[] + className?: string +} + +const List = ({ + onSelect, + tools, + viewType, + unInstalledPlugins, + className, +}: ListProps) => { + const language = useGetLanguage() + const isFlatView = viewType === ViewType.flat + + const { letters, groups: withLetterAndGroupViewToolsData } = groupItems(tools, tool => tool.label[language][0]) + const treeViewToolsData = useMemo(() => { + const result: Record = {} + Object.keys(withLetterAndGroupViewToolsData).forEach((letter) => { + Object.keys(withLetterAndGroupViewToolsData[letter]).forEach((groupName) => { + if (!result[groupName]) + result[groupName] = [] + result[groupName].push(...withLetterAndGroupViewToolsData[letter][groupName]) + }) + }) + return result + }, [withLetterAndGroupViewToolsData]) + + const listViewToolData = useMemo(() => { + const result: ToolWithProvider[] = [] + letters.forEach((letter) => { + Object.keys(withLetterAndGroupViewToolsData[letter]).forEach((groupName) => { + result.push(...withLetterAndGroupViewToolsData[letter][groupName].map((item) => { + return { + ...item, + letter, + } + })) + }) + }) + + return result + }, [withLetterAndGroupViewToolsData, letters]) + + const toolRefs = useRef({}) + + return ( +
+ {!!tools.length && ( + isFlatView ? ( + + ) : ( + + ) + )} + { + unInstalledPlugins.map((item) => { + return ( + + ) + }) + } +
+ ) +} + +export default List diff --git a/web/app/components/workflow/block-selector/rag-tool-recommendations/uninstalled-item.tsx b/web/app/components/workflow/block-selector/rag-tool-recommendations/uninstalled-item.tsx new file mode 100644 index 0000000000..98395ec25a --- /dev/null +++ b/web/app/components/workflow/block-selector/rag-tool-recommendations/uninstalled-item.tsx @@ -0,0 +1,63 @@ +'use client' +import React from 'react' +import { useContext } from 'use-context-selector' +import { useTranslation } from 'react-i18next' +import type { Plugin } from '@/app/components/plugins/types' +import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' +import I18n from '@/context/i18n' +import { useBoolean } from 'ahooks' +import { BlockEnum } from '../../types' +import BlockIcon from '../../block-icon' + +type UninstalledItemProps = { + payload: Plugin +} + +const UninstalledItem = ({ + payload, +}: UninstalledItemProps) => { + const { t } = useTranslation() + const { locale } = useContext(I18n) + + const getLocalizedText = (obj: Record | undefined) => + obj?.[locale] || obj?.['en-US'] || obj?.en_US || '' + const [isShowInstallModal, { + setTrue: showInstallModal, + setFalse: hideInstallModal, + }] = useBoolean(false) + + return ( +
+ +
+
+ + {getLocalizedText(payload.label)} + + + {payload.org} + +
+
+ {t('plugin.installAction')} +
+ {isShowInstallModal && ( + + )} +
+
+ ) +} +export default React.memo(UninstalledItem) diff --git a/web/app/components/workflow/block-selector/tools.tsx b/web/app/components/workflow/block-selector/tools.tsx index feb34d2651..71ed4092a3 100644 --- a/web/app/components/workflow/block-selector/tools.tsx +++ b/web/app/components/workflow/block-selector/tools.tsx @@ -30,7 +30,7 @@ type ToolsProps = { canChooseMCPTool?: boolean isShowRAGRecommendations?: boolean } -const Blocks = ({ +const Tools = ({ onSelect, canNotSelectMultiple, onSelectMultiple, @@ -146,4 +146,4 @@ const Blocks = ({ ) } -export default memo(Blocks) +export default memo(Tools) diff --git a/web/app/components/workflow/hooks/use-checklist.ts b/web/app/components/workflow/hooks/use-checklist.ts index 32945a8927..d29827f273 100644 --- a/web/app/components/workflow/hooks/use-checklist.ts +++ b/web/app/components/workflow/hooks/use-checklist.ts @@ -45,14 +45,19 @@ import { getNodeUsedVars, isSpecialVar } from '../nodes/_base/components/variabl import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { KnowledgeBaseNodeType } from '../nodes/knowledge-base/types' +import { + useAllBuiltInTools, + useAllCustomTools, + useAllWorkflowTools, +} from '@/service/use-tools' export const useChecklist = (nodes: Node[], edges: Edge[]) => { const { t } = useTranslation() const language = useGetLanguage() const { nodesMap: nodesExtraData } = useNodesMetaData() - const buildInTools = useStore(s => s.buildInTools) - const customTools = useStore(s => s.customTools) - const workflowTools = useStore(s => s.workflowTools) + const { data: buildInTools } = useAllBuiltInTools() + const { data: customTools } = useAllCustomTools() + const { data: workflowTools } = useAllWorkflowTools() const dataSourceList = useStore(s => s.dataSourceList) const { data: strategyProviders } = useStrategyProviders() const datasetsDetail = useDatasetsDetailStore(s => s.datasetsDetail) @@ -104,7 +109,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { let usedVars: ValueSelector[] = [] if (node.data.type === BlockEnum.Tool) - moreDataForCheckValid = getToolCheckParams(node.data as ToolNodeType, buildInTools, customTools, workflowTools, language) + moreDataForCheckValid = getToolCheckParams(node.data as ToolNodeType, buildInTools || [], customTools || [], workflowTools || [], language) if (node.data.type === BlockEnum.DataSource) moreDataForCheckValid = getDataSourceCheckParams(node.data as DataSourceNodeType, dataSourceList || [], language) @@ -194,6 +199,9 @@ export const useChecklistBeforePublish = () => { const { getNodesAvailableVarList } = useGetNodesAvailableVarList() const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding) const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank) + const { data: buildInTools } = useAllBuiltInTools() + const { data: customTools } = useAllCustomTools() + const { data: workflowTools } = useAllWorkflowTools() const getCheckData = useCallback((data: CommonNodeType<{}>, datasets: DataSet[]) => { let checkData = data @@ -221,7 +229,7 @@ export const useChecklistBeforePublish = () => { } as CommonNodeType } return checkData - }, []) + }, [embeddingModelList, rerankModelList]) const handleCheckBeforePublish = useCallback(async () => { const { @@ -230,9 +238,6 @@ export const useChecklistBeforePublish = () => { } = store.getState() const { dataSourceList, - buildInTools, - customTools, - workflowTools, } = workflowStore.getState() const nodes = getNodes() const filteredNodes = nodes.filter(node => node.type === CUSTOM_NODE) @@ -275,7 +280,7 @@ export const useChecklistBeforePublish = () => { let moreDataForCheckValid let usedVars: ValueSelector[] = [] if (node.data.type === BlockEnum.Tool) - moreDataForCheckValid = getToolCheckParams(node.data as ToolNodeType, buildInTools, customTools, workflowTools, language) + moreDataForCheckValid = getToolCheckParams(node.data as ToolNodeType, buildInTools || [], customTools || [], workflowTools || [], language) if (node.data.type === BlockEnum.DataSource) moreDataForCheckValid = getDataSourceCheckParams(node.data as DataSourceNodeType, dataSourceList || [], language) @@ -340,7 +345,7 @@ export const useChecklistBeforePublish = () => { } return true - }, [store, notify, t, language, nodesExtraData, strategyProviders, updateDatasetsDetail, getCheckData, getStartNodes, workflowStore]) + }, [store, notify, t, language, nodesExtraData, strategyProviders, updateDatasetsDetail, getCheckData, getStartNodes, workflowStore, buildInTools, customTools, workflowTools]) return { handleCheckBeforePublish, diff --git a/web/app/components/workflow/hooks/use-fetch-workflow-inspect-vars.ts b/web/app/components/workflow/hooks/use-fetch-workflow-inspect-vars.ts index 1527fb82e2..60f839b93d 100644 --- a/web/app/components/workflow/hooks/use-fetch-workflow-inspect-vars.ts +++ b/web/app/components/workflow/hooks/use-fetch-workflow-inspect-vars.ts @@ -11,6 +11,12 @@ import useMatchSchemaType, { getMatchedSchemaType } from '../nodes/_base/compone import { toNodeOutputVars } from '../nodes/_base/components/variable/utils' import type { SchemaTypeDefinition } from '@/service/use-common' import { useCallback } from 'react' +import { + useAllBuiltInTools, + useAllCustomTools, + useAllMCPTools, + useAllWorkflowTools, +} from '@/service/use-tools' type Params = { flowType: FlowType @@ -27,17 +33,17 @@ export const useSetWorkflowVarsWithValue = ({ const invalidateSysVarValues = useInvalidateSysVarValues(flowType, flowId) const { handleCancelAllNodeSuccessStatus } = useNodesInteractionsWithoutSync() const { schemaTypeDefinitions } = useMatchSchemaType() - const buildInTools = useStore(s => s.buildInTools) - const customTools = useStore(s => s.customTools) - const workflowTools = useStore(s => s.workflowTools) - const mcpTools = useStore(s => s.mcpTools) + const { data: buildInTools } = useAllBuiltInTools() + const { data: customTools } = useAllCustomTools() + const { data: workflowTools } = useAllWorkflowTools() + const { data: mcpTools } = useAllMCPTools() const dataSourceList = useStore(s => s.dataSourceList) const allPluginInfoList = { - buildInTools, - customTools, - workflowTools, - mcpTools, - dataSourceList: dataSourceList ?? [], + buildInTools: buildInTools || [], + customTools: customTools || [], + workflowTools: workflowTools || [], + mcpTools: mcpTools || [], + dataSourceList: dataSourceList || [], } const setInspectVarsToStore = (inspectVars: VarInInspect[], passedInAllPluginInfoList?: Record, passedInSchemaTypeDefinitions?: SchemaTypeDefinition[]) => { diff --git a/web/app/components/workflow/hooks/use-inspect-vars-crud-common.ts b/web/app/components/workflow/hooks/use-inspect-vars-crud-common.ts index f35f0c7dab..6b7acd0a85 100644 --- a/web/app/components/workflow/hooks/use-inspect-vars-crud-common.ts +++ b/web/app/components/workflow/hooks/use-inspect-vars-crud-common.ts @@ -18,6 +18,12 @@ import type { FlowType } from '@/types/common' import useFLow from '@/service/use-flow' import { useStoreApi } from 'reactflow' import type { SchemaTypeDefinition } from '@/service/use-common' +import { + useAllBuiltInTools, + useAllCustomTools, + useAllMCPTools, + useAllWorkflowTools, +} from '@/service/use-tools' type Params = { flowId: string @@ -51,6 +57,11 @@ export const useInspectVarsCrudCommon = ({ const { mutateAsync: doEditInspectorVar } = useEditInspectorVar(flowId) const { handleCancelNodeSuccessStatus } = useNodesInteractionsWithoutSync() const { handleEdgeCancelRunningStatus } = useEdgesInteractionsWithoutSync() + const { data: buildInTools } = useAllBuiltInTools() + const { data: customTools } = useAllCustomTools() + const { data: workflowTools } = useAllWorkflowTools() + const { data: mcpTools } = useAllMCPTools() + const getNodeInspectVars = useCallback((nodeId: string) => { const { nodesWithInspectVars } = workflowStore.getState() const node = nodesWithInspectVars.find(node => node.nodeId === nodeId) @@ -98,10 +109,6 @@ export const useInspectVarsCrudCommon = ({ const fetchInspectVarValue = useCallback(async (selector: ValueSelector, schemaTypeDefinitions: SchemaTypeDefinition[]) => { const { setNodeInspectVars, - buildInTools, - customTools, - workflowTools, - mcpTools, dataSourceList, } = workflowStore.getState() const nodeId = selector[0] @@ -119,11 +126,11 @@ export const useInspectVarsCrudCommon = ({ const nodeArr = getNodes() const currentNode = nodeArr.find(node => node.id === nodeId) const allPluginInfoList = { - buildInTools, - customTools, - workflowTools, - mcpTools, - dataSourceList: dataSourceList ?? [], + buildInTools: buildInTools || [], + customTools: customTools || [], + workflowTools: workflowTools || [], + mcpTools: mcpTools || [], + dataSourceList: dataSourceList || [], } const currentNodeOutputVars = toNodeOutputVars([currentNode], false, () => true, [], [], [], allPluginInfoList, schemaTypeDefinitions) const vars = await fetchNodeInspectVars(flowType, flowId, nodeId) @@ -135,7 +142,7 @@ export const useInspectVarsCrudCommon = ({ } }) setNodeInspectVars(nodeId, varsWithSchemaType) - }, [workflowStore, flowType, flowId, invalidateSysVarValues, invalidateConversationVarValues]) + }, [workflowStore, flowType, flowId, invalidateSysVarValues, invalidateConversationVarValues, buildInTools, customTools, workflowTools, mcpTools]) // after last run would call this const appendNodeInspectVars = useCallback((nodeId: string, payload: VarInInspect[], allNodes: Node[]) => { diff --git a/web/app/components/workflow/hooks/use-nodes-meta-data.ts b/web/app/components/workflow/hooks/use-nodes-meta-data.ts index cfeb41de34..fd63f23590 100644 --- a/web/app/components/workflow/hooks/use-nodes-meta-data.ts +++ b/web/app/components/workflow/hooks/use-nodes-meta-data.ts @@ -7,6 +7,11 @@ import { CollectionType } from '@/app/components/tools/types' import { useStore } from '@/app/components/workflow/store' import { canFindTool } from '@/utils' import { useGetLanguage } from '@/context/i18n' +import { + useAllBuiltInTools, + useAllCustomTools, + useAllWorkflowTools, +} from '@/service/use-tools' export const useNodesMetaData = () => { const availableNodesMetaData = useHooksStore(s => s.availableNodesMetaData) @@ -21,9 +26,9 @@ export const useNodesMetaData = () => { export const useNodeMetaData = (node: Node) => { const language = useGetLanguage() - const buildInTools = useStore(s => s.buildInTools) - const customTools = useStore(s => s.customTools) - const workflowTools = useStore(s => s.workflowTools) + const { data: buildInTools } = useAllBuiltInTools() + const { data: customTools } = useAllCustomTools() + const { data: workflowTools } = useAllWorkflowTools() const dataSourceList = useStore(s => s.dataSourceList) const availableNodesMetaData = useNodesMetaData() const { data } = node @@ -34,10 +39,10 @@ export const useNodeMetaData = (node: Node) => { if (data.type === BlockEnum.Tool) { if (data.provider_type === CollectionType.builtIn) - return buildInTools.find(toolWithProvider => canFindTool(toolWithProvider.id, data.provider_id))?.author + return buildInTools?.find(toolWithProvider => canFindTool(toolWithProvider.id, data.provider_id))?.author if (data.provider_type === CollectionType.workflow) - return workflowTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.author - return customTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.author + return workflowTools?.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.author + return customTools?.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.author } return nodeMetaData?.metaData.author }, [data, buildInTools, customTools, workflowTools, nodeMetaData, dataSourceList]) @@ -47,10 +52,10 @@ export const useNodeMetaData = (node: Node) => { return dataSourceList?.find(dataSource => dataSource.plugin_id === data.plugin_id)?.description[language] if (data.type === BlockEnum.Tool) { if (data.provider_type === CollectionType.builtIn) - return buildInTools.find(toolWithProvider => canFindTool(toolWithProvider.id, data.provider_id))?.description[language] + return buildInTools?.find(toolWithProvider => canFindTool(toolWithProvider.id, data.provider_id))?.description[language] if (data.provider_type === CollectionType.workflow) - return workflowTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language] - return customTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language] + return workflowTools?.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language] + return customTools?.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language] } return nodeMetaData?.metaData.description }, [data, buildInTools, customTools, workflowTools, nodeMetaData, dataSourceList, language]) diff --git a/web/app/components/workflow/hooks/use-tool-icon.ts b/web/app/components/workflow/hooks/use-tool-icon.ts index 734a7da390..32d65365db 100644 --- a/web/app/components/workflow/hooks/use-tool-icon.ts +++ b/web/app/components/workflow/hooks/use-tool-icon.ts @@ -14,12 +14,18 @@ import { } from '../store' import { CollectionType } from '@/app/components/tools/types' import { canFindTool } from '@/utils' +import { + useAllBuiltInTools, + useAllCustomTools, + useAllMCPTools, + useAllWorkflowTools, +} from '@/service/use-tools' export const useToolIcon = (data?: Node['data']) => { - const buildInTools = useStore(s => s.buildInTools) - const customTools = useStore(s => s.customTools) - const workflowTools = useStore(s => s.workflowTools) - const mcpTools = useStore(s => s.mcpTools) + const { data: buildInTools } = useAllBuiltInTools() + const { data: customTools } = useAllCustomTools() + const { data: workflowTools } = useAllWorkflowTools() + const { data: mcpTools } = useAllMCPTools() const dataSourceList = useStore(s => s.dataSourceList) // const a = useStore(s => s.data) const toolIcon = useMemo(() => { @@ -27,15 +33,15 @@ export const useToolIcon = (data?: Node['data']) => { return '' if (data.type === BlockEnum.Tool) { // eslint-disable-next-line sonarjs/no-dead-store - let targetTools = buildInTools + let targetTools = buildInTools || [] if (data.provider_type === CollectionType.builtIn) - targetTools = buildInTools + targetTools = buildInTools || [] else if (data.provider_type === CollectionType.custom) - targetTools = customTools + targetTools = customTools || [] else if (data.provider_type === CollectionType.mcp) - targetTools = mcpTools + targetTools = mcpTools || [] else - targetTools = workflowTools + targetTools = workflowTools || [] return targetTools.find(toolWithProvider => canFindTool(toolWithProvider.id, data.provider_id))?.icon } if (data.type === BlockEnum.DataSource) @@ -46,24 +52,24 @@ export const useToolIcon = (data?: Node['data']) => { } export const useGetToolIcon = () => { + const { data: buildInTools } = useAllBuiltInTools() + const { data: customTools } = useAllCustomTools() + const { data: workflowTools } = useAllWorkflowTools() const workflowStore = useWorkflowStore() const getToolIcon = useCallback((data: Node['data']) => { const { - buildInTools, - customTools, - workflowTools, dataSourceList, } = workflowStore.getState() if (data.type === BlockEnum.Tool) { // eslint-disable-next-line sonarjs/no-dead-store - let targetTools = buildInTools + let targetTools = buildInTools || [] if (data.provider_type === CollectionType.builtIn) - targetTools = buildInTools + targetTools = buildInTools || [] else if (data.provider_type === CollectionType.custom) - targetTools = customTools + targetTools = customTools || [] else - targetTools = workflowTools + targetTools = workflowTools || [] return targetTools.find(toolWithProvider => canFindTool(toolWithProvider.id, data.provider_id))?.icon } diff --git a/web/app/components/workflow/hooks/use-workflow-search.tsx b/web/app/components/workflow/hooks/use-workflow-search.tsx index 095ae4577a..68ad9873f9 100644 --- a/web/app/components/workflow/hooks/use-workflow-search.tsx +++ b/web/app/components/workflow/hooks/use-workflow-search.tsx @@ -8,11 +8,16 @@ import { workflowNodesAction } from '@/app/components/goto-anything/actions/work import BlockIcon from '@/app/components/workflow/block-icon' import { setupNodeSelectionListener } from '../utils/node-navigation' import { BlockEnum } from '../types' -import { useStore } from '../store' import type { Emoji } from '@/app/components/tools/types' import { CollectionType } from '@/app/components/tools/types' import { canFindTool } from '@/utils' import type { LLMNodeType } from '../nodes/llm/types' +import { + useAllBuiltInTools, + useAllCustomTools, + useAllMCPTools, + useAllWorkflowTools, +} from '@/service/use-tools' /** * Hook to register workflow nodes search functionality @@ -22,23 +27,23 @@ export const useWorkflowSearch = () => { const { handleNodeSelect } = useNodesInteractions() // Filter and process nodes for search - const buildInTools = useStore(s => s.buildInTools) - const customTools = useStore(s => s.customTools) - const workflowTools = useStore(s => s.workflowTools) - const mcpTools = useStore(s => s.mcpTools) + const { data: buildInTools } = useAllBuiltInTools() + const { data: customTools } = useAllCustomTools() + const { data: workflowTools } = useAllWorkflowTools() + const { data: mcpTools } = useAllMCPTools() // Extract tool icon logic - clean separation of concerns const getToolIcon = useCallback((nodeData: CommonNodeType): string | Emoji | undefined => { if (nodeData?.type !== BlockEnum.Tool) return undefined const toolCollections: Record = { - [CollectionType.builtIn]: buildInTools, - [CollectionType.custom]: customTools, - [CollectionType.mcp]: mcpTools, + [CollectionType.builtIn]: buildInTools || [], + [CollectionType.custom]: customTools || [], + [CollectionType.mcp]: mcpTools || [], } const targetTools = (nodeData.provider_type && toolCollections[nodeData.provider_type]) || workflowTools - return targetTools.find((tool: any) => canFindTool(tool.id, nodeData.provider_id))?.icon + return targetTools?.find((tool: any) => canFindTool(tool.id, nodeData.provider_id))?.icon }, [buildInTools, customTools, workflowTools, mcpTools]) // Extract model info logic - clean extraction diff --git a/web/app/components/workflow/hooks/use-workflow-variables.ts b/web/app/components/workflow/hooks/use-workflow-variables.ts index 8422a7fd0d..871937365a 100644 --- a/web/app/components/workflow/hooks/use-workflow-variables.ts +++ b/web/app/components/workflow/hooks/use-workflow-variables.ts @@ -10,20 +10,25 @@ import type { } from '@/app/components/workflow/types' import { useIsChatMode } from './use-workflow' import { useStoreApi } from 'reactflow' -import { useStore } from '@/app/components/workflow/store' import type { Type } from '../nodes/llm/types' import useMatchSchemaType from '../nodes/_base/components/variable/use-match-schema-type' +import { + useAllBuiltInTools, + useAllCustomTools, + useAllMCPTools, + useAllWorkflowTools, +} from '@/service/use-tools' export const useWorkflowVariables = () => { const { t } = useTranslation() const workflowStore = useWorkflowStore() const { schemaTypeDefinitions } = useMatchSchemaType() - const buildInTools = useStore(s => s.buildInTools) - const customTools = useStore(s => s.customTools) - const workflowTools = useStore(s => s.workflowTools) - const mcpTools = useStore(s => s.mcpTools) - const dataSourceList = useStore(s => s.dataSourceList) + const { data: buildInTools } = useAllBuiltInTools() + const { data: customTools } = useAllCustomTools() + const { data: workflowTools } = useAllWorkflowTools() + const { data: mcpTools } = useAllMCPTools() + const getNodeAvailableVars = useCallback(({ parentNode, beforeNodes, @@ -43,6 +48,7 @@ export const useWorkflowVariables = () => { conversationVariables, environmentVariables, ragPipelineVariables, + dataSourceList, } = workflowStore.getState() return toNodeAvailableVars({ parentNode, @@ -54,15 +60,15 @@ export const useWorkflowVariables = () => { ragVariables: ragPipelineVariables, filterVar, allPluginInfoList: { - buildInTools, - customTools, - workflowTools, - mcpTools, - dataSourceList: dataSourceList ?? [], + buildInTools: buildInTools || [], + customTools: customTools || [], + workflowTools: workflowTools || [], + mcpTools: mcpTools || [], + dataSourceList: dataSourceList || [], }, schemaTypeDefinitions, }) - }, [t, workflowStore, schemaTypeDefinitions, buildInTools]) + }, [t, workflowStore, schemaTypeDefinitions, buildInTools, customTools, workflowTools, mcpTools]) const getCurrentVariableType = useCallback(({ parentNode, @@ -87,10 +93,6 @@ export const useWorkflowVariables = () => { conversationVariables, environmentVariables, ragPipelineVariables, - buildInTools, - customTools, - workflowTools, - mcpTools, dataSourceList, } = workflowStore.getState() return getVarType({ @@ -105,16 +107,16 @@ export const useWorkflowVariables = () => { conversationVariables, ragVariables: ragPipelineVariables, allPluginInfoList: { - buildInTools, - customTools, - workflowTools, - mcpTools, + buildInTools: buildInTools || [], + customTools: customTools || [], + workflowTools: workflowTools || [], + mcpTools: mcpTools || [], dataSourceList: dataSourceList ?? [], }, schemaTypeDefinitions, preferSchemaType, }) - }, [workflowStore, getVarType, schemaTypeDefinitions]) + }, [workflowStore, getVarType, schemaTypeDefinitions, buildInTools, customTools, workflowTools, mcpTools]) return { getNodeAvailableVars, diff --git a/web/app/components/workflow/hooks/use-workflow.ts b/web/app/components/workflow/hooks/use-workflow.ts index 3f9f8106cf..66c499dc59 100644 --- a/web/app/components/workflow/hooks/use-workflow.ts +++ b/web/app/components/workflow/hooks/use-workflow.ts @@ -32,15 +32,9 @@ import { CUSTOM_NOTE_NODE } from '../note-node/constants' import { findUsedVarNodes, getNodeOutputVars, updateNodeVars } from '../nodes/_base/components/variable/utils' import { useAvailableBlocks } from './use-available-blocks' import { useStore as useAppStore } from '@/app/components/app/store' -import { - fetchAllBuiltInTools, - fetchAllCustomTools, - fetchAllMCPTools, - fetchAllWorkflowTools, -} from '@/service/tools' + import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants' import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants' -import { basePath } from '@/utils/var' import { useNodesMetaData } from '.' export const useIsChatMode = () => { @@ -416,51 +410,6 @@ export const useWorkflow = () => { } } -export const useFetchToolsData = () => { - const workflowStore = useWorkflowStore() - - const handleFetchAllTools = useCallback(async (type: string) => { - if (type === 'builtin') { - const buildInTools = await fetchAllBuiltInTools() - - if (basePath) { - buildInTools.forEach((item) => { - if (typeof item.icon == 'string' && !item.icon.includes(basePath)) - item.icon = `${basePath}${item.icon}` - }) - } - workflowStore.setState({ - buildInTools: buildInTools || [], - }) - } - if (type === 'custom') { - const customTools = await fetchAllCustomTools() - - workflowStore.setState({ - customTools: customTools || [], - }) - } - if (type === 'workflow') { - const workflowTools = await fetchAllWorkflowTools() - - workflowStore.setState({ - workflowTools: workflowTools || [], - }) - } - if (type === 'mcp') { - const mcpTools = await fetchAllMCPTools() - - workflowStore.setState({ - mcpTools: mcpTools || [], - }) - } - }, [workflowStore]) - - return { - handleFetchAllTools, - } -} - export const useWorkflowReadOnly = () => { const workflowStore = useWorkflowStore() const workflowRunningData = useStore(s => s.workflowRunningData) diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index b289cafefd..86c6bf153e 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -37,7 +37,6 @@ import { } from './types' import { useEdgesInteractions, - useFetchToolsData, useNodesInteractions, useNodesReadOnly, useNodesSyncDraft, @@ -92,6 +91,12 @@ import useMatchSchemaType from './nodes/_base/components/variable/use-match-sche import type { VarInInspect } from '@/types/workflow' import { fetchAllInspectVars } from '@/service/workflow' import cn from '@/utils/classnames' +import { + useAllBuiltInTools, + useAllCustomTools, + useAllMCPTools, + useAllWorkflowTools, +} from '@/service/use-tools' const Confirm = dynamic(() => import('@/app/components/base/confirm'), { ssr: false, @@ -242,13 +247,6 @@ export const Workflow: FC = memo(({ }) } }) - const { handleFetchAllTools } = useFetchToolsData() - useEffect(() => { - handleFetchAllTools('builtin') - handleFetchAllTools('custom') - handleFetchAllTools('workflow') - handleFetchAllTools('mcp') - }, [handleFetchAllTools]) const { handleNodeDragStart, @@ -299,10 +297,10 @@ export const Workflow: FC = memo(({ const { schemaTypeDefinitions } = useMatchSchemaType() const { fetchInspectVars } = useSetWorkflowVarsWithValue() - const buildInTools = useStore(s => s.buildInTools) - const customTools = useStore(s => s.customTools) - const workflowTools = useStore(s => s.workflowTools) - const mcpTools = useStore(s => s.mcpTools) + const { data: buildInTools } = useAllBuiltInTools() + const { data: customTools } = useAllCustomTools() + const { data: workflowTools } = useAllWorkflowTools() + const { data: mcpTools } = useAllMCPTools() const dataSourceList = useStore(s => s.dataSourceList) // buildInTools, customTools, workflowTools, mcpTools, dataSourceList const configsMap = useHooksStore(s => s.configsMap) @@ -323,10 +321,10 @@ export const Workflow: FC = memo(({ passInVars: true, vars, passedInAllPluginInfoList: { - buildInTools, - customTools, - workflowTools, - mcpTools, + buildInTools: buildInTools || [], + customTools: customTools || [], + workflowTools: workflowTools || [], + mcpTools: mcpTools || [], dataSourceList: dataSourceList ?? [], }, passedInSchemaTypeDefinitions: schemaTypeDefinitions, diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx index 03b142ba43..29aebd4fd5 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx @@ -75,6 +75,7 @@ import { DataSourceClassification } from '@/app/components/workflow/nodes/data-s import { useModalContext } from '@/context/modal-context' import DataSourceBeforeRunForm from '@/app/components/workflow/nodes/data-source/before-run-form' import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' +import { useAllBuiltInTools } from '@/service/use-tools' const getCustomRunForm = (params: CustomRunFormProps): React.JSX.Element => { const nodeType = params.payload.type @@ -259,9 +260,9 @@ const BasePanel: FC = ({ return {} })() - const buildInTools = useStore(s => s.buildInTools) + const { data: buildInTools } = useAllBuiltInTools() const currCollection = useMemo(() => { - return buildInTools.find(item => canFindTool(item.id, data.provider_id)) + return buildInTools?.find(item => canFindTool(item.id, data.provider_id)) }, [buildInTools, data.provider_id]) const showPluginAuth = useMemo(() => { return data.type === BlockEnum.Tool && currCollection?.allow_delete @@ -450,6 +451,7 @@ const BasePanel: FC = ({ className='px-4 pb-2' pluginPayload={{ provider: currCollection?.name || '', + providerType: currCollection?.type || '', category: AuthCategory.tool, }} > @@ -461,6 +463,7 @@ const BasePanel: FC = ({ = { [BlockEnum.LLM]: checkLLMValid, @@ -133,21 +140,23 @@ const useOneStepRun = ({ const availableNodesIncludeParent = getBeforeNodesInSameBranchIncludeParent(id) const workflowStore = useWorkflowStore() const { schemaTypeDefinitions } = useMatchSchemaType() + + const { data: buildInTools } = useAllBuiltInTools() + const { data: customTools } = useAllCustomTools() + const { data: workflowTools } = useAllWorkflowTools() + const { data: mcpTools } = useAllMCPTools() + const getVar = (valueSelector: ValueSelector): Var | undefined => { const isSystem = valueSelector[0] === 'sys' const { - buildInTools, - customTools, - workflowTools, - mcpTools, dataSourceList, } = workflowStore.getState() const allPluginInfoList = { - buildInTools, - customTools, - workflowTools, - mcpTools, - dataSourceList: dataSourceList ?? [], + buildInTools: buildInTools || [], + customTools: customTools || [], + workflowTools: workflowTools || [], + mcpTools: mcpTools || [], + dataSourceList: dataSourceList || [], } const allOutputVars = toNodeOutputVars(availableNodes, isChatMode, undefined, undefined, conversationVariables, [], allPluginInfoList, schemaTypeDefinitions) diff --git a/web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx b/web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx index 9bcd4b9671..65dac6f5be 100644 --- a/web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx +++ b/web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx @@ -42,6 +42,12 @@ import BoolValue from '@/app/components/workflow/panel/chat-variable-panel/compo import { getVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils' import { useIsChatMode } from '@/app/components/workflow/hooks/use-workflow' import useMatchSchemaType from '../../../_base/components/variable/use-match-schema-type' +import { + useAllBuiltInTools, + useAllCustomTools, + useAllMCPTools, + useAllWorkflowTools, +} from '@/service/use-tools' const optionNameI18NPrefix = 'workflow.nodes.ifElse.optionName' type ConditionItemProps = { @@ -91,15 +97,12 @@ const ConditionItem = ({ const [isHovered, setIsHovered] = useState(false) const [open, setOpen] = useState(false) + const { data: buildInTools } = useAllBuiltInTools() + const { data: customTools } = useAllCustomTools() + const { data: workflowTools } = useAllWorkflowTools() + const { data: mcpTools } = useAllMCPTools() + const workflowStore = useWorkflowStore() - const { - setControlPromptEditorRerenderKey, - buildInTools, - customTools, - mcpTools, - workflowTools, - dataSourceList, - } = workflowStore.getState() const doUpdateCondition = useCallback((newCondition: Condition) => { if (isSubVariableKey) @@ -213,6 +216,8 @@ const ConditionItem = ({ const handleVarChange = useCallback((valueSelector: ValueSelector, _varItem: Var) => { const { conversationVariables, + setControlPromptEditorRerenderKey, + dataSourceList, } = workflowStore.getState() const resolvedVarType = getVarType({ valueSelector, @@ -220,11 +225,11 @@ const ConditionItem = ({ availableNodes, isChatMode, allPluginInfoList: { - buildInTools, - customTools, - mcpTools, - workflowTools, - dataSourceList: dataSourceList ?? [], + buildInTools: buildInTools || [], + customTools: customTools || [], + mcpTools: mcpTools || [], + workflowTools: workflowTools || [], + dataSourceList: dataSourceList || [], }, schemaTypeDefinitions, }) @@ -241,12 +246,12 @@ const ConditionItem = ({ }) doUpdateCondition(newCondition) setOpen(false) - }, [condition, doUpdateCondition, availableNodes, isChatMode, setControlPromptEditorRerenderKey, schemaTypeDefinitions]) + }, [condition, doUpdateCondition, availableNodes, isChatMode, schemaTypeDefinitions, buildInTools, customTools, mcpTools, workflowTools]) const showBooleanInput = useMemo(() => { if(condition.varType === VarType.boolean) return true - // eslint-disable-next-line sonarjs/prefer-single-boolean-return + if(condition.varType === VarType.arrayBoolean && [ComparisonOperator.contains, ComparisonOperator.notContains].includes(condition.comparison_operator!)) return true return false diff --git a/web/app/components/workflow/nodes/iteration/use-config.ts b/web/app/components/workflow/nodes/iteration/use-config.ts index 9fd31d0484..2e47bb3740 100644 --- a/web/app/components/workflow/nodes/iteration/use-config.ts +++ b/web/app/components/workflow/nodes/iteration/use-config.ts @@ -15,6 +15,12 @@ import type { Item } from '@/app/components/base/select' import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud' import { isEqual } from 'lodash-es' import { useStore } from '../../store' +import { + useAllBuiltInTools, + useAllCustomTools, + useAllMCPTools, + useAllWorkflowTools, +} from '@/service/use-tools' const useConfig = (id: string, payload: IterationNodeType) => { const { @@ -40,17 +46,17 @@ const useConfig = (id: string, payload: IterationNodeType) => { // output const { getIterationNodeChildren } = useWorkflow() const iterationChildrenNodes = getIterationNodeChildren(id) - const buildInTools = useStore(s => s.buildInTools) - const customTools = useStore(s => s.customTools) - const workflowTools = useStore(s => s.workflowTools) - const mcpTools = useStore(s => s.mcpTools) + const { data: buildInTools } = useAllBuiltInTools() + const { data: customTools } = useAllCustomTools() + const { data: workflowTools } = useAllWorkflowTools() + const { data: mcpTools } = useAllMCPTools() const dataSourceList = useStore(s => s.dataSourceList) const allPluginInfoList = { - buildInTools, - customTools, - workflowTools, - mcpTools, - dataSourceList: dataSourceList ?? [], + buildInTools: buildInTools || [], + customTools: customTools || [], + workflowTools: workflowTools || [], + mcpTools: mcpTools || [], + dataSourceList: dataSourceList || [], } const childrenNodeVars = toNodeOutputVars(iterationChildrenNodes, isChatMode, undefined, [], [], [], allPluginInfoList) diff --git a/web/app/components/workflow/nodes/loop/use-config.ts b/web/app/components/workflow/nodes/loop/use-config.ts index fcf437eb96..e8504fb5e9 100644 --- a/web/app/components/workflow/nodes/loop/use-config.ts +++ b/web/app/components/workflow/nodes/loop/use-config.ts @@ -15,9 +15,24 @@ import useNodeCrud from '../_base/hooks/use-node-crud' import { toNodeOutputVars } from '../_base/components/variable/utils' import { getOperators } from './utils' import { LogicalOperator } from './types' -import type { HandleAddCondition, HandleAddSubVariableCondition, HandleRemoveCondition, HandleToggleConditionLogicalOperator, HandleToggleSubVariableConditionLogicalOperator, HandleUpdateCondition, HandleUpdateSubVariableCondition, LoopNodeType } from './types' +import type { + HandleAddCondition, + HandleAddSubVariableCondition, + HandleRemoveCondition, + HandleToggleConditionLogicalOperator, + HandleToggleSubVariableConditionLogicalOperator, + HandleUpdateCondition, + HandleUpdateSubVariableCondition, + LoopNodeType, +} from './types' import useIsVarFileAttribute from './use-is-var-file-attribute' import { useStore } from '@/app/components/workflow/store' +import { + useAllBuiltInTools, + useAllCustomTools, + useAllMCPTools, + useAllWorkflowTools, +} from '@/service/use-tools' const useConfig = (id: string, payload: LoopNodeType) => { const { nodesReadOnly: readOnly } = useNodesReadOnly() @@ -38,17 +53,17 @@ const useConfig = (id: string, payload: LoopNodeType) => { // output const { getLoopNodeChildren } = useWorkflow() const loopChildrenNodes = [{ id, data: payload } as any, ...getLoopNodeChildren(id)] - const buildInTools = useStore(s => s.buildInTools) - const customTools = useStore(s => s.customTools) - const workflowTools = useStore(s => s.workflowTools) - const mcpTools = useStore(s => s.mcpTools) + const { data: buildInTools } = useAllBuiltInTools() + const { data: customTools } = useAllCustomTools() + const { data: workflowTools } = useAllWorkflowTools() + const { data: mcpTools } = useAllMCPTools() const dataSourceList = useStore(s => s.dataSourceList) const allPluginInfoList = { - buildInTools, - customTools, - workflowTools, - mcpTools, - dataSourceList: dataSourceList ?? [], + buildInTools: buildInTools || [], + customTools: customTools || [], + workflowTools: workflowTools || [], + mcpTools: mcpTools || [], + dataSourceList: dataSourceList || [], } const childrenNodeVars = toNodeOutputVars(loopChildrenNodes, isChatMode, undefined, [], conversationVariables, [], allPluginInfoList) diff --git a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/import-from-tool.tsx b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/import-from-tool.tsx index d93d08a0ac..9392f28736 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/import-from-tool.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/import-from-tool.tsx @@ -8,7 +8,6 @@ import { useTranslation } from 'react-i18next' import BlockSelector from '../../../../block-selector' import type { Param, ParamType } from '../../types' import cn from '@/utils/classnames' -import { useStore } from '@/app/components/workflow/store' import type { DataSourceDefaultValue, ToolDefaultValue, @@ -18,6 +17,11 @@ import { CollectionType } from '@/app/components/tools/types' import type { BlockEnum } from '@/app/components/workflow/types' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import { canFindTool } from '@/utils' +import { + useAllBuiltInTools, + useAllCustomTools, + useAllWorkflowTools, +} from '@/service/use-tools' const i18nPrefix = 'workflow.nodes.parameterExtractor' @@ -42,9 +46,9 @@ const ImportFromTool: FC = ({ const { t } = useTranslation() const language = useLanguage() - const buildInTools = useStore(s => s.buildInTools) - const customTools = useStore(s => s.customTools) - const workflowTools = useStore(s => s.workflowTools) + const { data: buildInTools } = useAllBuiltInTools() + const { data: customTools } = useAllCustomTools() + const { data: workflowTools } = useAllWorkflowTools() const handleSelectTool = useCallback((_type: BlockEnum, toolInfo?: ToolDefaultValue | DataSourceDefaultValue) => { if (!toolInfo || 'datasource_name' in toolInfo) @@ -54,11 +58,11 @@ const ImportFromTool: FC = ({ const currentTools = (() => { switch (provider_type) { case CollectionType.builtIn: - return buildInTools + return buildInTools || [] case CollectionType.custom: - return customTools + return customTools || [] case CollectionType.workflow: - return workflowTools + return workflowTools || [] default: return [] } diff --git a/web/app/components/workflow/nodes/tool/use-config.ts b/web/app/components/workflow/nodes/tool/use-config.ts index 5b8827936c..fe3fe543e9 100644 --- a/web/app/components/workflow/nodes/tool/use-config.ts +++ b/web/app/components/workflow/nodes/tool/use-config.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { produce } from 'immer' import { useBoolean } from 'ahooks' -import { useStore, useWorkflowStore } from '../../store' +import { useWorkflowStore } from '../../store' import type { ToolNodeType, ToolVarInputs } from './types' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' @@ -15,15 +15,20 @@ import { import Toast from '@/app/components/base/toast' import type { InputVar } from '@/app/components/workflow/types' import { - useFetchToolsData, useNodesReadOnly, } from '@/app/components/workflow/hooks' import { canFindTool } from '@/utils' +import { + useAllBuiltInTools, + useAllCustomTools, + useAllMCPTools, + useAllWorkflowTools, + useInvalidToolsByType, +} from '@/service/use-tools' const useConfig = (id: string, payload: ToolNodeType) => { const workflowStore = useWorkflowStore() const { nodesReadOnly: readOnly } = useNodesReadOnly() - const { handleFetchAllTools } = useFetchToolsData() const { t } = useTranslation() const language = useLanguage() @@ -43,21 +48,21 @@ const useConfig = (id: string, payload: ToolNodeType) => { tool_parameters, } = inputs const isBuiltIn = provider_type === CollectionType.builtIn - const buildInTools = useStore(s => s.buildInTools) - const customTools = useStore(s => s.customTools) - const workflowTools = useStore(s => s.workflowTools) - const mcpTools = useStore(s => s.mcpTools) + const { data: buildInTools } = useAllBuiltInTools() + const { data: customTools } = useAllCustomTools() + const { data: workflowTools } = useAllWorkflowTools() + const { data: mcpTools } = useAllMCPTools() const currentTools = useMemo(() => { switch (provider_type) { case CollectionType.builtIn: - return buildInTools + return buildInTools || [] case CollectionType.custom: - return customTools + return customTools || [] case CollectionType.workflow: - return workflowTools + return workflowTools || [] case CollectionType.mcp: - return mcpTools + return mcpTools || [] default: return [] } @@ -75,6 +80,7 @@ const useConfig = (id: string, payload: ToolNodeType) => { { setTrue: showSetAuthModal, setFalse: hideSetAuthModal }, ] = useBoolean(false) + const invalidToolsByType = useInvalidToolsByType(provider_type) const handleSaveAuth = useCallback( async (value: any) => { await updateBuiltInToolCredential(currCollection?.name as string, value) @@ -83,14 +89,14 @@ const useConfig = (id: string, payload: ToolNodeType) => { type: 'success', message: t('common.api.actionSuccess'), }) - handleFetchAllTools(provider_type) + invalidToolsByType() hideSetAuthModal() }, [ currCollection?.name, hideSetAuthModal, t, - handleFetchAllTools, + invalidToolsByType, provider_type, ], ) @@ -241,17 +247,15 @@ const useConfig = (id: string, payload: ToolNodeType) => { name: outputKey, type: output.type === 'array' - ? `Array[${ - output.items?.type - ? output.items.type.slice(0, 1).toLocaleUpperCase() - + output.items.type.slice(1) - : 'Unknown' + ? `Array[${output.items?.type + ? output.items.type.slice(0, 1).toLocaleUpperCase() + + output.items.type.slice(1) + : 'Unknown' }]` - : `${ - output.type - ? output.type.slice(0, 1).toLocaleUpperCase() - + output.type.slice(1) - : 'Unknown' + : `${output.type + ? output.type.slice(0, 1).toLocaleUpperCase() + + output.type.slice(1) + : 'Unknown' }`, description: output.description, }) diff --git a/web/app/components/workflow/store/workflow/tool-slice.ts b/web/app/components/workflow/store/workflow/tool-slice.ts index d6d89abcf0..c5180022fc 100644 --- a/web/app/components/workflow/store/workflow/tool-slice.ts +++ b/web/app/components/workflow/store/workflow/tool-slice.ts @@ -1,30 +1,11 @@ import type { StateCreator } from 'zustand' -import type { - ToolWithProvider, -} from '@/app/components/workflow/types' export type ToolSliceShape = { - buildInTools: ToolWithProvider[] - setBuildInTools: (tools: ToolWithProvider[]) => void - customTools: ToolWithProvider[] - setCustomTools: (tools: ToolWithProvider[]) => void - workflowTools: ToolWithProvider[] - setWorkflowTools: (tools: ToolWithProvider[]) => void - mcpTools: ToolWithProvider[] - setMcpTools: (tools: ToolWithProvider[]) => void toolPublished: boolean setToolPublished: (toolPublished: boolean) => void } export const createToolSlice: StateCreator = set => ({ - buildInTools: [], - setBuildInTools: buildInTools => set(() => ({ buildInTools })), - customTools: [], - setCustomTools: customTools => set(() => ({ customTools })), - workflowTools: [], - setWorkflowTools: workflowTools => set(() => ({ workflowTools })), - mcpTools: [], - setMcpTools: mcpTools => set(() => ({ mcpTools })), toolPublished: false, setToolPublished: toolPublished => set(() => ({ toolPublished })), }) diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index f6a706a982..324443cfd1 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -19,7 +19,7 @@ import type { } from '@/app/components/workflow/nodes/_base/components/error-handle/types' import type { WorkflowRetryConfig } from '@/app/components/workflow/nodes/_base/components/retry/types' import type { StructuredOutput } from '@/app/components/workflow/nodes/llm/types' -import type { PluginMeta } from '../plugins/types' +import type { Plugin, PluginMeta } from '@/app/components/plugins/types' import type { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types' import type { SchemaTypeDefinition } from '@/service/use-common' @@ -451,16 +451,9 @@ export type ToolWithProvider = Collection & { meta: PluginMeta } -export type UninstalledRecommendedPlugin = { - plugin_id: string - name: string - icon: string - plugin_unique_identifier: string -} - export type RAGRecommendedPlugins = { installed_recommended_plugins: ToolWithProvider[] - uninstalled_recommended_plugins: UninstalledRecommendedPlugin[] + uninstalled_recommended_plugins: Plugin[] } export enum SupportUploadFileTypes { diff --git a/web/i18n/en-US/pipeline.ts b/web/i18n/en-US/pipeline.ts index 4b29bdbb00..8e5fd8a3e0 100644 --- a/web/i18n/en-US/pipeline.ts +++ b/web/i18n/en-US/pipeline.ts @@ -33,7 +33,7 @@ const translation = { }, ragToolSuggestions: { title: 'Suggestions for RAG', - noRecommendationPluginsInstalled: 'No recommended plugins installed, find more in Marketplace', + noRecommendationPlugins: 'No recommended plugins, find more in Marketplace', }, } diff --git a/web/i18n/ja-JP/pipeline.ts b/web/i18n/ja-JP/pipeline.ts index 64700acc09..9ec1b68273 100644 --- a/web/i18n/ja-JP/pipeline.ts +++ b/web/i18n/ja-JP/pipeline.ts @@ -33,7 +33,7 @@ const translation = { }, ragToolSuggestions: { title: 'RAGのための提案', - noRecommendationPluginsInstalled: '推奨プラグインがインストールされていません。マーケットプレイスで詳細をご確認ください', + noRecommendationPlugins: '推奨プラグインがありません。マーケットプレイスで詳細をご確認ください', }, } diff --git a/web/i18n/zh-Hans/pipeline.ts b/web/i18n/zh-Hans/pipeline.ts index 3c3a7a6506..1ae087fcfd 100644 --- a/web/i18n/zh-Hans/pipeline.ts +++ b/web/i18n/zh-Hans/pipeline.ts @@ -33,7 +33,7 @@ const translation = { }, ragToolSuggestions: { title: 'RAG 工具推荐', - noRecommendationPluginsInstalled: '暂无已安装的推荐插件,更多插件请在 Marketplace 中查找', + noRecommendationPlugins: '暂无推荐插件,更多插件请在 Marketplace 中查找', }, } diff --git a/web/service/use-plugins.ts b/web/service/use-plugins.ts index f59e500792..1dec97cdfa 100644 --- a/web/service/use-plugins.ts +++ b/web/service/use-plugins.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useState } from 'react' import type { FormOption, ModelProvider, @@ -39,7 +39,7 @@ import { useQuery, useQueryClient, } from '@tanstack/react-query' -import { useInvalidateAllBuiltInTools, useInvalidateRAGRecommendedPlugins } from './use-tools' +import { useInvalidateAllBuiltInTools } from './use-tools' import useReferenceSetting from '@/app/components/plugins/plugin-page/use-reference-setting' import { uninstallPlugin } from '@/service/plugins' import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list' @@ -135,14 +135,12 @@ export const useInstalledLatestVersion = (pluginIds: string[]) => { export const useInvalidateInstalledPluginList = () => { const queryClient = useQueryClient() const invalidateAllBuiltInTools = useInvalidateAllBuiltInTools() - const invalidateRAGRecommendedPlugins = useInvalidateRAGRecommendedPlugins() return () => { queryClient.invalidateQueries( { queryKey: useInstalledPluginListKey, }) invalidateAllBuiltInTools() - invalidateRAGRecommendedPlugins() } } @@ -489,6 +487,7 @@ export const useFetchPluginsInMarketPlaceByInfo = (infos: Record[]) const usePluginTaskListKey = [NAME_SPACE, 'pluginTaskList'] export const usePluginTaskList = (category?: PluginType) => { + const [initialized, setInitialized] = useState(false) const { canManagement, } = useReferenceSetting() @@ -512,7 +511,8 @@ export const usePluginTaskList = (category?: PluginType) => { useEffect(() => { // After first fetch, refresh plugin list each time all tasks are done - if (!isRefetching) { + // Skip initialization period, because the query cache is not updated yet + if (initialized && !isRefetching) { const lastData = cloneDeep(data) const taskDone = lastData?.tasks.every(task => task.status === TaskStatus.success || task.status === TaskStatus.failed) const taskAllFailed = lastData?.tasks.every(task => task.status === TaskStatus.failed) @@ -523,6 +523,10 @@ export const usePluginTaskList = (category?: PluginType) => { } }, [isRefetching]) + useEffect(() => { + setInitialized(true) + }, []) + const handleRefetch = useCallback(() => { refetch() }, [refetch]) diff --git a/web/service/use-tools.ts b/web/service/use-tools.ts index a881441cd5..306cb903df 100644 --- a/web/service/use-tools.ts +++ b/web/service/use-tools.ts @@ -4,9 +4,11 @@ import type { MCPServerDetail, Tool, } from '@/app/components/tools/types' +import { CollectionType } from '@/app/components/tools/types' import type { RAGRecommendedPlugins, ToolWithProvider } from '@/app/components/workflow/types' import type { AppIconType } from '@/types/app' import { useInvalid } from './use-base' +import type { QueryKey } from '@tanstack/react-query' import { useMutation, useQuery, @@ -76,6 +78,16 @@ export const useInvalidateAllMCPTools = () => { return useInvalid(useAllMCPToolsKey) } +const useInvalidToolsKeyMap: Record = { + [CollectionType.builtIn]: useAllBuiltInToolsKey, + [CollectionType.custom]: useAllCustomToolsKey, + [CollectionType.workflow]: useAllWorkflowToolsKey, + [CollectionType.mcp]: useAllMCPToolsKey, +} +export const useInvalidToolsByType = (type: CollectionType | string) => { + return useInvalid(useInvalidToolsKeyMap[type]) +} + export const useCreateMCP = () => { return useMutation({ mutationKey: [NAME_SPACE, 'create-mcp'], From 96f0d648faf4db69f9b7963b0e8f5a54c78b30b4 Mon Sep 17 00:00:00 2001 From: lyzno1 Date: Tue, 28 Oct 2025 11:31:02 +0800 Subject: [PATCH 016/394] feat: invalidate trigger plugin queries after marketplace installs --- .../install-plugin/hooks/use-refresh-plugin-list.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx b/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx index 05825c6fef..264c4782cd 100644 --- a/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx +++ b/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx @@ -8,6 +8,7 @@ import type { Plugin, PluginDeclaration, PluginManifestInMarket } from '../../ty import { PluginCategoryEnum } from '../../types' import { useInvalidDataSourceList } from '@/service/use-pipeline' import { useInvalidDataSourceListAuth } from '@/service/use-datasource' +import { useInvalidateAllTriggerPlugins } from '@/service/use-triggers' const useRefreshPluginList = () => { const invalidateInstalledPluginList = useInvalidateInstalledPluginList() @@ -24,6 +25,8 @@ const useRefreshPluginList = () => { const invalidateStrategyProviders = useInvalidateStrategyProviders() + const invalidateAllTriggerPlugins = useInvalidateAllTriggerPlugins() + const invalidateRAGRecommendedPlugins = useInvalidateRAGRecommendedPlugins() return { refreshPluginList: (manifest?: PluginManifestInMarket | Plugin | PluginDeclaration | null, refreshAllType?: boolean) => { @@ -38,6 +41,9 @@ const useRefreshPluginList = () => { // TODO: update suggested tools. It's a function in hook useMarketplacePlugins,handleUpdatePlugins } + if ((manifest && PluginCategoryEnum.trigger.includes(manifest.category)) || refreshAllType) + invalidateAllTriggerPlugins() + if ((manifest && PluginCategoryEnum.datasource.includes(manifest.category)) || refreshAllType) { invalidateAllDataSources() invalidateDataSourceListAuth() From e60a7c714353da89c48b959fae82b896e8fe8d41 Mon Sep 17 00:00:00 2001 From: zhaobingshuang <1475195565@qq.com> Date: Tue, 28 Oct 2025 11:56:06 +0800 Subject: [PATCH 017/394] fix(command): The vdb migrate command cannot be stopped (#27536) --- api/commands.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/commands.py b/api/commands.py index 8ca19e1dac..084fd576a1 100644 --- a/api/commands.py +++ b/api/commands.py @@ -321,6 +321,8 @@ def migrate_knowledge_vector_database(): ) datasets = db.paginate(select=stmt, page=page, per_page=50, max_per_page=50, error_out=False) + if not datasets.items: + break except SQLAlchemyError: raise From 3395297c3e716f42dab4046a05b737c97cda775a Mon Sep 17 00:00:00 2001 From: Joel Date: Tue, 28 Oct 2025 13:58:31 +0800 Subject: [PATCH 018/394] chore: warning messages too long in model config caused ui issue (#27542) --- .../account-setting/model-provider-page/index.tsx | 2 +- .../model-auth/config-provider.tsx | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/web/app/components/header/account-setting/model-provider-page/index.tsx b/web/app/components/header/account-setting/model-provider-page/index.tsx index 35de29185f..239c462ffe 100644 --- a/web/app/components/header/account-setting/model-provider-page/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/index.tsx @@ -93,7 +93,7 @@ const ModelProviderPage = ({ searchText }: Props) => { {defaultModelNotConfigured && (
- {t('common.modelProvider.notConfigured')} + {t('common.modelProvider.notConfigured')}
)} { + const text = hasCredential ? t('common.operation.config') : t('common.operation.setup') const Item = ( ) if (notAllowCustomCredential && !hasCredential) { From 0b1015e221b6d5dddb0e2aca6bc7c2cf07fee5b2 Mon Sep 17 00:00:00 2001 From: zhsama Date: Tue, 28 Oct 2025 14:12:20 +0800 Subject: [PATCH 019/394] feat(workflow): enhance variable inspector to support schedule trigger events with next execution time display --- .../workflow/variable-inspect/listening.tsx | 21 ++++++++++++++----- web/i18n/en-US/workflow.ts | 2 ++ web/i18n/ja-JP/workflow.ts | 2 ++ web/i18n/zh-Hans/workflow.ts | 2 ++ web/i18n/zh-Hant/workflow.ts | 2 ++ 5 files changed, 24 insertions(+), 5 deletions(-) diff --git a/web/app/components/workflow/variable-inspect/listening.tsx b/web/app/components/workflow/variable-inspect/listening.tsx index e325df2175..2eb250ee4b 100644 --- a/web/app/components/workflow/variable-inspect/listening.tsx +++ b/web/app/components/workflow/variable-inspect/listening.tsx @@ -8,6 +8,8 @@ import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAnd import { useStore } from '../store' import { useGetToolIcon } from '@/app/components/workflow/hooks/use-tool-icon' import type { TFunction } from 'i18next' +import { getNextExecutionTime } from '@/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator' +import type { ScheduleTriggerNodeType } from '@/app/components/workflow/nodes/trigger-schedule/types' const resolveListeningDescription = ( message: string | undefined, @@ -18,9 +20,13 @@ const resolveListeningDescription = ( if (message) return message - const nodeDescription = (triggerNode?.data as { desc?: string })?.desc - if (nodeDescription) - return nodeDescription + if (triggerType === BlockEnum.TriggerSchedule) { + const scheduleData = triggerNode?.data as ScheduleTriggerNodeType | undefined + const nextTriggerTime = scheduleData ? getNextExecutionTime(scheduleData) : '' + return t('workflow.debug.variableInspect.listening.tipSchedule', { + nextTriggerTime: nextTriggerTime || t('workflow.debug.variableInspect.listening.defaultScheduleTime'), + }) + } if (triggerType === BlockEnum.TriggerPlugin) { const pluginName = (triggerNode?.data as { provider_name?: string; title?: string })?.provider_name @@ -34,6 +40,10 @@ const resolveListeningDescription = ( return t('workflow.debug.variableInspect.listening.tip', { nodeName }) } + const nodeDescription = (triggerNode?.data as { desc?: string })?.desc + if (nodeDescription) + return nodeDescription + return t('workflow.debug.variableInspect.listening.tipFallback') } @@ -71,7 +81,6 @@ const Listening: FC = ({ const listeningTriggerNodeId = useStore(s => s.listeningTriggerNodeId) const listeningTriggerNodeIds = useStore(s => s.listeningTriggerNodeIds) const listeningTriggerIsAll = useStore(s => s.listeningTriggerIsAll) - const triggerType = listeningTriggerType || BlockEnum.TriggerWebhook const getToolIcon = useGetToolIcon() @@ -81,6 +90,8 @@ const Listening: FC = ({ const triggerNode = listeningTriggerNodeId ? nodes.find(node => node.id === listeningTriggerNodeId) : undefined + const inferredTriggerType = (triggerNode?.data as { type?: BlockEnum })?.type + const triggerType = listeningTriggerType || inferredTriggerType || BlockEnum.TriggerWebhook let displayNodes: Node[] = [] @@ -138,7 +149,7 @@ const Listening: FC = ({
{t('workflow.debug.variableInspect.listening.title')}
-
{description}
+
{description}
{webhookDebugUrl && ( -
-
+
+
{t('workflow.nodes.triggerWebhook.debugUrlTitle')}
-
- {webhookDebugUrl} -
+ + +
)}
From c5972343740f28c01f79b1391b392244afbd7fdd Mon Sep 17 00:00:00 2001 From: yangzheli <43645580+yangzheli@users.noreply.github.com> Date: Wed, 29 Oct 2025 10:19:29 +0800 Subject: [PATCH 038/394] fix(workflow): doc extractor node now correctly extracts mdx files (#27570) --- api/core/workflow/nodes/document_extractor/node.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/core/workflow/nodes/document_extractor/node.py b/api/core/workflow/nodes/document_extractor/node.py index cd5f50aaab..12cd7e2bd9 100644 --- a/api/core/workflow/nodes/document_extractor/node.py +++ b/api/core/workflow/nodes/document_extractor/node.py @@ -171,6 +171,7 @@ def _extract_text_by_file_extension(*, file_content: bytes, file_extension: str) ".txt" | ".markdown" | ".md" + | ".mdx" | ".html" | ".htm" | ".xml" From 42385f3ffa9fcfc309b108e58b4e761e74cb5204 Mon Sep 17 00:00:00 2001 From: Eric Guo Date: Wed, 29 Oct 2025 10:19:57 +0800 Subject: [PATCH 039/394] Sync celery queue name list (#27554) --- .gitignore | 1 + .vscode/launch.json.template | 2 +- api/.vscode/launch.json.example | 2 +- api/README.md | 2 +- api/docker/entrypoint.sh | 2 +- dev/start-worker | 2 +- 6 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 22a2c42566..76cfd7d9bf 100644 --- a/.gitignore +++ b/.gitignore @@ -97,6 +97,7 @@ __pypackages__/ # Celery stuff celerybeat-schedule +celerybeat-schedule.db celerybeat.pid # SageMath parsed files diff --git a/.vscode/launch.json.template b/.vscode/launch.json.template index f5a7f0893b..bf04d31998 100644 --- a/.vscode/launch.json.template +++ b/.vscode/launch.json.template @@ -40,7 +40,7 @@ "-c", "1", "-Q", - "dataset,generation,mail,ops_trace", + "dataset,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,priority_pipeline,pipeline", "--loglevel", "INFO" ], diff --git a/api/.vscode/launch.json.example b/api/.vscode/launch.json.example index b9e32e2511..e97828f9d8 100644 --- a/api/.vscode/launch.json.example +++ b/api/.vscode/launch.json.example @@ -54,7 +54,7 @@ "--loglevel", "DEBUG", "-Q", - "dataset,generation,mail,ops_trace,app_deletion" + "dataset,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,priority_pipeline,pipeline" ] } ] diff --git a/api/README.md b/api/README.md index ea6f547a0a..fc21158c7a 100644 --- a/api/README.md +++ b/api/README.md @@ -80,7 +80,7 @@ 1. If you need to handle and debug the async tasks (e.g. dataset importing and documents indexing), please start the worker service. ```bash -uv run celery -A app.celery worker -P gevent -c 2 --loglevel INFO -Q dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation +uv run celery -A app.celery worker -P gevent -c 2 --loglevel INFO -Q dataset,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,priority_pipeline,pipeline ``` Additionally, if you want to debug the celery scheduled tasks, you can run the following command in another terminal to start the beat service: diff --git a/api/docker/entrypoint.sh b/api/docker/entrypoint.sh index 798113af68..8f6998119e 100755 --- a/api/docker/entrypoint.sh +++ b/api/docker/entrypoint.sh @@ -32,7 +32,7 @@ if [[ "${MODE}" == "worker" ]]; then exec celery -A celery_entrypoint.celery worker -P ${CELERY_WORKER_CLASS:-gevent} $CONCURRENCY_OPTION \ --max-tasks-per-child ${MAX_TASKS_PER_CHILD:-50} --loglevel ${LOG_LEVEL:-INFO} \ - -Q ${CELERY_QUEUES:-dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation} \ + -Q ${CELERY_QUEUES:-dataset,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,priority_pipeline,pipeline} \ --prefetch-multiplier=1 elif [[ "${MODE}" == "beat" ]]; then diff --git a/dev/start-worker b/dev/start-worker index a7f16b853f..83d7bf0f3c 100755 --- a/dev/start-worker +++ b/dev/start-worker @@ -7,4 +7,4 @@ cd "$SCRIPT_DIR/.." uv --directory api run \ celery -A app.celery worker \ - -P gevent -c 1 --loglevel INFO -Q dataset,generation,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,priority_pipeline,pipeline + -P gevent -c 1 --loglevel INFO -Q dataset,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,priority_pipeline,pipeline From 07a2281730bb8c2ce909b89fd2199b545a70571e Mon Sep 17 00:00:00 2001 From: GuanMu Date: Wed, 29 Oct 2025 10:20:37 +0800 Subject: [PATCH 040/394] chore: add web type check step to GitHub Actions workflow (#27498) --- .github/workflows/style.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index 06584c1b78..e652657705 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -103,6 +103,11 @@ jobs: run: | pnpm run lint + - name: Web type check + if: steps.changed-files.outputs.any_changed == 'true' + working-directory: ./web + run: pnpm run type-check + docker-compose-template: name: Docker Compose Template runs-on: ubuntu-latest From d532b0631028d0d95bb6d0bd09d0b00d4c5236a9 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 29 Oct 2025 11:25:15 +0900 Subject: [PATCH 041/394] example of use api.model (#27514) --- api/controllers/console/extension.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/api/controllers/console/extension.py b/api/controllers/console/extension.py index 4e1a8aeb3e..a1d36def0d 100644 --- a/api/controllers/console/extension.py +++ b/api/controllers/console/extension.py @@ -66,13 +66,7 @@ class APIBasedExtensionAPI(Resource): @account_initialization_required @marshal_with(api_based_extension_fields) def post(self): - parser = ( - reqparse.RequestParser() - .add_argument("name", type=str, required=True, location="json") - .add_argument("api_endpoint", type=str, required=True, location="json") - .add_argument("api_key", type=str, required=True, location="json") - ) - args = parser.parse_args() + args = api.payload _, current_tenant_id = current_account_with_tenant() extension_data = APIBasedExtension( @@ -125,13 +119,7 @@ class APIBasedExtensionDetailAPI(Resource): extension_data_from_db = APIBasedExtensionService.get_with_tenant_id(current_tenant_id, api_based_extension_id) - parser = ( - reqparse.RequestParser() - .add_argument("name", type=str, required=True, location="json") - .add_argument("api_endpoint", type=str, required=True, location="json") - .add_argument("api_key", type=str, required=True, location="json") - ) - args = parser.parse_args() + args = api.payload extension_data_from_db.name = args["name"] extension_data_from_db.api_endpoint = args["api_endpoint"] From 9e97248ede703aa6313ada3ca299823c6ddc2302 Mon Sep 17 00:00:00 2001 From: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com> Date: Wed, 29 Oct 2025 10:26:40 +0800 Subject: [PATCH 042/394] fix unit test using enum (#27575) Signed-off-by: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com> --- .../apps/common/test_workflow_response_converter_truncation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_truncation.py b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_truncation.py index 964d62be1f..1c9f577a50 100644 --- a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_truncation.py +++ b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_truncation.py @@ -360,7 +360,7 @@ class TestWorkflowResponseConverterServiceApiTruncation: app_id="test_app_id", app_config=app_config, tenant_id="test_tenant", - app_mode="workflow", + app_mode=AppMode.WORKFLOW, invoke_from=invoke_from, inputs={"test_input": "test_value"}, user_id="test_user_id", From 23b49b830431e1776458730afcbb76f880771472 Mon Sep 17 00:00:00 2001 From: Jianwei Mao Date: Wed, 29 Oct 2025 10:40:59 +0800 Subject: [PATCH 043/394] =?UTF-8?q?fix=20issues=2027388,=20add=20missing?= =?UTF-8?q?=20env=20variable:=20ENFORCE=5FLANGGENIUS=5FPLUGIN=E2=80=A6=20(?= =?UTF-8?q?#27545)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/.env.example | 1 + docker/docker-compose.yaml | 1 + 2 files changed, 2 insertions(+) diff --git a/docker/.env.example b/docker/.env.example index 672d3d9836..7af92248dc 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -1255,6 +1255,7 @@ MARKETPLACE_ENABLED=true MARKETPLACE_API_URL=https://marketplace.dify.ai FORCE_VERIFYING_SIGNATURE=true +ENFORCE_LANGGENIUS_PLUGIN_SIGNATURES=true PLUGIN_STDIO_BUFFER_SIZE=1024 PLUGIN_STDIO_MAX_BUFFER_SIZE=5242880 diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 1b4012b446..34dfc19032 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -547,6 +547,7 @@ x-shared-env: &shared-api-worker-env MARKETPLACE_ENABLED: ${MARKETPLACE_ENABLED:-true} MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai} FORCE_VERIFYING_SIGNATURE: ${FORCE_VERIFYING_SIGNATURE:-true} + ENFORCE_LANGGENIUS_PLUGIN_SIGNATURES: ${ENFORCE_LANGGENIUS_PLUGIN_SIGNATURES:-true} PLUGIN_STDIO_BUFFER_SIZE: ${PLUGIN_STDIO_BUFFER_SIZE:-1024} PLUGIN_STDIO_MAX_BUFFER_SIZE: ${PLUGIN_STDIO_MAX_BUFFER_SIZE:-5242880} PLUGIN_PYTHON_ENV_INIT_TIMEOUT: ${PLUGIN_PYTHON_ENV_INIT_TIMEOUT:-120} From f06dc3ef908f5ed6c18d1f1bf374503450c79ff3 Mon Sep 17 00:00:00 2001 From: lyzno1 Date: Wed, 29 Oct 2025 11:55:30 +0800 Subject: [PATCH 044/394] fix: localize workflow block search filters --- .../workflow/block-selector/all-tools.tsx | 81 ++++++++++++------- .../workflow/block-selector/start-blocks.tsx | 12 ++- .../block-selector/trigger-plugin/list.tsx | 33 +++++++- 3 files changed, 91 insertions(+), 35 deletions(-) diff --git a/web/app/components/workflow/block-selector/all-tools.tsx b/web/app/components/workflow/block-selector/all-tools.tsx index 1e7cc09642..01eaf74d0f 100644 --- a/web/app/components/workflow/block-selector/all-tools.tsx +++ b/web/app/components/workflow/block-selector/all-tools.tsx @@ -34,6 +34,7 @@ import Link from 'next/link' import Divider from '@/app/components/base/divider' import { RiArrowRightUpLine } from '@remixicon/react' import { getMarketplaceUrl } from '@/utils/var' +import { useGetLanguage } from '@/context/i18n' const marketplaceFooterClassName = 'system-sm-medium z-10 flex h-8 flex-none cursor-pointer items-center rounded-b-lg border-[0.5px] border-t border-components-panel-border bg-components-panel-bg-blur px-4 py-1 text-text-accent-light-mode-only shadow-lg' @@ -83,6 +84,7 @@ const AllTools = ({ onFeaturedInstallSuccess, }: AllToolsProps) => { const { t } = useTranslation() + const language = useGetLanguage() const tabs = useToolTabs() const [activeTab, setActiveTab] = useState(ToolTypeEnum.All) const [activeView, setActiveView] = useState(ViewType.flat) @@ -117,12 +119,29 @@ const AllTools = ({ mergedTools = mcpTools const normalizedSearch = trimmedSearchText.toLowerCase() + const getLocalizedText = (text?: Record | null) => { + if (!text) + return '' + + if (text[language]) + return text[language] + + if (text['en-US']) + return text['en-US'] + + const firstValue = Object.values(text).find(Boolean) + return firstValue || '' + } if (!hasFilter || !normalizedSearch) return mergedTools.filter(toolWithProvider => toolWithProvider.tools.length > 0) return mergedTools.reduce((acc, toolWithProvider) => { - const providerMatches = isMatchingKeywords(toolWithProvider.name, normalizedSearch) + const providerLabel = getLocalizedText(toolWithProvider.label) + const providerMatches = [ + toolWithProvider.name, + providerLabel, + ].some(text => isMatchingKeywords(text || '', normalizedSearch)) if (providerMatches) { if (toolWithProvider.tools.length > 0) @@ -131,7 +150,11 @@ const AllTools = ({ } const matchedTools = toolWithProvider.tools.filter((tool) => { - return tool.name.toLowerCase().includes(normalizedSearch) + const toolLabel = getLocalizedText(tool.label) + return [ + tool.name, + toolLabel, + ].some(text => isMatchingKeywords(text || '', normalizedSearch)) }) if (matchedTools.length > 0) { @@ -143,7 +166,7 @@ const AllTools = ({ return acc }, []) - }, [activeTab, buildInTools, customTools, workflowTools, mcpTools, trimmedSearchText, hasFilter]) + }, [activeTab, buildInTools, customTools, workflowTools, mcpTools, trimmedSearchText, hasFilter, language]) const { queryPluginsWithDebounced: fetchPlugins, @@ -235,39 +258,37 @@ const AllTools = ({
)} - {(hasToolsListContent || enable_marketplace) && ( + {hasToolsListContent && ( <>
{t('tools.allTools')}
- {hasToolsListContent && ( - - )} - {enable_marketplace && ( - } - list={notInstalledPlugins} - searchText={searchText} - toolContentClassName={toolContentClassName} - tags={tags} - hideFindMoreFooter - /> - )} + )} + {enable_marketplace && ( + } + list={notInstalledPlugins} + searchText={searchText} + toolContentClassName={toolContentClassName} + tags={tags} + hideFindMoreFooter + /> + )}
{shouldShowEmptyState && ( diff --git a/web/app/components/workflow/block-selector/start-blocks.tsx b/web/app/components/workflow/block-selector/start-blocks.tsx index c9be37bc36..4fdfd475d9 100644 --- a/web/app/components/workflow/block-selector/start-blocks.tsx +++ b/web/app/components/workflow/block-selector/start-blocks.tsx @@ -36,6 +36,13 @@ const StartBlocks = ({ const filteredBlocks = useMemo(() => { // Check if Start node already exists in workflow const hasStartNode = nodes.some(node => (node.data as CommonNodeType)?.type === BlockEnumValues.Start) + const normalizedSearch = searchText.toLowerCase() + const getDisplayName = (blockType: BlockEnum) => { + if (blockType === BlockEnumValues.TriggerWebhook) + return t('workflow.customWebhook') + + return t(`workflow.blocks.${blockType}`) + } return START_BLOCKS.filter((block) => { // Hide User Input (Start) if it already exists in workflow @@ -43,13 +50,14 @@ const StartBlocks = ({ return false // Filter by search text - if (!block.title.toLowerCase().includes(searchText.toLowerCase())) + const displayName = getDisplayName(block.type).toLowerCase() + if (!displayName.includes(normalizedSearch) && !block.title.toLowerCase().includes(normalizedSearch)) return false // availableBlocksTypes now contains properly filtered entry node types from parent return availableBlocksTypes.includes(block.type) }) - }, [searchText, availableBlocksTypes, nodes]) + }, [searchText, availableBlocksTypes, nodes, t]) const isEmpty = filteredBlocks.length === 0 diff --git a/web/app/components/workflow/block-selector/trigger-plugin/list.tsx b/web/app/components/workflow/block-selector/trigger-plugin/list.tsx index b7d43a2167..3caf1149dd 100644 --- a/web/app/components/workflow/block-selector/trigger-plugin/list.tsx +++ b/web/app/components/workflow/block-selector/trigger-plugin/list.tsx @@ -4,6 +4,7 @@ import { useAllTriggerPlugins } from '@/service/use-triggers' import TriggerPluginItem from './item' import type { BlockEnum } from '../../types' import type { TriggerDefaultValue, TriggerWithProvider } from '../types' +import { useGetLanguage } from '@/context/i18n' type TriggerPluginListProps = { onSelect: (type: BlockEnum, trigger?: TriggerDefaultValue) => void @@ -18,10 +19,30 @@ const TriggerPluginList = ({ onContentStateChange, }: TriggerPluginListProps) => { const { data: triggerPluginsData } = useAllTriggerPlugins() + const language = useGetLanguage() const normalizedSearch = searchText.trim().toLowerCase() const triggerPlugins = useMemo(() => { const plugins = triggerPluginsData || [] + const getLocalizedText = (text?: Record | null) => { + if (!text) + return '' + + if (text[language]) + return text[language] + + if (text['en-US']) + return text['en-US'] + + const firstValue = Object.values(text).find(Boolean) + return (typeof firstValue === 'string') ? firstValue : '' + } + const getSearchableTexts = (name: string, label?: Record | null) => { + const localized = getLocalizedText(label) + const values = [localized, name].filter(Boolean) + return values.length > 0 ? values : [''] + } + const isMatchingKeywords = (value: string) => value.toLowerCase().includes(normalizedSearch) if (!normalizedSearch) return plugins.filter(triggerWithProvider => triggerWithProvider.events.length > 0) @@ -30,7 +51,10 @@ const TriggerPluginList = ({ if (triggerWithProvider.events.length === 0) return acc - const providerMatches = triggerWithProvider.name.toLowerCase().includes(normalizedSearch) + const providerMatches = getSearchableTexts( + triggerWithProvider.name, + triggerWithProvider.label, + ).some(text => isMatchingKeywords(text)) if (providerMatches) { acc.push(triggerWithProvider) @@ -38,7 +62,10 @@ const TriggerPluginList = ({ } const matchedEvents = triggerWithProvider.events.filter((event) => { - return event.name.toLowerCase().includes(normalizedSearch) + return getSearchableTexts( + event.name, + event.label, + ).some(text => isMatchingKeywords(text)) }) if (matchedEvents.length > 0) { @@ -50,7 +77,7 @@ const TriggerPluginList = ({ return acc }, []) - }, [triggerPluginsData, normalizedSearch]) + }, [triggerPluginsData, normalizedSearch, language]) const hasContent = triggerPlugins.length > 0 From 0b599b44b0a24ac0f71a3bc04edbe341f469f031 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Wed, 29 Oct 2025 12:15:34 +0800 Subject: [PATCH 045/394] chore: when delete app also delete related trigger tables --- api/tasks/remove_app_and_related_data_task.py | 87 ++++++++++++++++++- 1 file changed, 83 insertions(+), 4 deletions(-) diff --git a/api/tasks/remove_app_and_related_data_task.py b/api/tasks/remove_app_and_related_data_task.py index 770bdd6676..c66287c1d7 100644 --- a/api/tasks/remove_app_and_related_data_task.py +++ b/api/tasks/remove_app_and_related_data_task.py @@ -69,7 +69,11 @@ def remove_app_and_related_data_task(self, tenant_id: str, app_id: str): _delete_trace_app_configs(tenant_id, app_id) _delete_conversation_variables(app_id=app_id) _delete_draft_variables(app_id) - _delete_app_plugin_triggers(tenant_id, app_id) + _delete_app_triggers(tenant_id, app_id) + _delete_workflow_plugin_triggers(tenant_id, app_id) + _delete_workflow_webhook_triggers(tenant_id, app_id) + _delete_workflow_schedule_plans(tenant_id, app_id) + _delete_workflow_trigger_logs(tenant_id, app_id) end_at = time.perf_counter() logger.info(click.style(f"App and related data deleted: {app_id} latency: {end_at - start_at}", fg="green")) @@ -504,11 +508,86 @@ def _delete_records(query_sql: str, params: dict, delete_func: Callable, name: s rs.close() -def _delete_app_plugin_triggers(tenant_id: str, app_id: str): +def _delete_app_triggers(tenant_id: str, app_id: str): with db.engine.begin() as conn: result = conn.execute( - sa.text("DELETE FROM workflow_plugin_triggers WHERE app_id = :app_id"), {"app_id": app_id} + sa.text( + """ + DELETE FROM app_triggers + WHERE tenant_id = :tenant_id + AND app_id = :app_id + """ + ), + {"tenant_id": tenant_id, "app_id": app_id}, ) - deleted_count = result.rowcount + deleted_count = result.rowcount or 0 + if deleted_count > 0: + logger.info(click.style(f"Deleted {deleted_count} app triggers for app {app_id}", fg="green")) + + +def _delete_workflow_plugin_triggers(tenant_id: str, app_id: str): + with db.engine.begin() as conn: + result = conn.execute( + sa.text( + """ + DELETE FROM workflow_plugin_triggers + WHERE tenant_id = :tenant_id + AND app_id = :app_id + """ + ), + {"tenant_id": tenant_id, "app_id": app_id}, + ) + deleted_count = result.rowcount or 0 if deleted_count > 0: logger.info(click.style(f"Deleted {deleted_count} workflow plugin triggers for app {app_id}", fg="green")) + + +def _delete_workflow_webhook_triggers(tenant_id: str, app_id: str): + with db.engine.begin() as conn: + result = conn.execute( + sa.text( + """ + DELETE FROM workflow_webhook_triggers + WHERE tenant_id = :tenant_id + AND app_id = :app_id + """ + ), + {"tenant_id": tenant_id, "app_id": app_id}, + ) + deleted_count = result.rowcount or 0 + if deleted_count > 0: + logger.info(click.style(f"Deleted {deleted_count} workflow webhook triggers for app {app_id}", fg="green")) + + +def _delete_workflow_schedule_plans(tenant_id: str, app_id: str): + with db.engine.begin() as conn: + result = conn.execute( + sa.text( + """ + DELETE FROM workflow_schedule_plans + WHERE tenant_id = :tenant_id + AND app_id = :app_id + """ + ), + {"tenant_id": tenant_id, "app_id": app_id}, + ) + deleted_count = result.rowcount or 0 + if deleted_count > 0: + logger.info(click.style(f"Deleted {deleted_count} workflow schedule plans for app {app_id}", fg="green")) + + +def _delete_workflow_trigger_logs(tenant_id: str, app_id: str): + with db.engine.begin() as conn: + result = conn.execute( + sa.text( + """ + DELETE FROM workflow_trigger_logs + WHERE tenant_id = :tenant_id + AND app_id = :app_id + """ + ), + {"tenant_id": tenant_id, "app_id": app_id}, + ) + deleted_count = result.rowcount or 0 + if deleted_count > 0: + logger.info(click.style(f"Deleted {deleted_count} workflow trigger logs for app {app_id}", fg="green")) From 852d85199688ddd940f9c0e8d062ae538bbcb987 Mon Sep 17 00:00:00 2001 From: lyzno1 Date: Wed, 29 Oct 2025 12:36:43 +0800 Subject: [PATCH 046/394] fix(workflow): add empty array validation for required checklist fields in trigger plugin The checkValid function was not properly validating required checklist fields when they had empty array values. This caused required fields to pass validation even when no options were selected. Added array length check to the constant type validation to ensure required checklist fields must have at least one selected option. --- .../components/workflow/nodes/trigger-plugin/default.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/web/app/components/workflow/nodes/trigger-plugin/default.ts b/web/app/components/workflow/nodes/trigger-plugin/default.ts index c994007e82..928534e07c 100644 --- a/web/app/components/workflow/nodes/trigger-plugin/default.ts +++ b/web/app/components/workflow/nodes/trigger-plugin/default.ts @@ -272,7 +272,12 @@ const nodeDefault: NodeDefault = { errorMessage = t('workflow.errorMsg.fieldRequired', { field: field.label }) } else { - if (value === undefined || value === null || value === '') + if ( + value === undefined + || value === null + || value === '' + || (Array.isArray(value) && value.length === 0) + ) errorMessage = t('workflow.errorMsg.fieldRequired', { field: field.label }) } }) From dfc5e3609d20c21091984dcf556911df46bb036c Mon Sep 17 00:00:00 2001 From: Harry Date: Wed, 29 Oct 2025 14:22:56 +0800 Subject: [PATCH 047/394] refactor(trigger): streamline OAuth client existence check Replaced the method for checking the existence of a system OAuth client with a new dedicated method `is_oauth_system_client_exists` in the TriggerProviderService. This improves code clarity and encapsulates the logic for verifying the presence of a system-level OAuth client. Updated the TriggerOAuthClientManageApi to utilize the new method for better readability. --- .../console/workspace/trigger_providers.py | 8 +++----- .../trigger/trigger_provider_service.py | 18 +++++++++++++++++- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/api/controllers/console/workspace/trigger_providers.py b/api/controllers/console/workspace/trigger_providers.py index 631b82979b..3f398fbbc3 100644 --- a/api/controllers/console/workspace/trigger_providers.py +++ b/api/controllers/console/workspace/trigger_providers.py @@ -469,9 +469,7 @@ class TriggerOAuthClientManageApi(Resource): tenant_id=user.current_tenant_id, provider_id=provider_id, ) - - # Check if there's a system OAuth client - system_client = TriggerProviderService.get_oauth_client( + system_client_exists = TriggerProviderService.is_oauth_system_client_exists( tenant_id=user.current_tenant_id, provider_id=provider_id, ) @@ -479,8 +477,8 @@ class TriggerOAuthClientManageApi(Resource): redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider}/trigger/callback" return jsonable_encoder( { - "configured": bool(custom_params or system_client), - "system_configured": bool(system_client), + "configured": bool(custom_params or system_client_exists), + "system_configured": system_client_exists, "custom_configured": bool(custom_params), "oauth_client_schema": provider_controller.get_oauth_client_schema(), "custom_enabled": is_custom_enabled, diff --git a/api/services/trigger/trigger_provider_service.py b/api/services/trigger/trigger_provider_service.py index 0e543bb039..f701f44eab 100644 --- a/api/services/trigger/trigger_provider_service.py +++ b/api/services/trigger/trigger_provider_service.py @@ -471,7 +471,7 @@ class TriggerProviderService: is_verified = PluginService.is_plugin_verified(tenant_id, provider_id.plugin_id) if not is_verified: - return oauth_params + return None # Check for system-level OAuth client system_client: TriggerOAuthSystemClient | None = ( @@ -488,6 +488,22 @@ class TriggerProviderService: return oauth_params + @classmethod + def is_oauth_system_client_exists(cls, tenant_id: str, provider_id: TriggerProviderID) -> bool: + """ + Check if system OAuth client exists for a trigger provider. + """ + is_verified = PluginService.is_plugin_verified(tenant_id, provider_id.plugin_id) + if not is_verified: + return False + with Session(db.engine, expire_on_commit=False) as session: + system_client: TriggerOAuthSystemClient | None = ( + session.query(TriggerOAuthSystemClient) + .filter_by(plugin_id=provider_id.plugin_id, provider=provider_id.provider_name) + .first() + ) + return system_client is not None + @classmethod def save_custom_oauth_client_params( cls, From bebcbfd80ebf843960eff9e1a4c75abc2a87647d Mon Sep 17 00:00:00 2001 From: hjlarry Date: Wed, 29 Oct 2025 14:29:41 +0800 Subject: [PATCH 048/394] chore: improve delete app related tables --- api/tasks/remove_app_and_related_data_task.py | 154 ++++++++---------- 1 file changed, 69 insertions(+), 85 deletions(-) diff --git a/api/tasks/remove_app_and_related_data_task.py b/api/tasks/remove_app_and_related_data_task.py index c66287c1d7..3227f6da96 100644 --- a/api/tasks/remove_app_and_related_data_task.py +++ b/api/tasks/remove_app_and_related_data_task.py @@ -17,6 +17,7 @@ from models import ( AppDatasetJoin, AppMCPServer, AppModelConfig, + AppTrigger, Conversation, EndUser, InstalledApp, @@ -30,8 +31,10 @@ from models import ( Site, TagBinding, TraceAppConfig, + WorkflowSchedulePlan, ) from models.tools import WorkflowToolProvider +from models.trigger import WorkflowPluginTrigger, WorkflowTriggerLog, WorkflowWebhookTrigger from models.web import PinnedConversation, SavedMessage from models.workflow import ( ConversationVariable, @@ -489,6 +492,72 @@ def _delete_draft_variable_offload_data(conn, file_ids: list[str]) -> int: return files_deleted +def _delete_app_triggers(tenant_id: str, app_id: str): + def del_app_trigger(trigger_id: str): + db.session.query(AppTrigger).where(AppTrigger.id == trigger_id).delete(synchronize_session=False) + + _delete_records( + """select id from app_triggers where tenant_id=:tenant_id and app_id=:app_id limit 1000""", + {"tenant_id": tenant_id, "app_id": app_id}, + del_app_trigger, + "app trigger", + ) + + +def _delete_workflow_plugin_triggers(tenant_id: str, app_id: str): + def del_plugin_trigger(trigger_id: str): + db.session.query(WorkflowPluginTrigger).where(WorkflowPluginTrigger.id == trigger_id).delete( + synchronize_session=False + ) + + _delete_records( + """select id from workflow_plugin_triggers where tenant_id=:tenant_id and app_id=:app_id limit 1000""", + {"tenant_id": tenant_id, "app_id": app_id}, + del_plugin_trigger, + "workflow plugin trigger", + ) + + +def _delete_workflow_webhook_triggers(tenant_id: str, app_id: str): + def del_webhook_trigger(trigger_id: str): + db.session.query(WorkflowWebhookTrigger).where(WorkflowWebhookTrigger.id == trigger_id).delete( + synchronize_session=False + ) + + _delete_records( + """select id from workflow_webhook_triggers where tenant_id=:tenant_id and app_id=:app_id limit 1000""", + {"tenant_id": tenant_id, "app_id": app_id}, + del_webhook_trigger, + "workflow webhook trigger", + ) + + +def _delete_workflow_schedule_plans(tenant_id: str, app_id: str): + def del_schedule_plan(plan_id: str): + db.session.query(WorkflowSchedulePlan).where(WorkflowSchedulePlan.id == plan_id).delete( + synchronize_session=False + ) + + _delete_records( + """select id from workflow_schedule_plans where tenant_id=:tenant_id and app_id=:app_id limit 1000""", + {"tenant_id": tenant_id, "app_id": app_id}, + del_schedule_plan, + "workflow schedule plan", + ) + + +def _delete_workflow_trigger_logs(tenant_id: str, app_id: str): + def del_trigger_log(log_id: str): + db.session.query(WorkflowTriggerLog).where(WorkflowTriggerLog.id == log_id).delete(synchronize_session=False) + + _delete_records( + """select id from workflow_trigger_logs where tenant_id=:tenant_id and app_id=:app_id limit 1000""", + {"tenant_id": tenant_id, "app_id": app_id}, + del_trigger_log, + "workflow trigger log", + ) + + def _delete_records(query_sql: str, params: dict, delete_func: Callable, name: str) -> None: while True: with db.engine.begin() as conn: @@ -506,88 +575,3 @@ def _delete_records(query_sql: str, params: dict, delete_func: Callable, name: s logger.exception("Error occurred while deleting %s %s", name, record_id) continue rs.close() - - -def _delete_app_triggers(tenant_id: str, app_id: str): - with db.engine.begin() as conn: - result = conn.execute( - sa.text( - """ - DELETE FROM app_triggers - WHERE tenant_id = :tenant_id - AND app_id = :app_id - """ - ), - {"tenant_id": tenant_id, "app_id": app_id}, - ) - deleted_count = result.rowcount or 0 - if deleted_count > 0: - logger.info(click.style(f"Deleted {deleted_count} app triggers for app {app_id}", fg="green")) - - -def _delete_workflow_plugin_triggers(tenant_id: str, app_id: str): - with db.engine.begin() as conn: - result = conn.execute( - sa.text( - """ - DELETE FROM workflow_plugin_triggers - WHERE tenant_id = :tenant_id - AND app_id = :app_id - """ - ), - {"tenant_id": tenant_id, "app_id": app_id}, - ) - deleted_count = result.rowcount or 0 - if deleted_count > 0: - logger.info(click.style(f"Deleted {deleted_count} workflow plugin triggers for app {app_id}", fg="green")) - - -def _delete_workflow_webhook_triggers(tenant_id: str, app_id: str): - with db.engine.begin() as conn: - result = conn.execute( - sa.text( - """ - DELETE FROM workflow_webhook_triggers - WHERE tenant_id = :tenant_id - AND app_id = :app_id - """ - ), - {"tenant_id": tenant_id, "app_id": app_id}, - ) - deleted_count = result.rowcount or 0 - if deleted_count > 0: - logger.info(click.style(f"Deleted {deleted_count} workflow webhook triggers for app {app_id}", fg="green")) - - -def _delete_workflow_schedule_plans(tenant_id: str, app_id: str): - with db.engine.begin() as conn: - result = conn.execute( - sa.text( - """ - DELETE FROM workflow_schedule_plans - WHERE tenant_id = :tenant_id - AND app_id = :app_id - """ - ), - {"tenant_id": tenant_id, "app_id": app_id}, - ) - deleted_count = result.rowcount or 0 - if deleted_count > 0: - logger.info(click.style(f"Deleted {deleted_count} workflow schedule plans for app {app_id}", fg="green")) - - -def _delete_workflow_trigger_logs(tenant_id: str, app_id: str): - with db.engine.begin() as conn: - result = conn.execute( - sa.text( - """ - DELETE FROM workflow_trigger_logs - WHERE tenant_id = :tenant_id - AND app_id = :app_id - """ - ), - {"tenant_id": tenant_id, "app_id": app_id}, - ) - deleted_count = result.rowcount or 0 - if deleted_count > 0: - logger.info(click.style(f"Deleted {deleted_count} workflow trigger logs for app {app_id}", fg="green")) From f092bc19126168853a18b0d0a75d1f0c381084c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Wed, 29 Oct 2025 14:33:43 +0800 Subject: [PATCH 049/394] chore: add more stories (#27403) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- web/.storybook/utils/form-story-wrapper.tsx | 83 +++ .../base/action-button/index.stories.tsx | 2 +- .../base/agent-log-modal/index.stories.tsx | 146 +++++ .../base/answer-icon/index.stories.tsx | 107 ++++ .../base/app-icon-picker/index.stories.tsx | 91 +++ .../base/app-icon/index.stories.tsx | 108 ++++ .../base/audio-btn/index.stories.tsx | 2 +- .../base/audio-gallery/index.stories.tsx | 37 ++ .../auto-height-textarea/index.stories.tsx | 2 +- .../components/base/avatar/index.stories.tsx | 73 +++ .../components/base/badge/index.stories.tsx | 73 +++ .../base/block-input/index.stories.tsx | 2 +- .../base/button/add-button.stories.tsx | 2 +- .../components/base/button/index.stories.tsx | 2 +- .../base/button/sync-button.stories.tsx | 2 +- .../base/chat/chat/answer/index.stories.tsx | 2 +- .../base/chat/chat/question.stories.tsx | 2 +- .../base/checkbox/index.stories.tsx | 2 +- .../components/base/chip/index.stories.tsx | 88 +++ .../components/base/confirm/index.stories.tsx | 2 +- .../base/content-dialog/index.stories.tsx | 6 +- .../base/copy-feedback/index.stories.tsx | 54 ++ .../base/copy-icon/index.stories.tsx | 68 +++ .../base/corner-label/index.stories.tsx | 53 ++ .../date-and-time-picker/index.stories.tsx | 101 ++++ .../components/base/dialog/index.stories.tsx | 3 +- .../components/base/divider/index.stories.tsx | 46 ++ .../base/drawer-plus/index.stories.tsx | 124 ++++ .../components/base/drawer/index.stories.tsx | 114 ++++ .../base/dropdown/index.stories.tsx | 85 +++ .../components/base/effect/index.stories.tsx | 39 ++ .../base/emoji-picker/Inner.stories.tsx | 57 ++ .../base/emoji-picker/index.stories.tsx | 91 +++ .../base/features/index.stories.tsx | 73 +++ .../base/file-icon/index.stories.tsx | 79 +++ .../file-image-render.stories.tsx | 32 + .../base/file-uploader/file-list.stories.tsx | 96 +++ .../file-uploader/file-type-icon.stories.tsx | 38 ++ .../index.stories.tsx | 110 ++++ .../index.stories.tsx | 95 +++ .../float-right-container/index.stories.tsx | 74 +++ .../components/base/form/index.stories.tsx | 559 ++++++++++++++++++ .../base/fullscreen-modal/index.stories.tsx | 59 ++ .../base/grid-mask/index.stories.tsx | 51 ++ .../base/image-gallery/index.stories.tsx | 39 ++ .../image-uploader/image-list.stories.tsx | 182 ++++++ .../inline-delete-confirm/index.stories.tsx | 87 +++ .../base/input-number/index.stories.tsx | 2 +- .../components/base/input/index.stories.tsx | 2 +- .../base/linked-apps-panel/index.stories.tsx | 72 +++ .../base/list-empty/index.stories.tsx | 49 ++ .../components/base/loading/index.stories.tsx | 52 ++ .../components/base/logo/index.stories.tsx | 82 +++ .../markdown-blocks/code-block.stories.tsx | 70 +++ .../markdown-blocks/think-block.stories.tsx | 78 +++ .../base/markdown/index.stories.tsx | 88 +++ .../components/base/mermaid/index.stories.tsx | 64 ++ .../base/message-log-modal/index.stories.tsx | 185 ++++++ .../base/modal-like-wrap/index.stories.tsx | 2 +- .../components/base/modal/index.stories.tsx | 2 +- .../components/base/modal/modal.stories.tsx | 2 +- .../base/new-audio-button/index.stories.tsx | 2 +- .../base/notion-connector/index.stories.tsx | 26 + .../base/notion-icon/index.stories.tsx | 129 ++++ .../notion-page-selector/index.stories.tsx | 200 +++++++ .../base/pagination/index.stories.tsx | 81 +++ .../base/param-item/index.stories.tsx | 121 ++++ .../components/base/popover/index.stories.tsx | 120 ++++ .../portal-to-follow-elem/index.stories.tsx | 103 ++++ .../base/premium-badge/index.stories.tsx | 64 ++ .../progress-bar/progress-circle.stories.tsx | 89 +++ .../base/prompt-editor/index.stories.tsx | 2 +- .../base/prompt-log-modal/index.stories.tsx | 74 +++ .../components/base/qrcode/index.stories.tsx | 52 ++ .../base/radio-card/index.stories.tsx | 2 +- .../components/base/radio/index.stories.tsx | 2 +- .../base/search-input/index.stories.tsx | 2 +- .../base/segmented-control/index.stories.tsx | 92 +++ .../components/base/select/index.stories.tsx | 2 +- .../base/simple-pie-chart/index.stories.tsx | 89 +++ .../base/skeleton/index.stories.tsx | 59 ++ .../components/base/slider/index.stories.tsx | 2 +- .../components/base/sort/index.stories.tsx | 59 ++ .../components/base/spinner/index.stories.tsx | 50 ++ .../base/svg-gallery/index.stories.tsx | 51 ++ web/app/components/base/svg/index.stories.tsx | 36 ++ .../components/base/switch/index.stories.tsx | 2 +- .../base/tab-header/index.stories.tsx | 64 ++ .../base/tab-slider-new/index.stories.tsx | 52 ++ .../base/tab-slider-plain/index.stories.tsx | 56 ++ .../base/tab-slider/index.stories.tsx | 93 +++ .../base/tag-input/index.stories.tsx | 2 +- .../base/tag-management/index.stories.tsx | 131 ++++ web/app/components/base/tag/index.stories.tsx | 62 ++ .../base/textarea/index.stories.tsx | 2 +- .../components/base/toast/index.stories.tsx | 104 ++++ .../components/base/tooltip/index.stories.tsx | 60 ++ .../base/video-gallery/index.stories.tsx | 40 ++ .../base/voice-input/index.stories.tsx | 2 +- .../with-input-validation/index.stories.tsx | 2 +- 100 files changed, 6144 insertions(+), 30 deletions(-) create mode 100644 web/.storybook/utils/form-story-wrapper.tsx create mode 100644 web/app/components/base/agent-log-modal/index.stories.tsx create mode 100644 web/app/components/base/answer-icon/index.stories.tsx create mode 100644 web/app/components/base/app-icon-picker/index.stories.tsx create mode 100644 web/app/components/base/app-icon/index.stories.tsx create mode 100644 web/app/components/base/audio-gallery/index.stories.tsx create mode 100644 web/app/components/base/avatar/index.stories.tsx create mode 100644 web/app/components/base/badge/index.stories.tsx create mode 100644 web/app/components/base/chip/index.stories.tsx create mode 100644 web/app/components/base/copy-feedback/index.stories.tsx create mode 100644 web/app/components/base/copy-icon/index.stories.tsx create mode 100644 web/app/components/base/corner-label/index.stories.tsx create mode 100644 web/app/components/base/date-and-time-picker/index.stories.tsx create mode 100644 web/app/components/base/divider/index.stories.tsx create mode 100644 web/app/components/base/drawer-plus/index.stories.tsx create mode 100644 web/app/components/base/drawer/index.stories.tsx create mode 100644 web/app/components/base/dropdown/index.stories.tsx create mode 100644 web/app/components/base/effect/index.stories.tsx create mode 100644 web/app/components/base/emoji-picker/Inner.stories.tsx create mode 100644 web/app/components/base/emoji-picker/index.stories.tsx create mode 100644 web/app/components/base/features/index.stories.tsx create mode 100644 web/app/components/base/file-icon/index.stories.tsx create mode 100644 web/app/components/base/file-uploader/file-image-render.stories.tsx create mode 100644 web/app/components/base/file-uploader/file-list.stories.tsx create mode 100644 web/app/components/base/file-uploader/file-type-icon.stories.tsx create mode 100644 web/app/components/base/file-uploader/file-uploader-in-attachment/index.stories.tsx create mode 100644 web/app/components/base/file-uploader/file-uploader-in-chat-input/index.stories.tsx create mode 100644 web/app/components/base/float-right-container/index.stories.tsx create mode 100644 web/app/components/base/form/index.stories.tsx create mode 100644 web/app/components/base/fullscreen-modal/index.stories.tsx create mode 100644 web/app/components/base/grid-mask/index.stories.tsx create mode 100644 web/app/components/base/image-gallery/index.stories.tsx create mode 100644 web/app/components/base/image-uploader/image-list.stories.tsx create mode 100644 web/app/components/base/inline-delete-confirm/index.stories.tsx create mode 100644 web/app/components/base/linked-apps-panel/index.stories.tsx create mode 100644 web/app/components/base/list-empty/index.stories.tsx create mode 100644 web/app/components/base/loading/index.stories.tsx create mode 100644 web/app/components/base/logo/index.stories.tsx create mode 100644 web/app/components/base/markdown-blocks/code-block.stories.tsx create mode 100644 web/app/components/base/markdown-blocks/think-block.stories.tsx create mode 100644 web/app/components/base/markdown/index.stories.tsx create mode 100644 web/app/components/base/mermaid/index.stories.tsx create mode 100644 web/app/components/base/message-log-modal/index.stories.tsx create mode 100644 web/app/components/base/notion-connector/index.stories.tsx create mode 100644 web/app/components/base/notion-icon/index.stories.tsx create mode 100644 web/app/components/base/notion-page-selector/index.stories.tsx create mode 100644 web/app/components/base/pagination/index.stories.tsx create mode 100644 web/app/components/base/param-item/index.stories.tsx create mode 100644 web/app/components/base/popover/index.stories.tsx create mode 100644 web/app/components/base/portal-to-follow-elem/index.stories.tsx create mode 100644 web/app/components/base/premium-badge/index.stories.tsx create mode 100644 web/app/components/base/progress-bar/progress-circle.stories.tsx create mode 100644 web/app/components/base/prompt-log-modal/index.stories.tsx create mode 100644 web/app/components/base/qrcode/index.stories.tsx create mode 100644 web/app/components/base/segmented-control/index.stories.tsx create mode 100644 web/app/components/base/simple-pie-chart/index.stories.tsx create mode 100644 web/app/components/base/skeleton/index.stories.tsx create mode 100644 web/app/components/base/sort/index.stories.tsx create mode 100644 web/app/components/base/spinner/index.stories.tsx create mode 100644 web/app/components/base/svg-gallery/index.stories.tsx create mode 100644 web/app/components/base/svg/index.stories.tsx create mode 100644 web/app/components/base/tab-header/index.stories.tsx create mode 100644 web/app/components/base/tab-slider-new/index.stories.tsx create mode 100644 web/app/components/base/tab-slider-plain/index.stories.tsx create mode 100644 web/app/components/base/tab-slider/index.stories.tsx create mode 100644 web/app/components/base/tag-management/index.stories.tsx create mode 100644 web/app/components/base/tag/index.stories.tsx create mode 100644 web/app/components/base/toast/index.stories.tsx create mode 100644 web/app/components/base/tooltip/index.stories.tsx create mode 100644 web/app/components/base/video-gallery/index.stories.tsx diff --git a/web/.storybook/utils/form-story-wrapper.tsx b/web/.storybook/utils/form-story-wrapper.tsx new file mode 100644 index 0000000000..689c3a20ff --- /dev/null +++ b/web/.storybook/utils/form-story-wrapper.tsx @@ -0,0 +1,83 @@ +import { useState } from 'react' +import type { ReactNode } from 'react' +import { useStore } from '@tanstack/react-form' +import { useAppForm } from '@/app/components/base/form' + +type UseAppFormOptions = Parameters[0] +type AppFormInstance = ReturnType + +type FormStoryWrapperProps = { + options?: UseAppFormOptions + children: (form: AppFormInstance) => ReactNode + title?: string + subtitle?: string +} + +export const FormStoryWrapper = ({ + options, + children, + title, + subtitle, +}: FormStoryWrapperProps) => { + const [lastSubmitted, setLastSubmitted] = useState(null) + const [submitCount, setSubmitCount] = useState(0) + + const form = useAppForm({ + ...options, + onSubmit: (context) => { + setSubmitCount(count => count + 1) + setLastSubmitted(context.value) + options?.onSubmit?.(context) + }, + }) + + const values = useStore(form.store, state => state.values) + const isSubmitting = useStore(form.store, state => state.isSubmitting) + const canSubmit = useStore(form.store, state => state.canSubmit) + + return ( +
+
+ {(title || subtitle) && ( +
+ {title &&

{title}

} + {subtitle &&

{subtitle}

} +
+ )} + {children(form)} +
+ +
+ ) +} + +export type FormStoryRender = (form: AppFormInstance) => ReactNode diff --git a/web/app/components/base/action-button/index.stories.tsx b/web/app/components/base/action-button/index.stories.tsx index dd826c41ba..07e0592374 100644 --- a/web/app/components/base/action-button/index.stories.tsx +++ b/web/app/components/base/action-button/index.stories.tsx @@ -3,7 +3,7 @@ import { RiAddLine, RiDeleteBinLine, RiEditLine, RiMore2Fill, RiSaveLine, RiShar import ActionButton, { ActionButtonState } from '.' const meta = { - title: 'Base/Button/ActionButton', + title: 'Base/General/ActionButton', component: ActionButton, parameters: { layout: 'centered', diff --git a/web/app/components/base/agent-log-modal/index.stories.tsx b/web/app/components/base/agent-log-modal/index.stories.tsx new file mode 100644 index 0000000000..b512c8c581 --- /dev/null +++ b/web/app/components/base/agent-log-modal/index.stories.tsx @@ -0,0 +1,146 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useEffect, useRef } from 'react' +import AgentLogModal from '.' +import { ToastProvider } from '@/app/components/base/toast' +import { useStore as useAppStore } from '@/app/components/app/store' +import type { IChatItem } from '@/app/components/base/chat/chat/type' +import type { AgentLogDetailResponse } from '@/models/log' + +const MOCK_RESPONSE: AgentLogDetailResponse = { + meta: { + status: 'finished', + executor: 'Agent Runner', + start_time: '2024-03-12T10:00:00Z', + elapsed_time: 12.45, + total_tokens: 2589, + agent_mode: 'ReACT', + iterations: 2, + error: undefined, + }, + iterations: [ + { + created_at: '2024-03-12T10:00:05Z', + files: [], + thought: JSON.stringify({ reasoning: 'Summarise conversation' }, null, 2), + tokens: 934, + tool_calls: [ + { + status: 'success', + tool_icon: null, + tool_input: { query: 'Latest revenue numbers' }, + tool_output: { answer: 'Revenue up 12% QoQ' }, + tool_name: 'search', + tool_label: { + 'en-US': 'Revenue Search', + }, + time_cost: 1.8, + }, + ], + tool_raw: { + inputs: JSON.stringify({ context: 'Summaries' }, null, 2), + outputs: JSON.stringify({ observation: 'Revenue up 12% QoQ' }, null, 2), + }, + }, + { + created_at: '2024-03-12T10:00:09Z', + files: [], + thought: JSON.stringify({ final: 'Revenue increased 12% quarter-over-quarter.' }, null, 2), + tokens: 642, + tool_calls: [], + tool_raw: { + inputs: JSON.stringify({ context: 'Compose summary' }, null, 2), + outputs: JSON.stringify({ observation: 'Final answer ready' }, null, 2), + }, + }, + ], + files: [], +} + +const MOCK_CHAT_ITEM: IChatItem = { + id: 'message-1', + content: JSON.stringify({ answer: 'Revenue grew 12% QoQ.' }, null, 2), + input: JSON.stringify({ question: 'Summarise revenue trends.' }, null, 2), + isAnswer: true, + conversationId: 'conv-123', +} + +const AgentLogModalDemo = ({ + width = 960, +}: { + width?: number +}) => { + const originalFetchRef = useRef(null) + const setAppDetail = useAppStore(state => state.setAppDetail) + + useEffect(() => { + setAppDetail({ + id: 'app-1', + name: 'Analytics Agent', + mode: 'agent-chat', + } as any) + + originalFetchRef.current = globalThis.fetch?.bind(globalThis) + + const handler = async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init) + const url = request.url + const parsed = new URL(url, window.location.origin) + + if (parsed.pathname.endsWith('/apps/app-1/agent/logs')) { + return new Response(JSON.stringify(MOCK_RESPONSE), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + + if (originalFetchRef.current) + return originalFetchRef.current(request) + + throw new Error(`Unhandled request: ${url}`) + } + + globalThis.fetch = handler as typeof globalThis.fetch + + return () => { + if (originalFetchRef.current) + globalThis.fetch = originalFetchRef.current + setAppDetail(undefined) + } + }, [setAppDetail]) + + return ( + +
+ { + console.log('Agent log modal closed') + }} + /> +
+
+ ) +} + +const meta = { + title: 'Base/Other/AgentLogModal', + component: AgentLogModalDemo, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Agent execution viewer showing iterations, tool calls, and metadata. Fetch responses are mocked for Storybook.', + }, + }, + }, + args: { + width: 960, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/answer-icon/index.stories.tsx b/web/app/components/base/answer-icon/index.stories.tsx new file mode 100644 index 0000000000..0928d9cda6 --- /dev/null +++ b/web/app/components/base/answer-icon/index.stories.tsx @@ -0,0 +1,107 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import type { ReactNode } from 'react' +import AnswerIcon from '.' + +const SAMPLE_IMAGE = 'data:image/svg+xml;utf8,AI' + +const meta = { + title: 'Base/General/AnswerIcon', + component: AnswerIcon, + parameters: { + docs: { + description: { + component: 'Circular avatar used for assistant answers. Supports emoji, solid background colour, or uploaded imagery.', + }, + }, + }, + tags: ['autodocs'], + args: { + icon: '🤖', + background: '#D5F5F6', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +const StoryWrapper = (children: ReactNode) => ( +
+ {children} +
+) + +export const Default: Story = { + render: args => StoryWrapper( +
+ +
, + ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +
+ +
+ `.trim(), + }, + }, + }, +} + +export const CustomEmoji: Story = { + render: args => StoryWrapper( + <> +
+ +
+
+ +
+ , + ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +
+
+ +
+
+ +
+
+ `.trim(), + }, + }, + }, +} + +export const ImageIcon: Story = { + render: args => StoryWrapper( +
+ +
, + ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/app-icon-picker/index.stories.tsx b/web/app/components/base/app-icon-picker/index.stories.tsx new file mode 100644 index 0000000000..bd0ec0e200 --- /dev/null +++ b/web/app/components/base/app-icon-picker/index.stories.tsx @@ -0,0 +1,91 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import AppIconPicker, { type AppIconSelection } from '.' + +const meta = { + title: 'Base/Data Entry/AppIconPicker', + component: AppIconPicker, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Modal workflow for choosing an application avatar. Users can switch between emoji selections and image uploads (when enabled).', + }, + }, + nextjs: { + appDirectory: true, + navigation: { + pathname: '/apps/demo-app/icon-picker', + params: { appId: 'demo-app' }, + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const AppIconPickerDemo = () => { + const [open, setOpen] = useState(false) + const [selection, setSelection] = useState(null) + + return ( +
+ + +
+
Selection preview
+
+          {selection ? JSON.stringify(selection, null, 2) : 'No icon selected yet.'}
+        
+
+ + {open && ( + { + setSelection(result) + setOpen(false) + }} + onClose={() => setOpen(false)} + /> + )} +
+ ) +} + +export const Playground: Story = { + render: () => , + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +const [open, setOpen] = useState(false) +const [selection, setSelection] = useState(null) + +return ( + <> + + {open && ( + { + setSelection(result) + setOpen(false) + }} + onClose={() => setOpen(false)} + /> + )} + +) + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/app-icon/index.stories.tsx b/web/app/components/base/app-icon/index.stories.tsx new file mode 100644 index 0000000000..9fdffb54b0 --- /dev/null +++ b/web/app/components/base/app-icon/index.stories.tsx @@ -0,0 +1,108 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import type { ComponentProps } from 'react' +import AppIcon from '.' + +const meta = { + title: 'Base/General/AppIcon', + component: AppIcon, + parameters: { + docs: { + description: { + component: 'Reusable avatar for applications and workflows. Supports emoji or uploaded imagery, rounded mode, edit overlays, and multiple sizes.', + }, + }, + }, + tags: ['autodocs'], + args: { + icon: '🧭', + background: '#FFEAD5', + size: 'medium', + rounded: false, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: args => ( +
+ + +
+ ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` + + + `.trim(), + }, + }, + }, +} + +export const Sizes: Story = { + render: (args) => { + const sizes: Array['size']> = ['xs', 'tiny', 'small', 'medium', 'large', 'xl', 'xxl'] + return ( +
+ {sizes.map(size => ( +
+ + {size} +
+ ))} +
+ ) + }, + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +{(['xs','tiny','small','medium','large','xl','xxl'] as const).map(size => ( + +))} + `.trim(), + }, + }, + }, +} + +export const WithEditOverlay: Story = { + render: args => ( +
+ + +
+ ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` + + + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/audio-btn/index.stories.tsx b/web/app/components/base/audio-btn/index.stories.tsx index 8dc82d3413..1c989b80a6 100644 --- a/web/app/components/base/audio-btn/index.stories.tsx +++ b/web/app/components/base/audio-btn/index.stories.tsx @@ -20,7 +20,7 @@ const StoryWrapper = (props: ComponentProps) => { } const meta = { - title: 'Base/Button/AudioBtn', + title: 'Base/General/AudioBtn', component: AudioBtn, tags: ['autodocs'], parameters: { diff --git a/web/app/components/base/audio-gallery/index.stories.tsx b/web/app/components/base/audio-gallery/index.stories.tsx new file mode 100644 index 0000000000..539ab9e332 --- /dev/null +++ b/web/app/components/base/audio-gallery/index.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import AudioGallery from '.' + +const AUDIO_SOURCES = [ + 'https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3', +] + +const meta = { + title: 'Base/Data Display/AudioGallery', + component: AudioGallery, + parameters: { + docs: { + description: { + component: 'List of audio players that render waveform previews and playback controls for each source.', + }, + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, + tags: ['autodocs'], + args: { + srcs: AUDIO_SOURCES, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} diff --git a/web/app/components/base/auto-height-textarea/index.stories.tsx b/web/app/components/base/auto-height-textarea/index.stories.tsx index a9234fac9d..d0f36e4736 100644 --- a/web/app/components/base/auto-height-textarea/index.stories.tsx +++ b/web/app/components/base/auto-height-textarea/index.stories.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' import AutoHeightTextarea from '.' const meta = { - title: 'Base/Input/AutoHeightTextarea', + title: 'Base/Data Entry/AutoHeightTextarea', component: AutoHeightTextarea, parameters: { layout: 'centered', diff --git a/web/app/components/base/avatar/index.stories.tsx b/web/app/components/base/avatar/index.stories.tsx new file mode 100644 index 0000000000..1b3dc3eb3b --- /dev/null +++ b/web/app/components/base/avatar/index.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import Avatar from '.' + +const meta = { + title: 'Base/Data Display/Avatar', + component: Avatar, + parameters: { + docs: { + description: { + component: 'Initials or image-based avatar used across contacts and member lists. Falls back to the first letter when the image fails to load.', + }, + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, + tags: ['autodocs'], + args: { + name: 'Alex Doe', + avatar: 'https://cloud.dify.ai/logo/logo.svg', + size: 40, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} + +export const WithFallback: Story = { + args: { + avatar: null, + name: 'Fallback', + }, + parameters: { + docs: { + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, +} + +export const CustomSizes: Story = { + render: args => ( +
+ {[24, 32, 48, 64].map(size => ( +
+ + {size}px +
+ ))} +
+ ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +{[24, 32, 48, 64].map(size => ( + +))} + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/badge/index.stories.tsx b/web/app/components/base/badge/index.stories.tsx new file mode 100644 index 0000000000..e1fe8cb271 --- /dev/null +++ b/web/app/components/base/badge/index.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import Badge from '../badge' + +const meta = { + title: 'Base/Data Display/Badge', + component: Badge, + parameters: { + docs: { + description: { + component: 'Compact label used for statuses and counts. Supports uppercase styling and optional red corner marks.', + }, + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, + tags: ['autodocs'], + args: { + text: 'beta', + uppercase: true, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} + +export const WithCornerMark: Story = { + args: { + text: 'new', + hasRedCornerMark: true, + }, + parameters: { + docs: { + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, +} + +export const CustomContent: Story = { + render: args => ( + + + + Production + + + ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` + + + + Production + + + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/block-input/index.stories.tsx b/web/app/components/base/block-input/index.stories.tsx index 5f1967b9d0..d05cc221b6 100644 --- a/web/app/components/base/block-input/index.stories.tsx +++ b/web/app/components/base/block-input/index.stories.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' import BlockInput from '.' const meta = { - title: 'Base/Input/BlockInput', + title: 'Base/Data Entry/BlockInput', component: BlockInput, parameters: { layout: 'centered', diff --git a/web/app/components/base/button/add-button.stories.tsx b/web/app/components/base/button/add-button.stories.tsx index a46441aefe..edd52b2b78 100644 --- a/web/app/components/base/button/add-button.stories.tsx +++ b/web/app/components/base/button/add-button.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs' import AddButton from './add-button' const meta = { - title: 'Base/Button/AddButton', + title: 'Base/General/AddButton', component: AddButton, parameters: { layout: 'centered', diff --git a/web/app/components/base/button/index.stories.tsx b/web/app/components/base/button/index.stories.tsx index f369e2f71a..02d20b4af4 100644 --- a/web/app/components/base/button/index.stories.tsx +++ b/web/app/components/base/button/index.stories.tsx @@ -4,7 +4,7 @@ import { RocketLaunchIcon } from '@heroicons/react/20/solid' import { Button } from '.' const meta = { - title: 'Base/Button/Button', + title: 'Base/General/Button', component: Button, parameters: { layout: 'centered', diff --git a/web/app/components/base/button/sync-button.stories.tsx b/web/app/components/base/button/sync-button.stories.tsx index d55a7acf47..dcfbf6daf3 100644 --- a/web/app/components/base/button/sync-button.stories.tsx +++ b/web/app/components/base/button/sync-button.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs' import SyncButton from './sync-button' const meta = { - title: 'Base/Button/SyncButton', + title: 'Base/General/SyncButton', component: SyncButton, parameters: { layout: 'centered', diff --git a/web/app/components/base/chat/chat/answer/index.stories.tsx b/web/app/components/base/chat/chat/answer/index.stories.tsx index 822bdf7326..95bc3bd5c0 100644 --- a/web/app/components/base/chat/chat/answer/index.stories.tsx +++ b/web/app/components/base/chat/chat/answer/index.stories.tsx @@ -6,7 +6,7 @@ import { markdownContentSVG } from './__mocks__/markdownContentSVG' import Answer from '.' const meta = { - title: 'Base/Chat/Chat Answer', + title: 'Base/Other/Chat Answer', component: Answer, parameters: { layout: 'fullscreen', diff --git a/web/app/components/base/chat/chat/question.stories.tsx b/web/app/components/base/chat/chat/question.stories.tsx index 0b84ee91a8..f0ee860c89 100644 --- a/web/app/components/base/chat/chat/question.stories.tsx +++ b/web/app/components/base/chat/chat/question.stories.tsx @@ -5,7 +5,7 @@ import Question from './question' import { User } from '@/app/components/base/icons/src/public/avatar' const meta = { - title: 'Base/Chat/Chat Question', + title: 'Base/Other/Chat Question', component: Question, parameters: { layout: 'centered', diff --git a/web/app/components/base/checkbox/index.stories.tsx b/web/app/components/base/checkbox/index.stories.tsx index ba928baa6f..3f8d4606eb 100644 --- a/web/app/components/base/checkbox/index.stories.tsx +++ b/web/app/components/base/checkbox/index.stories.tsx @@ -13,7 +13,7 @@ const createToggleItem = ( } const meta = { - title: 'Base/Input/Checkbox', + title: 'Base/Data Entry/Checkbox', component: Checkbox, parameters: { layout: 'centered', diff --git a/web/app/components/base/chip/index.stories.tsx b/web/app/components/base/chip/index.stories.tsx new file mode 100644 index 0000000000..0ea018ef95 --- /dev/null +++ b/web/app/components/base/chip/index.stories.tsx @@ -0,0 +1,88 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import Chip, { type Item } from '.' + +const ITEMS: Item[] = [ + { value: 'all', name: 'All items' }, + { value: 'active', name: 'Active' }, + { value: 'archived', name: 'Archived' }, + { value: 'draft', name: 'Drafts' }, +] + +const meta = { + title: 'Base/Data Entry/Chip', + component: Chip, + parameters: { + docs: { + description: { + component: 'Filter chip with dropdown panel and optional left icon. Commonly used for status pickers in toolbars.', + }, + }, + }, + tags: ['autodocs'], + args: { + items: ITEMS, + value: 'all', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +const ChipDemo = (props: React.ComponentProps) => { + const [selection, setSelection] = useState(props.value) + + return ( +
+ setSelection(item.value)} + onClear={() => setSelection('all')} + /> +
+ Current value: {selection} +
+
+ ) +} + +export const Playground: Story = { + render: args => , + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +const [selection, setSelection] = useState('all') + + setSelection(item.value)} + onClear={() => setSelection('all')} +/> + `.trim(), + }, + }, + }, +} + +export const WithoutLeftIcon: Story = { + render: args => ( + + ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/confirm/index.stories.tsx b/web/app/components/base/confirm/index.stories.tsx index 9ec21cbd50..12cb46d9e4 100644 --- a/web/app/components/base/confirm/index.stories.tsx +++ b/web/app/components/base/confirm/index.stories.tsx @@ -4,7 +4,7 @@ import Confirm from '.' import Button from '../button' const meta = { - title: 'Base/Dialog/Confirm', + title: 'Base/Feedback/Confirm', component: Confirm, parameters: { layout: 'centered', diff --git a/web/app/components/base/content-dialog/index.stories.tsx b/web/app/components/base/content-dialog/index.stories.tsx index 67781a17a0..aaebcad1b7 100644 --- a/web/app/components/base/content-dialog/index.stories.tsx +++ b/web/app/components/base/content-dialog/index.stories.tsx @@ -5,7 +5,7 @@ import ContentDialog from '.' type Props = React.ComponentProps const meta = { - title: 'Base/Dialog/ContentDialog', + title: 'Base/Feedback/ContentDialog', component: ContentDialog, parameters: { layout: 'fullscreen', @@ -29,6 +29,10 @@ const meta = { control: false, description: 'Invoked when the overlay/backdrop is clicked.', }, + children: { + control: false, + table: { disable: true }, + }, }, args: { show: false, diff --git a/web/app/components/base/copy-feedback/index.stories.tsx b/web/app/components/base/copy-feedback/index.stories.tsx new file mode 100644 index 0000000000..3bab620aec --- /dev/null +++ b/web/app/components/base/copy-feedback/index.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import CopyFeedback, { CopyFeedbackNew } from '.' + +const meta = { + title: 'Base/Feedback/CopyFeedback', + component: CopyFeedback, + parameters: { + docs: { + description: { + component: 'Copy-to-clipboard button that shows instant feedback and a tooltip. Includes the original ActionButton wrapper and the newer ghost-button variant.', + }, + }, + }, + tags: ['autodocs'], + args: { + content: 'acc-3f92fa', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +const CopyDemo = ({ content }: { content: string }) => { + const [value] = useState(content) + return ( +
+
+ Client ID: + {value} + +
+
+ Use the new ghost variant: + +
+
+ ) +} + +export const Playground: Story = { + render: args => , + parameters: { + docs: { + source: { + language: 'tsx', + code: ` + + + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/copy-icon/index.stories.tsx b/web/app/components/base/copy-icon/index.stories.tsx new file mode 100644 index 0000000000..5962773792 --- /dev/null +++ b/web/app/components/base/copy-icon/index.stories.tsx @@ -0,0 +1,68 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import CopyIcon from '.' + +const meta = { + title: 'Base/General/CopyIcon', + component: CopyIcon, + parameters: { + docs: { + description: { + component: 'Interactive copy-to-clipboard glyph that swaps to a checkmark once the content has been copied. Tooltips rely on the app locale.', + }, + }, + }, + tags: ['autodocs'], + args: { + content: 'https://console.dify.ai/apps/12345', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: args => ( +
+ Hover or click to copy the app link: + +
+ ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +
+ Hover or click to copy the app link: + +
+ `.trim(), + }, + }, + }, +} + +export const InlineUsage: Story = { + render: args => ( +
+

+ Use the copy icon inline with labels or metadata. Clicking the icon copies the value to the clipboard and shows a success tooltip. +

+
+ Client ID + acc-3f92fa + +
+
+ ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/corner-label/index.stories.tsx b/web/app/components/base/corner-label/index.stories.tsx new file mode 100644 index 0000000000..1592f94259 --- /dev/null +++ b/web/app/components/base/corner-label/index.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import CornerLabel from '.' + +const meta = { + title: 'Base/Data Display/CornerLabel', + component: CornerLabel, + parameters: { + docs: { + description: { + component: 'Decorative label that anchors to card corners. Useful for marking “beta”, “deprecated”, or similar callouts.', + }, + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, + tags: ['autodocs'], + args: { + label: 'beta', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} + +export const OnCard: Story = { + render: args => ( +
+ +
+ Showcase how the label sits on a card header. Pair with contextual text or status information. +
+
+ ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +
+ + ...card content... +
+ `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/date-and-time-picker/index.stories.tsx b/web/app/components/base/date-and-time-picker/index.stories.tsx new file mode 100644 index 0000000000..1789407d03 --- /dev/null +++ b/web/app/components/base/date-and-time-picker/index.stories.tsx @@ -0,0 +1,101 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { fn } from 'storybook/test' +import { useState } from 'react' +import DatePicker from './date-picker' +import dayjs from './utils/dayjs' +import { getDateWithTimezone } from './utils/dayjs' +import type { DatePickerProps } from './types' + +const meta = { + title: 'Base/Data Entry/DateAndTimePicker', + component: DatePicker, + parameters: { + docs: { + description: { + component: 'Combined date and time picker with timezone support. Includes shortcuts for “now”, year-month navigation, and optional time selection.', + }, + }, + }, + tags: ['autodocs'], + args: { + value: getDateWithTimezone({}), + timezone: dayjs.tz.guess(), + needTimePicker: true, + placeholder: 'Select schedule time', + onChange: fn(), + onClear: fn(), + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +const DatePickerPlayground = (props: DatePickerProps) => { + const [value, setValue] = useState(props.value) + + return ( +
+ setValue(undefined)} + /> +
+ Selected datetime: {value ? value.format() : 'undefined'} +
+
+ ) +} + +export const Playground: Story = { + render: args => , + args: { + ...meta.args, + needTimePicker: false, + placeholder: 'Select due date', + }, + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +const [value, setValue] = useState(getDateWithTimezone({})) + + setValue(undefined)} +/> + `.trim(), + }, + }, + }, +} + +export const DateOnly: Story = { + render: args => ( + + ), + args: { + ...meta.args, + needTimePicker: false, + placeholder: 'Select due date', + }, + parameters: { + docs: { + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/dialog/index.stories.tsx b/web/app/components/base/dialog/index.stories.tsx index 94998c6d21..5fd833b666 100644 --- a/web/app/components/base/dialog/index.stories.tsx +++ b/web/app/components/base/dialog/index.stories.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react' import Dialog from '.' const meta = { - title: 'Base/Dialog/Dialog', + title: 'Base/Feedback/Dialog', component: Dialog, parameters: { layout: 'fullscreen', @@ -130,6 +130,7 @@ export const CustomStyling: Story = { bodyClassName: 'bg-gray-50 rounded-xl p-5', footerClassName: 'justify-between px-4 pb-4 pt-4', titleClassName: 'text-lg text-primary-600', + children: null, footer: ( <> Last synced 2 minutes ago diff --git a/web/app/components/base/divider/index.stories.tsx b/web/app/components/base/divider/index.stories.tsx new file mode 100644 index 0000000000..c634173202 --- /dev/null +++ b/web/app/components/base/divider/index.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import Divider from '.' + +const meta = { + title: 'Base/Layout/Divider', + component: Divider, + parameters: { + docs: { + description: { + component: 'Lightweight separator supporting horizontal and vertical orientations with gradient or solid backgrounds.', + }, + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Horizontal: Story = {} + +export const Vertical: Story = { + render: args => ( +
+ Filters + + Tags +
+ ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/drawer-plus/index.stories.tsx b/web/app/components/base/drawer-plus/index.stories.tsx new file mode 100644 index 0000000000..ddb39f2d63 --- /dev/null +++ b/web/app/components/base/drawer-plus/index.stories.tsx @@ -0,0 +1,124 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { fn } from 'storybook/test' +import { useState } from 'react' +import DrawerPlus from '.' + +const meta = { + title: 'Base/Feedback/DrawerPlus', + component: DrawerPlus, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Enhanced drawer built atop the base drawer component. Provides header/foot slots, mask control, and mobile breakpoints.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +type DrawerPlusProps = React.ComponentProps + +const storyBodyElement: React.JSX.Element = ( +
+

+ DrawerPlus allows rich content with sticky header/footer and responsive masking on mobile. Great for editing flows or showing execution logs. +

+
+ Body content scrolls if it exceeds the allotted height. +
+
+) + +const DrawerPlusDemo = (props: Partial) => { + const [open, setOpen] = useState(false) + + const { + body, + title, + foot, + isShow: _isShow, + onHide: _onHide, + ...rest + } = props + + const resolvedBody: React.JSX.Element = body ?? storyBodyElement + + return ( +
+ + + } + isShow={open} + onHide={() => setOpen(false)} + title={title ?? 'Workflow execution details'} + body={resolvedBody} + foot={foot} + /> +
+ ) +} + +export const Playground: Story = { + render: args => , + args: { + isShow: false, + onHide: fn(), + title: 'Edit configuration', + body: storyBodyElement, + }, +} + +export const WithFooter: Story = { + render: (args) => { + const FooterDemo = () => { + const [open, setOpen] = useState(false) + return ( +
+ + + setOpen(false)} + title={args.title ?? 'Workflow execution details'} + body={args.body ?? ( +
+

Populate the body with scrollable content. Footer stays pinned.

+
+ )} + foot={ +
+ + +
+ } + /> +
+ ) + } + return + }, + args: { + isShow: false, + onHide: fn(), + title: 'Edit configuration!', + body: storyBodyElement, + }, +} diff --git a/web/app/components/base/drawer/index.stories.tsx b/web/app/components/base/drawer/index.stories.tsx new file mode 100644 index 0000000000..e7711bc1a2 --- /dev/null +++ b/web/app/components/base/drawer/index.stories.tsx @@ -0,0 +1,114 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { fn } from 'storybook/test' +import { useState } from 'react' +import Drawer from '.' + +const meta = { + title: 'Base/Feedback/Drawer', + component: Drawer, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Sliding panel built on Headless UI dialog primitives. Supports optional mask, custom footer, and close behaviour.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const DrawerDemo = (props: React.ComponentProps) => { + const [open, setOpen] = useState(false) + + return ( +
+ + + setOpen(false)} + title={props.title ?? 'Edit configuration'} + description={props.description ?? 'Adjust settings in the side panel and save.'} + footer={props.footer ?? undefined} + > +
+

+ This example renders arbitrary content inside the drawer body. Use it for contextual forms, settings, or informational panels. +

+
+ Content area +
+
+
+
+ ) +} + +export const Playground: Story = { + render: args => , + args: { + children: null, + isOpen: false, + onClose: fn(), + }, + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +const [open, setOpen] = useState(false) + + setOpen(false)} + title="Edit configuration" + description="Adjust settings in the side panel and save." +> + ... + + `.trim(), + }, + }, + }, +} + +export const CustomFooter: Story = { + render: args => ( + + + +
+ } + /> + ), + args: { + children: null, + isOpen: false, + onClose: fn(), + }, + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +}> + ... + + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/dropdown/index.stories.tsx b/web/app/components/base/dropdown/index.stories.tsx new file mode 100644 index 0000000000..da70730744 --- /dev/null +++ b/web/app/components/base/dropdown/index.stories.tsx @@ -0,0 +1,85 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { fn } from 'storybook/test' +import { useState } from 'react' +import Dropdown, { type Item } from '.' + +const PRIMARY_ITEMS: Item[] = [ + { value: 'rename', text: 'Rename' }, + { value: 'duplicate', text: 'Duplicate' }, +] + +const SECONDARY_ITEMS: Item[] = [ + { value: 'archive', text: Archive }, + { value: 'delete', text: Delete }, +] + +const meta = { + title: 'Base/Navigation/Dropdown', + component: Dropdown, + parameters: { + docs: { + description: { + component: 'Small contextual menu with optional destructive section. Uses portal positioning utilities for precise placement.', + }, + }, + }, + tags: ['autodocs'], + args: { + items: PRIMARY_ITEMS, + secondItems: SECONDARY_ITEMS, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +const DropdownDemo = (props: React.ComponentProps) => { + const [lastAction, setLastAction] = useState('None') + + return ( +
+ { + setLastAction(String(item.value)) + props.onSelect?.(item) + }} + /> +
+ Last action: {lastAction} +
+
+ ) +} + +export const Playground: Story = { + render: args => , + args: { + items: PRIMARY_ITEMS, + secondItems: SECONDARY_ITEMS, + onSelect: fn(), + }, +} + +export const CustomTrigger: Story = { + render: args => ( + ( + + )} + /> + ), + args: { + items: PRIMARY_ITEMS, + onSelect: fn(), + }, +} diff --git a/web/app/components/base/effect/index.stories.tsx b/web/app/components/base/effect/index.stories.tsx new file mode 100644 index 0000000000..a7f316fe7e --- /dev/null +++ b/web/app/components/base/effect/index.stories.tsx @@ -0,0 +1,39 @@ +/* eslint-disable tailwindcss/classnames-order */ +import type { Meta, StoryObj } from '@storybook/nextjs' +import Effect from '.' + +const meta = { + title: 'Base/Other/Effect', + component: Effect, + parameters: { + docs: { + description: { + component: 'Blurred circular glow used as a decorative background accent. Combine with relatively positioned containers.', + }, + source: { + language: 'tsx', + code: ` +
+ +
+ `.trim(), + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = { + render: () => ( +
+ + +
+ Accent glow +
+
+ ), +} diff --git a/web/app/components/base/emoji-picker/Inner.stories.tsx b/web/app/components/base/emoji-picker/Inner.stories.tsx new file mode 100644 index 0000000000..5341d63ee3 --- /dev/null +++ b/web/app/components/base/emoji-picker/Inner.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import EmojiPickerInner from './Inner' + +const meta = { + title: 'Base/Data Entry/EmojiPickerInner', + component: EmojiPickerInner, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Core emoji grid with search and style swatches. Use this when embedding the selector inline without a modal frame.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const InnerDemo = () => { + const [selection, setSelection] = useState<{ emoji: string; background: string } | null>(null) + + return ( +
+ setSelection({ emoji, background })} + className="flex-1 overflow-hidden rounded-xl border border-divider-subtle bg-white" + /> +
+
Latest selection
+
+          {selection ? JSON.stringify(selection, null, 2) : 'Tap an emoji to set background options.'}
+        
+
+
+ ) +} + +export const Playground: Story = { + render: () => , + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +const [selection, setSelection] = useState<{ emoji: string; background: string } | null>(null) + +return ( + setSelection({ emoji, background })} /> +) + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/emoji-picker/index.stories.tsx b/web/app/components/base/emoji-picker/index.stories.tsx new file mode 100644 index 0000000000..7c9b07f138 --- /dev/null +++ b/web/app/components/base/emoji-picker/index.stories.tsx @@ -0,0 +1,91 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import EmojiPicker from '.' + +const meta = { + title: 'Base/Data Entry/EmojiPicker', + component: EmojiPicker, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Modal-based emoji selector that powers the icon picker. Supports search, background swatches, and confirmation callbacks.', + }, + }, + nextjs: { + appDirectory: true, + navigation: { + pathname: '/apps/demo-app/emoji-picker', + params: { appId: 'demo-app' }, + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const EmojiPickerDemo = () => { + const [open, setOpen] = useState(false) + const [selection, setSelection] = useState<{ emoji: string; background: string } | null>(null) + + return ( +
+ + +
+
Selection preview
+
+          {selection ? JSON.stringify(selection, null, 2) : 'No emoji selected yet.'}
+        
+
+ + {open && ( + { + setSelection({ emoji, background }) + setOpen(false) + }} + onClose={() => setOpen(false)} + /> + )} +
+ ) +} + +export const Playground: Story = { + render: () => , + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +const [open, setOpen] = useState(false) +const [selection, setSelection] = useState<{ emoji: string; background: string } | null>(null) + +return ( + <> + + {open && ( + { + setSelection({ emoji, background }) + setOpen(false) + }} + onClose={() => setOpen(false)} + /> + )} + +) + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/features/index.stories.tsx b/web/app/components/base/features/index.stories.tsx new file mode 100644 index 0000000000..f1eaf048b8 --- /dev/null +++ b/web/app/components/base/features/index.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import { FeaturesProvider } from '.' +import NewFeaturePanel from './new-feature-panel' +import type { Features } from './types' + +const DEFAULT_FEATURES: Features = { + moreLikeThis: { enabled: false }, + opening: { enabled: false }, + suggested: { enabled: false }, + text2speech: { enabled: false }, + speech2text: { enabled: false }, + citation: { enabled: false }, + moderation: { enabled: false }, + file: { enabled: false }, + annotationReply: { enabled: false }, +} + +const meta = { + title: 'Base/Other/FeaturesProvider', + component: FeaturesProvider, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Zustand-backed provider used for feature toggles. Paired with `NewFeaturePanel` for workflow settings.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const FeaturesDemo = () => { + const [show, setShow] = useState(true) + const [features, setFeatures] = useState(DEFAULT_FEATURES) + + return ( + +
+
+
Feature toggles preview
+
+ +
+
+
+ + setFeatures(prev => ({ ...prev, ...next }))} + onClose={() => setShow(false)} + /> +
+ ) +} + +export const Playground: Story = { + render: () => , + args: { + children: null, + }, +} diff --git a/web/app/components/base/file-icon/index.stories.tsx b/web/app/components/base/file-icon/index.stories.tsx new file mode 100644 index 0000000000..dbd3e13fea --- /dev/null +++ b/web/app/components/base/file-icon/index.stories.tsx @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import FileIcon from '.' + +const meta = { + title: 'Base/General/FileIcon', + component: FileIcon, + parameters: { + docs: { + description: { + component: 'Maps a file extension to the appropriate SVG icon used across upload and attachment surfaces.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + type: { + control: 'text', + description: 'File extension or identifier used to resolve the icon.', + }, + className: { + control: 'text', + description: 'Custom classes passed to the SVG wrapper.', + }, + }, + args: { + type: 'pdf', + className: 'h-10 w-10', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: args => ( +
+ + Extension: {args.type} +
+ ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, +} + +export const Gallery: Story = { + render: () => { + const examples = ['pdf', 'docx', 'xlsx', 'csv', 'json', 'md', 'txt', 'html', 'notion', 'unknown'] + return ( +
+ {examples.map(type => ( +
+ + {type} +
+ ))} +
+ ) + }, + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +{['pdf','docx','xlsx','csv','json','md','txt','html','notion','unknown'].map(type => ( + +))} + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/file-uploader/file-image-render.stories.tsx b/web/app/components/base/file-uploader/file-image-render.stories.tsx new file mode 100644 index 0000000000..132c0b61a3 --- /dev/null +++ b/web/app/components/base/file-uploader/file-image-render.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import FileImageRender from './file-image-render' + +const SAMPLE_IMAGE = 'data:image/svg+xml;utf8,Preview' + +const meta = { + title: 'Base/General/FileImageRender', + component: FileImageRender, + parameters: { + docs: { + description: { + component: 'Renders image previews inside a bordered frame. Often used in upload galleries and logs.', + }, + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, + tags: ['autodocs'], + args: { + imageUrl: SAMPLE_IMAGE, + className: 'h-32 w-52', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/file-uploader/file-list.stories.tsx b/web/app/components/base/file-uploader/file-list.stories.tsx new file mode 100644 index 0000000000..89c0568735 --- /dev/null +++ b/web/app/components/base/file-uploader/file-list.stories.tsx @@ -0,0 +1,96 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import { FileList } from './file-uploader-in-chat-input/file-list' +import type { FileEntity } from './types' +import { SupportUploadFileTypes } from '@/app/components/workflow/types' +import { TransferMethod } from '@/types/app' + +const SAMPLE_IMAGE = 'data:image/svg+xml;utf8,IMG' + +const filesSample: FileEntity[] = [ + { + id: '1', + name: 'Project Brief.pdf', + size: 256000, + type: 'application/pdf', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: SupportUploadFileTypes.document, + url: '', + }, + { + id: '2', + name: 'Design.png', + size: 128000, + type: 'image/png', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: SupportUploadFileTypes.image, + base64Url: SAMPLE_IMAGE, + }, + { + id: '3', + name: 'Voiceover.mp3', + size: 512000, + type: 'audio/mpeg', + progress: 45, + transferMethod: TransferMethod.remote_url, + supportFileType: SupportUploadFileTypes.audio, + url: '', + }, +] + +const meta = { + title: 'Base/Data Display/FileList', + component: FileList, + parameters: { + docs: { + description: { + component: 'Renders a responsive gallery of uploaded files, handling icons, previews, and progress states.', + }, + }, + }, + tags: ['autodocs'], + args: { + files: filesSample, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +const FileListPlayground = (args: React.ComponentProps) => { + const [items, setItems] = useState(args.files || []) + + return ( +
+ setItems(list => list.filter(file => file.id !== fileId))} + /> +
+ ) +} + +export const Playground: Story = { + render: args => , + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +const [files, setFiles] = useState(initialFiles) + + setFiles(list => list.filter(file => file.id !== id))} /> + `.trim(), + }, + }, + }, +} + +export const UploadStates: Story = { + args: { + files: filesSample.map(file => ({ ...file, progress: file.id === '3' ? 45 : 100 })), + }, +} diff --git a/web/app/components/base/file-uploader/file-type-icon.stories.tsx b/web/app/components/base/file-uploader/file-type-icon.stories.tsx new file mode 100644 index 0000000000..c317afab68 --- /dev/null +++ b/web/app/components/base/file-uploader/file-type-icon.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import FileTypeIcon from './file-type-icon' +import { FileAppearanceTypeEnum } from './types' + +const meta = { + title: 'Base/General/FileTypeIcon', + component: FileTypeIcon, + parameters: { + docs: { + description: { + component: 'Displays the appropriate icon and accent colour for a file appearance type. Useful in lists and attachments.', + }, + }, + }, + tags: ['autodocs'], + args: { + type: FileAppearanceTypeEnum.document, + size: 'md', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} + +export const Gallery: Story = { + render: () => ( +
+ {Object.values(FileAppearanceTypeEnum).map(type => ( +
+ + {type} +
+ ))} +
+ ), +} diff --git a/web/app/components/base/file-uploader/file-uploader-in-attachment/index.stories.tsx b/web/app/components/base/file-uploader/file-uploader-in-attachment/index.stories.tsx new file mode 100644 index 0000000000..dabb8b6615 --- /dev/null +++ b/web/app/components/base/file-uploader/file-uploader-in-attachment/index.stories.tsx @@ -0,0 +1,110 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { fn } from 'storybook/test' +import { useState } from 'react' +import FileUploaderInAttachmentWrapper from './index' +import type { FileEntity } from '../types' +import type { FileUpload } from '@/app/components/base/features/types' +import { PreviewMode } from '@/app/components/base/features/types' +import { TransferMethod } from '@/types/app' +import { ToastProvider } from '@/app/components/base/toast' +import { SupportUploadFileTypes } from '@/app/components/workflow/types' + +const SAMPLE_IMAGE = 'data:image/svg+xml;utf8,IMG' + +const mockFiles: FileEntity[] = [ + { + id: 'file-1', + name: 'Requirements.pdf', + size: 256000, + type: 'application/pdf', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: SupportUploadFileTypes.document, + url: '', + }, + { + id: 'file-2', + name: 'Interface.png', + size: 128000, + type: 'image/png', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: SupportUploadFileTypes.image, + base64Url: SAMPLE_IMAGE, + }, + { + id: 'file-3', + name: 'Voiceover.mp3', + size: 512000, + type: 'audio/mpeg', + progress: 35, + transferMethod: TransferMethod.remote_url, + supportFileType: SupportUploadFileTypes.audio, + url: '', + }, +] + +const fileConfig: FileUpload = { + enabled: true, + allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], + allowed_file_types: ['document', 'image', 'audio'], + number_limits: 5, + preview_config: { mode: PreviewMode.NewPage, file_type_list: ['pdf', 'png'] }, +} + +const meta = { + title: 'Base/Data Entry/FileUploaderInAttachment', + component: FileUploaderInAttachmentWrapper, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Attachment-style uploader that supports local files and remote links. Demonstrates upload progress, re-upload, and preview actions.', + }, + }, + nextjs: { + appDirectory: true, + navigation: { + pathname: '/apps/demo-app/uploads', + params: { appId: 'demo-app' }, + }, + }, + }, + tags: ['autodocs'], + args: { + fileConfig, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +const AttachmentDemo = (props: React.ComponentProps) => { + const [files, setFiles] = useState(mockFiles) + + return ( + +
+ +
+
+ ) +} + +export const Playground: Story = { + render: args => , + args: { + onChange: fn(), + }, +} + +export const Disabled: Story = { + render: args => , + args: { + onChange: fn(), + }, +} diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.stories.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.stories.tsx new file mode 100644 index 0000000000..f4165f64cb --- /dev/null +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.stories.tsx @@ -0,0 +1,95 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import FileUploaderInChatInput from '.' +import { FileContextProvider } from '../store' +import type { FileEntity } from '../types' +import type { FileUpload } from '@/app/components/base/features/types' +import { SupportUploadFileTypes } from '@/app/components/workflow/types' +import { TransferMethod } from '@/types/app' +import { FileList } from '../file-uploader-in-chat-input/file-list' +import { ToastProvider } from '@/app/components/base/toast' + +const mockFiles: FileEntity[] = [ + { + id: '1', + name: 'Dataset.csv', + size: 64000, + type: 'text/csv', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: SupportUploadFileTypes.document, + }, +] + +const chatUploadConfig: FileUpload = { + enabled: true, + allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], + allowed_file_types: ['image', 'document'], + number_limits: 3, +} + +type ChatInputDemoProps = React.ComponentProps & { + initialFiles?: FileEntity[] +} + +const ChatInputDemo = ({ initialFiles = mockFiles, ...props }: ChatInputDemoProps) => { + const [files, setFiles] = useState(initialFiles) + + return ( + + +
+
Simulated chat input
+
+ +
Type a message...
+
+
+ +
+
+
+
+ ) +} + +const meta = { + title: 'Base/Data Entry/FileUploaderInChatInput', + component: ChatInputDemo, + parameters: { + docs: { + description: { + component: 'Attachment trigger suited for chat inputs. Demonstrates integration with the shared file store and preview list.', + }, + }, + nextjs: { + appDirectory: true, + navigation: { + pathname: '/chats/demo', + params: { appId: 'demo-app' }, + }, + }, + }, + tags: ['autodocs'], + args: { + fileConfig: chatUploadConfig, + initialFiles: mockFiles, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = { + render: args => , +} + +export const RemoteOnly: Story = { + args: { + fileConfig: { + ...chatUploadConfig, + allowed_file_upload_methods: [TransferMethod.remote_url], + }, + initialFiles: [], + }, +} diff --git a/web/app/components/base/float-right-container/index.stories.tsx b/web/app/components/base/float-right-container/index.stories.tsx new file mode 100644 index 0000000000..18173f086d --- /dev/null +++ b/web/app/components/base/float-right-container/index.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { fn } from 'storybook/test' +import { useState } from 'react' +import FloatRightContainer from '.' + +const meta = { + title: 'Base/Feedback/FloatRightContainer', + component: FloatRightContainer, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Wrapper that renders content in a drawer on mobile and inline on desktop. Useful for responsive settings panels.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const ContainerDemo = () => { + const [open, setOpen] = useState(false) + const [isMobile, setIsMobile] = useState(false) + + return ( +
+
+ + +
+ + setOpen(false)} + title="Responsive panel" + description="Switch the toggle to see drawer vs inline behaviour." + mask + > +
+

Panel Content

+

+ On desktop, this block renders inline when `isOpen` is true. On mobile it appears inside the drawer wrapper. +

+
+
+
+ ) +} + +export const Playground: Story = { + render: () => , + args: { + isMobile: false, + isOpen: false, + onClose: fn(), + children: null, + }, +} diff --git a/web/app/components/base/form/index.stories.tsx b/web/app/components/base/form/index.stories.tsx new file mode 100644 index 0000000000..c1b9e894e0 --- /dev/null +++ b/web/app/components/base/form/index.stories.tsx @@ -0,0 +1,559 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useMemo, useState } from 'react' +import { useStore } from '@tanstack/react-form' +import ContactFields from './form-scenarios/demo/contact-fields' +import { demoFormOpts } from './form-scenarios/demo/shared-options' +import { ContactMethods, UserSchema } from './form-scenarios/demo/types' +import BaseForm from './components/base/base-form' +import type { FormSchema } from './types' +import { FormTypeEnum } from './types' +import { type FormStoryRender, FormStoryWrapper } from '../../../../.storybook/utils/form-story-wrapper' +import Button from '../button' +import { TransferMethod } from '@/types/app' +import { PreviewMode } from '@/app/components/base/features/types' + +const FormStoryHost = () => null + +const meta = { + title: 'Base/Data Entry/AppForm', + component: FormStoryHost, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Helper utilities built on top of `@tanstack/react-form` that power form rendering across Dify. These stories demonstrate the `useAppForm` hook, field primitives, conditional visibility, and custom actions.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +type AppFormInstance = Parameters[0] +type ContactFieldsProps = React.ComponentProps +type ContactFieldsFormApi = ContactFieldsProps['form'] + +type PlaygroundFormFieldsProps = { + form: AppFormInstance + status: string +} + +const PlaygroundFormFields = ({ form, status }: PlaygroundFormFieldsProps) => { + type PlaygroundFormValues = typeof demoFormOpts.defaultValues + const name = useStore(form.store, state => (state.values as PlaygroundFormValues).name) + const contactFormApi = form as ContactFieldsFormApi + + return ( +
{ + event.preventDefault() + event.stopPropagation() + form.handleSubmit() + }} + > + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + {!!name && } + + + + + +

{status}

+ + ) +} + +const FormPlayground = () => { + const [status, setStatus] = useState('Fill in the form and submit to see results.') + + return ( + { + const result = UserSchema.safeParse(value as typeof demoFormOpts.defaultValues) + if (!result.success) + return result.error.issues[0].message + return undefined + }, + }, + onSubmit: ({ value }) => { + setStatus('Successfully saved profile.') + }, + }} + > + {form => } + + ) +} + +const mockFileUploadConfig = { + enabled: true, + allowed_file_extensions: ['pdf', 'png'], + allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], + number_limits: 3, + preview_config: { + mode: PreviewMode.CurrentPage, + file_type_list: ['pdf', 'png'], + }, +} + +const mockFieldDefaults = { + headline: 'Dify App', + description: 'Streamline your AI workflows with configurable building blocks.', + category: 'workbench', + allowNotifications: true, + dailyLimit: 40, + attachment: [], +} + +const FieldGallery = () => { + const selectOptions = useMemo(() => [ + { value: 'workbench', label: 'Workbench' }, + { value: 'playground', label: 'Playground' }, + { value: 'production', label: 'Production' }, + ], []) + + return ( + + {form => ( +
{ + event.preventDefault() + event.stopPropagation() + form.handleSubmit() + }} + > + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> +
+ + + +
+ + )} +
+ ) +} + +const conditionalSchemas: FormSchema[] = [ + { + type: FormTypeEnum.select, + name: 'channel', + label: 'Preferred channel', + required: true, + default: 'email', + options: ContactMethods, + }, + { + type: FormTypeEnum.textInput, + name: 'contactEmail', + label: 'Email address', + required: true, + placeholder: 'user@example.com', + show_on: [{ variable: 'channel', value: 'email' }], + }, + { + type: FormTypeEnum.textInput, + name: 'contactPhone', + label: 'Phone number', + required: true, + placeholder: '+1 555 123 4567', + show_on: [{ variable: 'channel', value: 'phone' }], + }, + { + type: FormTypeEnum.boolean, + name: 'optIn', + label: 'Opt in to marketing messages', + required: false, + }, +] + +const ConditionalFieldsStory = () => { + const [values, setValues] = useState>({ + channel: 'email', + optIn: false, + }) + + return ( +
+
+ { + setValues(prev => ({ + ...prev, + [field]: value, + })) + }} + /> +
+ +
+ ) +} + +const CustomActionsStory = () => { + return ( + { + const nextValues = value as { datasetName?: string } + if (!nextValues.datasetName || nextValues.datasetName.length < 3) + return 'Dataset name must contain at least 3 characters.' + return undefined + }, + }, + }} + > + {form => ( +
{ + event.preventDefault() + event.stopPropagation() + form.handleSubmit() + }} + > + ( + + )} + /> + ( + + )} + /> + + ( +
+ + + +
+ )} + /> +
+ + )} +
+ ) +} + +export const Playground: Story = { + render: () => , + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +const form = useAppForm({ + ...demoFormOpts, + validators: { + onSubmit: ({ value }) => UserSchema.safeParse(value).success ? undefined : 'Validation failed', + }, + onSubmit: ({ value }) => { + setStatus(\`Successfully saved profile for \${value.name}\`) + }, +}) + +return ( +
+ + {field => } + + + {field => } + + + {field => } + + {!!form.store.state.values.name && } + + + + +) + `.trim(), + }, + }, + }, +} + +export const FieldExplorer: Story = { + render: () => , + parameters: { + nextjs: { + appDirectory: true, + navigation: { + pathname: '/apps/demo-app/form', + params: { appId: 'demo-app' }, + }, + }, + docs: { + source: { + language: 'tsx', + code: ` +const form = useAppForm({ + defaultValues: { + headline: 'Dify App', + description: 'Streamline your AI workflows', + category: 'workbench', + allowNotifications: true, + dailyLimit: 40, + attachment: [], + }, +}) + +return ( +
+ + {field => } + + + {field => } + + + {field => } + + + {field => } + + + {field => } + + + {field => } + + + + +
+) + `.trim(), + }, + }, + }, +} + +export const ConditionalVisibility: Story = { + render: () => , + parameters: { + docs: { + description: { + story: 'Demonstrates schema-driven visibility using `show_on` conditions rendered through the reusable `BaseForm` component.', + }, + source: { + language: 'tsx', + code: ` +const conditionalSchemas: FormSchema[] = [ + { type: FormTypeEnum.select, name: 'channel', label: 'Preferred channel', options: ContactMethods }, + { type: FormTypeEnum.textInput, name: 'contactEmail', label: 'Email', show_on: [{ variable: 'channel', value: 'email' }] }, + { type: FormTypeEnum.textInput, name: 'contactPhone', label: 'Phone', show_on: [{ variable: 'channel', value: 'phone' }] }, + { type: FormTypeEnum.boolean, name: 'optIn', label: 'Opt in to marketing messages' }, +] + +return ( + setValues(prev => ({ ...prev, [field]: value }))} + /> +) + `.trim(), + }, + }, + }, +} + +export const CustomActions: Story = { + render: () => , + parameters: { + docs: { + description: { + story: 'Shows how to replace the default submit button with a fully custom footer leveraging contextual form state.', + }, + source: { + language: 'tsx', + code: ` +const form = useAppForm({ + defaultValues: { + datasetName: 'Support FAQ', + datasetDescription: 'Knowledge base snippets sourced from Zendesk exports.', + }, + validators: { + onChange: ({ value }) => value.datasetName?.length >= 3 ? undefined : 'Dataset name must contain at least 3 characters.', + }, +}) + +return ( +
+ + {field => } + + + {field => } + + + ( +
+ + + +
+ )} + /> +
+
+) + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/fullscreen-modal/index.stories.tsx b/web/app/components/base/fullscreen-modal/index.stories.tsx new file mode 100644 index 0000000000..72fd28df66 --- /dev/null +++ b/web/app/components/base/fullscreen-modal/index.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import FullScreenModal from '.' + +const meta = { + title: 'Base/Feedback/FullScreenModal', + component: FullScreenModal, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Backdrop-blurred fullscreen modal. Supports close button, custom content, and optional overflow visibility.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const ModalDemo = (props: React.ComponentProps) => { + const [open, setOpen] = useState(false) + + return ( +
+ + + setOpen(false)} + closable + > +
+
+ Full-screen experience +
+
+ Place dashboards, flow builders, or immersive previews here. +
+
+
+
+ ) +} + +export const Playground: Story = { + render: args => , + args: { + open: false, + }, +} diff --git a/web/app/components/base/grid-mask/index.stories.tsx b/web/app/components/base/grid-mask/index.stories.tsx new file mode 100644 index 0000000000..1b67a1510d --- /dev/null +++ b/web/app/components/base/grid-mask/index.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import GridMask from '.' + +const meta = { + title: 'Base/Layout/GridMask', + component: GridMask, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Displays a soft grid overlay with gradient mask, useful for framing hero sections or marketing callouts.', + }, + }, + }, + args: { + wrapperClassName: 'rounded-2xl p-10', + canvasClassName: '', + gradientClassName: '', + children: ( +
+ Grid Mask Demo + Beautiful backgrounds for feature highlights +

+ Place any content inside the mask. On dark backgrounds the grid and soft gradient add depth without distracting from the main message. +

+
+ ), + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} + +export const CustomBackground: Story = { + args: { + wrapperClassName: 'rounded-3xl p-10 bg-[#0A0A1A]', + gradientClassName: 'bg-gradient-to-r from-[#0A0A1A]/90 via-[#101030]/60 to-[#05050A]/90', + children: ( +
+ Custom gradient + Use your own colors +

+ Override gradient and canvas classes to match brand palettes while keeping the grid texture. +

+
+ ), + }, +} diff --git a/web/app/components/base/image-gallery/index.stories.tsx b/web/app/components/base/image-gallery/index.stories.tsx new file mode 100644 index 0000000000..c1b463170c --- /dev/null +++ b/web/app/components/base/image-gallery/index.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import ImageGallery from '.' + +const IMAGE_SOURCES = [ + 'data:image/svg+xml;utf8,Dataset', + 'data:image/svg+xml;utf8,Playground', + 'data:image/svg+xml;utf8,Workflow', + 'data:image/svg+xml;utf8,Prompts', +] + +const meta = { + title: 'Base/Data Display/ImageGallery', + component: ImageGallery, + parameters: { + docs: { + description: { + component: 'Responsive thumbnail grid with lightbox preview for larger imagery.', + }, + source: { + language: 'tsx', + code: ` +', + 'data:image/svg+xml;utf8,', +]} /> + `.trim(), + }, + }, + }, + tags: ['autodocs'], + args: { + srcs: IMAGE_SOURCES, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} diff --git a/web/app/components/base/image-uploader/image-list.stories.tsx b/web/app/components/base/image-uploader/image-list.stories.tsx new file mode 100644 index 0000000000..530ef69556 --- /dev/null +++ b/web/app/components/base/image-uploader/image-list.stories.tsx @@ -0,0 +1,182 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useMemo, useState } from 'react' +import ImageList from './image-list' +import ImageLinkInput from './image-link-input' +import type { ImageFile } from '@/types/app' +import { TransferMethod } from '@/types/app' + +const SAMPLE_BASE64 + = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAYAAACtWK6eAAAACXBIWXMAAAsSAAALEgHS3X78AAABbElEQVR4nO3SsQkAIBDARMT+V20sTg6LXhWEATnnMHDx4sWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFu2r/H3n4BG518Gr4AAAAASUVORK5CYII=' + +const createRemoteImage = ( + id: string, + progress: number, + url: string, +): ImageFile => ({ + type: TransferMethod.remote_url, + _id: id, + fileId: `remote-${id}`, + progress, + url, +}) + +const createLocalImage = (id: string, progress: number): ImageFile => ({ + type: TransferMethod.local_file, + _id: id, + fileId: `local-${id}`, + progress, + url: SAMPLE_BASE64, + base64Url: SAMPLE_BASE64, +}) + +const initialImages: ImageFile[] = [ + createLocalImage('local-initial', 100), + createRemoteImage( + 'remote-loading', + 40, + 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=300&q=80', + ), + { + ...createRemoteImage( + 'remote-error', + -1, + 'https://example.com/not-an-image.jpg', + ), + url: 'https://example.com/not-an-image.jpg', + }, +] + +const meta = { + title: 'Base/Data Entry/ImageList', + component: ImageList, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Renders thumbnails for uploaded images and manages their states like uploading, error, and deletion.', + }, + }, + }, + argTypes: { + list: { control: false }, + onRemove: { control: false }, + onReUpload: { control: false }, + onImageLinkLoadError: { control: false }, + onImageLinkLoadSuccess: { control: false }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const ImageUploaderPlayground = ({ readonly }: Story['args']) => { + const [images, setImages] = useState(() => initialImages) + + const activeImages = useMemo(() => images.filter(item => !item.deleted), [images]) + + const handleRemove = (id: string) => { + setImages(prev => prev.map(item => (item._id === id ? { ...item, deleted: true } : item))) + } + + const handleReUpload = (id: string) => { + setImages(prev => prev.map((item) => { + if (item._id !== id) + return item + + return { + ...item, + progress: 60, + } + })) + + setTimeout(() => { + setImages(prev => prev.map((item) => { + if (item._id !== id) + return item + + return { + ...item, + progress: 100, + } + })) + }, 1200) + } + + const handleImageLinkLoadSuccess = (id: string) => { + setImages(prev => prev.map(item => (item._id === id ? { ...item, progress: 100 } : item))) + } + + const handleImageLinkLoadError = (id: string) => { + setImages(prev => prev.map(item => (item._id === id ? { ...item, progress: -1 } : item))) + } + + const handleUploadFromLink = (imageFile: ImageFile) => { + setImages(prev => [ + ...prev, + { + ...imageFile, + fileId: `remote-${imageFile._id}`, + }, + ]) + } + + const handleAddLocalImage = () => { + const id = `local-${Date.now()}` + setImages(prev => [ + ...prev, + createLocalImage(id, 100), + ]) + } + + return ( +
+
+ Add images +
+ + +
+
+ + + +
+ + Files state + +
+          {JSON.stringify(activeImages, null, 2)}
+        
+
+
+ ) +} + +export const Playground: Story = { + render: args => , + args: { + list: [], + }, +} + +export const ReadonlyList: Story = { + render: args => , + args: { + list: [], + }, +} diff --git a/web/app/components/base/inline-delete-confirm/index.stories.tsx b/web/app/components/base/inline-delete-confirm/index.stories.tsx new file mode 100644 index 0000000000..e0b0757718 --- /dev/null +++ b/web/app/components/base/inline-delete-confirm/index.stories.tsx @@ -0,0 +1,87 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { fn } from 'storybook/test' +import { useState } from 'react' +import InlineDeleteConfirm from '.' + +const meta = { + title: 'Base/Feedback/InlineDeleteConfirm', + component: InlineDeleteConfirm, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Compact confirmation prompt that appears inline, commonly used near delete buttons or destructive controls.', + }, + }, + }, + argTypes: { + variant: { + control: 'select', + options: ['delete', 'warning', 'info'], + }, + }, + args: { + title: 'Delete this item?', + confirmText: 'Delete', + cancelText: 'Cancel', + onConfirm: fn(), + onCancel: fn(), + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const InlineDeleteConfirmDemo = (args: Story['args']) => { + const [visible, setVisible] = useState(true) + + return ( +
+ + {visible && ( + { + console.log('✅ Confirm clicked') + setVisible(false) + }} + onCancel={() => { + console.log('❎ Cancel clicked') + setVisible(false) + }} + /> + )} +
+ ) +} + +export const Playground: Story = { + render: args => , +} + +export const WarningVariant: Story = { + render: args => , + args: { + variant: 'warning', + title: 'Archive conversation?', + confirmText: 'Archive', + cancelText: 'Keep', + }, +} + +export const InfoVariant: Story = { + render: args => , + args: { + variant: 'info', + title: 'Remove collaborator?', + confirmText: 'Remove', + cancelText: 'Keep', + }, +} diff --git a/web/app/components/base/input-number/index.stories.tsx b/web/app/components/base/input-number/index.stories.tsx index aa075b0ff1..88999af9e0 100644 --- a/web/app/components/base/input-number/index.stories.tsx +++ b/web/app/components/base/input-number/index.stories.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' import { InputNumber } from '.' const meta = { - title: 'Base/Input/InputNumber', + title: 'Base/Data Entry/InputNumber', component: InputNumber, parameters: { layout: 'centered', diff --git a/web/app/components/base/input/index.stories.tsx b/web/app/components/base/input/index.stories.tsx index 04df0bf943..c877579879 100644 --- a/web/app/components/base/input/index.stories.tsx +++ b/web/app/components/base/input/index.stories.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' import Input from '.' const meta = { - title: 'Base/Input/Input', + title: 'Base/Data Entry/Input', component: Input, parameters: { layout: 'centered', diff --git a/web/app/components/base/linked-apps-panel/index.stories.tsx b/web/app/components/base/linked-apps-panel/index.stories.tsx new file mode 100644 index 0000000000..786d1bdf56 --- /dev/null +++ b/web/app/components/base/linked-apps-panel/index.stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import LinkedAppsPanel from '.' +import type { RelatedApp } from '@/models/datasets' + +const mockRelatedApps: RelatedApp[] = [ + { + id: 'app-cx', + name: 'Customer Support Assistant', + mode: 'chat', + icon_type: 'emoji', + icon: '\u{1F4AC}', + icon_background: '#EEF2FF', + icon_url: '', + }, + { + id: 'app-ops', + name: 'Ops Workflow Orchestrator', + mode: 'workflow', + icon_type: 'emoji', + icon: '\u{1F6E0}\u{FE0F}', + icon_background: '#ECFDF3', + icon_url: '', + }, + { + id: 'app-research', + name: 'Research Synthesizer', + mode: 'advanced-chat', + icon_type: 'emoji', + icon: '\u{1F9E0}', + icon_background: '#FDF2FA', + icon_url: '', + }, +] + +const meta = { + title: 'Base/Feedback/LinkedAppsPanel', + component: LinkedAppsPanel, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Shows a curated list of related applications, pairing each app icon with quick navigation links.', + }, + }, + }, + args: { + relatedApps: mockRelatedApps, + isMobile: false, + }, + argTypes: { + isMobile: { + control: 'boolean', + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} + +export const Mobile: Story = { + args: { + isMobile: true, + }, + parameters: { + viewport: { + defaultViewport: 'mobile2', + }, + }, +} diff --git a/web/app/components/base/list-empty/index.stories.tsx b/web/app/components/base/list-empty/index.stories.tsx new file mode 100644 index 0000000000..36c0e3c7a7 --- /dev/null +++ b/web/app/components/base/list-empty/index.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import ListEmpty from '.' + +const meta = { + title: 'Base/Data Display/ListEmpty', + component: ListEmpty, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Large empty state card used in panels and drawers to hint at the next action for the user.', + }, + }, + }, + args: { + title: 'No items yet', + description: ( +

+ Add your first entry to see it appear here. Empty states help users discover what happens next. +

+ ), + }, + argTypes: { + description: { control: false }, + icon: { control: false }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} + +export const WithCustomIcon: Story = { + args: { + title: 'Connect a data source', + description: ( +

+ Choose a database, knowledge base, or upload documents to get started with retrieval. +

+ ), + icon: ( +
+ {'\u{26A1}\u{FE0F}'} +
+ ), + }, +} diff --git a/web/app/components/base/loading/index.stories.tsx b/web/app/components/base/loading/index.stories.tsx new file mode 100644 index 0000000000..f22f87516c --- /dev/null +++ b/web/app/components/base/loading/index.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import Loading from '.' + +const meta = { + title: 'Base/Feedback/Loading', + component: Loading, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Spinner used while fetching data (`area`) or bootstrapping the full application shell (`app`).', + }, + }, + }, + argTypes: { + type: { + control: 'radio', + options: ['area', 'app'], + }, + }, + args: { + type: 'area', + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const LoadingPreview = ({ type }: { type: 'area' | 'app' }) => { + const containerHeight = type === 'app' ? 'h-48' : 'h-20' + const title = type === 'app' ? 'App loading state' : 'Inline loading state' + + return ( +
+ {title} +
+ +
+
+ ) +} + +export const AreaSpinner: Story = { + render: () => , +} + +export const AppSpinner: Story = { + render: () => , +} diff --git a/web/app/components/base/logo/index.stories.tsx b/web/app/components/base/logo/index.stories.tsx new file mode 100644 index 0000000000..01464b8c13 --- /dev/null +++ b/web/app/components/base/logo/index.stories.tsx @@ -0,0 +1,82 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { ThemeProvider } from 'next-themes' +import type { ReactNode } from 'react' +import DifyLogo from './dify-logo' +import LogoSite from './logo-site' +import LogoEmbeddedChatHeader from './logo-embedded-chat-header' +import LogoEmbeddedChatAvatar from './logo-embedded-chat-avatar' + +const meta = { + title: 'Base/General/Logo', + component: DifyLogo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Brand assets rendered in different contexts. DifyLogo adapts to the active theme while other variants target specific surfaces.', + }, + }, + }, + args: { + size: 'medium', + style: 'default', + }, + argTypes: { + size: { + control: 'radio', + options: ['large', 'medium', 'small'], + }, + style: { + control: 'radio', + options: ['default', 'monochromeWhite'], + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const ThemePreview = ({ theme, children }: { theme: 'light' | 'dark'; children: ReactNode }) => { + return ( + +
+ {children} +
+
+ ) +} + +export const Playground: Story = { + render: ({ size, style }) => { + return ( + +
+
+ Primary logo +
+ + {`size="${size}" | style="${style}"`} +
+
+
+
+ Site favicon + +
+
+ Embedded header + +
+
+ Embedded avatar + +
+
+
+
+ ) + }, +} diff --git a/web/app/components/base/markdown-blocks/code-block.stories.tsx b/web/app/components/base/markdown-blocks/code-block.stories.tsx new file mode 100644 index 0000000000..98473bdf57 --- /dev/null +++ b/web/app/components/base/markdown-blocks/code-block.stories.tsx @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import CodeBlock from './code-block' + +const SAMPLE_CODE = `const greet = (name: string) => { + return \`Hello, \${name}\` +} + +console.log(greet('Dify'))` + +const CodeBlockDemo = ({ + language = 'typescript', +}: { + language?: string +}) => { + return ( +
+
Code block
+ + {SAMPLE_CODE} + +
+ ) +} + +const meta = { + title: 'Base/Data Display/CodeBlock', + component: CodeBlockDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Syntax highlighted code block with copy button and SVG toggle support.', + }, + }, + }, + argTypes: { + language: { + control: 'radio', + options: ['typescript', 'json', 'mermaid'], + }, + }, + args: { + language: 'typescript', + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} + +export const Mermaid: Story = { + args: { + language: 'mermaid', + }, + render: ({ language }) => ( +
+ + {`graph TD + Start --> Decision{User message?} + Decision -->|Tool| ToolCall[Call web search] + Decision -->|Respond| Answer[Compose draft] +`} + +
+ ), +} diff --git a/web/app/components/base/markdown-blocks/think-block.stories.tsx b/web/app/components/base/markdown-blocks/think-block.stories.tsx new file mode 100644 index 0000000000..571959259a --- /dev/null +++ b/web/app/components/base/markdown-blocks/think-block.stories.tsx @@ -0,0 +1,78 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import ThinkBlock from './think-block' +import { ChatContextProvider } from '@/app/components/base/chat/chat/context' + +const THOUGHT_TEXT = ` +Gather docs from knowledge base. +Score snippets against query. +[ENDTHINKFLAG] +` + +const ThinkBlockDemo = ({ + responding = false, +}: { + responding?: boolean +}) => { + const [isResponding, setIsResponding] = useState(responding) + + return ( + +
+
+ Think block + +
+ +
+            {THOUGHT_TEXT}
+          
+
+
+
+ ) +} + +const meta = { + title: 'Base/Data Display/ThinkBlock', + component: ThinkBlockDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Expandable chain-of-thought block used in chat responses. Toggles between “thinking” and completed states.', + }, + }, + }, + argTypes: { + responding: { control: 'boolean' }, + }, + args: { + responding: false, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/markdown/index.stories.tsx b/web/app/components/base/markdown/index.stories.tsx new file mode 100644 index 0000000000..8c940e01cf --- /dev/null +++ b/web/app/components/base/markdown/index.stories.tsx @@ -0,0 +1,88 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import { Markdown } from '.' + +const SAMPLE_MD = ` +# Product Update + +Our agent now supports **tool-runs** with structured outputs. + +## Highlights +- Faster reasoning with \\(O(n \\log n)\\) planning. +- Inline chain-of-thought: + +
+Thinking aloud + +Check cached metrics first. +If missing, fetch raw warehouse data. +[ENDTHINKFLAG] + +
+ +## Mermaid Diagram +\`\`\`mermaid +graph TD + Start[User Message] --> Parse{Detect Intent?} + Parse -->|Tool| ToolCall[Call search tool] + Parse -->|Answer| Respond[Stream response] + ToolCall --> Respond +\`\`\` + +## Code Example +\`\`\`typescript +const reply = await client.chat({ + message: 'Summarise weekly metrics.', + tags: ['analytics'], +}) +\`\`\` +` + +const MarkdownDemo = ({ + compact = false, +}: { + compact?: boolean +}) => { + const [content] = useState(SAMPLE_MD.trim()) + + return ( +
+
Markdown renderer
+ +
+ ) +} + +const meta = { + title: 'Base/Data Display/Markdown', + component: MarkdownDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Markdown wrapper with GitHub-flavored markdown, Mermaid diagrams, math, and custom blocks (details, audio, etc.).', + }, + }, + }, + argTypes: { + compact: { control: 'boolean' }, + }, + args: { + compact: false, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} + +export const Compact: Story = { + args: { + compact: true, + }, +} diff --git a/web/app/components/base/mermaid/index.stories.tsx b/web/app/components/base/mermaid/index.stories.tsx new file mode 100644 index 0000000000..73030d7905 --- /dev/null +++ b/web/app/components/base/mermaid/index.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import Flowchart from '.' + +const SAMPLE = ` +flowchart LR + A[User Message] --> B{Agent decides} + B -->|Needs tool| C[Search Tool] + C --> D[Combine result] + B -->|Direct answer| D + D --> E[Send response] +` + +const MermaidDemo = ({ + theme = 'light', +}: { + theme?: 'light' | 'dark' +}) => { + const [currentTheme, setCurrentTheme] = useState<'light' | 'dark'>(theme) + + return ( +
+
+ Mermaid diagram + +
+ +
+ ) +} + +const meta = { + title: 'Base/Data Display/Mermaid', + component: MermaidDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Mermaid renderer with custom theme toggle and caching. Useful for visualizing agent flows.', + }, + }, + }, + argTypes: { + theme: { + control: 'inline-radio', + options: ['light', 'dark'], + }, + }, + args: { + theme: 'light', + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/message-log-modal/index.stories.tsx b/web/app/components/base/message-log-modal/index.stories.tsx new file mode 100644 index 0000000000..3dd4b06a55 --- /dev/null +++ b/web/app/components/base/message-log-modal/index.stories.tsx @@ -0,0 +1,185 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useEffect } from 'react' +import MessageLogModal from '.' +import type { IChatItem } from '@/app/components/base/chat/chat/type' +import { useStore } from '@/app/components/app/store' +import type { WorkflowRunDetailResponse } from '@/models/log' +import type { NodeTracing, NodeTracingListResponse } from '@/types/workflow' +import { BlockEnum } from '@/app/components/workflow/types' + +const SAMPLE_APP_DETAIL = { + id: 'app-demo-1', + name: 'Support Assistant', + mode: 'chat', +} as any + +const mockRunDetail: WorkflowRunDetailResponse = { + id: 'run-demo-1', + version: 'v1.0.0', + graph: { + nodes: [], + edges: [], + }, + inputs: JSON.stringify({ question: 'How do I reset my password?' }, null, 2), + inputs_truncated: false, + status: 'succeeded', + outputs: JSON.stringify({ answer: 'Follow the reset link we just emailed you.' }, null, 2), + outputs_truncated: false, + total_steps: 3, + created_by_role: 'account', + created_by_account: { + id: 'account-1', + name: 'Demo Admin', + email: 'demo@example.com', + }, + created_at: 1700000000, + finished_at: 1700000006, + elapsed_time: 5.2, + total_tokens: 864, +} + +const buildNode = (override: Partial): NodeTracing => ({ + id: 'node-start', + index: 0, + predecessor_node_id: '', + node_id: 'node-start', + node_type: BlockEnum.Start, + title: 'Start', + inputs: {}, + inputs_truncated: false, + process_data: {}, + process_data_truncated: false, + outputs: {}, + outputs_truncated: false, + status: 'succeeded', + metadata: { + iterator_length: 1, + iterator_index: 0, + loop_length: 1, + loop_index: 0, + }, + created_at: 1700000000, + created_by: { + id: 'account-1', + name: 'Demo Admin', + email: 'demo@example.com', + }, + finished_at: 1700000001, + elapsed_time: 1.1, + extras: {}, + ...override, +}) + +const mockTracingList: NodeTracingListResponse = { + data: [ + buildNode({}), + buildNode({ + id: 'node-answer', + node_id: 'node-answer', + node_type: BlockEnum.Answer, + title: 'Answer', + inputs: { prompt: 'How do I reset my password?' }, + outputs: { output: 'Follow the reset link we just emailed you.' }, + finished_at: 1700000005, + elapsed_time: 2.6, + }), + ], +} + +const mockCurrentLogItem: IChatItem = { + id: 'message-1', + content: 'Follow the reset link we just emailed you.', + isAnswer: true, + workflow_run_id: 'run-demo-1', +} + +const useMessageLogMocks = () => { + useEffect(() => { + const store = useStore.getState() + store.setAppDetail(SAMPLE_APP_DETAIL) + + const originalFetch = globalThis.fetch?.bind(globalThis) ?? null + + const handle = async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url + + if (url.includes('/workflow-runs/run-demo-1/') && url.endsWith('/node-executions')) { + return new Response( + JSON.stringify(mockTracingList), + { headers: { 'Content-Type': 'application/json' }, status: 200 }, + ) + } + + if (url.endsWith('/workflow-runs/run-demo-1')) { + return new Response( + JSON.stringify(mockRunDetail), + { headers: { 'Content-Type': 'application/json' }, status: 200 }, + ) + } + + if (originalFetch) + return originalFetch(input, init) + + throw new Error(`Unmocked fetch call for ${url}`) + } + + globalThis.fetch = handle as typeof globalThis.fetch + + return () => { + globalThis.fetch = originalFetch || globalThis.fetch + useStore.getState().setAppDetail(undefined) + } + }, []) +} + +type MessageLogModalProps = React.ComponentProps + +const MessageLogPreview = (props: MessageLogModalProps) => { + useMessageLogMocks() + + return ( +
+ +
+ ) +} + +const meta = { + title: 'Base/Feedback/MessageLogModal', + component: MessageLogPreview, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Workflow run inspector presented alongside chat transcripts. This Storybook mock provides canned run details and tracing metadata.', + }, + }, + }, + args: { + defaultTab: 'DETAIL', + width: 960, + fixedWidth: true, + onCancel: () => { + console.log('Modal closed') + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const FixedPanel: Story = {} + +export const FloatingPanel: Story = { + args: { + fixedWidth: false, + }, +} diff --git a/web/app/components/base/modal-like-wrap/index.stories.tsx b/web/app/components/base/modal-like-wrap/index.stories.tsx index 1de38e14c9..c7d66b8e6a 100644 --- a/web/app/components/base/modal-like-wrap/index.stories.tsx +++ b/web/app/components/base/modal-like-wrap/index.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs' import ModalLikeWrap from '.' const meta = { - title: 'Base/Dialog/ModalLikeWrap', + title: 'Base/Feedback/ModalLikeWrap', component: ModalLikeWrap, parameters: { layout: 'centered', diff --git a/web/app/components/base/modal/index.stories.tsx b/web/app/components/base/modal/index.stories.tsx index e561acebbb..c0ea31eb42 100644 --- a/web/app/components/base/modal/index.stories.tsx +++ b/web/app/components/base/modal/index.stories.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react' import Modal from '.' const meta = { - title: 'Base/Dialog/Modal', + title: 'Base/Feedback/Modal', component: Modal, parameters: { layout: 'fullscreen', diff --git a/web/app/components/base/modal/modal.stories.tsx b/web/app/components/base/modal/modal.stories.tsx index 3e5be78a5b..adb80aebe6 100644 --- a/web/app/components/base/modal/modal.stories.tsx +++ b/web/app/components/base/modal/modal.stories.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react' import Modal from './modal' const meta = { - title: 'Base/Dialog/RichModal', + title: 'Base/Feedback/RichModal', component: Modal, parameters: { layout: 'fullscreen', diff --git a/web/app/components/base/new-audio-button/index.stories.tsx b/web/app/components/base/new-audio-button/index.stories.tsx index d2f9b8b4d5..c672392562 100644 --- a/web/app/components/base/new-audio-button/index.stories.tsx +++ b/web/app/components/base/new-audio-button/index.stories.tsx @@ -20,7 +20,7 @@ const StoryWrapper = (props: ComponentProps) => { } const meta = { - title: 'Base/Button/NewAudioButton', + title: 'Base/General/NewAudioButton', component: AudioBtn, tags: ['autodocs'], parameters: { diff --git a/web/app/components/base/notion-connector/index.stories.tsx b/web/app/components/base/notion-connector/index.stories.tsx new file mode 100644 index 0000000000..eb8b17df3f --- /dev/null +++ b/web/app/components/base/notion-connector/index.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import NotionConnector from '.' + +const meta = { + title: 'Base/Other/NotionConnector', + component: NotionConnector, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Call-to-action card inviting users to connect a Notion workspace. Shows the product icon, copy, and primary button.', + }, + }, + }, + args: { + onSetting: () => { + console.log('Open Notion settings') + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/notion-icon/index.stories.tsx b/web/app/components/base/notion-icon/index.stories.tsx new file mode 100644 index 0000000000..5389a6f935 --- /dev/null +++ b/web/app/components/base/notion-icon/index.stories.tsx @@ -0,0 +1,129 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import NotionIcon from '.' + +const meta = { + title: 'Base/General/NotionIcon', + component: NotionIcon, + parameters: { + docs: { + description: { + component: 'Renders workspace and page icons returned from Notion APIs, falling back to text initials or the default document glyph.', + }, + }, + }, + tags: ['autodocs'], + args: { + type: 'workspace', + name: 'Knowledge Base', + src: 'https://cloud.dify.ai/logo/logo.svg', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const WorkspaceIcon: Story = { + render: args => ( +
+ + Workspace icon pulled from a remote URL. +
+ ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +` + .trim(), + }, + }, + }, +} + +export const WorkspaceInitials: Story = { + render: args => ( +
+ + Fallback initial rendered when no icon URL is available. +
+ ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +` + .trim(), + }, + }, + }, +} + +export const PageEmoji: Story = { + render: args => ( +
+ + Page-level emoji icon returned by the API. +
+ ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +` + .trim(), + }, + }, + }, +} + +export const PageImage: Story = { + render: args => ( +
+ + Page icon resolved from an image URL. +
+ ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +` + .trim(), + }, + }, + }, +} + +export const DefaultIcon: Story = { + render: args => ( +
+ + When neither emoji nor URL is provided, the generic document icon is shown. +
+ ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` +` + .trim(), + }, + }, + }, +} diff --git a/web/app/components/base/notion-page-selector/index.stories.tsx b/web/app/components/base/notion-page-selector/index.stories.tsx new file mode 100644 index 0000000000..6fdee03adb --- /dev/null +++ b/web/app/components/base/notion-page-selector/index.stories.tsx @@ -0,0 +1,200 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useEffect, useMemo, useState } from 'react' +import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' +import { NotionPageSelector } from '.' +import type { DataSourceCredential } from '@/app/components/header/account-setting/data-source-page-new/types' +import type { NotionPage } from '@/models/common' + +const DATASET_ID = 'dataset-demo' +const CREDENTIALS: DataSourceCredential[] = [ + { + id: 'cred-1', + name: 'Marketing Workspace', + type: CredentialTypeEnum.OAUTH2, + is_default: true, + avatar_url: '', + credential: { + workspace_name: 'Marketing Workspace', + workspace_icon: null, + workspace_id: 'workspace-1', + }, + }, + { + id: 'cred-2', + name: 'Product Workspace', + type: CredentialTypeEnum.OAUTH2, + is_default: false, + avatar_url: '', + credential: { + workspace_name: 'Product Workspace', + workspace_icon: null, + workspace_id: 'workspace-2', + }, + }, +] + +const marketingPages = { + notion_info: [ + { + workspace_name: 'Marketing Workspace', + workspace_id: 'workspace-1', + workspace_icon: null, + pages: [ + { + page_icon: { type: 'emoji', emoji: '\u{1F4CB}', url: null }, + page_id: 'briefs', + page_name: 'Campaign Briefs', + parent_id: 'root', + type: 'page', + is_bound: false, + }, + { + page_icon: { type: 'emoji', emoji: '\u{1F4DD}', url: null }, + page_id: 'notes', + page_name: 'Meeting Notes', + parent_id: 'root', + type: 'page', + is_bound: true, + }, + { + page_icon: { type: 'emoji', emoji: '\u{1F30D}', url: null }, + page_id: 'localizations', + page_name: 'Localization Pipeline', + parent_id: 'briefs', + type: 'page', + is_bound: false, + }, + ], + }, + ], +} + +const productPages = { + notion_info: [ + { + workspace_name: 'Product Workspace', + workspace_id: 'workspace-2', + workspace_icon: null, + pages: [ + { + page_icon: { type: 'emoji', emoji: '\u{1F4A1}', url: null }, + page_id: 'ideas', + page_name: 'Idea Backlog', + parent_id: 'root', + type: 'page', + is_bound: false, + }, + { + page_icon: { type: 'emoji', emoji: '\u{1F9EA}', url: null }, + page_id: 'experiments', + page_name: 'Experiments', + parent_id: 'ideas', + type: 'page', + is_bound: false, + }, + ], + }, + ], +} + +type NotionApiResponse = typeof marketingPages +const emptyNotionResponse: NotionApiResponse = { notion_info: [] } + +const useMockNotionApi = () => { + const responseMap = useMemo(() => ({ + [`${DATASET_ID}:cred-1`]: marketingPages, + [`${DATASET_ID}:cred-2`]: productPages, + }) satisfies Record<`${typeof DATASET_ID}:${typeof CREDENTIALS[number]['id']}`, NotionApiResponse>, []) + + useEffect(() => { + const originalFetch = globalThis.fetch?.bind(globalThis) + + const handler = async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url + + if (url.includes('/notion/pre-import/pages')) { + const parsed = new URL(url, globalThis.location.origin) + const datasetId = parsed.searchParams.get('dataset_id') || '' + const credentialId = parsed.searchParams.get('credential_id') || '' + let payload: NotionApiResponse = emptyNotionResponse + + if (datasetId === DATASET_ID) { + const credential = CREDENTIALS.find(item => item.id === credentialId) + if (credential) { + const mapKey = `${DATASET_ID}:${credential.id}` as keyof typeof responseMap + payload = responseMap[mapKey] + } + } + + return new Response( + JSON.stringify(payload), + { headers: { 'Content-Type': 'application/json' }, status: 200 }, + ) + } + + if (originalFetch) + return originalFetch(input, init) + + throw new Error(`Unmocked fetch call for ${url}`) + } + + globalThis.fetch = handler as typeof globalThis.fetch + + return () => { + if (originalFetch) + globalThis.fetch = originalFetch + } + }, [responseMap]) +} + +const NotionSelectorPreview = () => { + const [selectedPages, setSelectedPages] = useState([]) + const [credentialId, setCredentialId] = useState() + + useMockNotionApi() + + return ( +
+ page.page_id)} + onSelect={setSelectedPages} + onSelectCredential={setCredentialId} + canPreview + /> +
+
+ Debug state +
+

Active credential: {credentialId || 'None'}

+
+          {JSON.stringify(selectedPages, null, 2)}
+        
+
+
+ ) +} + +const meta = { + title: 'Base/Other/NotionPageSelector', + component: NotionSelectorPreview, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Credential-aware selector that fetches Notion pages and lets users choose which ones to sync.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/pagination/index.stories.tsx b/web/app/components/base/pagination/index.stories.tsx new file mode 100644 index 0000000000..4ad5488b96 --- /dev/null +++ b/web/app/components/base/pagination/index.stories.tsx @@ -0,0 +1,81 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useMemo, useState } from 'react' +import Pagination from '.' + +const TOTAL_ITEMS = 120 + +const PaginationDemo = ({ + initialPage = 0, + initialLimit = 10, +}: { + initialPage?: number + initialLimit?: number +}) => { + const [current, setCurrent] = useState(initialPage) + const [limit, setLimit] = useState(initialLimit) + + const pageSummary = useMemo(() => { + const start = current * limit + 1 + const end = Math.min((current + 1) * limit, TOTAL_ITEMS) + return `${start}-${end} of ${TOTAL_ITEMS}` + }, [current, limit]) + + return ( +
+
+ Log pagination + + {pageSummary} + +
+ { + setCurrent(0) + setLimit(nextLimit) + }} + /> +
+ ) +} + +const meta = { + title: 'Base/Navigation/Pagination', + component: PaginationDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Paginate long lists with optional per-page selector. Demonstrates the inline page jump input and quick limit toggles.', + }, + }, + }, + args: { + initialPage: 0, + initialLimit: 10, + }, + argTypes: { + initialPage: { + control: { type: 'number', min: 0, max: 9, step: 1 }, + }, + initialLimit: { + control: { type: 'radio' }, + options: [10, 25, 50], + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} + +export const StartAtMiddle: Story = { + args: { + initialPage: 4, + }, +} diff --git a/web/app/components/base/param-item/index.stories.tsx b/web/app/components/base/param-item/index.stories.tsx new file mode 100644 index 0000000000..a256b56dbf --- /dev/null +++ b/web/app/components/base/param-item/index.stories.tsx @@ -0,0 +1,121 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import ParamItem from '.' + +type ParamConfig = { + id: string + name: string + tip: string + value: number + min: number + max: number + step: number + allowToggle?: boolean +} + +const PARAMS: ParamConfig[] = [ + { + id: 'temperature', + name: 'Temperature', + tip: 'Controls randomness. Lower values make the model more deterministic, higher values encourage creativity.', + value: 0.7, + min: 0, + max: 2, + step: 0.1, + allowToggle: true, + }, + { + id: 'top_p', + name: 'Top P', + tip: 'Nucleus sampling keeps only the most probable tokens whose cumulative probability exceeds this threshold.', + value: 0.9, + min: 0, + max: 1, + step: 0.05, + }, + { + id: 'frequency_penalty', + name: 'Frequency Penalty', + tip: 'Discourages repeating tokens. Increase to reduce repetition.', + value: 0.2, + min: 0, + max: 1, + step: 0.05, + }, +] + +const ParamItemPlayground = () => { + const [state, setState] = useState>(() => { + return PARAMS.reduce((acc, item) => { + acc[item.id] = { value: item.value, enabled: true } + return acc + }, {} as Record) + }) + + const handleChange = (id: string, value: number) => { + setState(prev => ({ + ...prev, + [id]: { + ...prev[id], + value: Number.parseFloat(value.toFixed(3)), + }, + })) + } + + const handleToggle = (id: string, enabled: boolean) => { + setState(prev => ({ + ...prev, + [id]: { + ...prev[id], + enabled, + }, + })) + } + + return ( +
+
+ Generation parameters + + {JSON.stringify(state, null, 0)} + +
+ {PARAMS.map(param => ( + + ))} +
+ ) +} + +const meta = { + title: 'Base/Data Entry/ParamItem', + component: ParamItemPlayground, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Slider + numeric input pairing used for model parameter tuning. Supports optional enable toggles per parameter.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/popover/index.stories.tsx b/web/app/components/base/popover/index.stories.tsx new file mode 100644 index 0000000000..1977c89116 --- /dev/null +++ b/web/app/components/base/popover/index.stories.tsx @@ -0,0 +1,120 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import CustomPopover from '.' + +type PopoverContentProps = { + open?: boolean + onClose?: () => void + onClick?: () => void + title: string + description: string +} + +const PopoverContent = ({ title, description, onClose }: PopoverContentProps) => { + return ( +
+
+ {title} +
+

{description}

+ +
+ ) +} + +const Template = ({ + trigger = 'hover', + position = 'bottom', + manualClose, + disabled, +}: { + trigger?: 'click' | 'hover' + position?: 'bottom' | 'bl' | 'br' + manualClose?: boolean + disabled?: boolean +}) => { + const [hoverHint] = useState( + trigger === 'hover' + ? 'Hover over the badge to reveal quick tips.' + : 'Click the badge to open the contextual menu.', + ) + + return ( +
+

{hoverHint}

+
+ Popover trigger} + htmlContent={ + + } + /> +
+
+ ) +} + +const meta = { + title: 'Base/Feedback/Popover', + component: Template, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Headless UI popover wrapper supporting hover and click triggers. These examples highlight alignment controls and manual closing.', + }, + }, + }, + argTypes: { + trigger: { + control: 'radio', + options: ['hover', 'click'], + }, + position: { + control: 'radio', + options: ['bottom', 'bl', 'br'], + }, + manualClose: { control: 'boolean' }, + disabled: { control: 'boolean' }, + }, + args: { + trigger: 'hover', + position: 'bottom', + manualClose: false, + disabled: false, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const HoverPopover: Story = {} + +export const ClickPopover: Story = { + args: { + trigger: 'click', + position: 'br', + }, +} + +export const DisabledState: Story = { + args: { + disabled: true, + }, +} diff --git a/web/app/components/base/portal-to-follow-elem/index.stories.tsx b/web/app/components/base/portal-to-follow-elem/index.stories.tsx new file mode 100644 index 0000000000..44c8e964ce --- /dev/null +++ b/web/app/components/base/portal-to-follow-elem/index.stories.tsx @@ -0,0 +1,103 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '.' + +const TooltipCard = ({ title, description }: { title: string; description: string }) => ( +
+
+ {title} +
+

{description}

+
+) + +const PortalDemo = ({ + placement = 'bottom', + triggerPopupSameWidth = false, +}: { + placement?: Parameters[0]['placement'] + triggerPopupSameWidth?: boolean +}) => { + const [controlledOpen, setControlledOpen] = useState(false) + + return ( +
+
+ + + Hover me + + + + + + + + + + + + + + +
+
+ ) +} + +const meta = { + title: 'Base/Feedback/PortalToFollowElem', + component: PortalDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Floating UI based portal that tracks trigger positioning. Demonstrates both hover-driven and controlled usage.', + }, + }, + }, + argTypes: { + placement: { + control: 'select', + options: ['top', 'top-start', 'top-end', 'bottom', 'bottom-start', 'bottom-end'], + }, + triggerPopupSameWidth: { control: 'boolean' }, + }, + args: { + placement: 'bottom', + triggerPopupSameWidth: false, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} + +export const SameWidthPanel: Story = { + args: { + triggerPopupSameWidth: true, + }, +} diff --git a/web/app/components/base/premium-badge/index.stories.tsx b/web/app/components/base/premium-badge/index.stories.tsx new file mode 100644 index 0000000000..c1f6ede869 --- /dev/null +++ b/web/app/components/base/premium-badge/index.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import PremiumBadge from '.' + +const colors: Array['color']>> = ['blue', 'indigo', 'gray', 'orange'] + +const PremiumBadgeGallery = ({ + size = 'm', + allowHover = false, +}: { + size?: 's' | 'm' + allowHover?: boolean +}) => { + return ( +
+

Brand badge variants

+
+ {colors.map(color => ( +
+ + Premium + + {color} +
+ ))} +
+
+ ) +} + +const meta = { + title: 'Base/General/PremiumBadge', + component: PremiumBadgeGallery, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Gradient badge used for premium features and upsell prompts. Hover animations can be toggled per instance.', + }, + }, + }, + argTypes: { + size: { + control: 'radio', + options: ['s', 'm'], + }, + allowHover: { control: 'boolean' }, + }, + args: { + size: 'm', + allowHover: false, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} + +export const HoverEnabled: Story = { + args: { + allowHover: true, + }, +} diff --git a/web/app/components/base/progress-bar/progress-circle.stories.tsx b/web/app/components/base/progress-bar/progress-circle.stories.tsx new file mode 100644 index 0000000000..a6a21d2695 --- /dev/null +++ b/web/app/components/base/progress-bar/progress-circle.stories.tsx @@ -0,0 +1,89 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import ProgressCircle from './progress-circle' + +const ProgressCircleDemo = ({ + initialPercentage = 42, + size = 24, +}: { + initialPercentage?: number + size?: number +}) => { + const [percentage, setPercentage] = useState(initialPercentage) + + return ( +
+
+ Upload progress + + {percentage}% + +
+
+ + setPercentage(Number.parseInt(event.target.value, 10))} + className="h-2 w-full cursor-pointer appearance-none rounded-full bg-divider-subtle accent-primary-600" + /> +
+
+ +
+
+ ProgressCircle renders a deterministic SVG slice. Advance the slider to preview how the arc grows for upload indicators. +
+
+ ) +} + +const meta = { + title: 'Base/Feedback/ProgressCircle', + component: ProgressCircleDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Compact radial progress indicator wired to upload flows. The story provides a slider to scrub through percentages.', + }, + }, + }, + argTypes: { + initialPercentage: { + control: { type: 'range', min: 0, max: 100, step: 1 }, + }, + size: { + control: { type: 'number', min: 12, max: 48, step: 2 }, + }, + }, + args: { + initialPercentage: 42, + size: 24, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} + +export const NearComplete: Story = { + args: { + initialPercentage: 92, + }, +} diff --git a/web/app/components/base/prompt-editor/index.stories.tsx b/web/app/components/base/prompt-editor/index.stories.tsx index e0d0777306..35058ac37d 100644 --- a/web/app/components/base/prompt-editor/index.stories.tsx +++ b/web/app/components/base/prompt-editor/index.stories.tsx @@ -25,7 +25,7 @@ const PromptEditorMock = ({ value, onChange, placeholder, editable, compact, cla } const meta = { - title: 'Base/Input/PromptEditor', + title: 'Base/Data Entry/PromptEditor', component: PromptEditorMock, parameters: { layout: 'centered', diff --git a/web/app/components/base/prompt-log-modal/index.stories.tsx b/web/app/components/base/prompt-log-modal/index.stories.tsx new file mode 100644 index 0000000000..55389874cd --- /dev/null +++ b/web/app/components/base/prompt-log-modal/index.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useEffect } from 'react' +import PromptLogModal from '.' +import { useStore } from '@/app/components/app/store' +import type { IChatItem } from '@/app/components/base/chat/chat/type' + +type PromptLogModalProps = React.ComponentProps + +const mockLogItem: IChatItem = { + id: 'message-1', + isAnswer: true, + content: 'Summarize our meeting notes about launch blockers.', + log: [ + { + role: 'system', + text: 'You are an assistant that extracts key launch blockers from the dialogue.', + }, + { + role: 'user', + text: 'Team discussed QA, marketing assets, and infra readiness. Highlight risks.', + }, + { + role: 'assistant', + text: 'Blocking items:\n1. QA needs staging data by Friday.\n2. Marketing awaiting final visuals.\n3. Infra rollout still missing approval.', + }, + ], +} + +const usePromptLogMocks = () => { + useEffect(() => { + useStore.getState().setCurrentLogItem(mockLogItem) + return () => { + useStore.getState().setCurrentLogItem(undefined) + } + }, []) +} + +const PromptLogPreview = (props: PromptLogModalProps) => { + usePromptLogMocks() + + return ( +
+ +
+ ) +} + +const meta = { + title: 'Base/Feedback/PromptLogModal', + component: PromptLogPreview, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Shows the prompt and message transcript used for a chat completion, with copy-to-clipboard support for single prompts.', + }, + }, + }, + args: { + width: 960, + onCancel: () => { + console.log('Prompt log closed') + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/qrcode/index.stories.tsx b/web/app/components/base/qrcode/index.stories.tsx new file mode 100644 index 0000000000..312dc6a5a8 --- /dev/null +++ b/web/app/components/base/qrcode/index.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import ShareQRCode from '.' + +const QRDemo = ({ + content = 'https://dify.ai', +}: { + content?: string +}) => { + return ( +
+

Share QR

+
+ Generated URL: + {content} +
+ +
+ ) +} + +const meta = { + title: 'Base/Data Display/QRCode', + component: QRDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Toggleable QR code generator for sharing app URLs. Clicking the trigger reveals the code with a download CTA.', + }, + }, + }, + argTypes: { + content: { + control: 'text', + }, + }, + args: { + content: 'https://dify.ai', + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} + +export const DemoLink: Story = { + args: { + content: 'https://dify.ai/docs', + }, +} diff --git a/web/app/components/base/radio-card/index.stories.tsx b/web/app/components/base/radio-card/index.stories.tsx index bb45db622c..63dd1ad1ec 100644 --- a/web/app/components/base/radio-card/index.stories.tsx +++ b/web/app/components/base/radio-card/index.stories.tsx @@ -4,7 +4,7 @@ import { RiCloudLine, RiCpuLine, RiDatabase2Line, RiLightbulbLine, RiRocketLine, import RadioCard from '.' const meta = { - title: 'Base/Input/RadioCard', + title: 'Base/Data Entry/RadioCard', component: RadioCard, parameters: { layout: 'centered', diff --git a/web/app/components/base/radio/index.stories.tsx b/web/app/components/base/radio/index.stories.tsx index 0f917320bb..699372097f 100644 --- a/web/app/components/base/radio/index.stories.tsx +++ b/web/app/components/base/radio/index.stories.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' import Radio from '.' const meta = { - title: 'Base/Input/Radio', + title: 'Base/Data Entry/Radio', component: Radio, parameters: { layout: 'centered', diff --git a/web/app/components/base/search-input/index.stories.tsx b/web/app/components/base/search-input/index.stories.tsx index eb051f892f..6b2326322b 100644 --- a/web/app/components/base/search-input/index.stories.tsx +++ b/web/app/components/base/search-input/index.stories.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' import SearchInput from '.' const meta = { - title: 'Base/Input/SearchInput', + title: 'Base/Data Entry/SearchInput', component: SearchInput, parameters: { layout: 'centered', diff --git a/web/app/components/base/segmented-control/index.stories.tsx b/web/app/components/base/segmented-control/index.stories.tsx new file mode 100644 index 0000000000..c83112bd54 --- /dev/null +++ b/web/app/components/base/segmented-control/index.stories.tsx @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { RiLineChartLine, RiListCheck2, RiRobot2Line } from '@remixicon/react' +import { useState } from 'react' +import { SegmentedControl } from '.' + +const SEGMENTS = [ + { value: 'overview', text: 'Overview', Icon: RiLineChartLine }, + { value: 'tasks', text: 'Tasks', Icon: RiListCheck2, count: 8 }, + { value: 'agents', text: 'Agents', Icon: RiRobot2Line }, +] + +const SegmentedControlDemo = ({ + initialValue = 'overview', + size = 'regular', + padding = 'with', + activeState = 'default', +}: { + initialValue?: string + size?: 'regular' | 'small' | 'large' + padding?: 'none' | 'with' + activeState?: 'default' | 'accent' | 'accentLight' +}) => { + const [value, setValue] = useState(initialValue) + + return ( +
+
+ Segmented control + + value="{value}" + +
+ +
+ ) +} + +const meta = { + title: 'Base/Data Entry/SegmentedControl', + component: SegmentedControlDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Multi-tab segmented control with optional icons and badge counts. Adjust sizing and accent states via controls.', + }, + }, + }, + argTypes: { + initialValue: { + control: 'radio', + options: SEGMENTS.map(segment => segment.value), + }, + size: { + control: 'inline-radio', + options: ['small', 'regular', 'large'], + }, + padding: { + control: 'inline-radio', + options: ['none', 'with'], + }, + activeState: { + control: 'inline-radio', + options: ['default', 'accent', 'accentLight'], + }, + }, + args: { + initialValue: 'overview', + size: 'regular', + padding: 'with', + activeState: 'default', + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} + +export const AccentState: Story = { + args: { + activeState: 'accent', + }, +} diff --git a/web/app/components/base/select/index.stories.tsx b/web/app/components/base/select/index.stories.tsx index 48a715498b..f1b46f2d55 100644 --- a/web/app/components/base/select/index.stories.tsx +++ b/web/app/components/base/select/index.stories.tsx @@ -4,7 +4,7 @@ import Select, { PortalSelect, SimpleSelect } from '.' import type { Item } from '.' const meta = { - title: 'Base/Input/Select', + title: 'Base/Data Entry/Select', component: SimpleSelect, parameters: { layout: 'centered', diff --git a/web/app/components/base/simple-pie-chart/index.stories.tsx b/web/app/components/base/simple-pie-chart/index.stories.tsx new file mode 100644 index 0000000000..d08c8fa0ce --- /dev/null +++ b/web/app/components/base/simple-pie-chart/index.stories.tsx @@ -0,0 +1,89 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useMemo, useState } from 'react' +import SimplePieChart from '.' + +const PieChartPlayground = ({ + initialPercentage = 65, + fill = '#fdb022', + stroke = '#f79009', +}: { + initialPercentage?: number + fill?: string + stroke?: string +}) => { + const [percentage, setPercentage] = useState(initialPercentage) + + const label = useMemo(() => `${percentage}%`, [percentage]) + + return ( +
+
+ Conversion snapshot + + {label} + +
+
+ +
+ + setPercentage(Number.parseInt(event.target.value, 10))} + className="h-2 w-full cursor-pointer appearance-none rounded-full bg-divider-subtle accent-primary-600" + /> +
+
+
+ ) +} + +const meta = { + title: 'Base/Data Display/SimplePieChart', + component: PieChartPlayground, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Thin radial indicator built with ECharts. Use it for quick percentage snapshots inside cards.', + }, + }, + }, + argTypes: { + initialPercentage: { + control: { type: 'range', min: 0, max: 100, step: 1 }, + }, + fill: { control: 'color' }, + stroke: { control: 'color' }, + }, + args: { + initialPercentage: 65, + fill: '#fdb022', + stroke: '#f79009', + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} + +export const BrandAccent: Story = { + args: { + fill: '#155EEF', + stroke: '#0040C1', + initialPercentage: 82, + }, +} diff --git a/web/app/components/base/skeleton/index.stories.tsx b/web/app/components/base/skeleton/index.stories.tsx new file mode 100644 index 0000000000..b5ea649b34 --- /dev/null +++ b/web/app/components/base/skeleton/index.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { + SkeletonContainer, + SkeletonPoint, + SkeletonRectangle, + SkeletonRow, +} from '.' + +const SkeletonDemo = () => { + return ( +
+
Loading skeletons
+
+ + + + + + + + + + + + + +
+
+ + + + + + + + +
+
+ ) +} + +const meta = { + title: 'Base/Feedback/Skeleton', + component: SkeletonDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Composable skeleton primitives (container, row, rectangle, point) to sketch loading states for panels and lists.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/slider/index.stories.tsx b/web/app/components/base/slider/index.stories.tsx index 691c75d7ad..4d06381d16 100644 --- a/web/app/components/base/slider/index.stories.tsx +++ b/web/app/components/base/slider/index.stories.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' import Slider from '.' const meta = { - title: 'Base/Input/Slider', + title: 'Base/Data Entry/Slider', component: Slider, parameters: { layout: 'centered', diff --git a/web/app/components/base/sort/index.stories.tsx b/web/app/components/base/sort/index.stories.tsx new file mode 100644 index 0000000000..fea21e8edc --- /dev/null +++ b/web/app/components/base/sort/index.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useMemo, useState } from 'react' +import Sort from '.' + +const SORT_ITEMS = [ + { value: 'created_at', name: 'Created time' }, + { value: 'updated_at', name: 'Updated time' }, + { value: 'latency', name: 'Latency' }, +] + +const SortPlayground = () => { + const [sortBy, setSortBy] = useState('-created_at') + + const { order, value } = useMemo(() => { + const isDesc = sortBy.startsWith('-') + return { + order: isDesc ? '-' : '', + value: sortBy.replace('-', '') || 'created_at', + } + }, [sortBy]) + + return ( +
+
+ Sort control + + sort_by="{sortBy}" + +
+ { + setSortBy(next as string) + }} + /> +
+ ) +} + +const meta = { + title: 'Base/Data Display/Sort', + component: SortPlayground, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Sorting trigger used in log tables. Includes dropdown selection and quick toggle between ascending and descending.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/spinner/index.stories.tsx b/web/app/components/base/spinner/index.stories.tsx new file mode 100644 index 0000000000..9792b9b2fc --- /dev/null +++ b/web/app/components/base/spinner/index.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import Spinner from '.' + +const SpinnerPlayground = ({ + loading = true, +}: { + loading?: boolean +}) => { + const [isLoading, setIsLoading] = useState(loading) + + return ( +
+

Spinner

+ + +
+ ) +} + +const meta = { + title: 'Base/Feedback/Spinner', + component: SpinnerPlayground, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Minimal spinner powered by Tailwind utilities. Toggle the state to inspect motion-reduced behaviour.', + }, + }, + }, + argTypes: { + loading: { control: 'boolean' }, + }, + args: { + loading: true, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/svg-gallery/index.stories.tsx b/web/app/components/base/svg-gallery/index.stories.tsx new file mode 100644 index 0000000000..65da97d243 --- /dev/null +++ b/web/app/components/base/svg-gallery/index.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import SVGRenderer from '.' + +const SAMPLE_SVG = ` + + + + + + + + + + SVG Preview + Click to open high-resolution preview + + + + + Inline SVG asset + +`.trim() + +const meta = { + title: 'Base/Data Display/SVGRenderer', + component: SVGRenderer, + parameters: { + docs: { + description: { + component: 'Renders sanitized SVG markup with zoom-to-preview capability.', + }, + source: { + language: 'tsx', + code: ` +... +\`} /> + `.trim(), + }, + }, + }, + tags: ['autodocs'], + args: { + content: SAMPLE_SVG, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} diff --git a/web/app/components/base/svg/index.stories.tsx b/web/app/components/base/svg/index.stories.tsx new file mode 100644 index 0000000000..0b7d8d23c9 --- /dev/null +++ b/web/app/components/base/svg/index.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import SVGBtn from '.' + +const SvgToggleDemo = () => { + const [isSVG, setIsSVG] = useState(false) + + return ( +
+

SVG toggle

+ + + Mode: {isSVG ? 'SVG' : 'PNG'} + +
+ ) +} + +const meta = { + title: 'Base/General/SVGBtn', + component: SvgToggleDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Small toggle used in icon pickers to switch between SVG and bitmap assets.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/switch/index.stories.tsx b/web/app/components/base/switch/index.stories.tsx index 2753a6a309..5b2b6e59c4 100644 --- a/web/app/components/base/switch/index.stories.tsx +++ b/web/app/components/base/switch/index.stories.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' import Switch from '.' const meta = { - title: 'Base/Input/Switch', + title: 'Base/Data Entry/Switch', component: Switch, parameters: { layout: 'centered', diff --git a/web/app/components/base/tab-header/index.stories.tsx b/web/app/components/base/tab-header/index.stories.tsx new file mode 100644 index 0000000000..cb383947d9 --- /dev/null +++ b/web/app/components/base/tab-header/index.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import TabHeader from '.' +import type { ITabHeaderProps } from '.' + +const items: ITabHeaderProps['items'] = [ + { id: 'overview', name: 'Overview' }, + { id: 'playground', name: 'Playground' }, + { id: 'changelog', name: 'Changelog', extra: New }, + { id: 'docs', name: 'Docs', isRight: true }, + { id: 'settings', name: 'Settings', isRight: true, disabled: true }, +] + +const TabHeaderDemo = ({ + initialTab = 'overview', +}: { + initialTab?: string +}) => { + const [activeTab, setActiveTab] = useState(initialTab) + + return ( +
+
+ Tabs + + active="{activeTab}" + +
+ +
+ ) +} + +const meta = { + title: 'Base/Navigation/TabHeader', + component: TabHeaderDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Two-sided header tabs with optional right-aligned actions. Disabled items illustrate read-only states.', + }, + }, + }, + argTypes: { + initialTab: { + control: 'radio', + options: items.map(item => item.id), + }, + }, + args: { + initialTab: 'overview', + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/tab-slider-new/index.stories.tsx b/web/app/components/base/tab-slider-new/index.stories.tsx new file mode 100644 index 0000000000..669ec9eed9 --- /dev/null +++ b/web/app/components/base/tab-slider-new/index.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import { RiSparklingFill, RiTerminalBoxLine } from '@remixicon/react' +import TabSliderNew from '.' + +const OPTIONS = [ + { value: 'visual', text: 'Visual builder', icon: }, + { value: 'code', text: 'Code', icon: }, +] + +const TabSliderNewDemo = ({ + initialValue = 'visual', +}: { + initialValue?: string +}) => { + const [value, setValue] = useState(initialValue) + + return ( +
+
Pill tabs
+ +
+ ) +} + +const meta = { + title: 'Base/Navigation/TabSliderNew', + component: TabSliderNewDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Rounded pill tabs suited for switching between editors. Icons illustrate mixed text/icon options.', + }, + }, + }, + argTypes: { + initialValue: { + control: 'radio', + options: OPTIONS.map(option => option.value), + }, + }, + args: { + initialValue: 'visual', + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/tab-slider-plain/index.stories.tsx b/web/app/components/base/tab-slider-plain/index.stories.tsx new file mode 100644 index 0000000000..dd8c7e0d30 --- /dev/null +++ b/web/app/components/base/tab-slider-plain/index.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useState } from 'react' +import TabSliderPlain from '.' + +const OPTIONS = [ + { value: 'analytics', text: 'Analytics' }, + { value: 'activity', text: 'Recent activity' }, + { value: 'alerts', text: 'Alerts' }, +] + +const TabSliderPlainDemo = ({ + initialValue = 'analytics', +}: { + initialValue?: string +}) => { + const [value, setValue] = useState(initialValue) + + return ( +
+
Underline tabs
+ +
+ ) +} + +const meta = { + title: 'Base/Navigation/TabSliderPlain', + component: TabSliderPlainDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Underline-style navigation commonly used in dashboards. Toggle between three sections.', + }, + }, + }, + argTypes: { + initialValue: { + control: 'radio', + options: OPTIONS.map(option => option.value), + }, + }, + args: { + initialValue: 'analytics', + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/tab-slider/index.stories.tsx b/web/app/components/base/tab-slider/index.stories.tsx new file mode 100644 index 0000000000..703116fe19 --- /dev/null +++ b/web/app/components/base/tab-slider/index.stories.tsx @@ -0,0 +1,93 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useEffect, useState } from 'react' +import TabSlider from '.' + +const OPTIONS = [ + { value: 'models', text: 'Models' }, + { value: 'datasets', text: 'Datasets' }, + { value: 'plugins', text: 'Plugins' }, +] + +const TabSliderDemo = ({ + initialValue = 'models', +}: { + initialValue?: string +}) => { + const [value, setValue] = useState(initialValue) + + useEffect(() => { + const originalFetch = globalThis.fetch?.bind(globalThis) + + const handler = async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url + + if (url.includes('/workspaces/current/plugin/list')) { + return new Response( + JSON.stringify({ + total: 6, + plugins: [], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + + if (originalFetch) + return originalFetch(input, init) + + throw new Error(`Unhandled request for ${url}`) + } + + globalThis.fetch = handler as typeof globalThis.fetch + + return () => { + if (originalFetch) + globalThis.fetch = originalFetch + } + }, []) + + return ( +
+
Segmented tabs
+ +
+ ) +} + +const meta = { + title: 'Base/Navigation/TabSlider', + component: TabSliderDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Animated segmented control with sliding highlight. A badge appears when plugins are installed (mocked in Storybook).', + }, + }, + }, + argTypes: { + initialValue: { + control: 'radio', + options: OPTIONS.map(option => option.value), + }, + }, + args: { + initialValue: 'models', + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/tag-input/index.stories.tsx b/web/app/components/base/tag-input/index.stories.tsx index bbb314cf3a..7aae9f2773 100644 --- a/web/app/components/base/tag-input/index.stories.tsx +++ b/web/app/components/base/tag-input/index.stories.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' import TagInput from '.' const meta = { - title: 'Base/Input/TagInput', + title: 'Base/Data Entry/TagInput', component: TagInput, parameters: { layout: 'centered', diff --git a/web/app/components/base/tag-management/index.stories.tsx b/web/app/components/base/tag-management/index.stories.tsx new file mode 100644 index 0000000000..51f4233461 --- /dev/null +++ b/web/app/components/base/tag-management/index.stories.tsx @@ -0,0 +1,131 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useEffect, useRef } from 'react' +import TagManagementModal from '.' +import { ToastProvider } from '@/app/components/base/toast' +import { useStore as useTagStore } from './store' +import type { Tag } from './constant' + +const INITIAL_TAGS: Tag[] = [ + { id: 'tag-product', name: 'Product', type: 'app', binding_count: 12 }, + { id: 'tag-growth', name: 'Growth', type: 'app', binding_count: 4 }, + { id: 'tag-beta', name: 'Beta User', type: 'app', binding_count: 2 }, + { id: 'tag-rag', name: 'RAG', type: 'knowledge', binding_count: 3 }, + { id: 'tag-updates', name: 'Release Notes', type: 'knowledge', binding_count: 6 }, +] + +const TagManagementPlayground = ({ + type = 'app', +}: { + type?: 'app' | 'knowledge' +}) => { + const originalFetchRef = useRef(null) + const tagsRef = useRef(INITIAL_TAGS) + const setTagList = useTagStore(s => s.setTagList) + const showModal = useTagStore(s => s.showTagManagementModal) + const setShowModal = useTagStore(s => s.setShowTagManagementModal) + + useEffect(() => { + setTagList(tagsRef.current) + setShowModal(true) + }, [setTagList, setShowModal]) + + useEffect(() => { + originalFetchRef.current = globalThis.fetch?.bind(globalThis) + + const handler = async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init) + const url = request.url + const method = request.method.toUpperCase() + const parsedUrl = new URL(url, window.location.origin) + + if (parsedUrl.pathname.endsWith('/tags')) { + if (method === 'GET') { + const tagType = parsedUrl.searchParams.get('type') || 'app' + const payload = tagsRef.current.filter(tag => tag.type === tagType) + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + if (method === 'POST') { + const body = await request.clone().json() as { name: string; type: string } + const newTag: Tag = { + id: `tag-${Date.now()}`, + name: body.name, + type: body.type, + binding_count: 0, + } + tagsRef.current = [newTag, ...tagsRef.current] + setTagList(tagsRef.current) + return new Response(JSON.stringify(newTag), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + } + + if (parsedUrl.pathname.endsWith('/tag-bindings/create') || parsedUrl.pathname.endsWith('/tag-bindings/remove')) { + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + + if (originalFetchRef.current) + return originalFetchRef.current(request) + + throw new Error(`Unhandled request in mock fetch: ${url}`) + } + + globalThis.fetch = handler as typeof globalThis.fetch + + return () => { + if (originalFetchRef.current) + globalThis.fetch = originalFetchRef.current + } + }, [setTagList]) + + return ( + +
+ +

Mocked tag management flows with create and bind actions.

+
+ +
+ ) +} + +const meta = { + title: 'Base/Data Display/TagManagementModal', + component: TagManagementPlayground, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Complete tag management modal with mocked service calls for browsing and creating tags.', + }, + }, + }, + argTypes: { + type: { + control: 'radio', + options: ['app', 'knowledge'], + }, + }, + args: { + type: 'app', + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/tag/index.stories.tsx b/web/app/components/base/tag/index.stories.tsx new file mode 100644 index 0000000000..8ca15c0c8b --- /dev/null +++ b/web/app/components/base/tag/index.stories.tsx @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import Tag from '.' + +const COLORS: Array['color']>> = ['green', 'yellow', 'red', 'gray'] + +const TagGallery = ({ + bordered = false, + hideBg = false, +}: { + bordered?: boolean + hideBg?: boolean +}) => { + return ( +
+
Tag variants
+
+ {COLORS.map(color => ( +
+ + {color.charAt(0).toUpperCase() + color.slice(1)} + + {color} +
+ ))} +
+
+ ) +} + +const meta = { + title: 'Base/Data Display/Tag', + component: TagGallery, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Color-coded label component. Toggle borders or remove background to fit dark/light surfaces.', + }, + }, + }, + argTypes: { + bordered: { control: 'boolean' }, + hideBg: { control: 'boolean' }, + }, + args: { + bordered: false, + hideBg: false, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} + +export const Outlined: Story = { + args: { + bordered: true, + hideBg: true, + }, +} diff --git a/web/app/components/base/textarea/index.stories.tsx b/web/app/components/base/textarea/index.stories.tsx index ec27aac22b..41d8bda458 100644 --- a/web/app/components/base/textarea/index.stories.tsx +++ b/web/app/components/base/textarea/index.stories.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' import Textarea from '.' const meta = { - title: 'Base/Input/Textarea', + title: 'Base/Data Entry/Textarea', component: Textarea, parameters: { layout: 'centered', diff --git a/web/app/components/base/toast/index.stories.tsx b/web/app/components/base/toast/index.stories.tsx new file mode 100644 index 0000000000..6ef65475cb --- /dev/null +++ b/web/app/components/base/toast/index.stories.tsx @@ -0,0 +1,104 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useCallback } from 'react' +import Toast, { ToastProvider, useToastContext } from '.' + +const ToastControls = () => { + const { notify } = useToastContext() + + const trigger = useCallback((type: 'success' | 'error' | 'warning' | 'info') => { + notify({ + type, + message: `This is a ${type} toast`, + children: type === 'info' ? 'Additional details can live here.' : undefined, + }) + }, [notify]) + + return ( +
+ + + + +
+ ) +} + +const ToastProviderDemo = () => { + return ( + +
+
Toast provider
+ +
+
+ ) +} + +const StaticToastDemo = () => { + return ( +
+
Static API
+ +
+ ) +} + +const meta = { + title: 'Base/Feedback/Toast', + component: ToastProviderDemo, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'ToastProvider based notifications and the static Toast.notify helper. Buttons showcase each toast variant.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Provider: Story = {} + +export const StaticApi: Story = { + render: () => , +} diff --git a/web/app/components/base/tooltip/index.stories.tsx b/web/app/components/base/tooltip/index.stories.tsx new file mode 100644 index 0000000000..aeca69464f --- /dev/null +++ b/web/app/components/base/tooltip/index.stories.tsx @@ -0,0 +1,60 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import Tooltip from '.' + +const TooltipGrid = () => { + return ( +
+
Hover tooltips
+
+ + + + + + Right tooltip + + +
+
Click tooltips
+
+ + + + + + Plain content + + +
+
+ ) +} + +const meta = { + title: 'Base/Feedback/Tooltip', + component: TooltipGrid, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Portal-based tooltip component supporting hover and click triggers, custom placements, and decorated content.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} diff --git a/web/app/components/base/video-gallery/index.stories.tsx b/web/app/components/base/video-gallery/index.stories.tsx new file mode 100644 index 0000000000..7e17ee208c --- /dev/null +++ b/web/app/components/base/video-gallery/index.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from '@storybook/nextjs' +import VideoGallery from '.' + +const VIDEO_SOURCES = [ + 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4', + 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/forest.mp4', +] + +const meta = { + title: 'Base/Data Display/VideoGallery', + component: VideoGallery, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Stacked list of video players with custom controls for progress, volume, and fullscreen.', + }, + source: { + language: 'tsx', + code: ` + + `.trim(), + }, + }, + }, + tags: ['autodocs'], + args: { + srcs: VIDEO_SOURCES, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} diff --git a/web/app/components/base/voice-input/index.stories.tsx b/web/app/components/base/voice-input/index.stories.tsx index 0a7980e9ac..714cde72b5 100644 --- a/web/app/components/base/voice-input/index.stories.tsx +++ b/web/app/components/base/voice-input/index.stories.tsx @@ -81,7 +81,7 @@ const VoiceInputMock = ({ onConverted, onCancel }: any) => { } const meta = { - title: 'Base/Input/VoiceInput', + title: 'Base/Data Entry/VoiceInput', component: VoiceInputMock, parameters: { layout: 'centered', diff --git a/web/app/components/base/with-input-validation/index.stories.tsx b/web/app/components/base/with-input-validation/index.stories.tsx index 5a7e4bc678..26fa9747d8 100644 --- a/web/app/components/base/with-input-validation/index.stories.tsx +++ b/web/app/components/base/with-input-validation/index.stories.tsx @@ -63,7 +63,7 @@ const ValidatedUserCard = withValidation(UserCard, userSchema) const ValidatedProductCard = withValidation(ProductCard, productSchema) const meta = { - title: 'Base/Input/WithInputValidation', + title: 'Base/Data Entry/WithInputValidation', parameters: { layout: 'centered', docs: { From c94dc523103c6c797d07866fa4a3241133fb0e06 Mon Sep 17 00:00:00 2001 From: lyzno1 Date: Wed, 29 Oct 2025 14:57:38 +0800 Subject: [PATCH 050/394] fix: remove duplicate RAG tool heading and fix select callback type --- .../workflow/block-selector/all-tools.tsx | 17 +++++++++-------- .../rag-tool-recommendations/list.tsx | 16 +++++++++------- .../workflow/block-selector/tools.tsx | 15 +-------------- 3 files changed, 19 insertions(+), 29 deletions(-) diff --git a/web/app/components/workflow/block-selector/all-tools.tsx b/web/app/components/workflow/block-selector/all-tools.tsx index 01eaf74d0f..c8f46f1045 100644 --- a/web/app/components/workflow/block-selector/all-tools.tsx +++ b/web/app/components/workflow/block-selector/all-tools.tsx @@ -3,12 +3,7 @@ import type { RefObject, SetStateAction, } from 'react' -import { - useEffect, - useMemo, - useRef, - useState, -} from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import type { BlockEnum, @@ -35,6 +30,7 @@ import Divider from '@/app/components/base/divider' import { RiArrowRightUpLine } from '@remixicon/react' import { getMarketplaceUrl } from '@/utils/var' import { useGetLanguage } from '@/context/i18n' +import type { OnSelectBlock } from '@/app/components/workflow/types' const marketplaceFooterClassName = 'system-sm-medium z-10 flex h-8 flex-none cursor-pointer items-center rounded-b-lg border-[0.5px] border-t border-components-panel-border bg-components-panel-bg-blur px-4 py-1 text-text-accent-light-mode-only shadow-lg' @@ -202,6 +198,12 @@ const AllTools = ({ && (featuredLoading || featuredPlugins.length > 0) const shouldShowMarketplaceFooter = enable_marketplace && !hasFilter + const handleRAGSelect = useCallback((type, pluginDefaultValue) => { + if (!pluginDefaultValue) + return + onSelect(type, pluginDefaultValue as ToolDefaultValue) + }, [onSelect]) + return (
@@ -236,7 +238,7 @@ const AllTools = ({ {isShowRAGRecommendations && onTagsChange && ( )} @@ -274,7 +276,6 @@ const AllTools = ({ hasSearchText={hasSearchText} selectedTools={selectedTools} canChooseMCPTool={canChooseMCPTool} - isShowRAGRecommendations={isShowRAGRecommendations} /> )} diff --git a/web/app/components/workflow/block-selector/rag-tool-recommendations/list.tsx b/web/app/components/workflow/block-selector/rag-tool-recommendations/list.tsx index 19378caf48..8c98fa9d7c 100644 --- a/web/app/components/workflow/block-selector/rag-tool-recommendations/list.tsx +++ b/web/app/components/workflow/block-selector/rag-tool-recommendations/list.tsx @@ -1,7 +1,4 @@ -import { - useMemo, - useRef, -} from 'react' +import { useCallback, useMemo, useRef } from 'react' import type { BlockEnum, ToolWithProvider } from '../../types' import type { ToolDefaultValue } from '../types' import { ViewType } from '../view-type-select' @@ -12,9 +9,10 @@ import ToolListTreeView from '../tool/tool-list-tree-view/list' import ToolListFlatView from '../tool/tool-list-flat-view/list' import UninstalledItem from './uninstalled-item' import type { Plugin } from '@/app/components/plugins/types' +import type { OnSelectBlock } from '@/app/components/workflow/types' type ListProps = { - onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + onSelect: OnSelectBlock tools: ToolWithProvider[] viewType: ViewType unInstalledPlugins: Plugin[] @@ -62,6 +60,10 @@ const List = ({ const toolRefs = useRef({}) + const handleSelect = useCallback((type: BlockEnum, tool: ToolDefaultValue) => { + onSelect(type, tool) + }, [onSelect]) + return (
{!!tools.length && ( @@ -72,7 +74,7 @@ const List = ({ payload={listViewToolData} isShowLetterIndex={false} hasSearchText={false} - onSelect={onSelect} + onSelect={handleSelect} canNotSelectMultiple indexBar={null} /> @@ -80,7 +82,7 @@ const List = ({ ) diff --git a/web/app/components/workflow/block-selector/tools.tsx b/web/app/components/workflow/block-selector/tools.tsx index 0b1a0041f2..c62f6a67f9 100644 --- a/web/app/components/workflow/block-selector/tools.tsx +++ b/web/app/components/workflow/block-selector/tools.tsx @@ -1,9 +1,4 @@ -import { - memo, - useMemo, - useRef, -} from 'react' -import { useTranslation } from 'react-i18next' +import { memo, useMemo, useRef } from 'react' import type { BlockEnum, ToolWithProvider } from '../types' import IndexBar, { groupItems } from './index-bar' import type { ToolDefaultValue, ToolValue } from './types' @@ -28,7 +23,6 @@ type ToolsProps = { indexBarClassName?: string selectedTools?: ToolValue[] canChooseMCPTool?: boolean - isShowRAGRecommendations?: boolean } const Tools = ({ onSelect, @@ -43,10 +37,8 @@ const Tools = ({ indexBarClassName, selectedTools, canChooseMCPTool, - isShowRAGRecommendations = false, }: ToolsProps) => { // const tools: any = [] - const { t } = useTranslation() const language = useGetLanguage() const isFlatView = viewType === ViewType.flat const isShowLetterIndex = isFlatView && tools.length > 10 @@ -105,11 +97,6 @@ const Tools = ({
)} - {!!tools.length && isShowRAGRecommendations && ( -
- {t('tools.allTools')} -
- )} {!!tools.length && ( isFlatView ? ( Date: Wed, 29 Oct 2025 15:10:45 +0800 Subject: [PATCH 051/394] feat(workflow): persist RAG recommendation panel collapse state --- .../rag-tool-recommendations/index.tsx | 121 +++++++++++------- 1 file changed, 77 insertions(+), 44 deletions(-) diff --git a/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx b/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx index eecd874335..240c0814a1 100644 --- a/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx +++ b/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx @@ -1,5 +1,6 @@ +'use client' import type { Dispatch, SetStateAction } from 'react' -import React, { useCallback, useMemo } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import type { OnSelectBlock } from '@/app/components/workflow/types' import type { ViewType } from '@/app/components/workflow/block-selector/view-type-select' @@ -10,6 +11,7 @@ import { getMarketplaceUrl } from '@/utils/var' import { useRAGRecommendedPlugins } from '@/service/use-tools' import List from './list' import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils' +import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/arrows' type RAGToolRecommendationsProps = { viewType: ViewType @@ -17,12 +19,34 @@ type RAGToolRecommendationsProps = { onTagsChange: Dispatch> } +const STORAGE_KEY = 'workflow_rag_recommendations_collapsed' + const RAGToolRecommendations = ({ viewType, onSelect, onTagsChange, }: RAGToolRecommendationsProps) => { const { t } = useTranslation() + const [isCollapsed, setIsCollapsed] = useState(() => { + if (typeof window === 'undefined') + return false + const stored = window.localStorage.getItem(STORAGE_KEY) + return stored === 'true' + }) + + useEffect(() => { + if (typeof window === 'undefined') + return + const stored = window.localStorage.getItem(STORAGE_KEY) + if (stored !== null) + setIsCollapsed(stored === 'true') + }, []) + + useEffect(() => { + if (typeof window === 'undefined') + return + window.localStorage.setItem(STORAGE_KEY, String(isCollapsed)) + }, [isCollapsed]) const { data: ragRecommendedPlugins, @@ -52,51 +76,60 @@ const RAGToolRecommendations = ({ return (
-
- {t('pipeline.ragToolSuggestions.title')} -
- {/* For first time loading, show loading */} - {isLoadingRAGRecommendedPlugins && ( -
- -
- )} - {!isFetchingRAGRecommendedPlugins && recommendedPlugins.length === 0 && unInstalledPlugins.length === 0 && ( -

- - ), - }} - /> -

- )} - {(recommendedPlugins.length > 0 || unInstalledPlugins.length > 0) && ( + + {!isCollapsed && ( <> - -
-
- + {/* For first time loading, show loading */} + {isLoadingRAGRecommendedPlugins && ( +
+
-
- {t('common.operation.more')} -
-
+ )} + {!isFetchingRAGRecommendedPlugins && recommendedPlugins.length === 0 && unInstalledPlugins.length === 0 && ( +

+ + ), + }} + /> +

+ )} + {(recommendedPlugins.length > 0 || unInstalledPlugins.length > 0) && ( + <> + +
+
+ +
+
+ {t('common.operation.more')} +
+
+ + )} )}
From 5ab315aeafeb702264d15190fb3465b9ddb83c96 Mon Sep 17 00:00:00 2001 From: Vivec <72788785+Vivecccccc@users.noreply.github.com> Date: Wed, 29 Oct 2025 15:11:45 +0800 Subject: [PATCH 052/394] fix: set conditional capabilities upon MCP client session initialization (#26234) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Novice --- api/core/entities/mcp_provider.py | 3 ++- api/core/mcp/session/client_session.py | 16 ++++++++++------ api/services/tools/tools_transform_service.py | 3 ++- .../unit_tests/core/mcp/client/test_session.py | 3 --- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/api/core/entities/mcp_provider.py b/api/core/entities/mcp_provider.py index 4ac39cef02..7484cea04a 100644 --- a/api/core/entities/mcp_provider.py +++ b/api/core/entities/mcp_provider.py @@ -14,7 +14,6 @@ from core.helper.provider_cache import NoOpProviderCredentialCache from core.mcp.types import OAuthClientInformation, OAuthClientMetadata, OAuthTokens from core.tools.entities.common_entities import I18nObject from core.tools.entities.tool_entities import ToolProviderType -from core.tools.utils.encryption import create_provider_encrypter if TYPE_CHECKING: from models.tools import MCPToolProvider @@ -272,6 +271,8 @@ class MCPProviderEntity(BaseModel): def _decrypt_dict(self, data: dict[str, Any]) -> dict[str, Any]: """Generic method to decrypt dictionary fields""" + from core.tools.utils.encryption import create_provider_encrypter + if not data: return {} diff --git a/api/core/mcp/session/client_session.py b/api/core/mcp/session/client_session.py index 77d35cca19..d684fe0dd7 100644 --- a/api/core/mcp/session/client_session.py +++ b/api/core/mcp/session/client_session.py @@ -109,12 +109,16 @@ class ClientSession( self._message_handler = message_handler or _default_message_handler def initialize(self) -> types.InitializeResult: - sampling = types.SamplingCapability() - roots = types.RootsCapability( - # TODO: Should this be based on whether we - # _will_ send notifications, or only whether - # they're supported? - listChanged=True, + # Only set capabilities if non-default callbacks are provided + # This prevents servers from attempting callbacks when we don't actually support them + sampling = types.SamplingCapability() if self._sampling_callback is not _default_sampling_callback else None + roots = ( + types.RootsCapability( + # Only enable listChanged if we have a custom callback + listChanged=True, + ) + if self._list_roots_callback is not _default_list_roots_callback + else None ) result = self.send_request( diff --git a/api/services/tools/tools_transform_service.py b/api/services/tools/tools_transform_service.py index 6e95513318..ab80af7a8d 100644 --- a/api/services/tools/tools_transform_service.py +++ b/api/services/tools/tools_transform_service.py @@ -7,7 +7,6 @@ from pydantic import ValidationError from yarl import URL from configs import dify_config -from core.entities.mcp_provider import MCPConfiguration from core.helper.provider_cache import ToolProviderCredentialsCache from core.mcp.types import Tool as MCPTool from core.plugin.entities.plugin_daemon import PluginDatasourceProviderEntity @@ -240,6 +239,8 @@ class ToolTransformService: user_name: str | None = None, include_sensitive: bool = True, ) -> ToolProviderApiEntity: + from core.entities.mcp_provider import MCPConfiguration + # Use provided user_name to avoid N+1 query, fallback to load_user() if not provided if user_name is None: user = db_provider.load_user() diff --git a/api/tests/unit_tests/core/mcp/client/test_session.py b/api/tests/unit_tests/core/mcp/client/test_session.py index 08d5b7d21c..8b24c8ce75 100644 --- a/api/tests/unit_tests/core/mcp/client/test_session.py +++ b/api/tests/unit_tests/core/mcp/client/test_session.py @@ -395,9 +395,6 @@ def test_client_capabilities_default(): # Assert default capabilities assert received_capabilities is not None - assert received_capabilities.sampling is not None - assert received_capabilities.roots is not None - assert received_capabilities.roots.listChanged is True def test_client_capabilities_with_custom_callbacks(): From addebc465a904f9ebb8b5e76066db5de692efa51 Mon Sep 17 00:00:00 2001 From: quicksand Date: Wed, 29 Oct 2025 15:31:18 +0800 Subject: [PATCH 053/394] fix: resolve 500 error when updating document chunk settings (#27551) (#27574) --- api/services/dataset_service.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/api/services/dataset_service.py b/api/services/dataset_service.py index 1e040abe3e..a3e62544c6 100644 --- a/api/services/dataset_service.py +++ b/api/services/dataset_service.py @@ -1416,8 +1416,6 @@ class DocumentService: # check document limit assert isinstance(current_user, Account) assert current_user.current_tenant_id is not None - assert knowledge_config.data_source - assert knowledge_config.data_source.info_list features = FeatureService.get_features(current_user.current_tenant_id) @@ -1448,7 +1446,7 @@ class DocumentService: DocumentService.check_documents_upload_quota(count, features) # if dataset is empty, update dataset data_source_type - if not dataset.data_source_type: + if not dataset.data_source_type and knowledge_config.data_source: dataset.data_source_type = knowledge_config.data_source.info_list.data_source_type if not dataset.indexing_technique: @@ -1494,6 +1492,10 @@ class DocumentService: documents.append(document) batch = document.batch else: + # When creating new documents, data_source must be provided + if not knowledge_config.data_source: + raise ValueError("Data source is required when creating new documents") + batch = time.strftime("%Y%m%d%H%M%S") + str(100000 + secrets.randbelow(exclusive_upper_bound=900000)) # save process rule if not dataset_process_rule: From 7dc7c8af981c10b71f5072d31af25e0c81b81739 Mon Sep 17 00:00:00 2001 From: Blackoutta <37723456+Blackoutta@users.noreply.github.com> Date: Wed, 29 Oct 2025 15:33:16 +0800 Subject: [PATCH 054/394] improve: speed up tracing config decryption process (#27549) --- api/core/ops/ops_trace_manager.py | 51 ++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/api/core/ops/ops_trace_manager.py b/api/core/ops/ops_trace_manager.py index de0d4560e3..6cc7859db7 100644 --- a/api/core/ops/ops_trace_manager.py +++ b/api/core/ops/ops_trace_manager.py @@ -14,7 +14,7 @@ from flask import current_app from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker -from core.helper.encrypter import decrypt_token, encrypt_token, obfuscated_token +from core.helper.encrypter import batch_decrypt_token, encrypt_token, obfuscated_token from core.ops.entities.config_entity import ( OPS_FILE_PATH, TracingProviderEnum, @@ -141,6 +141,8 @@ provider_config_map = OpsTraceProviderConfigMap() class OpsTraceManager: ops_trace_instances_cache: LRUCache = LRUCache(maxsize=128) + decrypted_configs_cache: LRUCache = LRUCache(maxsize=128) + _decryption_cache_lock = threading.RLock() @classmethod def encrypt_tracing_config( @@ -161,7 +163,7 @@ class OpsTraceManager: provider_config_map[tracing_provider]["other_keys"], ) - new_config = {} + new_config: dict[str, Any] = {} # Encrypt necessary keys for key in secret_keys: if key in tracing_config: @@ -191,20 +193,41 @@ class OpsTraceManager: :param tracing_config: tracing config :return: """ - config_class, secret_keys, other_keys = ( - provider_config_map[tracing_provider]["config_class"], - provider_config_map[tracing_provider]["secret_keys"], - provider_config_map[tracing_provider]["other_keys"], + config_json = json.dumps(tracing_config, sort_keys=True) + decrypted_config_key = ( + tenant_id, + tracing_provider, + config_json, ) - new_config = {} - for key in secret_keys: - if key in tracing_config: - new_config[key] = decrypt_token(tenant_id, tracing_config[key]) - for key in other_keys: - new_config[key] = tracing_config.get(key, "") + # First check without lock for performance + cached_config = cls.decrypted_configs_cache.get(decrypted_config_key) + if cached_config is not None: + return dict(cached_config) - return config_class(**new_config).model_dump() + with cls._decryption_cache_lock: + # Second check (double-checked locking) to prevent race conditions + cached_config = cls.decrypted_configs_cache.get(decrypted_config_key) + if cached_config is not None: + return dict(cached_config) + + config_class, secret_keys, other_keys = ( + provider_config_map[tracing_provider]["config_class"], + provider_config_map[tracing_provider]["secret_keys"], + provider_config_map[tracing_provider]["other_keys"], + ) + new_config: dict[str, Any] = {} + keys_to_decrypt = [key for key in secret_keys if key in tracing_config] + if keys_to_decrypt: + decrypted_values = batch_decrypt_token(tenant_id, [tracing_config[key] for key in keys_to_decrypt]) + new_config.update(zip(keys_to_decrypt, decrypted_values)) + + for key in other_keys: + new_config[key] = tracing_config.get(key, "") + + decrypted_config = config_class(**new_config).model_dump() + cls.decrypted_configs_cache[decrypted_config_key] = decrypted_config + return dict(decrypted_config) @classmethod def obfuscated_decrypt_token(cls, tracing_provider: str, decrypt_tracing_config: dict): @@ -219,7 +242,7 @@ class OpsTraceManager: provider_config_map[tracing_provider]["secret_keys"], provider_config_map[tracing_provider]["other_keys"], ) - new_config = {} + new_config: dict[str, Any] = {} for key in secret_keys: if key in decrypt_tracing_config: new_config[key] = obfuscated_token(decrypt_tracing_config[key]) From 82890fe38e65c34ed8fef19c6f06553c262de401 Mon Sep 17 00:00:00 2001 From: Jyong <76649700+JohnJyong@users.noreply.github.com> Date: Wed, 29 Oct 2025 15:33:41 +0800 Subject: [PATCH 055/394] add uninstalled recommend tools detail (#27537) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- api/core/helper/marketplace.py | 12 ++++++++++++ api/services/rag_pipeline/rag_pipeline.py | 13 +++---------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/api/core/helper/marketplace.py b/api/core/helper/marketplace.py index bddb864a95..b2286d39ed 100644 --- a/api/core/helper/marketplace.py +++ b/api/core/helper/marketplace.py @@ -29,6 +29,18 @@ def batch_fetch_plugin_manifests(plugin_ids: list[str]) -> Sequence[MarketplaceP return [MarketplacePluginDeclaration.model_validate(plugin) for plugin in response.json()["data"]["plugins"]] +def batch_fetch_plugin_by_ids(plugin_ids: list[str]) -> list[dict]: + if not plugin_ids: + return [] + + url = str(marketplace_api_url / "api/v1/plugins/batch") + response = httpx.post(url, json={"plugin_ids": plugin_ids}, headers={"X-Dify-Version": dify_config.project.version}) + response.raise_for_status() + + data = response.json() + return data.get("data", {}).get("plugins", []) + + def batch_fetch_plugin_manifests_ignore_deserialization_error( plugin_ids: list[str], ) -> Sequence[MarketplacePluginDeclaration]: diff --git a/api/services/rag_pipeline/rag_pipeline.py b/api/services/rag_pipeline/rag_pipeline.py index 50dec458a9..fed7a25e21 100644 --- a/api/services/rag_pipeline/rag_pipeline.py +++ b/api/services/rag_pipeline/rag_pipeline.py @@ -1265,8 +1265,8 @@ class RagPipelineService: ) providers_map = {provider.plugin_id: provider.to_dict() for provider in providers} - plugin_manifests = marketplace.batch_fetch_plugin_manifests(plugin_ids) - plugin_manifests_map = {manifest.plugin_id: manifest for manifest in plugin_manifests} + plugin_manifests = marketplace.batch_fetch_plugin_by_ids(plugin_ids) + plugin_manifests_map = {manifest["plugin_id"]: manifest for manifest in plugin_manifests} installed_plugin_list = [] uninstalled_plugin_list = [] @@ -1276,14 +1276,7 @@ class RagPipelineService: else: plugin_manifest = plugin_manifests_map.get(plugin_id) if plugin_manifest: - uninstalled_plugin_list.append( - { - "plugin_id": plugin_id, - "name": plugin_manifest.name, - "icon": plugin_manifest.icon, - "plugin_unique_identifier": plugin_manifest.latest_package_identifier, - } - ) + uninstalled_plugin_list.append(plugin_manifest) # Build recommended plugins list return { From 61d8809a0f86080aef0a7f25e902e6679cb36094 Mon Sep 17 00:00:00 2001 From: zhsama Date: Wed, 29 Oct 2025 15:53:13 +0800 Subject: [PATCH 056/394] fix(workflow): enhance validation before running workflows by integrating warning notifications --- web/app/components/workflow/header/run-mode.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/web/app/components/workflow/header/run-mode.tsx b/web/app/components/workflow/header/run-mode.tsx index f715008c59..7a1d444d30 100644 --- a/web/app/components/workflow/header/run-mode.tsx +++ b/web/app/components/workflow/header/run-mode.tsx @@ -11,6 +11,7 @@ import { RiLoader2Line, RiPlayLargeLine } from '@remixicon/react' import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' import { useDynamicTestRunOptions } from '../hooks/use-dynamic-test-run-options' import TestRunMenu, { type TestRunMenuRef, type TriggerOption, TriggerType } from './test-run-menu' +import { useToastContext } from '@/app/components/base/toast' type RunModeProps = { text?: string @@ -28,7 +29,7 @@ const RunMode = ({ handleWorkflowRunAllTriggersInWorkflow, } = useWorkflowStartRun() const { handleStopRun } = useWorkflowRun() - const { validateBeforeRun } = useWorkflowRunValidation() + const { validateBeforeRun, warningNodes } = useWorkflowRunValidation() const workflowRunningData = useStore(s => s.workflowRunningData) const isListening = useStore(s => s.isListening) @@ -37,6 +38,7 @@ const RunMode = ({ const dynamicOptions = useDynamicTestRunOptions() const testRunMenuRef = useRef(null) + const { notify } = useToastContext() useEffect(() => { // @ts-expect-error - Dynamic property for backward compatibility with keyboard shortcuts @@ -55,8 +57,15 @@ const RunMode = ({ const handleTriggerSelect = useCallback((option: TriggerOption) => { // Validate checklist before running any workflow - if (!validateBeforeRun()) + let isValid: boolean = true + warningNodes.forEach((node) => { + if (node.id === option.nodeId) + isValid = false + }) + if (!isValid) { + notify({ type: 'error', message: t('workflow.panel.checklistTip') }) return + } if (option.type === TriggerType.UserInput) { handleWorkflowStartRunInWorkflow() From 1e9142c213b250505ee837b18b70b04163a1dc8c Mon Sep 17 00:00:00 2001 From: XlKsyt Date: Wed, 29 Oct 2025 15:53:30 +0800 Subject: [PATCH 057/394] feat: enhance tencent trace integration with LLM core metrics (#27126) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> --- .../advanced_chat/generate_task_pipeline.py | 52 ++-- api/core/app/entities/task_entities.py | 3 + .../model_runtime/entities/llm_entities.py | 10 + api/core/ops/entities/trace_entity.py | 3 + api/core/ops/ops_trace_manager.py | 23 ++ api/core/ops/tencent_trace/client.py | 184 +++++++++++++- .../{tencent_semconv.py => semconv.py} | 16 ++ api/core/ops/tencent_trace/span_builder.py | 61 +++-- api/core/ops/tencent_trace/tencent_trace.py | 227 +++++++++++++++++- api/core/workflow/nodes/llm/node.py | 39 ++- api/uv.lock | 2 +- .../public/tracing/tencent-icon-big.svg | 23 ++ .../assets/public/tracing/tencent-icon.svg | 23 ++ 13 files changed, 609 insertions(+), 57 deletions(-) rename api/core/ops/tencent_trace/entities/{tencent_semconv.py => semconv.py} (69%) create mode 100644 web/app/components/base/icons/assets/public/tracing/tencent-icon-big.svg create mode 100644 web/app/components/base/icons/assets/public/tracing/tencent-icon.svg diff --git a/api/core/app/apps/advanced_chat/generate_task_pipeline.py b/api/core/app/apps/advanced_chat/generate_task_pipeline.py index 8c0102d9bd..01c377956b 100644 --- a/api/core/app/apps/advanced_chat/generate_task_pipeline.py +++ b/api/core/app/apps/advanced_chat/generate_task_pipeline.py @@ -1,3 +1,4 @@ +import json import logging import re import time @@ -60,6 +61,7 @@ from core.app.task_pipeline.based_generate_task_pipeline import BasedGenerateTas from core.app.task_pipeline.message_cycle_manager import MessageCycleManager from core.base.tts import AppGeneratorTTSPublisher, AudioTrunk from core.model_runtime.entities.llm_entities import LLMUsage +from core.model_runtime.utils.encoders import jsonable_encoder from core.ops.ops_trace_manager import TraceQueueManager from core.workflow.enums import WorkflowExecutionStatus from core.workflow.nodes import NodeType @@ -391,6 +393,14 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport): if should_direct_answer: return + current_time = time.perf_counter() + if self._task_state.first_token_time is None and delta_text.strip(): + self._task_state.first_token_time = current_time + self._task_state.is_streaming_response = True + + if delta_text.strip(): + self._task_state.last_token_time = current_time + # Only publish tts message at text chunk streaming if tts_publisher and queue_message: tts_publisher.publish(queue_message) @@ -772,7 +782,33 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport): message.answer = answer_text message.updated_at = naive_utc_now() message.provider_response_latency = time.perf_counter() - self._base_task_pipeline.start_at - message.message_metadata = self._task_state.metadata.model_dump_json() + + # Set usage first before dumping metadata + if graph_runtime_state and graph_runtime_state.llm_usage: + usage = graph_runtime_state.llm_usage + message.message_tokens = usage.prompt_tokens + message.message_unit_price = usage.prompt_unit_price + message.message_price_unit = usage.prompt_price_unit + message.answer_tokens = usage.completion_tokens + message.answer_unit_price = usage.completion_unit_price + message.answer_price_unit = usage.completion_price_unit + message.total_price = usage.total_price + message.currency = usage.currency + self._task_state.metadata.usage = usage + else: + usage = LLMUsage.empty_usage() + self._task_state.metadata.usage = usage + + # Add streaming metrics to usage if available + if self._task_state.is_streaming_response and self._task_state.first_token_time: + start_time = self._base_task_pipeline.start_at + first_token_time = self._task_state.first_token_time + last_token_time = self._task_state.last_token_time or first_token_time + usage.time_to_first_token = round(first_token_time - start_time, 3) + usage.time_to_generate = round(last_token_time - first_token_time, 3) + + metadata = self._task_state.metadata.model_dump() + message.message_metadata = json.dumps(jsonable_encoder(metadata)) message_files = [ MessageFile( message_id=message.id, @@ -790,20 +826,6 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport): ] session.add_all(message_files) - if graph_runtime_state and graph_runtime_state.llm_usage: - usage = graph_runtime_state.llm_usage - message.message_tokens = usage.prompt_tokens - message.message_unit_price = usage.prompt_unit_price - message.message_price_unit = usage.prompt_price_unit - message.answer_tokens = usage.completion_tokens - message.answer_unit_price = usage.completion_unit_price - message.answer_price_unit = usage.completion_price_unit - message.total_price = usage.total_price - message.currency = usage.currency - self._task_state.metadata.usage = usage - else: - self._task_state.metadata.usage = LLMUsage.empty_usage() - def _seed_graph_runtime_state_from_queue_manager(self) -> None: """Bootstrap the cached runtime state from the queue manager when present.""" candidate = self._base_task_pipeline.queue_manager.graph_runtime_state diff --git a/api/core/app/entities/task_entities.py b/api/core/app/entities/task_entities.py index 72a92add04..79a5e657b3 100644 --- a/api/core/app/entities/task_entities.py +++ b/api/core/app/entities/task_entities.py @@ -48,6 +48,9 @@ class WorkflowTaskState(TaskState): """ answer: str = "" + first_token_time: float | None = None + last_token_time: float | None = None + is_streaming_response: bool = False class StreamEvent(StrEnum): diff --git a/api/core/model_runtime/entities/llm_entities.py b/api/core/model_runtime/entities/llm_entities.py index 17f6000d93..2c7c421eed 100644 --- a/api/core/model_runtime/entities/llm_entities.py +++ b/api/core/model_runtime/entities/llm_entities.py @@ -38,6 +38,8 @@ class LLMUsageMetadata(TypedDict, total=False): prompt_price: Union[float, str] completion_price: Union[float, str] latency: float + time_to_first_token: float + time_to_generate: float class LLMUsage(ModelUsage): @@ -57,6 +59,8 @@ class LLMUsage(ModelUsage): total_price: Decimal currency: str latency: float + time_to_first_token: float | None = None + time_to_generate: float | None = None @classmethod def empty_usage(cls): @@ -73,6 +77,8 @@ class LLMUsage(ModelUsage): total_price=Decimal("0.0"), currency="USD", latency=0.0, + time_to_first_token=None, + time_to_generate=None, ) @classmethod @@ -108,6 +114,8 @@ class LLMUsage(ModelUsage): prompt_price=Decimal(str(metadata.get("prompt_price", 0))), completion_price=Decimal(str(metadata.get("completion_price", 0))), latency=metadata.get("latency", 0.0), + time_to_first_token=metadata.get("time_to_first_token"), + time_to_generate=metadata.get("time_to_generate"), ) def plus(self, other: LLMUsage) -> LLMUsage: @@ -133,6 +141,8 @@ class LLMUsage(ModelUsage): total_price=self.total_price + other.total_price, currency=other.currency, latency=self.latency + other.latency, + time_to_first_token=other.time_to_first_token, + time_to_generate=other.time_to_generate, ) def __add__(self, other: LLMUsage) -> LLMUsage: diff --git a/api/core/ops/entities/trace_entity.py b/api/core/ops/entities/trace_entity.py index 5b81c09a2d..50a2cdea63 100644 --- a/api/core/ops/entities/trace_entity.py +++ b/api/core/ops/entities/trace_entity.py @@ -62,6 +62,9 @@ class MessageTraceInfo(BaseTraceInfo): file_list: Union[str, dict[str, Any], list] | None = None message_file_data: Any | None = None conversation_mode: str + gen_ai_server_time_to_first_token: float | None = None + llm_streaming_time_to_generate: float | None = None + is_streaming_request: bool = False class ModerationTraceInfo(BaseTraceInfo): diff --git a/api/core/ops/ops_trace_manager.py b/api/core/ops/ops_trace_manager.py index 6cc7859db7..5bb539b7dc 100644 --- a/api/core/ops/ops_trace_manager.py +++ b/api/core/ops/ops_trace_manager.py @@ -619,6 +619,8 @@ class TraceTask: file_url = f"{self.file_base_url}/{message_file_data.url}" if message_file_data else "" file_list.append(file_url) + streaming_metrics = self._extract_streaming_metrics(message_data) + metadata = { "conversation_id": message_data.conversation_id, "ls_provider": message_data.model_provider, @@ -651,6 +653,9 @@ class TraceTask: metadata=metadata, message_file_data=message_file_data, conversation_mode=conversation_mode, + gen_ai_server_time_to_first_token=streaming_metrics.get("gen_ai_server_time_to_first_token"), + llm_streaming_time_to_generate=streaming_metrics.get("llm_streaming_time_to_generate"), + is_streaming_request=streaming_metrics.get("is_streaming_request", False), ) return message_trace_info @@ -876,6 +881,24 @@ class TraceTask: return generate_name_trace_info + def _extract_streaming_metrics(self, message_data) -> dict: + if not message_data.message_metadata: + return {} + + try: + metadata = json.loads(message_data.message_metadata) + usage = metadata.get("usage", {}) + time_to_first_token = usage.get("time_to_first_token") + time_to_generate = usage.get("time_to_generate") + + return { + "gen_ai_server_time_to_first_token": time_to_first_token, + "llm_streaming_time_to_generate": time_to_generate, + "is_streaming_request": time_to_first_token is not None, + } + except (json.JSONDecodeError, AttributeError): + return {} + trace_manager_timer: threading.Timer | None = None trace_manager_queue: queue.Queue = queue.Queue() diff --git a/api/core/ops/tencent_trace/client.py b/api/core/ops/tencent_trace/client.py index 270732aa02..733d5b8bb6 100644 --- a/api/core/ops/tencent_trace/client.py +++ b/api/core/ops/tencent_trace/client.py @@ -11,6 +11,11 @@ import socket from typing import TYPE_CHECKING from urllib.parse import urlparse +try: + from importlib.metadata import version +except ImportError: + from importlib_metadata import version # type: ignore[import-not-found] + if TYPE_CHECKING: from opentelemetry.metrics import Meter from opentelemetry.metrics._internal.instrument import Histogram @@ -27,12 +32,27 @@ from opentelemetry.util.types import AttributeValue from configs import dify_config -from .entities.tencent_semconv import LLM_OPERATION_DURATION +from .entities.semconv import ( + GEN_AI_SERVER_TIME_TO_FIRST_TOKEN, + GEN_AI_STREAMING_TIME_TO_GENERATE, + GEN_AI_TOKEN_USAGE, + GEN_AI_TRACE_DURATION, + LLM_OPERATION_DURATION, +) from .entities.tencent_trace_entity import SpanData logger = logging.getLogger(__name__) +def _get_opentelemetry_sdk_version() -> str: + """Get OpenTelemetry SDK version dynamically.""" + try: + return version("opentelemetry-sdk") + except Exception: + logger.debug("Failed to get opentelemetry-sdk version, using default") + return "1.27.0" # fallback version + + class TencentTraceClient: """Tencent APM trace client using OpenTelemetry OTLP exporter""" @@ -57,6 +77,9 @@ class TencentTraceClient: ResourceAttributes.SERVICE_VERSION: f"dify-{dify_config.project.version}-{dify_config.COMMIT_SHA}", ResourceAttributes.DEPLOYMENT_ENVIRONMENT: f"{dify_config.DEPLOY_ENV}-{dify_config.EDITION}", ResourceAttributes.HOST_NAME: socket.gethostname(), + ResourceAttributes.TELEMETRY_SDK_LANGUAGE: "python", + ResourceAttributes.TELEMETRY_SDK_NAME: "opentelemetry", + ResourceAttributes.TELEMETRY_SDK_VERSION: _get_opentelemetry_sdk_version(), } ) # Prepare gRPC endpoint/metadata @@ -80,13 +103,18 @@ class TencentTraceClient: ) self.tracer_provider.add_span_processor(self.span_processor) - self.tracer = self.tracer_provider.get_tracer("dify.tencent_apm") + # use dify api version as tracer version + self.tracer = self.tracer_provider.get_tracer("dify-sdk", dify_config.project.version) # Store span contexts for parent-child relationships self.span_contexts: dict[int, trace_api.SpanContext] = {} self.meter: Meter | None = None self.hist_llm_duration: Histogram | None = None + self.hist_token_usage: Histogram | None = None + self.hist_time_to_first_token: Histogram | None = None + self.hist_time_to_generate: Histogram | None = None + self.hist_trace_duration: Histogram | None = None self.metric_reader: MetricReader | None = None # Metrics exporter and instruments @@ -99,7 +127,7 @@ class TencentTraceClient: use_http_protobuf = protocol in {"http/protobuf", "http-protobuf"} use_http_json = protocol in {"http/json", "http-json"} - # Set preferred temporality for histograms to DELTA + # Tencent APM works best with delta aggregation temporality preferred_temporality: dict[type, AggregationTemporality] = {Histogram: AggregationTemporality.DELTA} def _create_metric_exporter(exporter_cls, **kwargs): @@ -177,20 +205,59 @@ class TencentTraceClient: provider = MeterProvider(resource=self.resource, metric_readers=[metric_reader]) metrics.set_meter_provider(provider) self.meter = metrics.get_meter("dify-sdk", dify_config.project.version) + + # LLM operation duration histogram self.hist_llm_duration = self.meter.create_histogram( name=LLM_OPERATION_DURATION, unit="s", description="LLM operation duration (seconds)", ) + + # Token usage histogram with exponential buckets + self.hist_token_usage = self.meter.create_histogram( + name=GEN_AI_TOKEN_USAGE, + unit="token", + description="Number of tokens used in prompt and completions", + ) + + # Time to first token histogram + self.hist_time_to_first_token = self.meter.create_histogram( + name=GEN_AI_SERVER_TIME_TO_FIRST_TOKEN, + unit="s", + description="Time to first token for streaming LLM responses (seconds)", + ) + + # Time to generate histogram + self.hist_time_to_generate = self.meter.create_histogram( + name=GEN_AI_STREAMING_TIME_TO_GENERATE, + unit="s", + description="Total time to generate streaming LLM responses (seconds)", + ) + + # Trace duration histogram + self.hist_trace_duration = self.meter.create_histogram( + name=GEN_AI_TRACE_DURATION, + unit="s", + description="End-to-end GenAI trace duration (seconds)", + ) + self.metric_reader = metric_reader else: self.meter = None self.hist_llm_duration = None + self.hist_token_usage = None + self.hist_time_to_first_token = None + self.hist_time_to_generate = None + self.hist_trace_duration = None self.metric_reader = None except Exception: logger.exception("[Tencent APM] Metrics initialization failed; metrics disabled") self.meter = None self.hist_llm_duration = None + self.hist_token_usage = None + self.hist_time_to_first_token = None + self.hist_time_to_generate = None + self.hist_trace_duration = None self.metric_reader = None def add_span(self, span_data: SpanData) -> None: @@ -216,6 +283,117 @@ class TencentTraceClient: except Exception: logger.debug("[Tencent APM] Failed to record LLM duration", exc_info=True) + def record_token_usage( + self, + token_count: int, + token_type: str, + operation_name: str, + request_model: str, + response_model: str, + server_address: str, + provider: str, + ) -> None: + """Record token usage histogram. + + Args: + token_count: Number of tokens used + token_type: "input" or "output" + operation_name: Operation name (e.g., "chat") + request_model: Model used in request + response_model: Model used in response + server_address: Server address + provider: Model provider name + """ + try: + if not hasattr(self, "hist_token_usage") or self.hist_token_usage is None: + return + + attributes = { + "gen_ai.operation.name": operation_name, + "gen_ai.request.model": request_model, + "gen_ai.response.model": response_model, + "gen_ai.system": provider, + "gen_ai.token.type": token_type, + "server.address": server_address, + } + + self.hist_token_usage.record(token_count, attributes) # type: ignore[attr-defined] + except Exception: + logger.debug("[Tencent APM] Failed to record token usage", exc_info=True) + + def record_time_to_first_token( + self, ttft_seconds: float, provider: str, model: str, operation_name: str = "chat" + ) -> None: + """Record time to first token histogram. + + Args: + ttft_seconds: Time to first token in seconds + provider: Model provider name + model: Model name + operation_name: Operation name (default: "chat") + """ + try: + if not hasattr(self, "hist_time_to_first_token") or self.hist_time_to_first_token is None: + return + + attributes = { + "gen_ai.operation.name": operation_name, + "gen_ai.system": provider, + "gen_ai.request.model": model, + "gen_ai.response.model": model, + "stream": "true", + } + + self.hist_time_to_first_token.record(ttft_seconds, attributes) # type: ignore[attr-defined] + except Exception: + logger.debug("[Tencent APM] Failed to record time to first token", exc_info=True) + + def record_time_to_generate( + self, ttg_seconds: float, provider: str, model: str, operation_name: str = "chat" + ) -> None: + """Record time to generate histogram. + + Args: + ttg_seconds: Time to generate in seconds + provider: Model provider name + model: Model name + operation_name: Operation name (default: "chat") + """ + try: + if not hasattr(self, "hist_time_to_generate") or self.hist_time_to_generate is None: + return + + attributes = { + "gen_ai.operation.name": operation_name, + "gen_ai.system": provider, + "gen_ai.request.model": model, + "gen_ai.response.model": model, + "stream": "true", + } + + self.hist_time_to_generate.record(ttg_seconds, attributes) # type: ignore[attr-defined] + except Exception: + logger.debug("[Tencent APM] Failed to record time to generate", exc_info=True) + + def record_trace_duration(self, duration_seconds: float, attributes: dict[str, str] | None = None) -> None: + """Record end-to-end trace duration histogram in seconds. + + Args: + duration_seconds: Trace duration in seconds + attributes: Optional attributes (e.g., conversation_mode, app_id) + """ + try: + if not hasattr(self, "hist_trace_duration") or self.hist_trace_duration is None: + return + + attrs: dict[str, str] = {} + if attributes: + for k, v in attributes.items(): + attrs[k] = str(v) if not isinstance(v, (str, int, float, bool)) else v # type: ignore[assignment] + self.hist_trace_duration.record(duration_seconds, attrs) # type: ignore[attr-defined] + except Exception: + logger.debug("[Tencent APM] Failed to record trace duration", exc_info=True) + def _create_and_export_span(self, span_data: SpanData) -> None: """Create span using OpenTelemetry Tracer API""" try: diff --git a/api/core/ops/tencent_trace/entities/tencent_semconv.py b/api/core/ops/tencent_trace/entities/semconv.py similarity index 69% rename from api/core/ops/tencent_trace/entities/tencent_semconv.py rename to api/core/ops/tencent_trace/entities/semconv.py index 5ea6eeacef..cd2dbade8b 100644 --- a/api/core/ops/tencent_trace/entities/tencent_semconv.py +++ b/api/core/ops/tencent_trace/entities/semconv.py @@ -47,6 +47,9 @@ GEN_AI_COMPLETION = "gen_ai.completion" GEN_AI_RESPONSE_FINISH_REASON = "gen_ai.response.finish_reason" +# Streaming Span Attributes +GEN_AI_IS_STREAMING_REQUEST = "llm.is_streaming" # Same as OpenLLMetry semconv + # Tool TOOL_NAME = "tool.name" @@ -62,6 +65,19 @@ INSTRUMENTATION_LANGUAGE = "python" # Metrics LLM_OPERATION_DURATION = "gen_ai.client.operation.duration" +GEN_AI_TOKEN_USAGE = "gen_ai.client.token.usage" +GEN_AI_SERVER_TIME_TO_FIRST_TOKEN = "gen_ai.server.time_to_first_token" +GEN_AI_STREAMING_TIME_TO_GENERATE = "gen_ai.streaming.time_to_generate" +# The LLM trace duration which is exclusive to tencent apm +GEN_AI_TRACE_DURATION = "gen_ai.trace.duration" + +# Token Usage Attributes +GEN_AI_OPERATION_NAME = "gen_ai.operation.name" +GEN_AI_REQUEST_MODEL = "gen_ai.request.model" +GEN_AI_RESPONSE_MODEL = "gen_ai.response.model" +GEN_AI_SYSTEM = "gen_ai.system" +GEN_AI_TOKEN_TYPE = "gen_ai.token.type" +SERVER_ADDRESS = "server.address" class GenAISpanKind(Enum): diff --git a/api/core/ops/tencent_trace/span_builder.py b/api/core/ops/tencent_trace/span_builder.py index 5ba592290d..26e8779e3e 100644 --- a/api/core/ops/tencent_trace/span_builder.py +++ b/api/core/ops/tencent_trace/span_builder.py @@ -14,10 +14,11 @@ from core.ops.entities.trace_entity import ( ToolTraceInfo, WorkflowTraceInfo, ) -from core.ops.tencent_trace.entities.tencent_semconv import ( +from core.ops.tencent_trace.entities.semconv import ( GEN_AI_COMPLETION, GEN_AI_FRAMEWORK, GEN_AI_IS_ENTRY, + GEN_AI_IS_STREAMING_REQUEST, GEN_AI_MODEL_NAME, GEN_AI_PROMPT, GEN_AI_PROVIDER, @@ -156,6 +157,25 @@ class TencentSpanBuilder: outputs = node_execution.outputs or {} usage_data = process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {}) + attributes = { + GEN_AI_SESSION_ID: trace_info.metadata.get("conversation_id", ""), + GEN_AI_SPAN_KIND: GenAISpanKind.GENERATION.value, + GEN_AI_FRAMEWORK: "dify", + GEN_AI_MODEL_NAME: process_data.get("model_name", ""), + GEN_AI_PROVIDER: process_data.get("model_provider", ""), + GEN_AI_USAGE_INPUT_TOKENS: str(usage_data.get("prompt_tokens", 0)), + GEN_AI_USAGE_OUTPUT_TOKENS: str(usage_data.get("completion_tokens", 0)), + GEN_AI_USAGE_TOTAL_TOKENS: str(usage_data.get("total_tokens", 0)), + GEN_AI_PROMPT: json.dumps(process_data.get("prompts", []), ensure_ascii=False), + GEN_AI_COMPLETION: str(outputs.get("text", "")), + GEN_AI_RESPONSE_FINISH_REASON: outputs.get("finish_reason", ""), + INPUT_VALUE: json.dumps(process_data.get("prompts", []), ensure_ascii=False), + OUTPUT_VALUE: str(outputs.get("text", "")), + } + + if usage_data.get("time_to_first_token") is not None: + attributes[GEN_AI_IS_STREAMING_REQUEST] = "true" + return SpanData( trace_id=trace_id, parent_span_id=workflow_span_id, @@ -163,21 +183,7 @@ class TencentSpanBuilder: name="GENERATION", start_time=TencentSpanBuilder._get_time_nanoseconds(node_execution.created_at), end_time=TencentSpanBuilder._get_time_nanoseconds(node_execution.finished_at), - attributes={ - GEN_AI_SESSION_ID: trace_info.metadata.get("conversation_id", ""), - GEN_AI_SPAN_KIND: GenAISpanKind.GENERATION.value, - GEN_AI_FRAMEWORK: "dify", - GEN_AI_MODEL_NAME: process_data.get("model_name", ""), - GEN_AI_PROVIDER: process_data.get("model_provider", ""), - GEN_AI_USAGE_INPUT_TOKENS: str(usage_data.get("prompt_tokens", 0)), - GEN_AI_USAGE_OUTPUT_TOKENS: str(usage_data.get("completion_tokens", 0)), - GEN_AI_USAGE_TOTAL_TOKENS: str(usage_data.get("total_tokens", 0)), - GEN_AI_PROMPT: json.dumps(process_data.get("prompts", []), ensure_ascii=False), - GEN_AI_COMPLETION: str(outputs.get("text", "")), - GEN_AI_RESPONSE_FINISH_REASON: outputs.get("finish_reason", ""), - INPUT_VALUE: json.dumps(process_data.get("prompts", []), ensure_ascii=False), - OUTPUT_VALUE: str(outputs.get("text", "")), - }, + attributes=attributes, status=TencentSpanBuilder._get_workflow_node_status(node_execution), ) @@ -191,6 +197,19 @@ class TencentSpanBuilder: if trace_info.error: status = Status(StatusCode.ERROR, trace_info.error) + attributes = { + GEN_AI_SESSION_ID: trace_info.metadata.get("conversation_id", ""), + GEN_AI_USER_ID: str(user_id), + GEN_AI_SPAN_KIND: GenAISpanKind.WORKFLOW.value, + GEN_AI_FRAMEWORK: "dify", + GEN_AI_IS_ENTRY: "true", + INPUT_VALUE: str(trace_info.inputs or ""), + OUTPUT_VALUE: str(trace_info.outputs or ""), + } + + if trace_info.is_streaming_request: + attributes[GEN_AI_IS_STREAMING_REQUEST] = "true" + return SpanData( trace_id=trace_id, parent_span_id=None, @@ -198,15 +217,7 @@ class TencentSpanBuilder: name="message", start_time=TencentSpanBuilder._get_time_nanoseconds(trace_info.start_time), end_time=TencentSpanBuilder._get_time_nanoseconds(trace_info.end_time), - attributes={ - GEN_AI_SESSION_ID: trace_info.metadata.get("conversation_id", ""), - GEN_AI_USER_ID: str(user_id), - GEN_AI_SPAN_KIND: GenAISpanKind.WORKFLOW.value, - GEN_AI_FRAMEWORK: "dify", - GEN_AI_IS_ENTRY: "true", - INPUT_VALUE: str(trace_info.inputs or ""), - OUTPUT_VALUE: str(trace_info.outputs or ""), - }, + attributes=attributes, status=status, links=links, ) diff --git a/api/core/ops/tencent_trace/tencent_trace.py b/api/core/ops/tencent_trace/tencent_trace.py index 5ef1c61b24..9b3df86e16 100644 --- a/api/core/ops/tencent_trace/tencent_trace.py +++ b/api/core/ops/tencent_trace/tencent_trace.py @@ -90,6 +90,9 @@ class TencentDataTrace(BaseTraceInstance): self._process_workflow_nodes(trace_info, trace_id) + # Record trace duration for entry span + self._record_workflow_trace_duration(trace_info) + except Exception: logger.exception("[Tencent APM] Failed to process workflow trace") @@ -107,6 +110,11 @@ class TencentDataTrace(BaseTraceInstance): self.trace_client.add_span(message_span) + self._record_message_llm_metrics(trace_info) + + # Record trace duration for entry span + self._record_message_trace_duration(trace_info) + except Exception: logger.exception("[Tencent APM] Failed to process message trace") @@ -290,24 +298,219 @@ class TencentDataTrace(BaseTraceInstance): def _record_llm_metrics(self, node_execution: WorkflowNodeExecution) -> None: """Record LLM performance metrics""" try: - if not hasattr(self.trace_client, "record_llm_duration"): - return - process_data = node_execution.process_data or {} - usage = process_data.get("usage", {}) - latency_s = float(usage.get("latency", 0.0)) + outputs = node_execution.outputs or {} + usage = process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {}) - if latency_s > 0: - attributes = { - "provider": process_data.get("model_provider", ""), - "model": process_data.get("model_name", ""), - "span_kind": "GENERATION", - } - self.trace_client.record_llm_duration(latency_s, attributes) + model_provider = process_data.get("model_provider", "unknown") + model_name = process_data.get("model_name", "unknown") + model_mode = process_data.get("model_mode", "chat") + + # Record LLM duration + if hasattr(self.trace_client, "record_llm_duration"): + latency_s = float(usage.get("latency", 0.0)) + + if latency_s > 0: + # Determine if streaming from usage metrics + is_streaming = usage.get("time_to_first_token") is not None + + attributes = { + "gen_ai.system": model_provider, + "gen_ai.response.model": model_name, + "gen_ai.operation.name": model_mode, + "stream": "true" if is_streaming else "false", + } + self.trace_client.record_llm_duration(latency_s, attributes) + + # Record streaming metrics from usage + time_to_first_token = usage.get("time_to_first_token") + if time_to_first_token is not None and hasattr(self.trace_client, "record_time_to_first_token"): + ttft_seconds = float(time_to_first_token) + if ttft_seconds > 0: + self.trace_client.record_time_to_first_token( + ttft_seconds=ttft_seconds, provider=model_provider, model=model_name, operation_name=model_mode + ) + + time_to_generate = usage.get("time_to_generate") + if time_to_generate is not None and hasattr(self.trace_client, "record_time_to_generate"): + ttg_seconds = float(time_to_generate) + if ttg_seconds > 0: + self.trace_client.record_time_to_generate( + ttg_seconds=ttg_seconds, provider=model_provider, model=model_name, operation_name=model_mode + ) + + # Record token usage + if hasattr(self.trace_client, "record_token_usage"): + # Extract token counts + input_tokens = int(usage.get("prompt_tokens", 0)) + output_tokens = int(usage.get("completion_tokens", 0)) + + if input_tokens > 0 or output_tokens > 0: + server_address = f"{model_provider}" + + # Record input tokens + if input_tokens > 0: + self.trace_client.record_token_usage( + token_count=input_tokens, + token_type="input", + operation_name=model_mode, + request_model=model_name, + response_model=model_name, + server_address=server_address, + provider=model_provider, + ) + + # Record output tokens + if output_tokens > 0: + self.trace_client.record_token_usage( + token_count=output_tokens, + token_type="output", + operation_name=model_mode, + request_model=model_name, + response_model=model_name, + server_address=server_address, + provider=model_provider, + ) except Exception: logger.debug("[Tencent APM] Failed to record LLM metrics") + def _record_message_llm_metrics(self, trace_info: MessageTraceInfo) -> None: + """Record LLM metrics for message traces""" + try: + trace_metadata = trace_info.metadata or {} + message_data = trace_info.message_data or {} + provider_latency = 0.0 + if isinstance(message_data, dict): + provider_latency = float(message_data.get("provider_response_latency", 0.0) or 0.0) + else: + provider_latency = float(getattr(message_data, "provider_response_latency", 0.0) or 0.0) + + model_provider = trace_metadata.get("ls_provider") or ( + message_data.get("model_provider", "") if isinstance(message_data, dict) else "" + ) + model_name = trace_metadata.get("ls_model_name") or ( + message_data.get("model_id", "") if isinstance(message_data, dict) else "" + ) + + # Record LLM duration + if provider_latency > 0 and hasattr(self.trace_client, "record_llm_duration"): + is_streaming = trace_info.is_streaming_request + + duration_attributes = { + "gen_ai.system": model_provider, + "gen_ai.response.model": model_name, + "gen_ai.operation.name": "chat", # Message traces are always chat + "stream": "true" if is_streaming else "false", + } + self.trace_client.record_llm_duration(provider_latency, duration_attributes) + + # Record streaming metrics for message traces + if trace_info.is_streaming_request: + # Record time to first token + if trace_info.gen_ai_server_time_to_first_token is not None and hasattr( + self.trace_client, "record_time_to_first_token" + ): + ttft_seconds = float(trace_info.gen_ai_server_time_to_first_token) + if ttft_seconds > 0: + self.trace_client.record_time_to_first_token( + ttft_seconds=ttft_seconds, provider=str(model_provider or ""), model=str(model_name or "") + ) + + # Record time to generate + if trace_info.llm_streaming_time_to_generate is not None and hasattr( + self.trace_client, "record_time_to_generate" + ): + ttg_seconds = float(trace_info.llm_streaming_time_to_generate) + if ttg_seconds > 0: + self.trace_client.record_time_to_generate( + ttg_seconds=ttg_seconds, provider=str(model_provider or ""), model=str(model_name or "") + ) + + # Record token usage + if hasattr(self.trace_client, "record_token_usage"): + input_tokens = int(trace_info.message_tokens or 0) + output_tokens = int(trace_info.answer_tokens or 0) + + if input_tokens > 0: + self.trace_client.record_token_usage( + token_count=input_tokens, + token_type="input", + operation_name="chat", + request_model=str(model_name or ""), + response_model=str(model_name or ""), + server_address=str(model_provider or ""), + provider=str(model_provider or ""), + ) + + if output_tokens > 0: + self.trace_client.record_token_usage( + token_count=output_tokens, + token_type="output", + operation_name="chat", + request_model=str(model_name or ""), + response_model=str(model_name or ""), + server_address=str(model_provider or ""), + provider=str(model_provider or ""), + ) + + except Exception: + logger.debug("[Tencent APM] Failed to record message LLM metrics") + + def _record_workflow_trace_duration(self, trace_info: WorkflowTraceInfo) -> None: + """Record end-to-end workflow trace duration.""" + try: + if not hasattr(self.trace_client, "record_trace_duration"): + return + + # Calculate duration from start_time and end_time to match span duration + if trace_info.start_time and trace_info.end_time: + duration_s = (trace_info.end_time - trace_info.start_time).total_seconds() + else: + # Fallback to workflow_run_elapsed_time if timestamps not available + duration_s = float(trace_info.workflow_run_elapsed_time) + + if duration_s > 0: + attributes = { + "conversation_mode": "workflow", + "workflow_status": trace_info.workflow_run_status, + } + + # Add conversation_id if available + if trace_info.conversation_id: + attributes["has_conversation"] = "true" + else: + attributes["has_conversation"] = "false" + + self.trace_client.record_trace_duration(duration_s, attributes) + + except Exception: + logger.debug("[Tencent APM] Failed to record workflow trace duration") + + def _record_message_trace_duration(self, trace_info: MessageTraceInfo) -> None: + """Record end-to-end message trace duration.""" + try: + if not hasattr(self.trace_client, "record_trace_duration"): + return + + # Calculate duration from start_time and end_time + if trace_info.start_time and trace_info.end_time: + duration = (trace_info.end_time - trace_info.start_time).total_seconds() + + if duration > 0: + attributes = { + "conversation_mode": trace_info.conversation_mode, + } + + # Add streaming flag if available + if hasattr(trace_info, "is_streaming_request"): + attributes["stream"] = "true" if trace_info.is_streaming_request else "false" + + self.trace_client.record_trace_duration(duration, attributes) + + except Exception: + logger.debug("[Tencent APM] Failed to record message trace duration") + def __del__(self): """Ensure proper cleanup on garbage collection.""" try: diff --git a/api/core/workflow/nodes/llm/node.py b/api/core/workflow/nodes/llm/node.py index 1644f683bf..06c9beaed2 100644 --- a/api/core/workflow/nodes/llm/node.py +++ b/api/core/workflow/nodes/llm/node.py @@ -3,6 +3,7 @@ import io import json import logging import re +import time from collections.abc import Generator, Mapping, Sequence from typing import TYPE_CHECKING, Any, Literal @@ -384,6 +385,8 @@ class LLMNode(Node): output_schema = LLMNode.fetch_structured_output_schema( structured_output=structured_output or {}, ) + request_start_time = time.perf_counter() + invoke_result = invoke_llm_with_structured_output( provider=model_instance.provider, model_schema=model_schema, @@ -396,6 +399,8 @@ class LLMNode(Node): user=user_id, ) else: + request_start_time = time.perf_counter() + invoke_result = model_instance.invoke_llm( prompt_messages=list(prompt_messages), model_parameters=node_data_model.completion_params, @@ -411,6 +416,7 @@ class LLMNode(Node): node_id=node_id, node_type=node_type, reasoning_format=reasoning_format, + request_start_time=request_start_time, ) @staticmethod @@ -422,14 +428,20 @@ class LLMNode(Node): node_id: str, node_type: NodeType, reasoning_format: Literal["separated", "tagged"] = "tagged", + request_start_time: float | None = None, ) -> Generator[NodeEventBase | LLMStructuredOutput, None, None]: # For blocking mode if isinstance(invoke_result, LLMResult): + duration = None + if request_start_time is not None: + duration = time.perf_counter() - request_start_time + invoke_result.usage.latency = round(duration, 3) event = LLMNode.handle_blocking_result( invoke_result=invoke_result, saver=file_saver, file_outputs=file_outputs, reasoning_format=reasoning_format, + request_latency=duration, ) yield event return @@ -441,6 +453,12 @@ class LLMNode(Node): usage = LLMUsage.empty_usage() finish_reason = None full_text_buffer = io.StringIO() + + # Initialize streaming metrics tracking + start_time = request_start_time if request_start_time is not None else time.perf_counter() + first_token_time = None + has_content = False + collected_structured_output = None # Collect structured_output from streaming chunks # Consume the invoke result and handle generator exception try: @@ -457,6 +475,11 @@ class LLMNode(Node): file_saver=file_saver, file_outputs=file_outputs, ): + # Detect first token for TTFT calculation + if text_part and not has_content: + first_token_time = time.perf_counter() + has_content = True + full_text_buffer.write(text_part) yield StreamChunkEvent( selector=[node_id, "text"], @@ -489,6 +512,16 @@ class LLMNode(Node): # Extract clean text and reasoning from tags clean_text, reasoning_content = LLMNode._split_reasoning(full_text, reasoning_format) + # Calculate streaming metrics + end_time = time.perf_counter() + total_duration = end_time - start_time + usage.latency = round(total_duration, 3) + if has_content and first_token_time: + gen_ai_server_time_to_first_token = first_token_time - start_time + llm_streaming_time_to_generate = end_time - first_token_time + usage.time_to_first_token = round(gen_ai_server_time_to_first_token, 3) + usage.time_to_generate = round(llm_streaming_time_to_generate, 3) + yield ModelInvokeCompletedEvent( # Use clean_text for separated mode, full_text for tagged mode text=clean_text if reasoning_format == "separated" else full_text, @@ -1068,6 +1101,7 @@ class LLMNode(Node): saver: LLMFileSaver, file_outputs: list["File"], reasoning_format: Literal["separated", "tagged"] = "tagged", + request_latency: float | None = None, ) -> ModelInvokeCompletedEvent: buffer = io.StringIO() for text_part in LLMNode._save_multimodal_output_and_convert_result_to_markdown( @@ -1088,7 +1122,7 @@ class LLMNode(Node): # Extract clean text and reasoning from tags clean_text, reasoning_content = LLMNode._split_reasoning(full_text, reasoning_format) - return ModelInvokeCompletedEvent( + event = ModelInvokeCompletedEvent( # Use clean_text for separated mode, full_text for tagged mode text=clean_text if reasoning_format == "separated" else full_text, usage=invoke_result.usage, @@ -1098,6 +1132,9 @@ class LLMNode(Node): # Pass structured output if enabled structured_output=getattr(invoke_result, "structured_output", None), ) + if request_latency is not None: + event.usage.latency = round(request_latency, 3) + return event @staticmethod def save_multimodal_image_output( diff --git a/api/uv.lock b/api/uv.lock index 7cf1e047de..9bbe392207 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.11, <3.13" resolution-markers = [ "python_full_version >= '3.12.4' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'", diff --git a/web/app/components/base/icons/assets/public/tracing/tencent-icon-big.svg b/web/app/components/base/icons/assets/public/tracing/tencent-icon-big.svg new file mode 100644 index 0000000000..b38316f3b6 --- /dev/null +++ b/web/app/components/base/icons/assets/public/tracing/tencent-icon-big.svg @@ -0,0 +1,23 @@ + + + logo + + + + diff --git a/web/app/components/base/icons/assets/public/tracing/tencent-icon.svg b/web/app/components/base/icons/assets/public/tracing/tencent-icon.svg new file mode 100644 index 0000000000..53347bf23c --- /dev/null +++ b/web/app/components/base/icons/assets/public/tracing/tencent-icon.svg @@ -0,0 +1,23 @@ + + + logo + + + + \ No newline at end of file From bc3421add8630b61d99bdf245fd3e7b75784a639 Mon Sep 17 00:00:00 2001 From: zhsama Date: Wed, 29 Oct 2025 15:53:37 +0800 Subject: [PATCH 058/394] refactor(variable): update global variable names and types for consistency --- web/app/components/workflow/constants.ts | 4 ++-- .../components/workflow/panel/global-variable-panel/index.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/app/components/workflow/constants.ts b/web/app/components/workflow/constants.ts index 83ebcf0029..79f5816e60 100644 --- a/web/app/components/workflow/constants.ts +++ b/web/app/components/workflow/constants.ts @@ -70,8 +70,8 @@ export const getGlobalVars = (isChatMode: boolean): Var[] => { }, ...((isInWorkflow && !isChatMode) ? [ { - variable: 'sys.trigger_timestamp', - type: VarType.string, + variable: 'sys.timestamp', + type: VarType.integer, }, ] : []), ] diff --git a/web/app/components/workflow/panel/global-variable-panel/index.tsx b/web/app/components/workflow/panel/global-variable-panel/index.tsx index bcad9b52ec..6a455cf571 100644 --- a/web/app/components/workflow/panel/global-variable-panel/index.tsx +++ b/web/app/components/workflow/panel/global-variable-panel/index.tsx @@ -51,8 +51,8 @@ const Panel = () => { }, // is workflow ...((isWorkflowPage && !isChatMode) ? [{ - name: 'trigger_timestamp', - value_type: 'string' as const, + name: 'timestamp', + value_type: 'integer' as const, description: t('workflow.globalVar.fieldsDescription.triggerTimestamp'), }] : []), ] From db2c6678e40c645e0fc0fa1e07d98f90963cc7c1 Mon Sep 17 00:00:00 2001 From: yessenia Date: Wed, 29 Oct 2025 14:35:12 +0800 Subject: [PATCH 059/394] fix(trigger): show subscription url & add readme in trigger plugin node --- .../components/base/markdown-blocks/img.tsx | 43 +----------- .../components/base/markdown-blocks/index.ts | 4 +- .../base/markdown-blocks/paragraph.tsx | 68 ++++-------------- .../base/markdown-blocks/plugin-img.tsx | 48 +++++++++++++ .../base/markdown-blocks/plugin-paragraph.tsx | 69 +++++++++++++++++++ .../base/markdown/react-markdown-wrapper.tsx | 34 ++++----- .../subscription-list/subscription-card.tsx | 17 ++++- .../components/plugins/readme-panel/index.tsx | 56 +++++++-------- .../_base/components/workflow-panel/index.tsx | 24 ++++++- web/service/demo/index.tsx | 4 +- 10 files changed, 208 insertions(+), 159 deletions(-) create mode 100644 web/app/components/base/markdown-blocks/plugin-img.tsx create mode 100644 web/app/components/base/markdown-blocks/plugin-paragraph.tsx diff --git a/web/app/components/base/markdown-blocks/img.tsx b/web/app/components/base/markdown-blocks/img.tsx index fe20bad6b1..33fce13f0b 100644 --- a/web/app/components/base/markdown-blocks/img.tsx +++ b/web/app/components/base/markdown-blocks/img.tsx @@ -3,48 +3,11 @@ * Extracted from the main markdown renderer for modularity. * Uses the ImageGallery component to display images. */ -import React, { useEffect, useMemo, useState } from 'react' +import React from 'react' import ImageGallery from '@/app/components/base/image-gallery' -import { getMarkdownImageURL } from './utils' -import { usePluginReadmeAsset } from '@/service/use-plugins' -import type { SimplePluginInfo } from '../markdown/react-markdown-wrapper' -type ImgProps = { - src: string - pluginInfo?: SimplePluginInfo -} - -const Img: React.FC = ({ src, pluginInfo }) => { - const { plugin_unique_identifier, plugin_id } = pluginInfo || {} - const { data: assetData } = usePluginReadmeAsset({ plugin_unique_identifier, file_name: src }) - const [blobUrl, setBlobUrl] = useState() - - useEffect(() => { - if (!assetData) { - setBlobUrl(undefined) - return - } - - const objectUrl = URL.createObjectURL(assetData) - setBlobUrl(objectUrl) - - return () => { - URL.revokeObjectURL(objectUrl) - } - }, [assetData]) - - const imageUrl = useMemo(() => { - if (blobUrl) - return blobUrl - - return getMarkdownImageURL(src, plugin_id) - }, [blobUrl, plugin_id, src]) - - return ( -
- -
- ) +const Img = ({ src }: any) => { + return
} export default Img diff --git a/web/app/components/base/markdown-blocks/index.ts b/web/app/components/base/markdown-blocks/index.ts index ba68b4e8b1..ab6be2e9e7 100644 --- a/web/app/components/base/markdown-blocks/index.ts +++ b/web/app/components/base/markdown-blocks/index.ts @@ -5,9 +5,11 @@ export { default as AudioBlock } from './audio-block' export { default as CodeBlock } from './code-block' +export * from './plugin-img' +export * from './plugin-paragraph' export { default as Img } from './img' -export { default as Link } from './link' export { default as Paragraph } from './paragraph' +export { default as Link } from './link' export { default as PreCode } from './pre-code' export { default as ScriptBlock } from './script-block' export { default as VideoBlock } from './video-block' diff --git a/web/app/components/base/markdown-blocks/paragraph.tsx b/web/app/components/base/markdown-blocks/paragraph.tsx index cb654118fd..fb1612477a 100644 --- a/web/app/components/base/markdown-blocks/paragraph.tsx +++ b/web/app/components/base/markdown-blocks/paragraph.tsx @@ -3,69 +3,25 @@ * Extracted from the main markdown renderer for modularity. * Handles special rendering for paragraphs that directly contain an image. */ -import React, { useEffect, useMemo, useState } from 'react' +import React from 'react' import ImageGallery from '@/app/components/base/image-gallery' -import { getMarkdownImageURL } from './utils' -import { usePluginReadmeAsset } from '@/service/use-plugins' -import type { SimplePluginInfo } from '../markdown/react-markdown-wrapper' - -type ParagraphProps = { - pluginInfo?: SimplePluginInfo - node?: any - children?: React.ReactNode -} - -const Paragraph: React.FC = ({ pluginInfo, node, children }) => { - const { plugin_unique_identifier, plugin_id } = pluginInfo || {} - const childrenNode = node?.children as Array | undefined - const firstChild = childrenNode?.[0] - const isImageParagraph = firstChild?.tagName === 'img' - const imageSrc = isImageParagraph ? firstChild?.properties?.src : undefined - - const { data: assetData } = usePluginReadmeAsset({ - plugin_unique_identifier, - file_name: isImageParagraph && imageSrc ? imageSrc : '', - }) - - const [blobUrl, setBlobUrl] = useState() - - useEffect(() => { - if (!assetData) { - setBlobUrl(undefined) - return - } - - const objectUrl = URL.createObjectURL(assetData) - setBlobUrl(objectUrl) - - return () => { - URL.revokeObjectURL(objectUrl) - } - }, [assetData]) - - const imageUrl = useMemo(() => { - if (blobUrl) - return blobUrl - - if (isImageParagraph && imageSrc) - return getMarkdownImageURL(imageSrc, plugin_id) - - return '' - }, [blobUrl, imageSrc, isImageParagraph, plugin_id]) - - if (isImageParagraph) { - const remainingChildren = Array.isArray(children) && children.length > 1 ? children.slice(1) : undefined +const Paragraph = (paragraph: any) => { + const { node }: any = paragraph + const children_node = node.children + if (children_node && children_node[0] && 'tagName' in children_node[0] && children_node[0].tagName === 'img') { return (
- - {remainingChildren && ( -
{remainingChildren}
- )} + + { + Array.isArray(paragraph.children) && paragraph.children.length > 1 && ( +
{paragraph.children.slice(1)}
+ ) + }
) } - return

{children}

+ return

{paragraph.children}

} export default Paragraph diff --git a/web/app/components/base/markdown-blocks/plugin-img.tsx b/web/app/components/base/markdown-blocks/plugin-img.tsx new file mode 100644 index 0000000000..ed1ee8fa0b --- /dev/null +++ b/web/app/components/base/markdown-blocks/plugin-img.tsx @@ -0,0 +1,48 @@ +/** + * @fileoverview Img component for rendering tags in Markdown. + * Extracted from the main markdown renderer for modularity. + * Uses the ImageGallery component to display images. + */ +import React, { useEffect, useMemo, useState } from 'react' +import ImageGallery from '@/app/components/base/image-gallery' +import { getMarkdownImageURL } from './utils' +import { usePluginReadmeAsset } from '@/service/use-plugins' +import type { SimplePluginInfo } from '../markdown/react-markdown-wrapper' + +type ImgProps = { + src: string + pluginInfo?: SimplePluginInfo +} + +export const PluginImg: React.FC = ({ src, pluginInfo }) => { + const { pluginUniqueIdentifier, pluginId } = pluginInfo || {} + const { data: assetData } = usePluginReadmeAsset({ plugin_unique_identifier: pluginUniqueIdentifier, file_name: src }) + const [blobUrl, setBlobUrl] = useState() + + useEffect(() => { + if (!assetData) { + setBlobUrl(undefined) + return + } + + const objectUrl = URL.createObjectURL(assetData) + setBlobUrl(objectUrl) + + return () => { + URL.revokeObjectURL(objectUrl) + } + }, [assetData]) + + const imageUrl = useMemo(() => { + if (blobUrl) + return blobUrl + + return getMarkdownImageURL(src, pluginId) + }, [blobUrl, pluginId, src]) + + return ( +
+ +
+ ) +} diff --git a/web/app/components/base/markdown-blocks/plugin-paragraph.tsx b/web/app/components/base/markdown-blocks/plugin-paragraph.tsx new file mode 100644 index 0000000000..ae1e2d7101 --- /dev/null +++ b/web/app/components/base/markdown-blocks/plugin-paragraph.tsx @@ -0,0 +1,69 @@ +/** + * @fileoverview Paragraph component for rendering

tags in Markdown. + * Extracted from the main markdown renderer for modularity. + * Handles special rendering for paragraphs that directly contain an image. + */ +import ImageGallery from '@/app/components/base/image-gallery' +import { usePluginReadmeAsset } from '@/service/use-plugins' +import React, { useEffect, useMemo, useState } from 'react' +import type { SimplePluginInfo } from '../markdown/react-markdown-wrapper' +import { getMarkdownImageURL } from './utils' + +type PluginParagraphProps = { + pluginInfo?: SimplePluginInfo + node?: any + children?: React.ReactNode +} + +export const PluginParagraph: React.FC = ({ pluginInfo, node, children }) => { + const { pluginUniqueIdentifier, pluginId } = pluginInfo || {} + const childrenNode = node?.children as Array | undefined + const firstChild = childrenNode?.[0] + const isImageParagraph = firstChild?.tagName === 'img' + const imageSrc = isImageParagraph ? firstChild?.properties?.src : undefined + + const { data: assetData } = usePluginReadmeAsset({ + plugin_unique_identifier: pluginUniqueIdentifier, + file_name: isImageParagraph && imageSrc ? imageSrc : '', + }) + + const [blobUrl, setBlobUrl] = useState() + + useEffect(() => { + if (!assetData) { + setBlobUrl(undefined) + return + } + + const objectUrl = URL.createObjectURL(assetData) + setBlobUrl(objectUrl) + + return () => { + URL.revokeObjectURL(objectUrl) + } + }, [assetData]) + + const imageUrl = useMemo(() => { + if (blobUrl) + return blobUrl + + if (isImageParagraph && imageSrc) + return getMarkdownImageURL(imageSrc, pluginId) + + return '' + }, [blobUrl, imageSrc, isImageParagraph, pluginId]) + + if (isImageParagraph) { + const remainingChildren = Array.isArray(children) && children.length > 1 ? children.slice(1) : undefined + + return ( +

+ + {remainingChildren && ( +
{remainingChildren}
+ )} +
+ ) + } + return

{children}

+} diff --git a/web/app/components/base/markdown/react-markdown-wrapper.tsx b/web/app/components/base/markdown/react-markdown-wrapper.tsx index 83b76d97cc..22964ec04f 100644 --- a/web/app/components/base/markdown/react-markdown-wrapper.tsx +++ b/web/app/components/base/markdown/react-markdown-wrapper.tsx @@ -1,30 +1,20 @@ -import ReactMarkdown from 'react-markdown' -import RemarkMath from 'remark-math' -import RemarkBreaks from 'remark-breaks' -import RehypeKatex from 'rehype-katex' -import RemarkGfm from 'remark-gfm' -import RehypeRaw from 'rehype-raw' +import { AudioBlock, Img, Link, MarkdownButton, MarkdownForm, Paragraph, PluginImg, PluginParagraph, ScriptBlock, ThinkBlock, VideoBlock } from '@/app/components/base/markdown-blocks' import { ENABLE_SINGLE_DOLLAR_LATEX } from '@/config' -import AudioBlock from '@/app/components/base/markdown-blocks/audio-block' -import Img from '@/app/components/base/markdown-blocks/img' -import Link from '@/app/components/base/markdown-blocks/link' -import MarkdownButton from '@/app/components/base/markdown-blocks/button' -import MarkdownForm from '@/app/components/base/markdown-blocks/form' -import Paragraph from '@/app/components/base/markdown-blocks/paragraph' -import ScriptBlock from '@/app/components/base/markdown-blocks/script-block' -import ThinkBlock from '@/app/components/base/markdown-blocks/think-block' -import VideoBlock from '@/app/components/base/markdown-blocks/video-block' -import { customUrlTransform } from './markdown-utils' - -import type { FC } from 'react' - import dynamic from 'next/dynamic' +import type { FC } from 'react' +import ReactMarkdown from 'react-markdown' +import RehypeKatex from 'rehype-katex' +import RehypeRaw from 'rehype-raw' +import RemarkBreaks from 'remark-breaks' +import RemarkGfm from 'remark-gfm' +import RemarkMath from 'remark-math' +import { customUrlTransform } from './markdown-utils' const CodeBlock = dynamic(() => import('@/app/components/base/markdown-blocks/code-block'), { ssr: false }) export type SimplePluginInfo = { pluginUniqueIdentifier: string - plugin_id: string + pluginId: string } export type ReactMarkdownWrapperProps = { @@ -70,11 +60,11 @@ export const ReactMarkdownWrapper: FC = (props) => { disallowedElements={['iframe', 'head', 'html', 'meta', 'link', 'style', 'body', ...(props.customDisallowedElements || [])]} components={{ code: CodeBlock, - img: (props: any) => , + img: (props: any) => pluginInfo ? : , video: VideoBlock, audio: AudioBlock, a: Link, - p: (props: any) => , + p: (props: any) => pluginInfo ? : , button: MarkdownButton, form: MarkdownForm, script: ScriptBlock as any, diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx index f4766803a4..b2a86b5c76 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx @@ -1,5 +1,6 @@ 'use client' import ActionButton from '@/app/components/base/action-button' +import Tooltip from '@/app/components/base/tooltip' import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' import cn from '@/utils/classnames' import { @@ -48,9 +49,19 @@ const SubscriptionCard = ({ data }: Props) => {
-
- {data.endpoint} -
+ + {data.endpoint} +
+ )} + position='left' + > +
+ {data.endpoint} +
+
·
{data.workflows_in_use > 0 ? t('pluginTrigger.subscription.list.item.usedByNum', { num: data.workflows_in_use }) : t('pluginTrigger.subscription.list.item.noUsed')} diff --git a/web/app/components/plugins/readme-panel/index.tsx b/web/app/components/plugins/readme-panel/index.tsx index 369f6538e4..b77d59fb0b 100644 --- a/web/app/components/plugins/readme-panel/index.tsx +++ b/web/app/components/plugins/readme-panel/index.tsx @@ -3,13 +3,12 @@ import ActionButton from '@/app/components/base/action-button' import Loading from '@/app/components/base/loading' import { Markdown } from '@/app/components/base/markdown' import Modal from '@/app/components/base/modal' -import Drawer from '@/app/components/base/drawer' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import { usePluginReadme } from '@/service/use-plugins' import cn from '@/utils/classnames' import { RiBookReadLine, RiCloseLine } from '@remixicon/react' import type { FC } from 'react' -import React from 'react' +import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' import DetailHeader from '../plugin-detail-panel/detail-header' import { ReadmeShowType, useReadmePanelStore } from './store' @@ -34,7 +33,7 @@ const ReadmePanel: FC = () => { const children = (
-
+
@@ -71,7 +70,7 @@ const ReadmePanel: FC = () => { return ( ) } @@ -86,38 +85,29 @@ const ReadmePanel: FC = () => {
) - return ( - showType === ReadmeShowType.drawer ? ( - +
{children} - - ) : ( - - {children} - - ) +
+
, + document.body, + ) : ( + + {children} + ) } diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx index ca91021885..32b9cb2671 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx @@ -342,6 +342,25 @@ const BasePanel: FC = ({ ) }, [handleNodeDataUpdateWithSyncDraft, id]) + const readmeEntranceComponent = useMemo(() => { + let pluginDetail + switch (data.type) { + case BlockEnum.Tool: + pluginDetail = currToolCollection + break + case BlockEnum.DataSource: + pluginDetail = currentDataSource + break + case BlockEnum.TriggerPlugin: + pluginDetail = currentTriggerProvider + break + + default: + break + } + return !pluginDetail ? null : + }, [data.type, currToolCollection, currentDataSource, currentTriggerProvider]) + if (logParams.showSpecialResultPanel) { return (
= ({
{tabType === TabType.settings && ( -
+
{cloneElement(children as any, { id, @@ -609,6 +628,7 @@ const BasePanel: FC = ({
) } + {readmeEntranceComponent}
)} @@ -628,8 +648,6 @@ const BasePanel: FC = ({ /> )} - {data.type === BlockEnum.Tool && } - {data.type === BlockEnum.DataSource && }
) diff --git a/web/service/demo/index.tsx b/web/service/demo/index.tsx index aa02968549..5cbfa7c52a 100644 --- a/web/service/demo/index.tsx +++ b/web/service/demo/index.tsx @@ -4,6 +4,8 @@ import React from 'react' import useSWR, { useSWRConfig } from 'swr' import { createApp, fetchAppDetail, fetchAppList, getAppDailyConversations, getAppDailyEndUsers, updateAppApiStatus, updateAppModelConfig, updateAppRateLimit, updateAppSiteAccessToken, updateAppSiteConfig, updateAppSiteStatus } from '../apps' import Loading from '@/app/components/base/loading' +import { AppModeEnum } from '@/types/app' + const Service: FC = () => { const { data: appList, error: appListError } = useSWR({ url: '/apps', params: { page: 1 } }, fetchAppList) const { data: firstApp, error: appDetailError } = useSWR({ url: '/apps', id: '1' }, fetchAppDetail) @@ -21,7 +23,7 @@ const Service: FC = () => { const handleCreateApp = async () => { await createApp({ name: `new app${Math.round(Math.random() * 100)}`, - mode: 'chat', + mode: AppModeEnum.CHAT, }) // reload app list mutate({ url: '/apps', params: { page: 1 } }) From f260627660e0043f6291a24c8abd09ac925bd347 Mon Sep 17 00:00:00 2001 From: Xiyuan Chen <52963600+GareArc@users.noreply.github.com> Date: Wed, 29 Oct 2025 01:45:40 -0700 Subject: [PATCH 060/394] feat: use id for webapp (#27576) --- api/services/enterprise/enterprise_service.py | 10 ---------- .../services/test_webapp_auth_service.py | 4 +--- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/api/services/enterprise/enterprise_service.py b/api/services/enterprise/enterprise_service.py index 974aa849db..83d0fcf296 100644 --- a/api/services/enterprise/enterprise_service.py +++ b/api/services/enterprise/enterprise_service.py @@ -92,16 +92,6 @@ class EnterpriseService: return ret - @classmethod - def get_app_access_mode_by_code(cls, app_code: str) -> WebAppSettings: - if not app_code: - raise ValueError("app_code must be provided.") - params = {"appCode": app_code} - data = EnterpriseRequest.send_request("GET", "/webapp/access-mode/code", params=params) - if not data: - raise ValueError("No data found.") - return WebAppSettings.model_validate(data) - @classmethod def update_app_access_mode(cls, app_id: str, access_mode: str): if not app_id: diff --git a/api/tests/test_containers_integration_tests/services/test_webapp_auth_service.py b/api/tests/test_containers_integration_tests/services/test_webapp_auth_service.py index 73e622b061..72b119b4ff 100644 --- a/api/tests/test_containers_integration_tests/services/test_webapp_auth_service.py +++ b/api/tests/test_containers_integration_tests/services/test_webapp_auth_service.py @@ -35,9 +35,7 @@ class TestWebAppAuthService: mock_enterprise_service.WebAppAuth.get_app_access_mode_by_id.return_value = type( "MockWebAppAuth", (), {"access_mode": "private"} )() - mock_enterprise_service.WebAppAuth.get_app_access_mode_by_code.return_value = type( - "MockWebAppAuth", (), {"access_mode": "private"} - )() + # Note: get_app_access_mode_by_code method was removed in refactoring yield { "passport_service": mock_passport_service, From fb12f31df2940970cb3cbdd72272ccc26a139d52 Mon Sep 17 00:00:00 2001 From: Harry Date: Wed, 29 Oct 2025 18:06:41 +0800 Subject: [PATCH 061/394] feat(trigger): system variables for trigger nodes Added a timestamp field to the SystemVariable model and updated the WorkflowAppRunner to include the current timestamp during execution. Enhanced node type checks to recognize trigger nodes in various services, ensuring proper handling of system variables and node outputs in TriggerEventNode and TriggerScheduleNode. This improves the overall workflow execution context and maintains consistency across node types. --- api/core/app/apps/workflow/app_runner.py | 2 ++ api/core/workflow/enums.py | 11 +++++++++++ api/core/workflow/graph/graph.py | 2 +- .../nodes/trigger_plugin/trigger_event_node.py | 15 +++++++++++---- .../trigger_schedule/trigger_schedule_node.py | 14 ++++++++++---- api/core/workflow/system_variable.py | 4 ++++ api/services/workflow_draft_variable_service.py | 2 +- api/services/workflow_service.py | 3 ++- 8 files changed, 42 insertions(+), 11 deletions(-) diff --git a/api/core/app/apps/workflow/app_runner.py b/api/core/app/apps/workflow/app_runner.py index 439ecb2491..f05eb0e596 100644 --- a/api/core/app/apps/workflow/app_runner.py +++ b/api/core/app/apps/workflow/app_runner.py @@ -19,6 +19,7 @@ from core.workflow.system_variable import SystemVariable from core.workflow.variable_loader import VariableLoader from core.workflow.workflow_entry import WorkflowEntry from extensions.ext_redis import redis_client +from libs.datetime_utils import naive_utc_now from models.enums import UserFrom from models.workflow import Workflow @@ -67,6 +68,7 @@ class WorkflowAppRunner(WorkflowBasedAppRunner): files=self.application_generate_entity.files, user_id=self._sys_user_id, app_id=app_config.app_id, + timestamp=int(naive_utc_now().timestamp()), workflow_id=app_config.workflow_id, workflow_execution_id=self.application_generate_entity.workflow_execution_id, ) diff --git a/api/core/workflow/enums.py b/api/core/workflow/enums.py index 4e9a30dd6e..882d7762f2 100644 --- a/api/core/workflow/enums.py +++ b/api/core/workflow/enums.py @@ -22,6 +22,7 @@ class SystemVariableKey(StrEnum): APP_ID = "app_id" WORKFLOW_ID = "workflow_id" WORKFLOW_EXECUTION_ID = "workflow_run_id" + TIMESTAMP = "timestamp" # RAG Pipeline DOCUMENT_ID = "document_id" ORIGINAL_DOCUMENT_ID = "original_document_id" @@ -63,11 +64,21 @@ class NodeType(StrEnum): TRIGGER_PLUGIN = "trigger-plugin" HUMAN_INPUT = "human-input" + @property + def is_trigger_node(self) -> bool: + """Check if this node type is a trigger node.""" + return self in [ + NodeType.TRIGGER_WEBHOOK, + NodeType.TRIGGER_SCHEDULE, + NodeType.TRIGGER_PLUGIN, + ] + @property def is_start_node(self) -> bool: """Check if this node type can serve as a workflow entry point.""" return self in [ NodeType.START, + NodeType.DATASOURCE, NodeType.TRIGGER_WEBHOOK, NodeType.TRIGGER_SCHEDULE, NodeType.TRIGGER_PLUGIN, diff --git a/api/core/workflow/graph/graph.py b/api/core/workflow/graph/graph.py index d04724425c..ba5a01fc94 100644 --- a/api/core/workflow/graph/graph.py +++ b/api/core/workflow/graph/graph.py @@ -117,7 +117,7 @@ class Graph: node_type = node_data.get("type") if not isinstance(node_type, str): continue - if node_type in [NodeType.START, NodeType.DATASOURCE]: + if NodeType(node_type).is_start_node: start_node_id = nid break diff --git a/api/core/workflow/nodes/trigger_plugin/trigger_event_node.py b/api/core/workflow/nodes/trigger_plugin/trigger_event_node.py index 367c6852ff..0a70aa8727 100644 --- a/api/core/workflow/nodes/trigger_plugin/trigger_event_node.py +++ b/api/core/workflow/nodes/trigger_plugin/trigger_event_node.py @@ -1,7 +1,7 @@ from collections.abc import Mapping -from copy import deepcopy from typing import Any, Optional +from core.workflow.constants import SYSTEM_VARIABLE_NODE_ID from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus from core.workflow.enums import ErrorStrategy, NodeExecutionType, NodeType from core.workflow.node_events import NodeRunResult @@ -66,7 +66,6 @@ class TriggerEventNode(Node): """ # Get trigger data passed when workflow was triggered - inputs = deepcopy(self.graph_runtime_state.variable_pool.user_inputs) metadata = { WorkflowNodeExecutionMetadataKey.TRIGGER_INFO: { "provider_id": self._node_data.provider_id, @@ -74,9 +73,17 @@ class TriggerEventNode(Node): "plugin_unique_identifier": self._node_data.plugin_unique_identifier, }, } + node_inputs = dict(self.graph_runtime_state.variable_pool.user_inputs) + system_inputs = self.graph_runtime_state.variable_pool.system_variables.to_dict() + + # TODO: System variables should be directly accessible, no need for special handling + # Set system variables as node outputs. + for var in system_inputs: + node_inputs[SYSTEM_VARIABLE_NODE_ID + "." + var] = system_inputs[var] + outputs = dict(node_inputs) return NodeRunResult( status=WorkflowNodeExecutionStatus.SUCCEEDED, - inputs={}, - outputs=inputs, + inputs=node_inputs, + outputs=outputs, metadata=metadata, ) diff --git a/api/core/workflow/nodes/trigger_schedule/trigger_schedule_node.py b/api/core/workflow/nodes/trigger_schedule/trigger_schedule_node.py index 4fa50f1ead..55d66ac907 100644 --- a/api/core/workflow/nodes/trigger_schedule/trigger_schedule_node.py +++ b/api/core/workflow/nodes/trigger_schedule/trigger_schedule_node.py @@ -1,7 +1,7 @@ from collections.abc import Mapping -from datetime import UTC, datetime from typing import Any, Optional +from core.workflow.constants import SYSTEM_VARIABLE_NODE_ID from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus from core.workflow.enums import ErrorStrategy, NodeExecutionType, NodeType from core.workflow.node_events import NodeRunResult @@ -54,10 +54,16 @@ class TriggerScheduleNode(Node): } def _run(self) -> NodeRunResult: - current_time = datetime.now(UTC) - node_outputs = {"current_time": current_time.isoformat()} + node_inputs = dict(self.graph_runtime_state.variable_pool.user_inputs) + system_inputs = self.graph_runtime_state.variable_pool.system_variables.to_dict() + # TODO: System variables should be directly accessible, no need for special handling + # Set system variables as node outputs. + for var in system_inputs: + node_inputs[SYSTEM_VARIABLE_NODE_ID + "." + var] = system_inputs[var] + outputs = dict(node_inputs) return NodeRunResult( status=WorkflowNodeExecutionStatus.SUCCEEDED, - outputs=node_outputs, + inputs=node_inputs, + outputs=outputs, ) diff --git a/api/core/workflow/system_variable.py b/api/core/workflow/system_variable.py index 6716e745cd..d35d6db7ec 100644 --- a/api/core/workflow/system_variable.py +++ b/api/core/workflow/system_variable.py @@ -28,6 +28,8 @@ class SystemVariable(BaseModel): app_id: str | None = None workflow_id: str | None = None + timestamp: int | None = None + files: Sequence[File] = Field(default_factory=list) # NOTE: The `workflow_execution_id` field was previously named `workflow_run_id`. @@ -107,4 +109,6 @@ class SystemVariable(BaseModel): d[SystemVariableKey.DATASOURCE_INFO] = self.datasource_info if self.invoke_from is not None: d[SystemVariableKey.INVOKE_FROM] = self.invoke_from + if self.timestamp is not None: + d[SystemVariableKey.TIMESTAMP] = self.timestamp return d diff --git a/api/services/workflow_draft_variable_service.py b/api/services/workflow_draft_variable_service.py index 5e63a83bb1..2690b55dbc 100644 --- a/api/services/workflow_draft_variable_service.py +++ b/api/services/workflow_draft_variable_service.py @@ -1026,7 +1026,7 @@ class DraftVariableSaver: return if self._node_type == NodeType.VARIABLE_ASSIGNER: draft_vars = self._build_from_variable_assigner_mapping(process_data=process_data) - elif self._node_type == NodeType.START: + elif self._node_type == NodeType.START or self._node_type.is_trigger_node: draft_vars = self._build_variables_from_start_mapping(outputs) else: draft_vars = self._build_variables_from_mapping(outputs) diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index aa53e27ece..c61e76402f 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -1007,10 +1007,11 @@ def _setup_variable_pool( conversation_variables: list[Variable], ): # Only inject system variables for START node type. - if node_type == NodeType.START: + if node_type == NodeType.START or node_type.is_trigger_node: system_variable = SystemVariable( user_id=user_id, app_id=workflow.app_id, + timestamp=int(naive_utc_now().timestamp()), workflow_id=workflow.id, files=files or [], workflow_execution_id=str(uuid.uuid4()), From 9b5e5f0f50764f428371fef3faf2ddd338df0a20 Mon Sep 17 00:00:00 2001 From: Harry Date: Wed, 29 Oct 2025 18:10:23 +0800 Subject: [PATCH 062/394] refactor(api): replace dict type hints with Mapping for improved type safety Updated type hints in several services to use Mapping instead of dict for better compatibility with various dictionary-like objects. Adjusted credential handling to ensure consistent encryption and decryption processes across ToolManager, DatasourceProviderService, ApiToolManageService, BuiltinToolManageService, and MCPToolManageService. This change enhances code clarity and adheres to strong typing practices. --- api/core/tools/tool_manager.py | 9 +++------ api/services/datasource_provider_service.py | 4 ++-- api/services/tools/api_tools_manage_service.py | 2 +- api/services/tools/builtin_tools_manage_service.py | 2 +- api/services/tools/mcp_tools_manage_service.py | 3 ++- 5 files changed, 9 insertions(+), 11 deletions(-) diff --git a/api/core/tools/tool_manager.py b/api/core/tools/tool_manager.py index cf33185f48..daf3772d30 100644 --- a/api/core/tools/tool_manager.py +++ b/api/core/tools/tool_manager.py @@ -8,7 +8,6 @@ from threading import Lock from typing import TYPE_CHECKING, Any, Literal, Optional, Union, cast import sqlalchemy as sa -from pydantic import TypeAdapter from sqlalchemy import select from sqlalchemy.orm import Session from yarl import URL @@ -289,10 +288,8 @@ class ToolManager: credentials=decrypted_credentials, ) # update the credentials - builtin_provider.encrypted_credentials = ( - TypeAdapter(dict[str, Any]) - .dump_json(encrypter.encrypt(dict(refreshed_credentials.credentials))) - .decode("utf-8") + builtin_provider.encrypted_credentials = json.dumps( + encrypter.encrypt(refreshed_credentials.credentials) ) builtin_provider.expires_at = refreshed_credentials.expires_at db.session.commit() @@ -322,7 +319,7 @@ class ToolManager: return api_provider.get_tool(tool_name).fork_tool_runtime( runtime=ToolRuntime( tenant_id=tenant_id, - credentials=encrypter.decrypt(credentials), + credentials=dict(encrypter.decrypt(credentials)), invoke_from=invoke_from, tool_invoke_from=tool_invoke_from, ) diff --git a/api/services/datasource_provider_service.py b/api/services/datasource_provider_service.py index 1e018af19f..6ca5a5b100 100644 --- a/api/services/datasource_provider_service.py +++ b/api/services/datasource_provider_service.py @@ -374,7 +374,7 @@ class DatasourceProviderService: def get_tenant_oauth_client( self, tenant_id: str, datasource_provider_id: DatasourceProviderID, mask: bool = False - ) -> dict[str, Any] | None: + ) -> Mapping[str, Any] | None: """ get tenant oauth client """ @@ -434,7 +434,7 @@ class DatasourceProviderService: ) if tenant_oauth_client_params: encrypter, _ = self.get_oauth_encrypter(tenant_id, datasource_provider_id) - return encrypter.decrypt(tenant_oauth_client_params.client_params) + return dict(encrypter.decrypt(tenant_oauth_client_params.client_params)) provider_controller = self.provider_manager.fetch_datasource_provider( tenant_id=tenant_id, provider_id=str(datasource_provider_id) diff --git a/api/services/tools/api_tools_manage_service.py b/api/services/tools/api_tools_manage_service.py index f0a0bcde1b..250d29f335 100644 --- a/api/services/tools/api_tools_manage_service.py +++ b/api/services/tools/api_tools_manage_service.py @@ -306,7 +306,7 @@ class ApiToolManageService: if name in masked_credentials and value == masked_credentials[name]: credentials[name] = original_credentials[name] - credentials = encrypter.encrypt(credentials) + credentials = dict(encrypter.encrypt(credentials)) provider.credentials_str = json.dumps(credentials) db.session.add(provider) diff --git a/api/services/tools/builtin_tools_manage_service.py b/api/services/tools/builtin_tools_manage_service.py index 1543b1a02e..783f2f0d21 100644 --- a/api/services/tools/builtin_tools_manage_service.py +++ b/api/services/tools/builtin_tools_manage_service.py @@ -353,7 +353,7 @@ class BuiltinToolManageService: decrypt_credential = encrypter.mask_plugin_credentials(encrypter.decrypt(provider.credentials)) credential_entity = ToolTransformService.convert_builtin_provider_to_credential_entity( provider=provider, - credentials=decrypt_credential, + credentials=dict(decrypt_credential), ) credentials.append(credential_entity) return credentials diff --git a/api/services/tools/mcp_tools_manage_service.py b/api/services/tools/mcp_tools_manage_service.py index e219bd4ce9..d798e11ff1 100644 --- a/api/services/tools/mcp_tools_manage_service.py +++ b/api/services/tools/mcp_tools_manage_service.py @@ -1,6 +1,7 @@ import hashlib import json import logging +from collections.abc import Mapping from datetime import datetime from enum import StrEnum from typing import Any @@ -420,7 +421,7 @@ class MCPToolManageService: return json.dumps({"content": icon, "background": icon_background}) return icon - def _encrypt_dict_fields(self, data: dict[str, Any], secret_fields: list[str], tenant_id: str) -> dict[str, str]: + def _encrypt_dict_fields(self, data: dict[str, Any], secret_fields: list[str], tenant_id: str) -> Mapping[str, str]: """Encrypt specified fields in a dictionary. Args: From 1e477af05f333aae2137abb92b4ea2ba9973fcc6 Mon Sep 17 00:00:00 2001 From: Harry Date: Wed, 29 Oct 2025 18:15:36 +0800 Subject: [PATCH 063/394] feat(trigger): add system variables to webhook node outputs Enhanced the TriggerWebhookNode to include system variables as outputs. This change allows for better accessibility of system variables during node execution, improving the overall functionality of the webhook trigger process. A TODO comment has been added to address future improvements for direct access to system variables. --- api/core/workflow/nodes/trigger_webhook/node.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/api/core/workflow/nodes/trigger_webhook/node.py b/api/core/workflow/nodes/trigger_webhook/node.py index aa49b3f3e2..58ccf5a3cc 100644 --- a/api/core/workflow/nodes/trigger_webhook/node.py +++ b/api/core/workflow/nodes/trigger_webhook/node.py @@ -1,6 +1,7 @@ from collections.abc import Mapping from typing import Any, Optional +from core.workflow.constants import SYSTEM_VARIABLE_NODE_ID from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus from core.workflow.enums import ErrorStrategy, NodeExecutionType, NodeType from core.workflow.node_events import NodeRunResult @@ -71,7 +72,12 @@ class TriggerWebhookNode(Node): # Extract webhook-specific outputs based on node configuration outputs = self._extract_configured_outputs(webhook_inputs) + system_inputs = self.graph_runtime_state.variable_pool.system_variables.to_dict() + # TODO: System variables should be directly accessible, no need for special handling + # Set system variables as node outputs. + for var in system_inputs: + outputs[SYSTEM_VARIABLE_NODE_ID + "." + var] = system_inputs[var] return NodeRunResult( status=WorkflowNodeExecutionStatus.SUCCEEDED, inputs=webhook_inputs, From 4ca7ba000cd497902fc957de4c3cf237cfdfa164 Mon Sep 17 00:00:00 2001 From: Wu Tianwei <30284043+WTW0313@users.noreply.github.com> Date: Wed, 29 Oct 2025 18:31:02 +0800 Subject: [PATCH 064/394] refactor: update install status handling in plugin installation process (#27594) --- .../agent-tools/setting-built-in-tool.tsx | 1 + .../components/base/chip/index.stories.tsx | 11 +++ .../components/base/dialog/index.stories.tsx | 4 -- .../data-source-page-new/card.tsx | 2 + .../install-bundle/ready-to-install.tsx | 6 +- .../install-bundle/steps/install.tsx | 72 ++++++++++++++++--- .../install-bundle/steps/installed.tsx | 4 +- .../plugin-detail-panel/detail-header.tsx | 1 + .../tool-selector/index.tsx | 1 + web/app/components/plugins/types.ts | 6 ++ web/global.d.ts | 3 +- web/service/use-plugins.ts | 54 +++++++++++--- web/types/assets.d.ts | 24 +++++++ 13 files changed, 157 insertions(+), 32 deletions(-) create mode 100644 web/types/assets.d.ts diff --git a/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx index 62bd57c5d1..604b5532b0 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx @@ -214,6 +214,7 @@ const SettingBuiltInTool: FC = ({ pluginPayload={{ provider: collection.name, category: AuthCategory.tool, + providerType: collection.type, }} credentialId={credentialId} onAuthorizationItemClick={onAuthorizationItemClick} diff --git a/web/app/components/base/chip/index.stories.tsx b/web/app/components/base/chip/index.stories.tsx index 0ea018ef95..46d91c8cd6 100644 --- a/web/app/components/base/chip/index.stories.tsx +++ b/web/app/components/base/chip/index.stories.tsx @@ -23,6 +23,10 @@ const meta = { args: { items: ITEMS, value: 'all', + // eslint-disable-next-line no-empty-function + onSelect: () => {}, + // eslint-disable-next-line no-empty-function + onClear: () => {}, }, } satisfies Meta @@ -69,6 +73,13 @@ const [selection, setSelection] = useState('all') } export const WithoutLeftIcon: Story = { + args: { + showLeftIcon: false, + // eslint-disable-next-line no-empty-function + onSelect: () => {}, + // eslint-disable-next-line no-empty-function + onClear: () => {}, + }, render: args => ( ), - children: null, }, } @@ -112,7 +111,6 @@ export const WithoutFooter: Story = { args: { footer: undefined, title: 'Read-only summary', - children: null, }, parameters: { docs: { @@ -130,7 +128,6 @@ export const CustomStyling: Story = { bodyClassName: 'bg-gray-50 rounded-xl p-5', footerClassName: 'justify-between px-4 pb-4 pt-4', titleClassName: 'text-lg text-primary-600', - children: null, footer: ( <> Last synced 2 minutes ago @@ -144,7 +141,6 @@ export const CustomStyling: Story = {
), - children: null, }, parameters: { docs: { diff --git a/web/app/components/header/account-setting/data-source-page-new/card.tsx b/web/app/components/header/account-setting/data-source-page-new/card.tsx index 7a8790e76d..1e2e60bb7a 100644 --- a/web/app/components/header/account-setting/data-source-page-new/card.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/card.tsx @@ -20,6 +20,7 @@ import { useDataSourceAuthUpdate } from './hooks' import Confirm from '@/app/components/base/confirm' import { useGetDataSourceOAuthUrl } from '@/service/use-datasource' import { openOAuthPopup } from '@/hooks/use-oauth' +import { CollectionType } from '@/app/components/tools/types' type CardProps = { item: DataSourceAuth @@ -42,6 +43,7 @@ const Card = ({ const pluginPayload = { category: AuthCategory.datasource, provider: `${item.plugin_id}/${item.name}`, + providerType: CollectionType.datasource, } const { handleAuthUpdate } = useDataSourceAuthUpdate({ pluginId: item.plugin_id, diff --git a/web/app/components/plugins/install-plugin/install-bundle/ready-to-install.tsx b/web/app/components/plugins/install-plugin/install-bundle/ready-to-install.tsx index 63c0b5b07e..b2b0aefb9b 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/ready-to-install.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/ready-to-install.tsx @@ -4,7 +4,7 @@ import React, { useCallback, useState } from 'react' import { InstallStep } from '../../types' import Install from './steps/install' import Installed from './steps/installed' -import type { Dependency, InstallStatusResponse, Plugin } from '../../types' +import type { Dependency, InstallStatus, Plugin } from '../../types' type Props = { step: InstallStep @@ -26,8 +26,8 @@ const ReadyToInstall: FC = ({ isFromMarketPlace, }) => { const [installedPlugins, setInstalledPlugins] = useState([]) - const [installStatus, setInstallStatus] = useState([]) - const handleInstalled = useCallback((plugins: Plugin[], installStatus: InstallStatusResponse[]) => { + const [installStatus, setInstallStatus] = useState([]) + const handleInstalled = useCallback((plugins: Plugin[], installStatus: InstallStatus[]) => { setInstallStatus(installStatus) setInstalledPlugins(plugins) onStepChange(InstallStep.installed) diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/install.tsx b/web/app/components/plugins/install-plugin/install-bundle/steps/install.tsx index 758daafca0..a717e0a24a 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/steps/install.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/install.tsx @@ -2,23 +2,31 @@ import type { FC } from 'react' import { useRef } from 'react' import React, { useCallback, useState } from 'react' -import type { Dependency, InstallStatusResponse, Plugin, VersionInfo } from '../../../types' +import { + type Dependency, + type InstallStatus, + type InstallStatusResponse, + type Plugin, + TaskStatus, + type VersionInfo, +} from '../../../types' import Button from '@/app/components/base/button' import { RiLoader2Line } from '@remixicon/react' import { useTranslation } from 'react-i18next' import type { ExposeRefs } from './install-multi' import InstallMulti from './install-multi' -import { useInstallOrUpdate } from '@/service/use-plugins' +import { useInstallOrUpdate, usePluginTaskList } from '@/service/use-plugins' import useRefreshPluginList from '../../hooks/use-refresh-plugin-list' import { useCanInstallPluginFromMarketplace } from '@/app/components/plugins/plugin-page/use-reference-setting' import { useMittContextSelector } from '@/context/mitt-context' import Checkbox from '@/app/components/base/checkbox' +import checkTaskStatus from '../../base/check-task-status' const i18nPrefix = 'plugin.installModal' type Props = { allPlugins: Dependency[] onStartToInstall?: () => void - onInstalled: (plugins: Plugin[], installStatus: InstallStatusResponse[]) => void + onInstalled: (plugins: Plugin[], installStatus: InstallStatus[]) => void onCancel: () => void isFromMarketPlace?: boolean isHideButton?: boolean @@ -55,18 +63,60 @@ const Install: FC = ({ setCanInstall(true) }, []) + const { + check, + stop, + } = checkTaskStatus() + + const handleCancel = useCallback(() => { + stop() + onCancel() + }, [onCancel, stop]) + + const { handleRefetch } = usePluginTaskList() + // Install from marketplace and github const { mutate: installOrUpdate, isPending: isInstalling } = useInstallOrUpdate({ - onSuccess: (res: InstallStatusResponse[]) => { - onInstalled(selectedPlugins, res.map((r, i) => { - return ({ - ...r, - isFromMarketPlace: allPlugins[selectedIndexes[i]].type === 'marketplace', + onSuccess: async (res: InstallStatusResponse[]) => { + const isAllSettled = res.every(r => r.status === TaskStatus.success || r.status === TaskStatus.failed) + // if all settled, return the install status + if (isAllSettled) { + onInstalled(selectedPlugins, res.map((r, i) => { + return ({ + success: r.status === TaskStatus.success, + isFromMarketPlace: allPlugins[selectedIndexes[i]].type === 'marketplace', + }) + })) + const hasInstallSuccess = res.some(r => r.status === TaskStatus.success) + if (hasInstallSuccess) { + refreshPluginList(undefined, true) + emit('plugin:install:success', selectedPlugins.map((p) => { + return `${p.plugin_id}/${p.name}` + })) + } + return + } + // if not all settled, keep checking the status of the plugins + handleRefetch() + const installStatus = await Promise.all(res.map(async (item, index) => { + if (item.status !== TaskStatus.running) { + return { + success: item.status === TaskStatus.success, + isFromMarketPlace: allPlugins[selectedIndexes[index]].type === 'marketplace', + } + } + const { status } = await check({ + taskId: item.taskId, + pluginUniqueIdentifier: item.uniqueIdentifier, }) + return { + success: status === TaskStatus.success, + isFromMarketPlace: allPlugins[selectedIndexes[index]].type === 'marketplace', + } })) - const hasInstallSuccess = res.some(r => r.success) + onInstalled(selectedPlugins, installStatus) + const hasInstallSuccess = installStatus.some(r => r.success) if (hasInstallSuccess) { - refreshPluginList(undefined, true) emit('plugin:install:success', selectedPlugins.map((p) => { return `${p.plugin_id}/${p.name}` })) @@ -150,7 +200,7 @@ const Install: FC = ({
{!canInstall && ( - )} diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/installed.tsx b/web/app/components/plugins/install-plugin/install-bundle/steps/installed.tsx index 4e16d200e7..f787882211 100644 --- a/web/app/components/plugins/install-plugin/install-bundle/steps/installed.tsx +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/installed.tsx @@ -1,7 +1,7 @@ 'use client' import type { FC } from 'react' import React from 'react' -import type { InstallStatusResponse, Plugin } from '../../../types' +import type { InstallStatus, Plugin } from '../../../types' import Card from '@/app/components/plugins/card' import Button from '@/app/components/base/button' import { useTranslation } from 'react-i18next' @@ -11,7 +11,7 @@ import { MARKETPLACE_API_PREFIX } from '@/config' type Props = { list: Plugin[] - installStatus: InstallStatusResponse[] + installStatus: InstallStatus[] onCancel: () => void isHideButton?: boolean } diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header.tsx index 318d1112bb..ccd0d8be1b 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header.tsx @@ -331,6 +331,7 @@ const DetailHeader = ({ pluginPayload={{ provider: provider?.name || '', category: AuthCategory.tool, + providerType: provider?.type || '', }} /> ) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx index d2797b99f4..d56d48d6d5 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx @@ -314,6 +314,7 @@ const ToolSelector: FC = ({ pluginPayload={{ provider: currentProvider.name, category: AuthCategory.tool, + providerType: currentProvider.type, }} credentialId={value?.credential_id} onAuthorizationItemClick={handleAuthorizationItemClick} diff --git a/web/app/components/plugins/types.ts b/web/app/components/plugins/types.ts index 0f312476d5..2e061d7d69 100644 --- a/web/app/components/plugins/types.ts +++ b/web/app/components/plugins/types.ts @@ -280,6 +280,12 @@ export type InstallPackageResponse = { } export type InstallStatusResponse = { + status: TaskStatus, + taskId: string, + uniqueIdentifier: string, +} + +export type InstallStatus = { success: boolean, isFromMarketPlace?: boolean } diff --git a/web/global.d.ts b/web/global.d.ts index c5488a6cae..0ccadf7887 100644 --- a/web/global.d.ts +++ b/web/global.d.ts @@ -1,6 +1,7 @@ import './types/i18n' import './types/jsx' import './types/mdx' +import './types/assets' declare module 'lamejs'; declare module 'lamejs/src/js/MPEGMode'; @@ -8,4 +9,4 @@ declare module 'lamejs/src/js/Lame'; declare module 'lamejs/src/js/BitStream'; declare module 'react-18-input-autosize'; -export {} +export { } diff --git a/web/service/use-plugins.ts b/web/service/use-plugins.ts index 1dec97cdfa..5d2bc080d3 100644 --- a/web/service/use-plugins.ts +++ b/web/service/use-plugins.ts @@ -10,6 +10,7 @@ import type { Dependency, GitHubItemAndMarketPlaceDependency, InstallPackageResponse, + InstallStatusResponse, InstalledLatestVersionResponse, InstalledPluginListWithTotalResponse, PackageDependency, @@ -233,7 +234,7 @@ export const useUploadGitHub = (payload: { export const useInstallOrUpdate = ({ onSuccess, }: { - onSuccess?: (res: { success: boolean }[]) => void + onSuccess?: (res: InstallStatusResponse[]) => void }) => { const { mutateAsync: updatePackageFromMarketPlace } = useUpdatePackageFromMarketPlace() @@ -251,6 +252,8 @@ export const useInstallOrUpdate = ({ const installedPayload = installedInfo[orgAndName] const isInstalled = !!installedPayload let uniqueIdentifier = '' + let taskId = '' + let isFinishedInstallation = false if (item.type === 'github') { const data = item as GitHubItemAndMarketPlaceDependency @@ -268,12 +271,14 @@ export const useInstallOrUpdate = ({ // has the same version, but not installed if (uniqueIdentifier === installedPayload?.uniqueIdentifier) { return { - success: true, + status: TaskStatus.success, + taskId: '', + uniqueIdentifier: '', } } } if (!isInstalled) { - await post('/workspaces/current/plugin/install/github', { + const { task_id, all_installed } = await post('/workspaces/current/plugin/install/github', { body: { repo: data.value.repo!, version: data.value.release! || data.value.version!, @@ -281,6 +286,8 @@ export const useInstallOrUpdate = ({ plugin_unique_identifier: uniqueIdentifier, }, }) + taskId = task_id + isFinishedInstallation = all_installed } } if (item.type === 'marketplace') { @@ -288,15 +295,19 @@ export const useInstallOrUpdate = ({ uniqueIdentifier = data.value.marketplace_plugin_unique_identifier! || plugin[i]?.plugin_id if (uniqueIdentifier === installedPayload?.uniqueIdentifier) { return { - success: true, + status: TaskStatus.success, + taskId: '', + uniqueIdentifier: '', } } if (!isInstalled) { - await post('/workspaces/current/plugin/install/marketplace', { + const { task_id, all_installed } = await post('/workspaces/current/plugin/install/marketplace', { body: { plugin_unique_identifiers: [uniqueIdentifier], }, }) + taskId = task_id + isFinishedInstallation = all_installed } } if (item.type === 'package') { @@ -304,38 +315,59 @@ export const useInstallOrUpdate = ({ uniqueIdentifier = data.value.unique_identifier if (uniqueIdentifier === installedPayload?.uniqueIdentifier) { return { - success: true, + status: TaskStatus.success, + taskId: '', + uniqueIdentifier: '', } } if (!isInstalled) { - await post('/workspaces/current/plugin/install/pkg', { + const { task_id, all_installed } = await post('/workspaces/current/plugin/install/pkg', { body: { plugin_unique_identifiers: [uniqueIdentifier], }, }) + taskId = task_id + isFinishedInstallation = all_installed } } if (isInstalled) { if (item.type === 'package') { await uninstallPlugin(installedPayload.installedId) - await post('/workspaces/current/plugin/install/pkg', { + const { task_id, all_installed } = await post('/workspaces/current/plugin/install/pkg', { body: { plugin_unique_identifiers: [uniqueIdentifier], }, }) + taskId = task_id + isFinishedInstallation = all_installed } else { - await updatePackageFromMarketPlace({ + const { task_id, all_installed } = await updatePackageFromMarketPlace({ original_plugin_unique_identifier: installedPayload?.uniqueIdentifier, new_plugin_unique_identifier: uniqueIdentifier, }) + taskId = task_id + isFinishedInstallation = all_installed + } + } + if (isFinishedInstallation) { + return { + status: TaskStatus.success, + taskId: '', + uniqueIdentifier: '', + } + } + else { + return { + status: TaskStatus.running, + taskId, + uniqueIdentifier, } } - return ({ success: true }) } // eslint-disable-next-line unused-imports/no-unused-vars catch (e) { - return Promise.resolve({ success: false }) + return Promise.resolve({ status: TaskStatus.failed, taskId: '', uniqueIdentifier: '' }) } })) }, diff --git a/web/types/assets.d.ts b/web/types/assets.d.ts new file mode 100644 index 0000000000..d7711f7eb4 --- /dev/null +++ b/web/types/assets.d.ts @@ -0,0 +1,24 @@ +declare module '*.svg' { + const value: any + export default value +} + +declare module '*.png' { + const value: any + export default value +} + +declare module '*.jpg' { + const value: any + export default value +} + +declare module '*.jpeg' { + const value: any + export default value +} + +declare module '*.gif' { + const value: any + export default value +} From 6767a8f72c23e8bd184f5ed26bea96ba01b7f869 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Wed, 29 Oct 2025 21:10:26 +0800 Subject: [PATCH 065/394] chore: i18n for system var --- web/i18n/de-DE/workflow.ts | 13 +++++++++++++ web/i18n/en-US/workflow.ts | 2 +- web/i18n/es-ES/workflow.ts | 13 +++++++++++++ web/i18n/fa-IR/workflow.ts | 13 +++++++++++++ web/i18n/fr-FR/workflow.ts | 13 +++++++++++++ web/i18n/hi-IN/workflow.ts | 13 +++++++++++++ web/i18n/id-ID/workflow.ts | 13 +++++++++++++ web/i18n/it-IT/workflow.ts | 13 +++++++++++++ web/i18n/ja-JP/workflow.ts | 13 +++++++++++++ web/i18n/ko-KR/workflow.ts | 13 +++++++++++++ web/i18n/pl-PL/workflow.ts | 13 +++++++++++++ web/i18n/pt-BR/workflow.ts | 13 +++++++++++++ web/i18n/ro-RO/workflow.ts | 13 +++++++++++++ web/i18n/ru-RU/workflow.ts | 13 +++++++++++++ web/i18n/sl-SI/workflow.ts | 13 +++++++++++++ web/i18n/th-TH/workflow.ts | 13 +++++++++++++ web/i18n/tr-TR/workflow.ts | 13 +++++++++++++ web/i18n/uk-UA/workflow.ts | 13 +++++++++++++ web/i18n/vi-VN/workflow.ts | 13 +++++++++++++ web/i18n/zh-Hans/workflow.ts | 13 +++++++++++++ web/i18n/zh-Hant/workflow.ts | 13 +++++++++++++ 21 files changed, 261 insertions(+), 1 deletion(-) diff --git a/web/i18n/de-DE/workflow.ts b/web/i18n/de-DE/workflow.ts index 053be276e0..28aa8bdc19 100644 --- a/web/i18n/de-DE/workflow.ts +++ b/web/i18n/de-DE/workflow.ts @@ -137,6 +137,19 @@ const translation = { export: 'DSL mit geheimen Werten exportieren', }, }, + globalVar: { + title: 'Systemvariablen', + description: 'Systemvariablen sind globale Variablen, die von jedem Knoten ohne Verkabelung referenziert werden können, sofern der Typ passt, etwa Endnutzer-ID und Workflow-ID.', + fieldsDescription: { + conversationId: 'Konversations-ID', + dialogCount: 'Konversationsanzahl', + userId: 'Benutzer-ID', + triggerTimestamp: 'Zeitstempel des Anwendungsstarts', + appId: 'Anwendungs-ID', + workflowId: 'Workflow-ID', + workflowRunId: 'Workflow-Ausführungs-ID', + }, + }, chatVariable: { panelTitle: 'Gesprächsvariablen', panelDescription: 'Gesprächsvariablen werden verwendet, um interaktive Informationen zu speichern, die das LLM benötigt, einschließlich Gesprächsverlauf, hochgeladene Dateien und Benutzereinstellungen. Sie sind les- und schreibbar.', diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index 463ef38b58..d8de0eabaf 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -153,7 +153,7 @@ const translation = { conversationId: 'Conversation ID', dialogCount: 'Conversation Count', userId: 'User ID', - triggerTimestamp: 'Application trigger time', + triggerTimestamp: 'Application start timestamp', appId: 'Application ID', workflowId: 'Workflow ID', workflowRunId: 'Workflow run ID', diff --git a/web/i18n/es-ES/workflow.ts b/web/i18n/es-ES/workflow.ts index 804a510024..dd9519b68f 100644 --- a/web/i18n/es-ES/workflow.ts +++ b/web/i18n/es-ES/workflow.ts @@ -137,6 +137,19 @@ const translation = { export: 'Exportar DSL con valores secretos', }, }, + globalVar: { + title: 'Variables del sistema', + description: 'Las variables del sistema son variables globales que cualquier nodo puede usar sin conexiones cuando el tipo es correcto, como el ID del usuario final y el ID del flujo de trabajo.', + fieldsDescription: { + conversationId: 'ID de la conversación', + dialogCount: 'Número de conversaciones', + userId: 'ID de usuario', + triggerTimestamp: 'Marca de tiempo de inicio de la aplicación', + appId: 'ID de la aplicación', + workflowId: 'ID del flujo de trabajo', + workflowRunId: 'ID de ejecución del flujo de trabajo', + }, + }, chatVariable: { panelTitle: 'Variables de Conversación', panelDescription: 'Las Variables de Conversación se utilizan para almacenar información interactiva que el LLM necesita recordar, incluyendo el historial de conversación, archivos subidos y preferencias del usuario. Son de lectura y escritura.', diff --git a/web/i18n/fa-IR/workflow.ts b/web/i18n/fa-IR/workflow.ts index 15aaeb3568..e27b8934e2 100644 --- a/web/i18n/fa-IR/workflow.ts +++ b/web/i18n/fa-IR/workflow.ts @@ -137,6 +137,19 @@ const translation = { export: 'صادر کردن DSL با مقادیر مخفی', }, }, + globalVar: { + title: 'متغیرهای سیستمی', + description: 'متغیرهای سیستمی متغیرهای سراسری هستند که هر گره در صورت مطابقت نوع می‌تواند بدون سیم‌کشی از آن‌ها استفاده کند، مانند شناسه کاربر نهایی و شناسه گردش‌کار.', + fieldsDescription: { + conversationId: 'شناسه گفتگو', + dialogCount: 'تعداد گفتگو', + userId: 'شناسه کاربر', + triggerTimestamp: 'برچسب زمانی شروع اجرای برنامه', + appId: 'شناسه برنامه', + workflowId: 'شناسه گردش‌کار', + workflowRunId: 'شناسه اجرای گردش‌کار', + }, + }, chatVariable: { panelTitle: 'متغیرهای مکالمه', panelDescription: 'متغیرهای مکالمه برای ذخیره اطلاعات تعاملی که LLM نیاز به یادآوری دارد استفاده می‌شوند، از جمله تاریخچه مکالمه، فایل‌های آپلود شده و ترجیحات کاربر. آنها قابل خواندن و نوشتن هستند.', diff --git a/web/i18n/fr-FR/workflow.ts b/web/i18n/fr-FR/workflow.ts index 53314968b0..c6405e0851 100644 --- a/web/i18n/fr-FR/workflow.ts +++ b/web/i18n/fr-FR/workflow.ts @@ -137,6 +137,19 @@ const translation = { export: 'Exporter les DSL avec des valeurs secrètes', }, }, + globalVar: { + title: 'Variables système', + description: 'Les variables système sont des variables globales que tout nœud peut référencer sans câblage lorsque le type correspond, comme l\'ID utilisateur final et l\'ID du workflow.', + fieldsDescription: { + conversationId: 'ID de conversation', + dialogCount: 'Nombre de conversations', + userId: 'ID utilisateur', + triggerTimestamp: 'Horodatage du lancement de l\'application', + appId: 'ID de l\'application', + workflowId: 'ID du workflow', + workflowRunId: 'ID d\'exécution du workflow', + }, + }, chatVariable: { panelTitle: 'Variables de Conversation', panelDescription: 'Les Variables de Conversation sont utilisées pour stocker des informations interactives dont le LLM a besoin de se souvenir, y compris l\'historique des conversations, les fichiers téléchargés et les préférences de l\'utilisateur. Elles sont en lecture-écriture.', diff --git a/web/i18n/hi-IN/workflow.ts b/web/i18n/hi-IN/workflow.ts index 5fc25c6c92..f739f64cf0 100644 --- a/web/i18n/hi-IN/workflow.ts +++ b/web/i18n/hi-IN/workflow.ts @@ -140,6 +140,19 @@ const translation = { export: 'गुप्त मानों के साथ DSL निर्यात करें', }, }, + globalVar: { + title: 'सिस्टम वेरिएबल्स', + description: 'सिस्टम वेरिएबल्स वैश्विक वेरिएबल्स हैं जिन्हें सही प्रकार होने पर किसी भी नोड द्वारा बिना वायरिंग के संदर्भित किया जा सकता है, जैसे एंड-यूज़र ID और वर्कफ़्लो ID.', + fieldsDescription: { + conversationId: 'संवाद ID', + dialogCount: 'संवाद गणना', + userId: 'उपयोगकर्ता ID', + triggerTimestamp: 'एप्लिकेशन शुरू होने का टाइमस्टैम्प', + appId: 'एप्लिकेशन ID', + workflowId: 'वर्कफ़्लो ID', + workflowRunId: 'वर्कफ़्लो रन ID', + }, + }, chatVariable: { panelTitle: 'वार्तालाप चर', panelDescription: 'वार्तालाप चर का उपयोग इंटरैक्टिव जानकारी संग्रहित करने के लिए किया जाता है जिसे LLM को याद रखने की आवश्यकता होती है, जिसमें वार्तालाप इतिहास, अपलोड की गई फाइलें, उपयोगकर्ता प्राथमिकताएं शामिल हैं। वे पठनीय और लेखनीय हैं।', diff --git a/web/i18n/id-ID/workflow.ts b/web/i18n/id-ID/workflow.ts index e440d19a0e..506b17d925 100644 --- a/web/i18n/id-ID/workflow.ts +++ b/web/i18n/id-ID/workflow.ts @@ -137,6 +137,19 @@ const translation = { envPanelTitle: 'Variabel Lingkungan', envDescription: 'Variabel lingkungan dapat digunakan untuk menyimpan informasi pribadi dan kredensial. Mereka hanya baca dan dapat dipisahkan dari file DSL selama ekspor.', }, + globalVar: { + title: 'Variabel Sistem', + description: 'Variabel sistem adalah variabel global yang dapat dirujuk oleh node apa pun tanpa koneksi jika tipenya sesuai, seperti ID pengguna akhir dan ID alur kerja.', + fieldsDescription: { + conversationId: 'ID percakapan', + dialogCount: 'Jumlah percakapan', + userId: 'ID pengguna', + triggerTimestamp: 'Cap waktu saat aplikasi mulai berjalan', + appId: 'ID aplikasi', + workflowId: 'ID alur kerja', + workflowRunId: 'ID eksekusi alur kerja', + }, + }, chatVariable: { modal: { valuePlaceholder: 'Nilai default, biarkan kosong untuk tidak diatur', diff --git a/web/i18n/it-IT/workflow.ts b/web/i18n/it-IT/workflow.ts index 1c35a24c99..b188bc3666 100644 --- a/web/i18n/it-IT/workflow.ts +++ b/web/i18n/it-IT/workflow.ts @@ -141,6 +141,19 @@ const translation = { export: 'Esporta DSL con valori segreti', }, }, + globalVar: { + title: 'Variabili di sistema', + description: 'Le variabili di sistema sono variabili globali che possono essere richiamate da qualsiasi nodo senza collegamenti quando il tipo è corretto, come l\'ID dell\'utente finale e l\'ID del workflow.', + fieldsDescription: { + conversationId: 'ID conversazione', + dialogCount: 'Conteggio conversazioni', + userId: 'ID utente', + triggerTimestamp: 'Timestamp di avvio dell\'applicazione', + appId: 'ID applicazione', + workflowId: 'ID workflow', + workflowRunId: 'ID esecuzione workflow', + }, + }, chatVariable: { panelTitle: 'Variabili di Conversazione', panelDescription: 'Le Variabili di Conversazione sono utilizzate per memorizzare informazioni interattive che il LLM deve ricordare, inclusi la cronologia delle conversazioni, i file caricati e le preferenze dell\'utente. Sono in lettura e scrittura.', diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index 4f976d2b2a..d55f12fc8b 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -142,6 +142,19 @@ const translation = { export: 'シークレット値付きでエクスポート', }, }, + globalVar: { + title: 'システム変数', + description: 'システム変数は、タイプが適合していれば配線なしで任意のノードから参照できるグローバル変数です。エンドユーザーIDやワークフローIDなどが含まれます。', + fieldsDescription: { + conversationId: '会話ID', + dialogCount: '会話数', + userId: 'ユーザーID', + triggerTimestamp: 'アプリケーションの起動タイムスタンプ', + appId: 'アプリケーションID', + workflowId: 'ワークフローID', + workflowRunId: 'ワークフロー実行ID', + }, + }, sidebar: { exportWarning: '現在保存されているバージョンをエクスポート', exportWarningDesc: 'これは現在保存されているワークフローのバージョンをエクスポートします。エディターで未保存の変更がある場合は、まずワークフローキャンバスのエクスポートオプションを使用して保存してください。', diff --git a/web/i18n/ko-KR/workflow.ts b/web/i18n/ko-KR/workflow.ts index 4f8d5d248f..e661b6b340 100644 --- a/web/i18n/ko-KR/workflow.ts +++ b/web/i18n/ko-KR/workflow.ts @@ -143,6 +143,19 @@ const translation = { export: '비밀 값이 포함된 DSL 내보내기', }, }, + globalVar: { + title: '시스템 변수', + description: '시스템 변수는 타입이 맞으면 배선 없이도 모든 노드에서 참조할 수 있는 전역 변수로, 엔드유저 ID와 워크플로 ID 등이 포함됩니다.', + fieldsDescription: { + conversationId: '대화 ID', + dialogCount: '대화 수', + userId: '사용자 ID', + triggerTimestamp: '애플리케이션 시작 타임스탬프', + appId: '애플리케이션 ID', + workflowId: '워크플로 ID', + workflowRunId: '워크플로 실행 ID', + }, + }, chatVariable: { panelTitle: '대화 변수', panelDescription: diff --git a/web/i18n/pl-PL/workflow.ts b/web/i18n/pl-PL/workflow.ts index fd9f9cb460..f30e9350f7 100644 --- a/web/i18n/pl-PL/workflow.ts +++ b/web/i18n/pl-PL/workflow.ts @@ -137,6 +137,19 @@ const translation = { export: 'Eksportuj DSL z tajnymi wartościami', }, }, + globalVar: { + title: 'Zmienne systemowe', + description: 'Zmienne systemowe to zmienne globalne, do których może odwołać się każdy węzeł bez okablowania, jeśli typ jest zgodny, na przykład identyfikator użytkownika końcowego i identyfikator przepływu pracy.', + fieldsDescription: { + conversationId: 'ID konwersacji', + dialogCount: 'Liczba konwersacji', + userId: 'ID użytkownika', + triggerTimestamp: 'Znacznik czasu uruchomienia aplikacji', + appId: 'ID aplikacji', + workflowId: 'ID przepływu pracy', + workflowRunId: 'ID uruchomienia przepływu pracy', + }, + }, chatVariable: { panelTitle: 'Zmienne Konwersacji', panelDescription: 'Zmienne Konwersacji służą do przechowywania interaktywnych informacji, które LLM musi pamiętać, w tym historii konwersacji, przesłanych plików, preferencji użytkownika. Są one do odczytu i zapisu.', diff --git a/web/i18n/pt-BR/workflow.ts b/web/i18n/pt-BR/workflow.ts index 21fb89d272..265274c979 100644 --- a/web/i18n/pt-BR/workflow.ts +++ b/web/i18n/pt-BR/workflow.ts @@ -137,6 +137,19 @@ const translation = { export: 'Exportar DSL com valores secretos', }, }, + globalVar: { + title: 'Variáveis do sistema', + description: 'Variáveis do sistema são variáveis globais que qualquer nó pode referenciar sem conexões quando o tipo está correto, como o ID do usuário final e o ID do fluxo de trabalho.', + fieldsDescription: { + conversationId: 'ID da conversa', + dialogCount: 'Contagem de conversas', + userId: 'ID do usuário', + triggerTimestamp: 'Carimbo de data/hora do início da aplicação', + appId: 'ID da aplicação', + workflowId: 'ID do fluxo de trabalho', + workflowRunId: 'ID da execução do fluxo de trabalho', + }, + }, chatVariable: { panelTitle: 'Variáveis de Conversação', panelDescription: 'As Variáveis de Conversação são usadas para armazenar informações interativas que o LLM precisa lembrar, incluindo histórico de conversas, arquivos carregados, preferências do usuário. Elas são de leitura e escrita.', diff --git a/web/i18n/ro-RO/workflow.ts b/web/i18n/ro-RO/workflow.ts index ce9fd4e1f2..8d55033929 100644 --- a/web/i18n/ro-RO/workflow.ts +++ b/web/i18n/ro-RO/workflow.ts @@ -137,6 +137,19 @@ const translation = { export: 'Exportă DSL cu valori secrete', }, }, + globalVar: { + title: 'Variabile de sistem', + description: 'Variabilele de sistem sunt variabile globale care pot fi folosite de orice nod fără conexiuni dacă tipul este corect, precum ID-ul utilizatorului final și ID-ul fluxului de lucru.', + fieldsDescription: { + conversationId: 'ID conversație', + dialogCount: 'Număr conversații', + userId: 'ID utilizator', + triggerTimestamp: 'Marcaj temporal al pornirii aplicației', + appId: 'ID aplicație', + workflowId: 'ID flux de lucru', + workflowRunId: 'ID rulare flux de lucru', + }, + }, chatVariable: { panelTitle: 'Variabile de Conversație', panelDescription: 'Variabilele de Conversație sunt utilizate pentru a stoca informații interactive pe care LLM trebuie să le rețină, inclusiv istoricul conversației, fișiere încărcate, preferințele utilizatorului. Acestea sunt citibile și inscriptibile.', diff --git a/web/i18n/ru-RU/workflow.ts b/web/i18n/ru-RU/workflow.ts index 257e38691c..9d7c99acea 100644 --- a/web/i18n/ru-RU/workflow.ts +++ b/web/i18n/ru-RU/workflow.ts @@ -137,6 +137,19 @@ const translation = { export: 'Экспортировать DSL с секретными значениями ', }, }, + globalVar: { + title: 'Системные переменные', + description: 'Системные переменные — это глобальные переменные, к которым любой узел может обращаться без соединений при корректном типе, например идентификатор конечного пользователя и идентификатор рабочего процесса.', + fieldsDescription: { + conversationId: 'ID беседы', + dialogCount: 'Количество бесед', + userId: 'ID пользователя', + triggerTimestamp: 'Отметка времени запуска приложения', + appId: 'ID приложения', + workflowId: 'ID рабочего процесса', + workflowRunId: 'ID запуска рабочего процесса', + }, + }, chatVariable: { panelTitle: 'Переменные разговора', panelDescription: 'Переменные разговора используются для хранения интерактивной информации, которую LLM необходимо запомнить, включая историю разговоров, загруженные файлы, пользовательские настройки. Они доступны для чтения и записи. ', diff --git a/web/i18n/sl-SI/workflow.ts b/web/i18n/sl-SI/workflow.ts index 118aa6cd94..fb1f709162 100644 --- a/web/i18n/sl-SI/workflow.ts +++ b/web/i18n/sl-SI/workflow.ts @@ -137,6 +137,19 @@ const translation = { envPanelButton: 'Dodaj spremenljivko', envDescription: 'Okoljske spremenljivke se lahko uporabljajo za shranjevanje zasebnih informacij in poverilnic. So samo za branje in jih je mogoče ločiti od DSL datoteke med izvozem.', }, + globalVar: { + title: 'Sistemske spremenljivke', + description: 'Sistemske spremenljivke so globalne spremenljivke, do katerih lahko vsako vozlišče dostopa brez povezovanja, če je tip pravilen, na primer ID končnega uporabnika in ID poteka dela.', + fieldsDescription: { + conversationId: 'ID pogovora', + dialogCount: 'Število pogovorov', + userId: 'ID uporabnika', + triggerTimestamp: 'Časovni žig začetka delovanja aplikacije', + appId: 'ID aplikacije', + workflowId: 'ID poteka dela', + workflowRunId: 'ID izvajanja poteka dela', + }, + }, chatVariable: { modal: { namePlaceholder: 'Ime spremenljivke', diff --git a/web/i18n/th-TH/workflow.ts b/web/i18n/th-TH/workflow.ts index 9373b01584..51e9b4d088 100644 --- a/web/i18n/th-TH/workflow.ts +++ b/web/i18n/th-TH/workflow.ts @@ -137,6 +137,19 @@ const translation = { export: 'ส่งออก DSL ด้วยค่าลับ', }, }, + globalVar: { + title: 'ตัวแปรระบบ', + description: 'ตัวแปรระบบเป็นตัวแปรแบบโกลบอลที่โหนดใด ๆ สามารถอ้างอิงได้โดยไม่ต้องเดินสายเมื่อชนิดข้อมูลถูกต้อง เช่น รหัสผู้ใช้ปลายทางและรหัสเวิร์กโฟลว์', + fieldsDescription: { + conversationId: 'รหัสการสนทนา', + dialogCount: 'จำนวนการสนทนา', + userId: 'รหัสผู้ใช้', + triggerTimestamp: 'ตราประทับเวลาที่แอปเริ่มทำงาน', + appId: 'รหัสแอปพลิเคชัน', + workflowId: 'รหัสเวิร์กโฟลว์', + workflowRunId: 'รหัสการรันเวิร์กโฟลว์', + }, + }, chatVariable: { panelTitle: 'ตัวแปรการสนทนา', panelDescription: 'ตัวแปรการสนทนาใช้เพื่อจัดเก็บข้อมูลแบบโต้ตอบที่ LLM จําเป็นต้องจดจํา รวมถึงประวัติการสนทนา ไฟล์ที่อัปโหลด การตั้งค่าของผู้ใช้ พวกเขาอ่าน-เขียน', diff --git a/web/i18n/tr-TR/workflow.ts b/web/i18n/tr-TR/workflow.ts index e406e47d86..df4f9c8093 100644 --- a/web/i18n/tr-TR/workflow.ts +++ b/web/i18n/tr-TR/workflow.ts @@ -137,6 +137,19 @@ const translation = { export: 'Gizli değerlerle DSL\'yi dışa aktar', }, }, + globalVar: { + title: 'Sistem Değişkenleri', + description: 'Sistem değişkenleri, tipi uyumlu olduğunda herhangi bir düğümün bağlantı gerektirmeden başvurabileceği küresel değişkenlerdir; örneğin son kullanıcı kimliği ve iş akışı kimliği.', + fieldsDescription: { + conversationId: 'Konuşma Kimliği', + dialogCount: 'Konuşma Sayısı', + userId: 'Kullanıcı Kimliği', + triggerTimestamp: 'Uygulamanın çalışmaya başladığı zaman damgası', + appId: 'Uygulama Kimliği', + workflowId: 'İş Akışı Kimliği', + workflowRunId: 'İş akışı yürütme kimliği', + }, + }, chatVariable: { panelTitle: 'Konuşma Değişkenleri', panelDescription: 'Konuşma Değişkenleri, LLM\'nin hatırlaması gereken interaktif bilgileri (konuşma geçmişi, yüklenen dosyalar, kullanıcı tercihleri dahil) depolamak için kullanılır. Bunlar okunabilir ve yazılabilirdir.', diff --git a/web/i18n/uk-UA/workflow.ts b/web/i18n/uk-UA/workflow.ts index 774f1644a6..95425f0a32 100644 --- a/web/i18n/uk-UA/workflow.ts +++ b/web/i18n/uk-UA/workflow.ts @@ -137,6 +137,19 @@ const translation = { export: 'Експортувати DSL з секретними значеннями', }, }, + globalVar: { + title: 'Системні змінні', + description: 'Системні змінні — це глобальні змінні, до яких будь-який вузол може звертатися без з’єднання, якщо тип відповідає, наприклад ID кінцевого користувача та ID робочого процесу.', + fieldsDescription: { + conversationId: 'ID розмови', + dialogCount: 'Кількість розмов', + userId: 'ID користувача', + triggerTimestamp: 'Мітка часу запуску застосунку', + appId: 'ID застосунку', + workflowId: 'ID робочого процесу', + workflowRunId: 'ID запуску робочого процесу', + }, + }, chatVariable: { panelTitle: 'Змінні розмови', panelDescription: 'Змінні розмови використовуються для зберігання інтерактивної інформації, яку LLM повинен пам\'ятати, включаючи історію розмови, завантажені файли, вподобання користувача. Вони доступні для читання та запису.', diff --git a/web/i18n/vi-VN/workflow.ts b/web/i18n/vi-VN/workflow.ts index 89a09095bb..4fe45a8cc6 100644 --- a/web/i18n/vi-VN/workflow.ts +++ b/web/i18n/vi-VN/workflow.ts @@ -137,6 +137,19 @@ const translation = { export: 'Xuất DSL với giá trị bí mật', }, }, + globalVar: { + title: 'Biến hệ thống', + description: 'Biến hệ thống là biến toàn cục mà bất kỳ nút nào cũng có thể tham chiếu mà không cần nối dây khi kiểu dữ liệu phù hợp, chẳng hạn như ID người dùng cuối và ID quy trình làm việc.', + fieldsDescription: { + conversationId: 'ID cuộc trò chuyện', + dialogCount: 'Số lần trò chuyện', + userId: 'ID người dùng', + triggerTimestamp: 'Dấu thời gian ứng dụng bắt đầu chạy', + appId: 'ID ứng dụng', + workflowId: 'ID quy trình làm việc', + workflowRunId: 'ID lần chạy quy trình làm việc', + }, + }, chatVariable: { panelTitle: 'Biến Hội Thoại', panelDescription: 'Biến Hội Thoại được sử dụng để lưu trữ thông tin tương tác mà LLM cần ghi nhớ, bao gồm lịch sử hội thoại, tệp đã tải lên, tùy chọn người dùng. Chúng có thể đọc và ghi được.', diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index ab034b2280..53ec625160 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -145,6 +145,19 @@ const translation = { export: '导出包含 Secret 值的 DSL', }, }, + globalVar: { + title: '系统变量', + description: '系统变量是全局变量,在类型匹配时无需连线即可被任意节点引用,例如终端用户 ID 和工作流 ID。', + fieldsDescription: { + conversationId: '会话 ID', + dialogCount: '会话次数', + userId: '用户 ID', + triggerTimestamp: '应用开始运行的时间戳', + appId: '应用 ID', + workflowId: '工作流 ID', + workflowRunId: '工作流运行 ID', + }, + }, sidebar: { exportWarning: '导出当前已保存版本', exportWarningDesc: '这将导出您工作流的当前已保存版本。如果您在编辑器中有未保存的更改,请先使用工作流画布中的导出选项保存它们。', diff --git a/web/i18n/zh-Hant/workflow.ts b/web/i18n/zh-Hant/workflow.ts index 968d26e29b..324577ab73 100644 --- a/web/i18n/zh-Hant/workflow.ts +++ b/web/i18n/zh-Hant/workflow.ts @@ -137,6 +137,19 @@ const translation = { export: '導出帶有機密值的 DSL', }, }, + globalVar: { + title: '系統變數', + description: '系統變數是全域變數,在類型符合時可由任意節點在無需連線的情況下引用,例如終端使用者 ID 與工作流程 ID。', + fieldsDescription: { + conversationId: '對話 ID', + dialogCount: '對話次數', + userId: '使用者 ID', + triggerTimestamp: '應用程式開始運行的時間戳', + appId: '應用程式 ID', + workflowId: '工作流程 ID', + workflowRunId: '工作流程執行 ID', + }, + }, chatVariable: { panelTitle: '對話變數', panelDescription: '對話變數用於儲存 LLM 需要記住的互動資訊,包括對話歷史、上傳的檔案、使用者偏好等。這些變數可讀寫。', From 48b1829b14e4d873f41e989f17e5cda00f783536 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Wed, 29 Oct 2025 22:08:04 +0800 Subject: [PATCH 066/394] chore: improve toggle env/conversation/global var panel --- .../workflow/header/chat-variable-button.tsx | 5 +++- .../components/workflow/header/env-button.tsx | 5 +++- .../header/global-variable-button.tsx | 27 ++++++++++++++++--- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/web/app/components/workflow/header/chat-variable-button.tsx b/web/app/components/workflow/header/chat-variable-button.tsx index 8fdc86e6f7..aa68182c23 100644 --- a/web/app/components/workflow/header/chat-variable-button.tsx +++ b/web/app/components/workflow/header/chat-variable-button.tsx @@ -7,13 +7,16 @@ import cn from '@/utils/classnames' const ChatVariableButton = ({ disabled }: { disabled: boolean }) => { const { theme } = useTheme() + const showChatVariablePanel = useStore(s => s.showChatVariablePanel) const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel) const setShowEnvPanel = useStore(s => s.setShowEnvPanel) + const setShowGlobalVariablePanel = useStore(s => s.setShowGlobalVariablePanel) const setShowDebugAndPreviewPanel = useStore(s => s.setShowDebugAndPreviewPanel) const handleClick = () => { setShowChatVariablePanel(true) setShowEnvPanel(false) + setShowGlobalVariablePanel(false) setShowDebugAndPreviewPanel(false) } @@ -21,7 +24,7 @@ const ChatVariableButton = ({ disabled }: { disabled: boolean }) => { ) From 0607db41e5cc36b6595f5ed50119aa700f2cd41f Mon Sep 17 00:00:00 2001 From: hjlarry Date: Thu, 30 Oct 2025 09:02:38 +0800 Subject: [PATCH 067/394] fix(variable): draft run workflow cause global var panel misalign --- .../workflow-app/hooks/use-workflow-start-run.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/web/app/components/workflow-app/hooks/use-workflow-start-run.tsx b/web/app/components/workflow-app/hooks/use-workflow-start-run.tsx index a5daab0525..d2e3b3e3c9 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-start-run.tsx +++ b/web/app/components/workflow-app/hooks/use-workflow-start-run.tsx @@ -41,9 +41,11 @@ export const useWorkflowStartRun = () => { setShowDebugAndPreviewPanel, setShowInputsPanel, setShowEnvPanel, + setShowGlobalVariablePanel, } = workflowStore.getState() setShowEnvPanel(false) + setShowGlobalVariablePanel(false) if (showDebugAndPreviewPanel) { handleCancelDebugAndPreviewPanel() @@ -72,6 +74,7 @@ export const useWorkflowStartRun = () => { setShowDebugAndPreviewPanel, setShowInputsPanel, setShowEnvPanel, + setShowGlobalVariablePanel, setListeningTriggerType, setListeningTriggerNodeId, setListeningTriggerNodeIds, @@ -91,6 +94,7 @@ export const useWorkflowStartRun = () => { } setShowEnvPanel(false) + setShowGlobalVariablePanel(false) if (showDebugAndPreviewPanel) { handleCancelDebugAndPreviewPanel() @@ -125,6 +129,7 @@ export const useWorkflowStartRun = () => { setShowDebugAndPreviewPanel, setShowInputsPanel, setShowEnvPanel, + setShowGlobalVariablePanel, setListeningTriggerType, setListeningTriggerNodeId, setListeningTriggerNodeIds, @@ -144,6 +149,7 @@ export const useWorkflowStartRun = () => { } setShowEnvPanel(false) + setShowGlobalVariablePanel(false) if (!showDebugAndPreviewPanel) setShowDebugAndPreviewPanel(true) @@ -174,6 +180,7 @@ export const useWorkflowStartRun = () => { setShowDebugAndPreviewPanel, setShowInputsPanel, setShowEnvPanel, + setShowGlobalVariablePanel, setListeningTriggerType, setListeningTriggerNodeId, setListeningTriggerNodeIds, @@ -193,6 +200,7 @@ export const useWorkflowStartRun = () => { } setShowEnvPanel(false) + setShowGlobalVariablePanel(false) if (!showDebugAndPreviewPanel) setShowDebugAndPreviewPanel(true) @@ -223,6 +231,7 @@ export const useWorkflowStartRun = () => { setShowDebugAndPreviewPanel, setShowInputsPanel, setShowEnvPanel, + setShowGlobalVariablePanel, setListeningTriggerIsAll, setListeningTriggerNodeIds, setListeningTriggerNodeId, @@ -232,6 +241,7 @@ export const useWorkflowStartRun = () => { return setShowEnvPanel(false) + setShowGlobalVariablePanel(false) setShowInputsPanel(false) setListeningTriggerIsAll(true) setListeningTriggerNodeIds(nodeIds) @@ -258,10 +268,12 @@ export const useWorkflowStartRun = () => { setHistoryWorkflowData, setShowEnvPanel, setShowChatVariablePanel, + setShowGlobalVariablePanel, } = workflowStore.getState() setShowEnvPanel(false) setShowChatVariablePanel(false) + setShowGlobalVariablePanel(false) if (showDebugAndPreviewPanel) handleCancelDebugAndPreviewPanel() From c905c4777583c78f8c6d67485e3a12277839d00b Mon Sep 17 00:00:00 2001 From: yangzheli <43645580+yangzheli@users.noreply.github.com> Date: Thu, 30 Oct 2025 09:31:24 +0800 Subject: [PATCH 068/394] fix(web): add a scrollbar when the setting-modal content overflows (#27620) --- .../features/new-feature-panel/file-upload/setting-modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx b/web/app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx index 92f93b8819..6ebbc05ae5 100644 --- a/web/app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx +++ b/web/app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx @@ -37,7 +37,7 @@ const FileUploadSettings = ({ {children} -
+
onOpen(false)} From c71f7c76136f14002309400c6b5377cb7223f4b9 Mon Sep 17 00:00:00 2001 From: kurokobo Date: Thu, 30 Oct 2025 10:34:59 +0900 Subject: [PATCH 069/394] fix(http_request): set response.text if there is no file (#27610) --- api/core/workflow/nodes/http_request/node.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/core/workflow/nodes/http_request/node.py b/api/core/workflow/nodes/http_request/node.py index 55dec3fb08..152d3cc562 100644 --- a/api/core/workflow/nodes/http_request/node.py +++ b/api/core/workflow/nodes/http_request/node.py @@ -104,7 +104,7 @@ class HttpRequestNode(Node): status=WorkflowNodeExecutionStatus.FAILED, outputs={ "status_code": response.status_code, - "body": response.text if not files else "", + "body": response.text if not files.value else "", "headers": response.headers, "files": files, }, From b7360140ee83adebccd6a07fba5375b21352fc83 Mon Sep 17 00:00:00 2001 From: issac2e <90555819+issac2e@users.noreply.github.com> Date: Thu, 30 Oct 2025 09:38:39 +0800 Subject: [PATCH 070/394] fix: resolve stale closure values in LLM node callbacks (#27612) (#27614) Co-authored-by: liuchen15 --- .../workflow/nodes/llm/use-config.ts | 35 ++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/web/app/components/workflow/nodes/llm/use-config.ts b/web/app/components/workflow/nodes/llm/use-config.ts index 44c7096744..c0608865b8 100644 --- a/web/app/components/workflow/nodes/llm/use-config.ts +++ b/web/app/components/workflow/nodes/llm/use-config.ts @@ -27,6 +27,9 @@ const useConfig = (id: string, payload: LLMNodeType) => { const [defaultRolePrefix, setDefaultRolePrefix] = useState<{ user: string; assistant: string }>({ user: '', assistant: '' }) const { inputs, setInputs: doSetInputs } = useNodeCrud(id, payload) const inputRef = useRef(inputs) + useEffect(() => { + inputRef.current = inputs + }, [inputs]) const { deleteNodeInspectorVars } = useInspectVarsCrud() @@ -117,7 +120,7 @@ const useConfig = (id: string, payload: LLMNodeType) => { } = useConfigVision(model, { payload: inputs.vision, onChange: (newPayload) => { - const newInputs = produce(inputs, (draft) => { + const newInputs = produce(inputRef.current, (draft) => { draft.vision = newPayload }) setInputs(newInputs) @@ -148,11 +151,11 @@ const useConfig = (id: string, payload: LLMNodeType) => { }, [model.provider, currentProvider, currentModel, handleModelChanged]) const handleCompletionParamsChange = useCallback((newParams: Record) => { - const newInputs = produce(inputs, (draft) => { + const newInputs = produce(inputRef.current, (draft) => { draft.model.completion_params = newParams }) setInputs(newInputs) - }, [inputs, setInputs]) + }, [setInputs]) // change to vision model to set vision enabled, else disabled useEffect(() => { @@ -238,29 +241,29 @@ const useConfig = (id: string, payload: LLMNodeType) => { // context const handleContextVarChange = useCallback((newVar: ValueSelector | string) => { - const newInputs = produce(inputs, (draft) => { + const newInputs = produce(inputRef.current, (draft) => { draft.context.variable_selector = newVar as ValueSelector || [] draft.context.enabled = !!(newVar && newVar.length > 0) }) setInputs(newInputs) - }, [inputs, setInputs]) + }, [setInputs]) const handlePromptChange = useCallback((newPrompt: PromptItem[] | PromptItem) => { const newInputs = produce(inputRef.current, (draft) => { draft.prompt_template = newPrompt }) setInputs(newInputs) - }, [inputs, setInputs]) + }, [setInputs]) const handleMemoryChange = useCallback((newMemory?: Memory) => { - const newInputs = produce(inputs, (draft) => { + const newInputs = produce(inputRef.current, (draft) => { draft.memory = newMemory }) setInputs(newInputs) - }, [inputs, setInputs]) + }, [setInputs]) const handleSyeQueryChange = useCallback((newQuery: string) => { - const newInputs = produce(inputs, (draft) => { + const newInputs = produce(inputRef.current, (draft) => { if (!draft.memory) { draft.memory = { window: { @@ -275,7 +278,7 @@ const useConfig = (id: string, payload: LLMNodeType) => { } }) setInputs(newInputs) - }, [inputs, setInputs]) + }, [setInputs]) // structure output const { data: modelList } = useModelList(ModelTypeEnum.textGeneration) @@ -286,22 +289,22 @@ const useConfig = (id: string, payload: LLMNodeType) => { const [structuredOutputCollapsed, setStructuredOutputCollapsed] = useState(true) const handleStructureOutputEnableChange = useCallback((enabled: boolean) => { - const newInputs = produce(inputs, (draft) => { + const newInputs = produce(inputRef.current, (draft) => { draft.structured_output_enabled = enabled }) setInputs(newInputs) if (enabled) setStructuredOutputCollapsed(false) deleteNodeInspectorVars(id) - }, [inputs, setInputs, deleteNodeInspectorVars, id]) + }, [setInputs, deleteNodeInspectorVars, id]) const handleStructureOutputChange = useCallback((newOutput: StructuredOutput) => { - const newInputs = produce(inputs, (draft) => { + const newInputs = produce(inputRef.current, (draft) => { draft.structured_output = newOutput }) setInputs(newInputs) deleteNodeInspectorVars(id) - }, [inputs, setInputs, deleteNodeInspectorVars, id]) + }, [setInputs, deleteNodeInspectorVars, id]) const filterInputVar = useCallback((varPayload: Var) => { return [VarType.number, VarType.string, VarType.secret, VarType.arrayString, VarType.arrayNumber, VarType.file, VarType.arrayFile].includes(varPayload.type) @@ -317,11 +320,11 @@ const useConfig = (id: string, payload: LLMNodeType) => { // reasoning format const handleReasoningFormatChange = useCallback((reasoningFormat: 'tagged' | 'separated') => { - const newInputs = produce(inputs, (draft) => { + const newInputs = produce(inputRef.current, (draft) => { draft.reasoning_format = reasoningFormat }) setInputs(newInputs) - }, [inputs, setInputs]) + }, [setInputs]) const { availableVars, From ca9d92b1e5045684a99ff7757d2d1c4ab77f9e75 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Thu, 30 Oct 2025 09:53:00 +0800 Subject: [PATCH 071/394] fix(variable): when open history mode not close global var panel --- web/app/components/workflow/header/header-in-normal.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/app/components/workflow/header/header-in-normal.tsx b/web/app/components/workflow/header/header-in-normal.tsx index 9b0fdec41b..20fdafaff5 100644 --- a/web/app/components/workflow/header/header-in-normal.tsx +++ b/web/app/components/workflow/header/header-in-normal.tsx @@ -42,6 +42,7 @@ const HeaderInNormal = ({ const setShowDebugAndPreviewPanel = useStore(s => s.setShowDebugAndPreviewPanel) const setShowVariableInspectPanel = useStore(s => s.setShowVariableInspectPanel) const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel) + const setShowGlobalVariablePanel = useStore(s => s.setShowGlobalVariablePanel) const nodes = useNodes() const selectedNode = nodes.find(node => node.data.selected) const { handleBackupDraft } = useWorkflowRun() @@ -58,8 +59,9 @@ const HeaderInNormal = ({ setShowDebugAndPreviewPanel(false) setShowVariableInspectPanel(false) setShowChatVariablePanel(false) + setShowGlobalVariablePanel(false) closeAllInputFieldPanels() - }, [workflowStore, handleBackupDraft, selectedNode, handleNodeSelect, setShowWorkflowVersionHistoryPanel, setShowEnvPanel, setShowDebugAndPreviewPanel, setShowVariableInspectPanel, setShowChatVariablePanel]) + }, [workflowStore, handleBackupDraft, selectedNode, handleNodeSelect, setShowWorkflowVersionHistoryPanel, setShowEnvPanel, setShowDebugAndPreviewPanel, setShowVariableInspectPanel, setShowChatVariablePanel, setShowGlobalVariablePanel]) return (
From 41e549af14e7e0ca90c3f8547c446ba0dd9f8641 Mon Sep 17 00:00:00 2001 From: quicksand Date: Thu, 30 Oct 2025 09:59:08 +0800 Subject: [PATCH 072/394] fix(weaviate): skip init checks to prevent PyPI requests on each search (#27624) Co-authored-by: Claude --- api/core/rag/datasource/vdb/weaviate/weaviate_vector.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/core/rag/datasource/vdb/weaviate/weaviate_vector.py b/api/core/rag/datasource/vdb/weaviate/weaviate_vector.py index d2d8fcf964..dceade0af9 100644 --- a/api/core/rag/datasource/vdb/weaviate/weaviate_vector.py +++ b/api/core/rag/datasource/vdb/weaviate/weaviate_vector.py @@ -100,6 +100,7 @@ class WeaviateVector(BaseVector): grpc_port=grpc_port, grpc_secure=grpc_secure, auth_credentials=Auth.api_key(config.api_key) if config.api_key else None, + skip_init_checks=True, # Skip PyPI version check to avoid unnecessary HTTP requests ) if not client.is_ready(): From fd7c4e8a6df274561f119b75cc589f389865fadf Mon Sep 17 00:00:00 2001 From: Wu Tianwei <30284043+WTW0313@users.noreply.github.com> Date: Thu, 30 Oct 2025 11:02:54 +0800 Subject: [PATCH 073/394] feat: enhance pipeline template list with marketplace feature toggle (#27604) --- .../create-from-pipeline/list/built-in-pipeline-list.tsx | 4 +++- web/service/use-pipeline.ts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx b/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx index 6d22f2115a..74e565a494 100644 --- a/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx @@ -4,6 +4,7 @@ import CreateCard from './create-card' import { useI18N } from '@/context/i18n' import { useMemo } from 'react' import { LanguagesSupported } from '@/i18n-config/language' +import { useGlobalPublicStore } from '@/context/global-public-context' const BuiltInPipelineList = () => { const { locale } = useI18N() @@ -12,7 +13,8 @@ const BuiltInPipelineList = () => { return locale return LanguagesSupported[0] }, [locale]) - const { data: pipelineList, isLoading } = usePipelineTemplateList({ type: 'built-in', language }) + const enableMarketplace = useGlobalPublicStore(s => s.systemFeatures.enable_marketplace) + const { data: pipelineList, isLoading } = usePipelineTemplateList({ type: 'built-in', language }, enableMarketplace) const list = pipelineList?.pipeline_templates || [] return ( diff --git a/web/service/use-pipeline.ts b/web/service/use-pipeline.ts index a7b9c89410..92a7542c56 100644 --- a/web/service/use-pipeline.ts +++ b/web/service/use-pipeline.ts @@ -39,13 +39,14 @@ import { useInvalid } from './use-base' const NAME_SPACE = 'pipeline' export const PipelineTemplateListQueryKeyPrefix = [NAME_SPACE, 'template-list'] -export const usePipelineTemplateList = (params: PipelineTemplateListParams) => { +export const usePipelineTemplateList = (params: PipelineTemplateListParams, enabled = true) => { const { type, language } = params return useQuery({ queryKey: [...PipelineTemplateListQueryKeyPrefix, type, language], queryFn: () => { return get('/rag/pipeline/templates', { params }) }, + enabled, }) } From a1c0bd7a1c4daaf0226a8dc58392beb99b36aaff Mon Sep 17 00:00:00 2001 From: QuantumGhost Date: Thu, 30 Oct 2025 14:41:09 +0800 Subject: [PATCH 074/394] feat(api): Introduce workflow pause state management (#27298) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/core/app/apps/advanced_chat/app_runner.py | 7 +- api/core/app/apps/workflow/app_runner.py | 2 + api/core/app/apps/workflow_app_runner.py | 5 +- .../app/layers/pause_state_persist_layer.py | 71 ++ api/core/workflow/entities/__init__.py | 2 + api/core/workflow/entities/pause_reason.py | 49 + api/core/workflow/entities/workflow_pause.py | 61 ++ api/core/workflow/enums.py | 98 ++ .../command_processing/command_handlers.py | 7 +- .../graph_engine/domain/graph_execution.py | 7 +- .../graph_engine/entities/commands.py | 2 +- .../event_management/event_handlers.py | 2 +- .../workflow/graph_engine/graph_engine.py | 5 +- .../graph_engine/layers/persistence.py | 3 +- api/core/workflow/graph_events/graph.py | 4 +- api/core/workflow/graph_events/node.py | 3 +- api/core/workflow/node_events/node.py | 3 +- .../nodes/human_input/human_input_node.py | 3 +- .../runtime/graph_runtime_state_protocol.py | 4 + .../workflow/runtime/read_only_wrappers.py | 5 + api/core/workflow/system_variable.py | 100 ++ api/extensions/ext_storage.py | 2 +- api/extensions/storage/base_storage.py | 2 +- ...11-03f8dcbc611e_add_workflowpause_model.py | 41 + api/models/__init__.py | 2 + api/models/base.py | 39 +- api/models/workflow.py | 124 ++- .../api_workflow_run_repository.py | 111 ++ .../sqlalchemy_api_workflow_run_repository.py | 356 ++++++- api/services/workflow_run_service.py | 18 +- .../core/__init__.py | 1 + .../core/app/__init__.py | 1 + .../core/app/layers/__init__.py | 1 + .../layers/test_pause_state_persist_layer.py | 520 ++++++++++ .../test_workflow_pause_integration.py | 948 ++++++++++++++++++ .../layers/test_pause_state_persist_layer.py | 278 +++++ .../entities/test_private_workflow_pause.py | 171 ++++ .../graph_engine/test_command_system.py | 5 +- .../unit_tests/core/workflow/test_enums.py | 32 + .../test_system_variable_read_only_view.py | 202 ++++ api/tests/unit_tests/models/test_base.py | 11 + ..._sqlalchemy_api_workflow_run_repository.py | 370 +++++++ .../test_workflow_run_service_pause.py | 200 ++++ 43 files changed, 3834 insertions(+), 44 deletions(-) create mode 100644 api/core/app/layers/pause_state_persist_layer.py create mode 100644 api/core/workflow/entities/pause_reason.py create mode 100644 api/core/workflow/entities/workflow_pause.py create mode 100644 api/migrations/versions/2025_10_22_1611-03f8dcbc611e_add_workflowpause_model.py create mode 100644 api/tests/test_containers_integration_tests/core/__init__.py create mode 100644 api/tests/test_containers_integration_tests/core/app/__init__.py create mode 100644 api/tests/test_containers_integration_tests/core/app/layers/__init__.py create mode 100644 api/tests/test_containers_integration_tests/core/app/layers/test_pause_state_persist_layer.py create mode 100644 api/tests/test_containers_integration_tests/test_workflow_pause_integration.py create mode 100644 api/tests/unit_tests/core/app/layers/test_pause_state_persist_layer.py create mode 100644 api/tests/unit_tests/core/workflow/entities/test_private_workflow_pause.py create mode 100644 api/tests/unit_tests/core/workflow/test_enums.py create mode 100644 api/tests/unit_tests/core/workflow/test_system_variable_read_only_view.py create mode 100644 api/tests/unit_tests/models/test_base.py create mode 100644 api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py create mode 100644 api/tests/unit_tests/services/test_workflow_run_service_pause.py diff --git a/api/core/app/apps/advanced_chat/app_runner.py b/api/core/app/apps/advanced_chat/app_runner.py index 587c663482..c029e00553 100644 --- a/api/core/app/apps/advanced_chat/app_runner.py +++ b/api/core/app/apps/advanced_chat/app_runner.py @@ -1,6 +1,6 @@ import logging import time -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from typing import Any, cast from sqlalchemy import select @@ -25,6 +25,7 @@ from core.moderation.input_moderation import InputModeration from core.variables.variables import VariableUnion from core.workflow.enums import WorkflowType from core.workflow.graph_engine.command_channels.redis_channel import RedisChannel +from core.workflow.graph_engine.layers.base import GraphEngineLayer from core.workflow.graph_engine.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer from core.workflow.repositories.workflow_execution_repository import WorkflowExecutionRepository from core.workflow.repositories.workflow_node_execution_repository import WorkflowNodeExecutionRepository @@ -61,11 +62,13 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner): app: App, workflow_execution_repository: WorkflowExecutionRepository, workflow_node_execution_repository: WorkflowNodeExecutionRepository, + graph_engine_layers: Sequence[GraphEngineLayer] = (), ): super().__init__( queue_manager=queue_manager, variable_loader=variable_loader, app_id=application_generate_entity.app_config.app_id, + graph_engine_layers=graph_engine_layers, ) self.application_generate_entity = application_generate_entity self.conversation = conversation @@ -195,6 +198,8 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner): ) workflow_entry.graph_engine.layer(persistence_layer) + for layer in self._graph_engine_layers: + workflow_entry.graph_engine.layer(layer) generator = workflow_entry.run() diff --git a/api/core/app/apps/workflow/app_runner.py b/api/core/app/apps/workflow/app_runner.py index 3c9bf176b5..eab2256426 100644 --- a/api/core/app/apps/workflow/app_runner.py +++ b/api/core/app/apps/workflow/app_runner.py @@ -135,6 +135,8 @@ class WorkflowAppRunner(WorkflowBasedAppRunner): ) workflow_entry.graph_engine.layer(persistence_layer) + for layer in self._graph_engine_layers: + workflow_entry.graph_engine.layer(layer) generator = workflow_entry.run() diff --git a/api/core/app/apps/workflow_app_runner.py b/api/core/app/apps/workflow_app_runner.py index 5e2bd17f8c..73725e75b5 100644 --- a/api/core/app/apps/workflow_app_runner.py +++ b/api/core/app/apps/workflow_app_runner.py @@ -1,5 +1,5 @@ import time -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from typing import Any, cast from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom @@ -27,6 +27,7 @@ from core.app.entities.queue_entities import ( ) from core.workflow.entities import GraphInitParams from core.workflow.graph import Graph +from core.workflow.graph_engine.layers.base import GraphEngineLayer from core.workflow.graph_events import ( GraphEngineEvent, GraphRunFailedEvent, @@ -69,10 +70,12 @@ class WorkflowBasedAppRunner: queue_manager: AppQueueManager, variable_loader: VariableLoader = DUMMY_VARIABLE_LOADER, app_id: str, + graph_engine_layers: Sequence[GraphEngineLayer] = (), ): self._queue_manager = queue_manager self._variable_loader = variable_loader self._app_id = app_id + self._graph_engine_layers = graph_engine_layers def _init_graph( self, diff --git a/api/core/app/layers/pause_state_persist_layer.py b/api/core/app/layers/pause_state_persist_layer.py new file mode 100644 index 0000000000..3dee75c082 --- /dev/null +++ b/api/core/app/layers/pause_state_persist_layer.py @@ -0,0 +1,71 @@ +from sqlalchemy import Engine +from sqlalchemy.orm import sessionmaker + +from core.workflow.graph_engine.layers.base import GraphEngineLayer +from core.workflow.graph_events.base import GraphEngineEvent +from core.workflow.graph_events.graph import GraphRunPausedEvent +from repositories.api_workflow_run_repository import APIWorkflowRunRepository +from repositories.factory import DifyAPIRepositoryFactory + + +class PauseStatePersistenceLayer(GraphEngineLayer): + def __init__(self, session_factory: Engine | sessionmaker, state_owner_user_id: str): + """Create a PauseStatePersistenceLayer. + + The `state_owner_user_id` is used when creating state file for pause. + It generally should id of the creator of workflow. + """ + if isinstance(session_factory, Engine): + session_factory = sessionmaker(session_factory) + self._session_maker = session_factory + self._state_owner_user_id = state_owner_user_id + + def _get_repo(self) -> APIWorkflowRunRepository: + return DifyAPIRepositoryFactory.create_api_workflow_run_repository(self._session_maker) + + def on_graph_start(self) -> None: + """ + Called when graph execution starts. + + This is called after the engine has been initialized but before any nodes + are executed. Layers can use this to set up resources or log start information. + """ + pass + + def on_event(self, event: GraphEngineEvent) -> None: + """ + Called for every event emitted by the engine. + + This method receives all events generated during graph execution, including: + - Graph lifecycle events (start, success, failure) + - Node execution events (start, success, failure, retry) + - Stream events for response nodes + - Container events (iteration, loop) + + Args: + event: The event emitted by the engine + """ + if not isinstance(event, GraphRunPausedEvent): + return + + assert self.graph_runtime_state is not None + workflow_run_id: str | None = self.graph_runtime_state.system_variable.workflow_execution_id + assert workflow_run_id is not None + repo = self._get_repo() + repo.create_workflow_pause( + workflow_run_id=workflow_run_id, + state_owner_user_id=self._state_owner_user_id, + state=self.graph_runtime_state.dumps(), + ) + + def on_graph_end(self, error: Exception | None) -> None: + """ + Called when graph execution ends. + + This is called after all nodes have been executed or when execution is + aborted. Layers can use this to clean up resources or log final state. + + Args: + error: The exception that caused execution to fail, or None if successful + """ + pass diff --git a/api/core/workflow/entities/__init__.py b/api/core/workflow/entities/__init__.py index 185f0ad620..f4ce9052e0 100644 --- a/api/core/workflow/entities/__init__.py +++ b/api/core/workflow/entities/__init__.py @@ -4,6 +4,7 @@ from .agent import AgentNodeStrategyInit from .graph_init_params import GraphInitParams from .workflow_execution import WorkflowExecution from .workflow_node_execution import WorkflowNodeExecution +from .workflow_pause import WorkflowPauseEntity __all__ = [ "AgentNodeStrategyInit", @@ -12,4 +13,5 @@ __all__ = [ "VariablePool", "WorkflowExecution", "WorkflowNodeExecution", + "WorkflowPauseEntity", ] diff --git a/api/core/workflow/entities/pause_reason.py b/api/core/workflow/entities/pause_reason.py new file mode 100644 index 0000000000..16ad3d639d --- /dev/null +++ b/api/core/workflow/entities/pause_reason.py @@ -0,0 +1,49 @@ +from enum import StrEnum, auto +from typing import Annotated, Any, ClassVar, TypeAlias + +from pydantic import BaseModel, Discriminator, Tag + + +class _PauseReasonType(StrEnum): + HUMAN_INPUT_REQUIRED = auto() + SCHEDULED_PAUSE = auto() + + +class _PauseReasonBase(BaseModel): + TYPE: ClassVar[_PauseReasonType] + + +class HumanInputRequired(_PauseReasonBase): + TYPE = _PauseReasonType.HUMAN_INPUT_REQUIRED + + +class SchedulingPause(_PauseReasonBase): + TYPE = _PauseReasonType.SCHEDULED_PAUSE + + message: str + + +def _get_pause_reason_discriminator(v: Any) -> _PauseReasonType | None: + if isinstance(v, _PauseReasonBase): + return v.TYPE + elif isinstance(v, dict): + reason_type_str = v.get("TYPE") + if reason_type_str is None: + return None + try: + reason_type = _PauseReasonType(reason_type_str) + except ValueError: + return None + return reason_type + else: + # return None if the discriminator value isn't found + return None + + +PauseReason: TypeAlias = Annotated[ + ( + Annotated[HumanInputRequired, Tag(_PauseReasonType.HUMAN_INPUT_REQUIRED)] + | Annotated[SchedulingPause, Tag(_PauseReasonType.SCHEDULED_PAUSE)] + ), + Discriminator(_get_pause_reason_discriminator), +] diff --git a/api/core/workflow/entities/workflow_pause.py b/api/core/workflow/entities/workflow_pause.py new file mode 100644 index 0000000000..2f31c1ff53 --- /dev/null +++ b/api/core/workflow/entities/workflow_pause.py @@ -0,0 +1,61 @@ +""" +Domain entities for workflow pause management. + +This module contains the domain model for workflow pause, which is used +by the core workflow module. These models are independent of the storage mechanism +and don't contain implementation details like tenant_id, app_id, etc. +""" + +from abc import ABC, abstractmethod +from datetime import datetime + + +class WorkflowPauseEntity(ABC): + """ + Abstract base class for workflow pause entities. + + This domain model represents a paused workflow execution state, + without implementation details like tenant_id, app_id, etc. + It provides the interface for managing workflow pause/resume operations + and state persistence through file storage. + + The `WorkflowPauseEntity` is never reused. If a workflow execution pauses multiple times, + it will generate multiple `WorkflowPauseEntity` records. + """ + + @property + @abstractmethod + def id(self) -> str: + """The identifier of current WorkflowPauseEntity""" + pass + + @property + @abstractmethod + def workflow_execution_id(self) -> str: + """The identifier of the workflow execution record the pause associated with. + Correspond to `WorkflowExecution.id`. + """ + + @abstractmethod + def get_state(self) -> bytes: + """ + Retrieve the serialized workflow state from storage. + + This method should load and return the workflow execution state + that was saved when the workflow was paused. The state contains + all necessary information to resume the workflow execution. + + Returns: + bytes: The serialized workflow state containing + execution context, variable values, node states, etc. + + """ + ... + + @property + @abstractmethod + def resumed_at(self) -> datetime | None: + """`resumed_at` return the resumption time of the current pause, or `None` if + the pause is not resumed yet. + """ + pass diff --git a/api/core/workflow/enums.py b/api/core/workflow/enums.py index 83b9281e51..6f95ecc76f 100644 --- a/api/core/workflow/enums.py +++ b/api/core/workflow/enums.py @@ -92,13 +92,111 @@ class WorkflowType(StrEnum): class WorkflowExecutionStatus(StrEnum): + # State diagram for the workflw status: + # (@) means start, (*) means end + # + # ┌------------------>------------------------->------------------->--------------┐ + # | | + # | ┌-----------------------<--------------------┐ | + # ^ | | | + # | | ^ | + # | V | | + # ┌-----------┐ ┌-----------------------┐ ┌-----------┐ V + # | Scheduled |------->| Running |---------------------->| paused | | + # └-----------┘ └-----------------------┘ └-----------┘ | + # | | | | | | | + # | | | | | | | + # ^ | | | V V | + # | | | | | ┌---------┐ | + # (@) | | | └------------------------>| Stopped |<----┘ + # | | | └---------┘ + # | | | | + # | | V V + # | | ┌-----------┐ | + # | | | Succeeded |------------->--------------┤ + # | | └-----------┘ | + # | V V + # | +--------┐ | + # | | Failed |---------------------->----------------┤ + # | └--------┘ | + # V V + # ┌---------------------┐ | + # | Partially Succeeded |---------------------->-----------------┘--------> (*) + # └---------------------┘ + # + # Mermaid diagram: + # + # --- + # title: State diagram for Workflow run state + # --- + # stateDiagram-v2 + # scheduled: Scheduled + # running: Running + # succeeded: Succeeded + # failed: Failed + # partial_succeeded: Partial Succeeded + # paused: Paused + # stopped: Stopped + # + # [*] --> scheduled: + # scheduled --> running: Start Execution + # running --> paused: Human input required + # paused --> running: human input added + # paused --> stopped: User stops execution + # running --> succeeded: Execution finishes without any error + # running --> failed: Execution finishes with errors + # running --> stopped: User stops execution + # running --> partial_succeeded: some execution occurred and handled during execution + # + # scheduled --> stopped: User stops execution + # + # succeeded --> [*] + # failed --> [*] + # partial_succeeded --> [*] + # stopped --> [*] + + # `SCHEDULED` means that the workflow is scheduled to run, but has not + # started running yet. (maybe due to possible worker saturation.) + # + # This enum value is currently unused. + SCHEDULED = "scheduled" + + # `RUNNING` means the workflow is exeuting. RUNNING = "running" + + # `SUCCEEDED` means the execution of workflow succeed without any error. SUCCEEDED = "succeeded" + + # `FAILED` means the execution of workflow failed without some errors. FAILED = "failed" + + # `STOPPED` means the execution of workflow was stopped, either manually + # by the user, or automatically by the Dify application (E.G. the moderation + # mechanism.) STOPPED = "stopped" + + # `PARTIAL_SUCCEEDED` indicates that some errors occurred during the workflow + # execution, but they were successfully handled (e.g., by using an error + # strategy such as "fail branch" or "default value"). PARTIAL_SUCCEEDED = "partial-succeeded" + + # `PAUSED` indicates that the workflow execution is temporarily paused + # (e.g., awaiting human input) and is expected to resume later. PAUSED = "paused" + def is_ended(self) -> bool: + return self in _END_STATE + + +_END_STATE = frozenset( + [ + WorkflowExecutionStatus.SUCCEEDED, + WorkflowExecutionStatus.FAILED, + WorkflowExecutionStatus.PARTIAL_SUCCEEDED, + WorkflowExecutionStatus.STOPPED, + ] +) + class WorkflowNodeExecutionMetadataKey(StrEnum): """ diff --git a/api/core/workflow/graph_engine/command_processing/command_handlers.py b/api/core/workflow/graph_engine/command_processing/command_handlers.py index c26c98c496..e9f109c88c 100644 --- a/api/core/workflow/graph_engine/command_processing/command_handlers.py +++ b/api/core/workflow/graph_engine/command_processing/command_handlers.py @@ -3,6 +3,8 @@ from typing import final from typing_extensions import override +from core.workflow.entities.pause_reason import SchedulingPause + from ..domain.graph_execution import GraphExecution from ..entities.commands import AbortCommand, GraphEngineCommand, PauseCommand from .command_processor import CommandHandler @@ -25,4 +27,7 @@ class PauseCommandHandler(CommandHandler): def handle(self, command: GraphEngineCommand, execution: GraphExecution) -> None: assert isinstance(command, PauseCommand) logger.debug("Pausing workflow %s: %s", execution.workflow_id, command.reason) - execution.pause(command.reason) + # Convert string reason to PauseReason if needed + reason = command.reason + pause_reason = SchedulingPause(message=reason) + execution.pause(pause_reason) diff --git a/api/core/workflow/graph_engine/domain/graph_execution.py b/api/core/workflow/graph_engine/domain/graph_execution.py index 6482c927d6..3d587d6691 100644 --- a/api/core/workflow/graph_engine/domain/graph_execution.py +++ b/api/core/workflow/graph_engine/domain/graph_execution.py @@ -8,6 +8,7 @@ from typing import Literal from pydantic import BaseModel, Field +from core.workflow.entities.pause_reason import PauseReason from core.workflow.enums import NodeState from .node_execution import NodeExecution @@ -41,7 +42,7 @@ class GraphExecutionState(BaseModel): completed: bool = Field(default=False) aborted: bool = Field(default=False) paused: bool = Field(default=False) - pause_reason: str | None = Field(default=None) + pause_reason: PauseReason | None = Field(default=None) error: GraphExecutionErrorState | None = Field(default=None) exceptions_count: int = Field(default=0) node_executions: list[NodeExecutionState] = Field(default_factory=list[NodeExecutionState]) @@ -106,7 +107,7 @@ class GraphExecution: completed: bool = False aborted: bool = False paused: bool = False - pause_reason: str | None = None + pause_reason: PauseReason | None = None error: Exception | None = None node_executions: dict[str, NodeExecution] = field(default_factory=dict[str, NodeExecution]) exceptions_count: int = 0 @@ -130,7 +131,7 @@ class GraphExecution: self.aborted = True self.error = RuntimeError(f"Aborted: {reason}") - def pause(self, reason: str | None = None) -> None: + def pause(self, reason: PauseReason) -> None: """Pause the graph execution without marking it complete.""" if self.completed: raise RuntimeError("Cannot pause execution that has completed") diff --git a/api/core/workflow/graph_engine/entities/commands.py b/api/core/workflow/graph_engine/entities/commands.py index 6070ed8812..0d51b2b716 100644 --- a/api/core/workflow/graph_engine/entities/commands.py +++ b/api/core/workflow/graph_engine/entities/commands.py @@ -36,4 +36,4 @@ class PauseCommand(GraphEngineCommand): """Command to pause a running workflow execution.""" command_type: CommandType = Field(default=CommandType.PAUSE, description="Type of command") - reason: str | None = Field(default=None, description="Optional reason for pause") + reason: str = Field(default="unknown reason", description="reason for pause") diff --git a/api/core/workflow/graph_engine/event_management/event_handlers.py b/api/core/workflow/graph_engine/event_management/event_handlers.py index b054ebd7ad..5b0f56e59d 100644 --- a/api/core/workflow/graph_engine/event_management/event_handlers.py +++ b/api/core/workflow/graph_engine/event_management/event_handlers.py @@ -210,7 +210,7 @@ class EventHandler: def _(self, event: NodeRunPauseRequestedEvent) -> None: """Handle pause requests emitted by nodes.""" - pause_reason = event.reason or "Awaiting human input" + pause_reason = event.reason self._graph_execution.pause(pause_reason) self._state_manager.finish_execution(event.node_id) if event.node_id in self._graph.nodes: diff --git a/api/core/workflow/graph_engine/graph_engine.py b/api/core/workflow/graph_engine/graph_engine.py index dd2ca3f93b..7071a1f33a 100644 --- a/api/core/workflow/graph_engine/graph_engine.py +++ b/api/core/workflow/graph_engine/graph_engine.py @@ -247,8 +247,11 @@ class GraphEngine: # Handle completion if self._graph_execution.is_paused: + pause_reason = self._graph_execution.pause_reason + assert pause_reason is not None, "pause_reason should not be None when execution is paused." + # Ensure we have a valid PauseReason for the event paused_event = GraphRunPausedEvent( - reason=self._graph_execution.pause_reason, + reason=pause_reason, outputs=self._graph_runtime_state.outputs, ) self._event_manager.notify_layers(paused_event) diff --git a/api/core/workflow/graph_engine/layers/persistence.py b/api/core/workflow/graph_engine/layers/persistence.py index ecd8e12ca5..b70f36ec9e 100644 --- a/api/core/workflow/graph_engine/layers/persistence.py +++ b/api/core/workflow/graph_engine/layers/persistence.py @@ -216,7 +216,6 @@ class WorkflowPersistenceLayer(GraphEngineLayer): def _handle_graph_run_paused(self, event: GraphRunPausedEvent) -> None: execution = self._get_workflow_execution() execution.status = WorkflowExecutionStatus.PAUSED - execution.error_message = event.reason or "Workflow execution paused" execution.outputs = event.outputs self._populate_completion_statistics(execution, update_finished=False) @@ -296,7 +295,7 @@ class WorkflowPersistenceLayer(GraphEngineLayer): domain_execution, event.node_run_result, WorkflowNodeExecutionStatus.PAUSED, - error=event.reason, + error="", update_outputs=False, ) diff --git a/api/core/workflow/graph_events/graph.py b/api/core/workflow/graph_events/graph.py index 0da962aa1c..9faafc3173 100644 --- a/api/core/workflow/graph_events/graph.py +++ b/api/core/workflow/graph_events/graph.py @@ -1,5 +1,6 @@ from pydantic import Field +from core.workflow.entities.pause_reason import PauseReason from core.workflow.graph_events import BaseGraphEvent @@ -44,7 +45,8 @@ class GraphRunAbortedEvent(BaseGraphEvent): class GraphRunPausedEvent(BaseGraphEvent): """Event emitted when a graph run is paused by user command.""" - reason: str | None = Field(default=None, description="reason for pause") + # reason: str | None = Field(default=None, description="reason for pause") + reason: PauseReason = Field(..., description="reason for pause") outputs: dict[str, object] = Field( default_factory=dict, description="Outputs available to the client while the run is paused.", diff --git a/api/core/workflow/graph_events/node.py b/api/core/workflow/graph_events/node.py index b880df60d1..f225798d41 100644 --- a/api/core/workflow/graph_events/node.py +++ b/api/core/workflow/graph_events/node.py @@ -5,6 +5,7 @@ from pydantic import Field from core.rag.entities.citation_metadata import RetrievalSourceMetadata from core.workflow.entities import AgentNodeStrategyInit +from core.workflow.entities.pause_reason import PauseReason from .base import GraphNodeEventBase @@ -54,4 +55,4 @@ class NodeRunRetryEvent(NodeRunStartedEvent): class NodeRunPauseRequestedEvent(GraphNodeEventBase): - reason: str | None = Field(default=None, description="Optional pause reason") + reason: PauseReason = Field(..., description="pause reason") diff --git a/api/core/workflow/node_events/node.py b/api/core/workflow/node_events/node.py index 4fd5684436..ebf93f2fc2 100644 --- a/api/core/workflow/node_events/node.py +++ b/api/core/workflow/node_events/node.py @@ -5,6 +5,7 @@ from pydantic import Field from core.model_runtime.entities.llm_entities import LLMUsage from core.rag.entities.citation_metadata import RetrievalSourceMetadata +from core.workflow.entities.pause_reason import PauseReason from core.workflow.node_events import NodeRunResult from .base import NodeEventBase @@ -43,4 +44,4 @@ class StreamCompletedEvent(NodeEventBase): class PauseRequestedEvent(NodeEventBase): - reason: str | None = Field(default=None, description="Optional pause reason") + reason: PauseReason = Field(..., description="pause reason") diff --git a/api/core/workflow/nodes/human_input/human_input_node.py b/api/core/workflow/nodes/human_input/human_input_node.py index e49f9a8c81..2d6d9760af 100644 --- a/api/core/workflow/nodes/human_input/human_input_node.py +++ b/api/core/workflow/nodes/human_input/human_input_node.py @@ -1,6 +1,7 @@ from collections.abc import Mapping from typing import Any +from core.workflow.entities.pause_reason import HumanInputRequired from core.workflow.enums import ErrorStrategy, NodeExecutionType, NodeType, WorkflowNodeExecutionStatus from core.workflow.node_events import NodeRunResult, PauseRequestedEvent from core.workflow.nodes.base.entities import BaseNodeData, RetryConfig @@ -64,7 +65,7 @@ class HumanInputNode(Node): return self._pause_generator() def _pause_generator(self): - yield PauseRequestedEvent(reason=self._node_data.pause_reason) + yield PauseRequestedEvent(reason=HumanInputRequired()) def _is_completion_ready(self) -> bool: """Determine whether all required inputs are satisfied.""" diff --git a/api/core/workflow/runtime/graph_runtime_state_protocol.py b/api/core/workflow/runtime/graph_runtime_state_protocol.py index 40835a936f..5e0878e873 100644 --- a/api/core/workflow/runtime/graph_runtime_state_protocol.py +++ b/api/core/workflow/runtime/graph_runtime_state_protocol.py @@ -3,6 +3,7 @@ from typing import Any, Protocol from core.model_runtime.entities.llm_entities import LLMUsage from core.variables.segments import Segment +from core.workflow.system_variable import SystemVariableReadOnlyView class ReadOnlyVariablePool(Protocol): @@ -30,6 +31,9 @@ class ReadOnlyGraphRuntimeState(Protocol): All methods return defensive copies to ensure immutability. """ + @property + def system_variable(self) -> SystemVariableReadOnlyView: ... + @property def variable_pool(self) -> ReadOnlyVariablePool: """Get read-only access to the variable pool.""" diff --git a/api/core/workflow/runtime/read_only_wrappers.py b/api/core/workflow/runtime/read_only_wrappers.py index 664c365295..8539727fd6 100644 --- a/api/core/workflow/runtime/read_only_wrappers.py +++ b/api/core/workflow/runtime/read_only_wrappers.py @@ -6,6 +6,7 @@ from typing import Any from core.model_runtime.entities.llm_entities import LLMUsage from core.variables.segments import Segment +from core.workflow.system_variable import SystemVariableReadOnlyView from .graph_runtime_state import GraphRuntimeState from .variable_pool import VariablePool @@ -42,6 +43,10 @@ class ReadOnlyGraphRuntimeStateWrapper: self._state = state self._variable_pool_wrapper = ReadOnlyVariablePoolWrapper(state.variable_pool) + @property + def system_variable(self) -> SystemVariableReadOnlyView: + return self._state.variable_pool.system_variables.as_view() + @property def variable_pool(self) -> ReadOnlyVariablePoolWrapper: return self._variable_pool_wrapper diff --git a/api/core/workflow/system_variable.py b/api/core/workflow/system_variable.py index 6716e745cd..29bf19716c 100644 --- a/api/core/workflow/system_variable.py +++ b/api/core/workflow/system_variable.py @@ -1,4 +1,5 @@ from collections.abc import Mapping, Sequence +from types import MappingProxyType from typing import Any from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator @@ -108,3 +109,102 @@ class SystemVariable(BaseModel): if self.invoke_from is not None: d[SystemVariableKey.INVOKE_FROM] = self.invoke_from return d + + def as_view(self) -> "SystemVariableReadOnlyView": + return SystemVariableReadOnlyView(self) + + +class SystemVariableReadOnlyView: + """ + A read-only view of a SystemVariable that implements the ReadOnlySystemVariable protocol. + + This class wraps a SystemVariable instance and provides read-only access to all its fields. + It always reads the latest data from the wrapped instance and prevents any write operations. + """ + + def __init__(self, system_variable: SystemVariable) -> None: + """ + Initialize the read-only view with a SystemVariable instance. + + Args: + system_variable: The SystemVariable instance to wrap + """ + self._system_variable = system_variable + + @property + def user_id(self) -> str | None: + return self._system_variable.user_id + + @property + def app_id(self) -> str | None: + return self._system_variable.app_id + + @property + def workflow_id(self) -> str | None: + return self._system_variable.workflow_id + + @property + def workflow_execution_id(self) -> str | None: + return self._system_variable.workflow_execution_id + + @property + def query(self) -> str | None: + return self._system_variable.query + + @property + def conversation_id(self) -> str | None: + return self._system_variable.conversation_id + + @property + def dialogue_count(self) -> int | None: + return self._system_variable.dialogue_count + + @property + def document_id(self) -> str | None: + return self._system_variable.document_id + + @property + def original_document_id(self) -> str | None: + return self._system_variable.original_document_id + + @property + def dataset_id(self) -> str | None: + return self._system_variable.dataset_id + + @property + def batch(self) -> str | None: + return self._system_variable.batch + + @property + def datasource_type(self) -> str | None: + return self._system_variable.datasource_type + + @property + def invoke_from(self) -> str | None: + return self._system_variable.invoke_from + + @property + def files(self) -> Sequence[File]: + """ + Get a copy of the files from the wrapped SystemVariable. + + Returns: + A defensive copy of the files sequence to prevent modification + """ + return tuple(self._system_variable.files) # Convert to immutable tuple + + @property + def datasource_info(self) -> Mapping[str, Any] | None: + """ + Get a copy of the datasource info from the wrapped SystemVariable. + + Returns: + A view of the datasource info mapping to prevent modification + """ + if self._system_variable.datasource_info is None: + return None + return MappingProxyType(self._system_variable.datasource_info) + + def __repr__(self) -> str: + """Return a string representation of the read-only view.""" + return f"SystemVariableReadOnlyView(system_variable={self._system_variable!r})" diff --git a/api/extensions/ext_storage.py b/api/extensions/ext_storage.py index 2960cde242..a609f13dbc 100644 --- a/api/extensions/ext_storage.py +++ b/api/extensions/ext_storage.py @@ -85,7 +85,7 @@ class Storage: case _: raise ValueError(f"unsupported storage type {storage_type}") - def save(self, filename, data): + def save(self, filename: str, data: bytes): self.storage_runner.save(filename, data) @overload diff --git a/api/extensions/storage/base_storage.py b/api/extensions/storage/base_storage.py index 0393206e54..8ddedb24ae 100644 --- a/api/extensions/storage/base_storage.py +++ b/api/extensions/storage/base_storage.py @@ -8,7 +8,7 @@ class BaseStorage(ABC): """Interface for file storage.""" @abstractmethod - def save(self, filename, data): + def save(self, filename: str, data: bytes): raise NotImplementedError @abstractmethod diff --git a/api/migrations/versions/2025_10_22_1611-03f8dcbc611e_add_workflowpause_model.py b/api/migrations/versions/2025_10_22_1611-03f8dcbc611e_add_workflowpause_model.py new file mode 100644 index 0000000000..1ab4202674 --- /dev/null +++ b/api/migrations/versions/2025_10_22_1611-03f8dcbc611e_add_workflowpause_model.py @@ -0,0 +1,41 @@ +"""add WorkflowPause model + +Revision ID: 03f8dcbc611e +Revises: ae662b25d9bc +Create Date: 2025-10-22 16:11:31.805407 + +""" + +from alembic import op +import models as models +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "03f8dcbc611e" +down_revision = "ae662b25d9bc" +branch_labels = None +depends_on = None + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "workflow_pauses", + sa.Column("workflow_id", models.types.StringUUID(), nullable=False), + sa.Column("workflow_run_id", models.types.StringUUID(), nullable=False), + sa.Column("resumed_at", sa.DateTime(), nullable=True), + sa.Column("state_object_key", sa.String(length=255), nullable=False), + sa.Column("id", models.types.StringUUID(), server_default=sa.text("uuidv7()"), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("workflow_pauses_pkey")), + sa.UniqueConstraint("workflow_run_id", name=op.f("workflow_pauses_workflow_run_id_key")), + ) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("workflow_pauses") + # ### end Alembic commands ### diff --git a/api/models/__init__.py b/api/models/__init__.py index 779484283f..1c09b4610d 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -88,6 +88,7 @@ from .workflow import ( WorkflowNodeExecutionModel, WorkflowNodeExecutionOffload, WorkflowNodeExecutionTriggeredFrom, + WorkflowPause, WorkflowRun, WorkflowType, ) @@ -177,6 +178,7 @@ __all__ = [ "WorkflowNodeExecutionModel", "WorkflowNodeExecutionOffload", "WorkflowNodeExecutionTriggeredFrom", + "WorkflowPause", "WorkflowRun", "WorkflowRunTriggeredFrom", "WorkflowToolProvider", diff --git a/api/models/base.py b/api/models/base.py index 76848825fe..3660068035 100644 --- a/api/models/base.py +++ b/api/models/base.py @@ -1,6 +1,12 @@ -from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass +from datetime import datetime +from sqlalchemy import DateTime, func, text +from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, mapped_column + +from libs.datetime_utils import naive_utc_now +from libs.uuid_utils import uuidv7 from models.engine import metadata +from models.types import StringUUID class Base(DeclarativeBase): @@ -13,3 +19,34 @@ class TypeBase(MappedAsDataclass, DeclarativeBase): """ metadata = metadata + + +class DefaultFieldsMixin: + id: Mapped[str] = mapped_column( + StringUUID, + primary_key=True, + # NOTE: The default and server_default serve as fallback mechanisms. + # The application can generate the `id` before saving to optimize + # the insertion process (especially for interdependent models) + # and reduce database roundtrips. + default=uuidv7, + server_default=text("uuidv7()"), + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + default=naive_utc_now, + server_default=func.current_timestamp(), + ) + + updated_at: Mapped[datetime] = mapped_column( + __name_pos=DateTime, + nullable=False, + default=naive_utc_now, + server_default=func.current_timestamp(), + onupdate=func.current_timestamp(), + ) + + def __repr__(self) -> str: + return f"<{self.__class__.__name__}(id={self.id})>" diff --git a/api/models/workflow.py b/api/models/workflow.py index b898f02612..d312b96b39 100644 --- a/api/models/workflow.py +++ b/api/models/workflow.py @@ -13,8 +13,11 @@ from core.file.constants import maybe_file_object from core.file.models import File from core.variables import utils as variable_utils from core.variables.variables import FloatVariable, IntegerVariable, StringVariable -from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID -from core.workflow.enums import NodeType +from core.workflow.constants import ( + CONVERSATION_VARIABLE_NODE_ID, + SYSTEM_VARIABLE_NODE_ID, +) +from core.workflow.enums import NodeType, WorkflowExecutionStatus from extensions.ext_storage import Storage from factories.variable_factory import TypeMismatchError, build_segment_with_type from libs.datetime_utils import naive_utc_now @@ -35,7 +38,7 @@ from factories import variable_factory from libs import helper from .account import Account -from .base import Base +from .base import Base, DefaultFieldsMixin from .engine import db from .enums import CreatorUserRole, DraftVariableType, ExecutionOffLoadType from .types import EnumText, StringUUID @@ -247,7 +250,9 @@ class Workflow(Base): return node_type @staticmethod - def get_enclosing_node_type_and_id(node_config: Mapping[str, Any]) -> tuple[NodeType, str] | None: + def get_enclosing_node_type_and_id( + node_config: Mapping[str, Any], + ) -> tuple[NodeType, str] | None: in_loop = node_config.get("isInLoop", False) in_iteration = node_config.get("isInIteration", False) if in_loop: @@ -306,7 +311,10 @@ class Workflow(Base): if "nodes" not in graph_dict: return [] - start_node = next((node for node in graph_dict["nodes"] if node["data"]["type"] == "start"), None) + start_node = next( + (node for node in graph_dict["nodes"] if node["data"]["type"] == "start"), + None, + ) if not start_node: return [] @@ -359,7 +367,9 @@ class Workflow(Base): return db.session.execute(stmt).scalar_one() @property - def environment_variables(self) -> Sequence[StringVariable | IntegerVariable | FloatVariable | SecretVariable]: + def environment_variables( + self, + ) -> Sequence[StringVariable | IntegerVariable | FloatVariable | SecretVariable]: # TODO: find some way to init `self._environment_variables` when instance created. if self._environment_variables is None: self._environment_variables = "{}" @@ -376,7 +386,9 @@ class Workflow(Base): ] # decrypt secret variables value - def decrypt_func(var: Variable) -> StringVariable | IntegerVariable | FloatVariable | SecretVariable: + def decrypt_func( + var: Variable, + ) -> StringVariable | IntegerVariable | FloatVariable | SecretVariable: if isinstance(var, SecretVariable): return var.model_copy(update={"value": encrypter.decrypt_token(tenant_id=tenant_id, token=var.value)}) elif isinstance(var, (StringVariable, IntegerVariable, FloatVariable)): @@ -537,7 +549,10 @@ class WorkflowRun(Base): version: Mapped[str] = mapped_column(String(255)) graph: Mapped[str | None] = mapped_column(sa.Text) inputs: Mapped[str | None] = mapped_column(sa.Text) - status: Mapped[str] = mapped_column(String(255)) # running, succeeded, failed, stopped, partial-succeeded + status: Mapped[str] = mapped_column( + EnumText(WorkflowExecutionStatus, length=255), + nullable=False, + ) outputs: Mapped[str | None] = mapped_column(sa.Text, default="{}") error: Mapped[str | None] = mapped_column(sa.Text) elapsed_time: Mapped[float] = mapped_column(sa.Float, nullable=False, server_default=sa.text("0")) @@ -549,6 +564,15 @@ class WorkflowRun(Base): finished_at: Mapped[datetime | None] = mapped_column(DateTime) exceptions_count: Mapped[int] = mapped_column(sa.Integer, server_default=sa.text("0"), nullable=True) + pause: Mapped[Optional["WorkflowPause"]] = orm.relationship( + "WorkflowPause", + primaryjoin="WorkflowRun.id == foreign(WorkflowPause.workflow_run_id)", + uselist=False, + # require explicit preloading. + lazy="raise", + back_populates="workflow_run", + ) + @property def created_by_account(self): created_by_role = CreatorUserRole(self.created_by_role) @@ -1073,7 +1097,10 @@ class ConversationVariable(Base): DateTime, nullable=False, server_default=func.current_timestamp(), index=True ) updated_at: Mapped[datetime] = mapped_column( - DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp() + DateTime, + nullable=False, + server_default=func.current_timestamp(), + onupdate=func.current_timestamp(), ) def __init__(self, *, id: str, app_id: str, conversation_id: str, data: str): @@ -1101,10 +1128,6 @@ class ConversationVariable(Base): _EDITABLE_SYSTEM_VARIABLE = frozenset(["query", "files"]) -def _naive_utc_datetime(): - return naive_utc_now() - - class WorkflowDraftVariable(Base): """`WorkflowDraftVariable` record variables and outputs generated during debugging workflow or chatflow. @@ -1138,14 +1161,14 @@ class WorkflowDraftVariable(Base): created_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, - default=_naive_utc_datetime, + default=naive_utc_now, server_default=func.current_timestamp(), ) updated_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, - default=_naive_utc_datetime, + default=naive_utc_now, server_default=func.current_timestamp(), onupdate=func.current_timestamp(), ) @@ -1412,8 +1435,8 @@ class WorkflowDraftVariable(Base): file_id: str | None = None, ) -> "WorkflowDraftVariable": variable = WorkflowDraftVariable() - variable.created_at = _naive_utc_datetime() - variable.updated_at = _naive_utc_datetime() + variable.created_at = naive_utc_now() + variable.updated_at = naive_utc_now() variable.description = description variable.app_id = app_id variable.node_id = node_id @@ -1518,7 +1541,7 @@ class WorkflowDraftVariableFile(Base): created_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, - default=_naive_utc_datetime, + default=naive_utc_now, server_default=func.current_timestamp(), ) @@ -1583,3 +1606,68 @@ class WorkflowDraftVariableFile(Base): def is_system_variable_editable(name: str) -> bool: return name in _EDITABLE_SYSTEM_VARIABLE + + +class WorkflowPause(DefaultFieldsMixin, Base): + """ + WorkflowPause records the paused state and related metadata for a specific workflow run. + + Each `WorkflowRun` can have zero or one associated `WorkflowPause`, depending on its execution status. + If a `WorkflowRun` is in the `PAUSED` state, there must be a corresponding `WorkflowPause` + that has not yet been resumed. + Otherwise, there should be no active (non-resumed) `WorkflowPause` linked to that run. + + This model captures the execution context required to resume workflow processing at a later time. + """ + + __tablename__ = "workflow_pauses" + __table_args__ = ( + # Design Note: + # Instead of adding a `pause_id` field to the `WorkflowRun` model—which would require a migration + # on a potentially large table—we reference `WorkflowRun` from `WorkflowPause` and enforce a unique + # constraint on `workflow_run_id` to guarantee a one-to-one relationship. + UniqueConstraint("workflow_run_id"), + ) + + # `workflow_id` represents the unique identifier of the workflow associated with this pause. + # It corresponds to the `id` field in the `Workflow` model. + # + # Since an application can have multiple versions of a workflow, each with its own unique ID, + # the `app_id` alone is insufficient to determine which workflow version should be loaded + # when resuming a suspended workflow. + workflow_id: Mapped[str] = mapped_column( + StringUUID, + nullable=False, + ) + + # `workflow_run_id` represents the identifier of the execution of workflow, + # correspond to the `id` field of `WorkflowRun`. + workflow_run_id: Mapped[str] = mapped_column( + StringUUID, + nullable=False, + ) + + # `resumed_at` records the timestamp when the suspended workflow was resumed. + # It is set to `NULL` if the workflow has not been resumed. + # + # NOTE: Resuming a suspended WorkflowPause does not delete the record immediately. + # It only set `resumed_at` to a non-null value. + resumed_at: Mapped[datetime | None] = mapped_column( + sa.DateTime, + nullable=True, + ) + + # state_object_key stores the object key referencing the serialized runtime state + # of the `GraphEngine`. This object captures the complete execution context of the + # workflow at the moment it was paused, enabling accurate resumption. + state_object_key: Mapped[str] = mapped_column(String(length=255), nullable=False) + + # Relationship to WorkflowRun + workflow_run: Mapped["WorkflowRun"] = orm.relationship( + foreign_keys=[workflow_run_id], + # require explicit preloading. + lazy="raise", + uselist=False, + primaryjoin="WorkflowPause.workflow_run_id == WorkflowRun.id", + back_populates="pause", + ) diff --git a/api/repositories/api_workflow_run_repository.py b/api/repositories/api_workflow_run_repository.py index eb6d599224..21fd57cd22 100644 --- a/api/repositories/api_workflow_run_repository.py +++ b/api/repositories/api_workflow_run_repository.py @@ -38,6 +38,7 @@ from collections.abc import Sequence from datetime import datetime from typing import Protocol +from core.workflow.entities.workflow_pause import WorkflowPauseEntity from core.workflow.repositories.workflow_execution_repository import WorkflowExecutionRepository from libs.infinite_scroll_pagination import InfiniteScrollPagination from models.enums import WorkflowRunTriggeredFrom @@ -251,6 +252,116 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol): """ ... + def create_workflow_pause( + self, + workflow_run_id: str, + state_owner_user_id: str, + state: str, + ) -> WorkflowPauseEntity: + """ + Create a new workflow pause state. + + Creates a pause state for a workflow run, storing the current execution + state and marking the workflow as paused. This is used when a workflow + needs to be suspended and later resumed. + + Args: + workflow_run_id: Identifier of the workflow run to pause + state_owner_user_id: User ID who owns the pause state for file storage + state: Serialized workflow execution state (JSON string) + + Returns: + WorkflowPauseEntity representing the created pause state + + Raises: + ValueError: If workflow_run_id is invalid or workflow run doesn't exist + RuntimeError: If workflow is already paused or in invalid state + """ + # NOTE: we may get rid of the `state_owner_user_id` in parameter list. + # However, removing it would require an extra for `Workflow` model + # while creating pause. + ... + + def resume_workflow_pause( + self, + workflow_run_id: str, + pause_entity: WorkflowPauseEntity, + ) -> WorkflowPauseEntity: + """ + Resume a paused workflow. + + Marks a paused workflow as resumed, set the `resumed_at` field of WorkflowPauseEntity + and returning the workflow to running status. Returns the pause entity + that was resumed. + + The returned `WorkflowPauseEntity` model has `resumed_at` set. + + NOTE: this method does not delete the correspond `WorkflowPauseEntity` record and associated states. + It's the callers responsibility to clear the correspond state with `delete_workflow_pause`. + + Args: + workflow_run_id: Identifier of the workflow run to resume + pause_entity: The pause entity to resume + + Returns: + WorkflowPauseEntity representing the resumed pause state + + Raises: + ValueError: If workflow_run_id is invalid + RuntimeError: If workflow is not paused or already resumed + """ + ... + + def delete_workflow_pause( + self, + pause_entity: WorkflowPauseEntity, + ) -> None: + """ + Delete a workflow pause state. + + Permanently removes the pause state for a workflow run, including + the stored state file. Used for cleanup operations when a paused + workflow is no longer needed. + + Args: + pause_entity: The pause entity to delete + + Raises: + ValueError: If pause_entity is invalid + RuntimeError: If workflow is not paused + + Note: + This operation is irreversible. The stored workflow state will be + permanently deleted along with the pause record. + """ + ... + + def prune_pauses( + self, + expiration: datetime, + resumption_expiration: datetime, + limit: int | None = None, + ) -> Sequence[str]: + """ + Clean up expired and old pause states. + + Removes pause states that have expired (created before expiration time) + and pause states that were resumed more than resumption_duration ago. + This is used for maintenance and cleanup operations. + + Args: + expiration: Remove pause states created before this time + resumption_expiration: Remove pause states resumed before this time + limit: maximum number of records deleted in one call + + Returns: + a list of ids for pause records that were pruned + + Raises: + ValueError: If parameters are invalid + """ + ... + def get_daily_runs_statistics( self, tenant_id: str, diff --git a/api/repositories/sqlalchemy_api_workflow_run_repository.py b/api/repositories/sqlalchemy_api_workflow_run_repository.py index f08eab0b01..0d52c56138 100644 --- a/api/repositories/sqlalchemy_api_workflow_run_repository.py +++ b/api/repositories/sqlalchemy_api_workflow_run_repository.py @@ -20,19 +20,26 @@ Implementation Notes: """ import logging +import uuid from collections.abc import Sequence from datetime import datetime from decimal import Decimal from typing import Any, cast import sqlalchemy as sa -from sqlalchemy import delete, func, select +from sqlalchemy import and_, delete, func, null, or_, select from sqlalchemy.engine import CursorResult -from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.orm import Session, selectinload, sessionmaker +from core.workflow.entities.workflow_pause import WorkflowPauseEntity +from core.workflow.enums import WorkflowExecutionStatus +from extensions.ext_storage import storage +from libs.datetime_utils import naive_utc_now from libs.infinite_scroll_pagination import InfiniteScrollPagination from libs.time_parser import get_time_threshold +from libs.uuid_utils import uuidv7 from models.enums import WorkflowRunTriggeredFrom +from models.workflow import WorkflowPause as WorkflowPauseModel from models.workflow import WorkflowRun from repositories.api_workflow_run_repository import APIWorkflowRunRepository from repositories.types import ( @@ -45,6 +52,10 @@ from repositories.types import ( logger = logging.getLogger(__name__) +class _WorkflowRunError(Exception): + pass + + class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): """ SQLAlchemy implementation of APIWorkflowRunRepository. @@ -301,6 +312,281 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): logger.info("Total deleted %s workflow runs for app %s", total_deleted, app_id) return total_deleted + def create_workflow_pause( + self, + workflow_run_id: str, + state_owner_user_id: str, + state: str, + ) -> WorkflowPauseEntity: + """ + Create a new workflow pause state. + + Creates a pause state for a workflow run, storing the current execution + state and marking the workflow as paused. This is used when a workflow + needs to be suspended and later resumed. + + Args: + workflow_run_id: Identifier of the workflow run to pause + state_owner_user_id: User ID who owns the pause state for file storage + state: Serialized workflow execution state (JSON string) + + Returns: + RepositoryWorkflowPauseEntity representing the created pause state + + Raises: + ValueError: If workflow_run_id is invalid or workflow run doesn't exist + RuntimeError: If workflow is already paused or in invalid state + """ + previous_pause_model_query = select(WorkflowPauseModel).where( + WorkflowPauseModel.workflow_run_id == workflow_run_id + ) + with self._session_maker() as session, session.begin(): + # Get the workflow run + workflow_run = session.get(WorkflowRun, workflow_run_id) + if workflow_run is None: + raise ValueError(f"WorkflowRun not found: {workflow_run_id}") + + # Check if workflow is in RUNNING status + if workflow_run.status != WorkflowExecutionStatus.RUNNING: + raise _WorkflowRunError( + f"Only WorkflowRun with RUNNING status can be paused, " + f"workflow_run_id={workflow_run_id}, current_status={workflow_run.status}" + ) + # + previous_pause = session.scalars(previous_pause_model_query).first() + if previous_pause: + self._delete_pause_model(session, previous_pause) + # we need to flush here to ensure that the old one is actually deleted. + session.flush() + + state_obj_key = f"workflow-state-{uuid.uuid4()}.json" + storage.save(state_obj_key, state.encode()) + # Upload the state file + + # Create the pause record + pause_model = WorkflowPauseModel() + pause_model.id = str(uuidv7()) + pause_model.workflow_id = workflow_run.workflow_id + pause_model.workflow_run_id = workflow_run.id + pause_model.state_object_key = state_obj_key + pause_model.created_at = naive_utc_now() + + # Update workflow run status + workflow_run.status = WorkflowExecutionStatus.PAUSED + + # Save everything in a transaction + session.add(pause_model) + session.add(workflow_run) + + logger.info("Created workflow pause %s for workflow run %s", pause_model.id, workflow_run_id) + + return _PrivateWorkflowPauseEntity.from_models(pause_model) + + def get_workflow_pause( + self, + workflow_run_id: str, + ) -> WorkflowPauseEntity | None: + """ + Get an existing workflow pause state. + + Retrieves the pause state for a specific workflow run if it exists. + Used to check if a workflow is paused and to retrieve its saved state. + + Args: + workflow_run_id: Identifier of the workflow run to get pause state for + + Returns: + RepositoryWorkflowPauseEntity if pause state exists, None otherwise + + Raises: + ValueError: If workflow_run_id is invalid + """ + with self._session_maker() as session: + # Query workflow run with pause and state file + stmt = select(WorkflowRun).options(selectinload(WorkflowRun.pause)).where(WorkflowRun.id == workflow_run_id) + workflow_run = session.scalar(stmt) + + if workflow_run is None: + raise ValueError(f"WorkflowRun not found: {workflow_run_id}") + + pause_model = workflow_run.pause + if pause_model is None: + return None + + return _PrivateWorkflowPauseEntity.from_models(pause_model) + + def resume_workflow_pause( + self, + workflow_run_id: str, + pause_entity: WorkflowPauseEntity, + ) -> WorkflowPauseEntity: + """ + Resume a paused workflow. + + Marks a paused workflow as resumed, clearing the pause state and + returning the workflow to running status. Returns the pause entity + that was resumed. + + Args: + workflow_run_id: Identifier of the workflow run to resume + pause_entity: The pause entity to resume + + Returns: + RepositoryWorkflowPauseEntity representing the resumed pause state + + Raises: + ValueError: If workflow_run_id is invalid + RuntimeError: If workflow is not paused or already resumed + """ + with self._session_maker() as session, session.begin(): + # Get the workflow run with pause + stmt = select(WorkflowRun).options(selectinload(WorkflowRun.pause)).where(WorkflowRun.id == workflow_run_id) + workflow_run = session.scalar(stmt) + + if workflow_run is None: + raise ValueError(f"WorkflowRun not found: {workflow_run_id}") + + if workflow_run.status != WorkflowExecutionStatus.PAUSED: + raise _WorkflowRunError( + f"WorkflowRun is not in PAUSED status, workflow_run_id={workflow_run_id}, " + f"current_status={workflow_run.status}" + ) + pause_model = workflow_run.pause + if pause_model is None: + raise _WorkflowRunError(f"No pause state found for workflow run: {workflow_run_id}") + + if pause_model.id != pause_entity.id: + raise _WorkflowRunError( + "different id in WorkflowPause and WorkflowPauseEntity, " + f"WorkflowPause.id={pause_model.id}, " + f"WorkflowPauseEntity.id={pause_entity.id}" + ) + + if pause_model.resumed_at is not None: + raise _WorkflowRunError(f"Cannot resume an already resumed pause, pause_id={pause_model.id}") + + # Mark as resumed + pause_model.resumed_at = naive_utc_now() + workflow_run.pause_id = None # type: ignore + workflow_run.status = WorkflowExecutionStatus.RUNNING + + session.add(pause_model) + session.add(workflow_run) + + logger.info("Resumed workflow pause %s for workflow run %s", pause_model.id, workflow_run_id) + + return _PrivateWorkflowPauseEntity.from_models(pause_model) + + def delete_workflow_pause( + self, + pause_entity: WorkflowPauseEntity, + ) -> None: + """ + Delete a workflow pause state. + + Permanently removes the pause state for a workflow run, including + the stored state file. Used for cleanup operations when a paused + workflow is no longer needed. + + Args: + pause_entity: The pause entity to delete + + Raises: + ValueError: If pause_entity is invalid + _WorkflowRunError: If workflow is not paused + + Note: + This operation is irreversible. The stored workflow state will be + permanently deleted along with the pause record. + """ + with self._session_maker() as session, session.begin(): + # Get the pause model by ID + pause_model = session.get(WorkflowPauseModel, pause_entity.id) + if pause_model is None: + raise _WorkflowRunError(f"WorkflowPause not found: {pause_entity.id}") + self._delete_pause_model(session, pause_model) + + @staticmethod + def _delete_pause_model(session: Session, pause_model: WorkflowPauseModel): + storage.delete(pause_model.state_object_key) + + # Delete the pause record + session.delete(pause_model) + + logger.info("Deleted workflow pause %s for workflow run %s", pause_model.id, pause_model.workflow_run_id) + + def prune_pauses( + self, + expiration: datetime, + resumption_expiration: datetime, + limit: int | None = None, + ) -> Sequence[str]: + """ + Clean up expired and old pause states. + + Removes pause states that have expired (created before expiration time) + and pause states that were resumed more than resumption_duration ago. + This is used for maintenance and cleanup operations. + + Args: + expiration: Remove pause states created before this time + resumption_expiration: Remove pause states resumed before this time + limit: maximum number of records deleted in one call + + Returns: + a list of ids for pause records that were pruned + + Raises: + ValueError: If parameters are invalid + """ + _limit: int = limit or 1000 + pruned_record_ids: list[str] = [] + cond = or_( + WorkflowPauseModel.created_at < expiration, + and_( + WorkflowPauseModel.resumed_at.is_not(null()), + WorkflowPauseModel.resumed_at < resumption_expiration, + ), + ) + # First, collect pause records to delete with their state files + # Expired pauses (created before expiration time) + stmt = select(WorkflowPauseModel).where(cond).limit(_limit) + + with self._session_maker(expire_on_commit=False) as session: + # Old resumed pauses (resumed more than resumption_duration ago) + + # Get all records to delete + pauses_to_delete = session.scalars(stmt).all() + + # Delete state files from storage + for pause in pauses_to_delete: + with self._session_maker(expire_on_commit=False) as session, session.begin(): + # todo: this issues a separate query for each WorkflowPauseModel record. + # consider batching this lookup. + try: + storage.delete(pause.state_object_key) + logger.info( + "Deleted state object for pause, pause_id=%s, object_key=%s", + pause.id, + pause.state_object_key, + ) + except Exception: + logger.exception( + "Failed to delete state file for pause, pause_id=%s, object_key=%s", + pause.id, + pause.state_object_key, + ) + continue + session.delete(pause) + pruned_record_ids.append(pause.id) + logger.info( + "workflow pause records deleted, id=%s, resumed_at=%s", + pause.id, + pause.resumed_at, + ) + + return pruned_record_ids + def get_daily_runs_statistics( self, tenant_id: str, @@ -510,3 +796,69 @@ GROUP BY ) return cast(list[AverageInteractionStats], response_data) + + +class _PrivateWorkflowPauseEntity(WorkflowPauseEntity): + """ + Private implementation of WorkflowPauseEntity for SQLAlchemy repository. + + This implementation is internal to the repository layer and provides + the concrete implementation of the WorkflowPauseEntity interface. + """ + + def __init__( + self, + *, + pause_model: WorkflowPauseModel, + ) -> None: + self._pause_model = pause_model + self._cached_state: bytes | None = None + + @classmethod + def from_models(cls, workflow_pause_model) -> "_PrivateWorkflowPauseEntity": + """ + Create a _PrivateWorkflowPauseEntity from database models. + + Args: + workflow_pause_model: The WorkflowPause database model + upload_file_model: The UploadFile database model + + Returns: + _PrivateWorkflowPauseEntity: The constructed entity + + Raises: + ValueError: If required model attributes are missing + """ + return cls(pause_model=workflow_pause_model) + + @property + def id(self) -> str: + return self._pause_model.id + + @property + def workflow_execution_id(self) -> str: + return self._pause_model.workflow_run_id + + def get_state(self) -> bytes: + """ + Retrieve the serialized workflow state from storage. + + Returns: + Mapping[str, Any]: The workflow state as a dictionary + + Raises: + FileNotFoundError: If the state file cannot be found + IOError: If there are issues reading the state file + _Workflow: If the state cannot be deserialized properly + """ + if self._cached_state is not None: + return self._cached_state + + # Load the state from storage + state_data = storage.load(self._pause_model.state_object_key) + self._cached_state = state_data + return state_data + + @property + def resumed_at(self) -> datetime | None: + return self._pause_model.resumed_at diff --git a/api/services/workflow_run_service.py b/api/services/workflow_run_service.py index 5c8719b499..b903d8df5f 100644 --- a/api/services/workflow_run_service.py +++ b/api/services/workflow_run_service.py @@ -1,6 +1,7 @@ import threading from collections.abc import Sequence +from sqlalchemy import Engine from sqlalchemy.orm import sessionmaker import contexts @@ -14,17 +15,26 @@ from models import ( WorkflowRun, WorkflowRunTriggeredFrom, ) +from repositories.api_workflow_run_repository import APIWorkflowRunRepository from repositories.factory import DifyAPIRepositoryFactory class WorkflowRunService: - def __init__(self): + _session_factory: sessionmaker + _workflow_run_repo: APIWorkflowRunRepository + + def __init__(self, session_factory: Engine | sessionmaker | None = None): """Initialize WorkflowRunService with repository dependencies.""" - session_maker = sessionmaker(bind=db.engine, expire_on_commit=False) + if session_factory is None: + session_factory = sessionmaker(bind=db.engine, expire_on_commit=False) + elif isinstance(session_factory, Engine): + session_factory = sessionmaker(bind=session_factory, expire_on_commit=False) + + self._session_factory = session_factory self._node_execution_service_repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository( - session_maker + self._session_factory ) - self._workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker) + self._workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(self._session_factory) def get_paginate_advanced_chat_workflow_runs( self, app_model: App, args: dict, triggered_from: WorkflowRunTriggeredFrom = WorkflowRunTriggeredFrom.DEBUGGING diff --git a/api/tests/test_containers_integration_tests/core/__init__.py b/api/tests/test_containers_integration_tests/core/__init__.py new file mode 100644 index 0000000000..5860ad0399 --- /dev/null +++ b/api/tests/test_containers_integration_tests/core/__init__.py @@ -0,0 +1 @@ +# Core integration tests package diff --git a/api/tests/test_containers_integration_tests/core/app/__init__.py b/api/tests/test_containers_integration_tests/core/app/__init__.py new file mode 100644 index 0000000000..0822a865b7 --- /dev/null +++ b/api/tests/test_containers_integration_tests/core/app/__init__.py @@ -0,0 +1 @@ +# App integration tests package diff --git a/api/tests/test_containers_integration_tests/core/app/layers/__init__.py b/api/tests/test_containers_integration_tests/core/app/layers/__init__.py new file mode 100644 index 0000000000..90e5229b1a --- /dev/null +++ b/api/tests/test_containers_integration_tests/core/app/layers/__init__.py @@ -0,0 +1 @@ +# Layers integration tests package diff --git a/api/tests/test_containers_integration_tests/core/app/layers/test_pause_state_persist_layer.py b/api/tests/test_containers_integration_tests/core/app/layers/test_pause_state_persist_layer.py new file mode 100644 index 0000000000..133e600ca0 --- /dev/null +++ b/api/tests/test_containers_integration_tests/core/app/layers/test_pause_state_persist_layer.py @@ -0,0 +1,520 @@ +"""Comprehensive TestContainers-based integration tests for PauseStatePersistenceLayer class. + +This test suite covers complete integration scenarios including: +- Real database interactions using containerized PostgreSQL +- Real storage operations using test storage backend +- Complete workflow: event -> state serialization -> database save -> storage save +- Testing with actual WorkflowRunService (not mocked) +- Real Workflow and WorkflowRun instances in database +- Database transactions and rollback behavior +- Actual file upload and retrieval through storage +- Workflow status transitions in database +- Error handling with real database constraints +- Multiple pause events in sequence +- Integration with real ReadOnlyGraphRuntimeState implementations + +These tests use TestContainers to spin up real services for integration testing, +providing more reliable and realistic test scenarios than mocks. +""" + +import json +import uuid +from time import time + +import pytest +from sqlalchemy import Engine, delete, select +from sqlalchemy.orm import Session + +from core.app.layers.pause_state_persist_layer import PauseStatePersistenceLayer +from core.model_runtime.entities.llm_entities import LLMUsage +from core.workflow.entities.pause_reason import SchedulingPause +from core.workflow.enums import WorkflowExecutionStatus +from core.workflow.graph_engine.entities.commands import GraphEngineCommand +from core.workflow.graph_events.graph import GraphRunPausedEvent +from core.workflow.runtime.graph_runtime_state import GraphRuntimeState +from core.workflow.runtime.graph_runtime_state_protocol import ReadOnlyGraphRuntimeState +from core.workflow.runtime.read_only_wrappers import ReadOnlyGraphRuntimeStateWrapper +from core.workflow.runtime.variable_pool import SystemVariable, VariablePool +from extensions.ext_storage import storage +from libs.datetime_utils import naive_utc_now +from models import Account +from models import WorkflowPause as WorkflowPauseModel +from models.model import UploadFile +from models.workflow import Workflow, WorkflowRun +from services.file_service import FileService +from services.workflow_run_service import WorkflowRunService + + +class _TestCommandChannelImpl: + """Real implementation of CommandChannel for testing.""" + + def __init__(self): + self._commands: list[GraphEngineCommand] = [] + + def fetch_commands(self) -> list[GraphEngineCommand]: + """Fetch pending commands for this GraphEngine instance.""" + return self._commands.copy() + + def send_command(self, command: GraphEngineCommand) -> None: + """Send a command to be processed by this GraphEngine instance.""" + self._commands.append(command) + + +class TestPauseStatePersistenceLayerTestContainers: + """Comprehensive TestContainers-based integration tests for PauseStatePersistenceLayer class.""" + + @pytest.fixture + def engine(self, db_session_with_containers: Session): + """Get database engine from TestContainers session.""" + bind = db_session_with_containers.get_bind() + assert isinstance(bind, Engine) + return bind + + @pytest.fixture + def file_service(self, engine: Engine): + """Create FileService instance with TestContainers engine.""" + return FileService(engine) + + @pytest.fixture + def workflow_run_service(self, engine: Engine, file_service: FileService): + """Create WorkflowRunService instance with TestContainers engine and FileService.""" + return WorkflowRunService(engine) + + @pytest.fixture(autouse=True) + def setup_test_data(self, db_session_with_containers, file_service, workflow_run_service): + """Set up test data for each test method using TestContainers.""" + # Create test tenant and account + from models.account import Tenant, TenantAccountJoin, TenantAccountRole + + tenant = Tenant( + name="Test Tenant", + status="normal", + ) + db_session_with_containers.add(tenant) + db_session_with_containers.commit() + + account = Account( + email="test@example.com", + name="Test User", + interface_language="en-US", + status="active", + ) + db_session_with_containers.add(account) + db_session_with_containers.commit() + + # Create tenant-account join + tenant_join = TenantAccountJoin( + tenant_id=tenant.id, + account_id=account.id, + role=TenantAccountRole.OWNER, + current=True, + ) + db_session_with_containers.add(tenant_join) + db_session_with_containers.commit() + + # Set test data + self.test_tenant_id = tenant.id + self.test_user_id = account.id + self.test_app_id = str(uuid.uuid4()) + self.test_workflow_id = str(uuid.uuid4()) + self.test_workflow_run_id = str(uuid.uuid4()) + + # Create test workflow + self.test_workflow = Workflow( + id=self.test_workflow_id, + tenant_id=self.test_tenant_id, + app_id=self.test_app_id, + type="workflow", + version="draft", + graph='{"nodes": [], "edges": []}', + features='{"file_upload": {"enabled": false}}', + created_by=self.test_user_id, + created_at=naive_utc_now(), + ) + + # Create test workflow run + self.test_workflow_run = WorkflowRun( + id=self.test_workflow_run_id, + tenant_id=self.test_tenant_id, + app_id=self.test_app_id, + workflow_id=self.test_workflow_id, + type="workflow", + triggered_from="debugging", + version="draft", + status=WorkflowExecutionStatus.RUNNING, + created_by=self.test_user_id, + created_by_role="account", + created_at=naive_utc_now(), + ) + + # Store session and service instances + self.session = db_session_with_containers + self.file_service = file_service + self.workflow_run_service = workflow_run_service + + # Save test data to database + self.session.add(self.test_workflow) + self.session.add(self.test_workflow_run) + self.session.commit() + + yield + + # Cleanup + self._cleanup_test_data() + + def _cleanup_test_data(self): + """Clean up test data after each test method.""" + try: + # Clean up workflow pauses + self.session.execute(delete(WorkflowPauseModel)) + # Clean up upload files + self.session.execute( + delete(UploadFile).where( + UploadFile.tenant_id == self.test_tenant_id, + ) + ) + # Clean up workflow runs + self.session.execute( + delete(WorkflowRun).where( + WorkflowRun.tenant_id == self.test_tenant_id, + WorkflowRun.app_id == self.test_app_id, + ) + ) + # Clean up workflows + self.session.execute( + delete(Workflow).where( + Workflow.tenant_id == self.test_tenant_id, + Workflow.app_id == self.test_app_id, + ) + ) + self.session.commit() + except Exception as e: + self.session.rollback() + raise e + + def _create_graph_runtime_state( + self, + outputs: dict[str, object] | None = None, + total_tokens: int = 0, + node_run_steps: int = 0, + variables: dict[tuple[str, str], object] | None = None, + workflow_run_id: str | None = None, + ) -> ReadOnlyGraphRuntimeState: + """Create a real GraphRuntimeState for testing.""" + start_at = time() + + execution_id = workflow_run_id or getattr(self, "test_workflow_run_id", None) or str(uuid.uuid4()) + + # Create variable pool + variable_pool = VariablePool(system_variables=SystemVariable(workflow_execution_id=execution_id)) + if variables: + for (node_id, var_key), value in variables.items(): + variable_pool.add([node_id, var_key], value) + + # Create LLM usage + llm_usage = LLMUsage.empty_usage() + + # Create graph runtime state + graph_runtime_state = GraphRuntimeState( + variable_pool=variable_pool, + start_at=start_at, + total_tokens=total_tokens, + llm_usage=llm_usage, + outputs=outputs or {}, + node_run_steps=node_run_steps, + ) + + return ReadOnlyGraphRuntimeStateWrapper(graph_runtime_state) + + def _create_pause_state_persistence_layer( + self, + workflow_run: WorkflowRun | None = None, + workflow: Workflow | None = None, + state_owner_user_id: str | None = None, + ) -> PauseStatePersistenceLayer: + """Create PauseStatePersistenceLayer with real dependencies.""" + owner_id = state_owner_user_id + if owner_id is None: + if workflow is not None and workflow.created_by: + owner_id = workflow.created_by + elif workflow_run is not None and workflow_run.created_by: + owner_id = workflow_run.created_by + else: + owner_id = getattr(self, "test_user_id", None) + + assert owner_id is not None + owner_id = str(owner_id) + + return PauseStatePersistenceLayer( + session_factory=self.session.get_bind(), + state_owner_user_id=owner_id, + ) + + def test_complete_pause_flow_with_real_dependencies(self, db_session_with_containers): + """Test complete pause flow: event -> state serialization -> database save -> storage save.""" + # Arrange + layer = self._create_pause_state_persistence_layer() + + # Create real graph runtime state with test data + test_outputs = {"result": "test_output", "step": "intermediate"} + test_variables = { + ("node1", "var1"): "string_value", + ("node2", "var2"): {"complex": "object"}, + } + graph_runtime_state = self._create_graph_runtime_state( + outputs=test_outputs, + total_tokens=100, + node_run_steps=5, + variables=test_variables, + ) + + command_channel = _TestCommandChannelImpl() + layer.initialize(graph_runtime_state, command_channel) + + # Create pause event + event = GraphRunPausedEvent( + reason=SchedulingPause(message="test pause"), + outputs={"intermediate": "result"}, + ) + + # Act + layer.on_event(event) + + # Assert - Verify pause state was saved to database + self.session.refresh(self.test_workflow_run) + workflow_run = self.session.get(WorkflowRun, self.test_workflow_run_id) + assert workflow_run is not None + assert workflow_run.status == WorkflowExecutionStatus.PAUSED + + # Verify pause state exists in database + pause_model = self.session.scalars( + select(WorkflowPauseModel).where(WorkflowPauseModel.workflow_run_id == workflow_run.id) + ).first() + assert pause_model is not None + assert pause_model.workflow_id == self.test_workflow_id + assert pause_model.workflow_run_id == self.test_workflow_run_id + assert pause_model.state_object_key != "" + assert pause_model.resumed_at is None + + storage_content = storage.load(pause_model.state_object_key).decode() + expected_state = json.loads(graph_runtime_state.dumps()) + actual_state = json.loads(storage_content) + + assert actual_state == expected_state + + def test_state_persistence_and_retrieval(self, db_session_with_containers): + """Test that pause state can be persisted and retrieved correctly.""" + # Arrange + layer = self._create_pause_state_persistence_layer() + + # Create complex test data + complex_outputs = { + "nested": {"key": "value", "number": 42}, + "list": [1, 2, 3, {"nested": "item"}], + "boolean": True, + "null_value": None, + } + complex_variables = { + ("node1", "var1"): "string_value", + ("node2", "var2"): {"complex": "object"}, + ("node3", "var3"): [1, 2, 3], + } + + graph_runtime_state = self._create_graph_runtime_state( + outputs=complex_outputs, + total_tokens=250, + node_run_steps=10, + variables=complex_variables, + ) + + command_channel = _TestCommandChannelImpl() + layer.initialize(graph_runtime_state, command_channel) + + event = GraphRunPausedEvent(reason=SchedulingPause(message="test pause")) + + # Act - Save pause state + layer.on_event(event) + + # Assert - Retrieve and verify + pause_entity = self.workflow_run_service._workflow_run_repo.get_workflow_pause(self.test_workflow_run_id) + assert pause_entity is not None + assert pause_entity.workflow_execution_id == self.test_workflow_run_id + + state_bytes = pause_entity.get_state() + retrieved_state = json.loads(state_bytes.decode()) + expected_state = json.loads(graph_runtime_state.dumps()) + + assert retrieved_state == expected_state + assert retrieved_state["outputs"] == complex_outputs + assert retrieved_state["total_tokens"] == 250 + assert retrieved_state["node_run_steps"] == 10 + + def test_database_transaction_handling(self, db_session_with_containers): + """Test that database transactions are handled correctly.""" + # Arrange + layer = self._create_pause_state_persistence_layer() + graph_runtime_state = self._create_graph_runtime_state( + outputs={"test": "transaction"}, + total_tokens=50, + ) + + command_channel = _TestCommandChannelImpl() + layer.initialize(graph_runtime_state, command_channel) + + event = GraphRunPausedEvent(reason=SchedulingPause(message="test pause")) + + # Act + layer.on_event(event) + + # Assert - Verify data is committed and accessible in new session + with Session(bind=self.session.get_bind(), expire_on_commit=False) as new_session: + workflow_run = new_session.get(WorkflowRun, self.test_workflow_run_id) + assert workflow_run is not None + assert workflow_run.status == WorkflowExecutionStatus.PAUSED + + pause_model = new_session.scalars( + select(WorkflowPauseModel).where(WorkflowPauseModel.workflow_run_id == workflow_run.id) + ).first() + assert pause_model is not None + assert pause_model.workflow_run_id == self.test_workflow_run_id + assert pause_model.resumed_at is None + assert pause_model.state_object_key != "" + + def test_file_storage_integration(self, db_session_with_containers): + """Test integration with file storage system.""" + # Arrange + layer = self._create_pause_state_persistence_layer() + + # Create large state data to test storage + large_outputs = {"data": "x" * 10000} # 10KB of data + graph_runtime_state = self._create_graph_runtime_state( + outputs=large_outputs, + total_tokens=1000, + ) + + command_channel = _TestCommandChannelImpl() + layer.initialize(graph_runtime_state, command_channel) + + event = GraphRunPausedEvent(reason=SchedulingPause(message="test pause")) + + # Act + layer.on_event(event) + + # Assert - Verify file was uploaded to storage + self.session.refresh(self.test_workflow_run) + pause_model = self.session.scalars( + select(WorkflowPauseModel).where(WorkflowPauseModel.workflow_run_id == self.test_workflow_run.id) + ).first() + assert pause_model is not None + assert pause_model.state_object_key != "" + + # Verify content in storage + storage_content = storage.load(pause_model.state_object_key).decode() + assert storage_content == graph_runtime_state.dumps() + + def test_workflow_with_different_creators(self, db_session_with_containers): + """Test pause state with workflows created by different users.""" + # Arrange - Create workflow with different creator + different_user_id = str(uuid.uuid4()) + different_workflow = Workflow( + id=str(uuid.uuid4()), + tenant_id=self.test_tenant_id, + app_id=self.test_app_id, + type="workflow", + version="draft", + graph='{"nodes": [], "edges": []}', + features='{"file_upload": {"enabled": false}}', + created_by=different_user_id, + created_at=naive_utc_now(), + ) + + different_workflow_run = WorkflowRun( + id=str(uuid.uuid4()), + tenant_id=self.test_tenant_id, + app_id=self.test_app_id, + workflow_id=different_workflow.id, + type="workflow", + triggered_from="debugging", + version="draft", + status=WorkflowExecutionStatus.RUNNING, + created_by=self.test_user_id, # Run created by different user + created_by_role="account", + created_at=naive_utc_now(), + ) + + self.session.add(different_workflow) + self.session.add(different_workflow_run) + self.session.commit() + + layer = self._create_pause_state_persistence_layer( + workflow_run=different_workflow_run, + workflow=different_workflow, + ) + + graph_runtime_state = self._create_graph_runtime_state( + outputs={"creator_test": "different_creator"}, + workflow_run_id=different_workflow_run.id, + ) + + command_channel = _TestCommandChannelImpl() + layer.initialize(graph_runtime_state, command_channel) + + event = GraphRunPausedEvent(reason=SchedulingPause(message="test pause")) + + # Act + layer.on_event(event) + + # Assert - Should use workflow creator (not run creator) + self.session.refresh(different_workflow_run) + pause_model = self.session.scalars( + select(WorkflowPauseModel).where(WorkflowPauseModel.workflow_run_id == different_workflow_run.id) + ).first() + assert pause_model is not None + + # Verify the state owner is the workflow creator + pause_entity = self.workflow_run_service._workflow_run_repo.get_workflow_pause(different_workflow_run.id) + assert pause_entity is not None + + def test_layer_ignores_non_pause_events(self, db_session_with_containers): + """Test that layer ignores non-pause events.""" + # Arrange + layer = self._create_pause_state_persistence_layer() + graph_runtime_state = self._create_graph_runtime_state() + + command_channel = _TestCommandChannelImpl() + layer.initialize(graph_runtime_state, command_channel) + + # Import other event types + from core.workflow.graph_events.graph import ( + GraphRunFailedEvent, + GraphRunStartedEvent, + GraphRunSucceededEvent, + ) + + # Act - Send non-pause events + layer.on_event(GraphRunStartedEvent()) + layer.on_event(GraphRunSucceededEvent(outputs={"result": "success"})) + layer.on_event(GraphRunFailedEvent(error="test error", exceptions_count=1)) + + # Assert - No pause state should be created + self.session.refresh(self.test_workflow_run) + assert self.test_workflow_run.status == WorkflowExecutionStatus.RUNNING + + pause_states = ( + self.session.query(WorkflowPauseModel) + .filter(WorkflowPauseModel.workflow_run_id == self.test_workflow_run_id) + .all() + ) + assert len(pause_states) == 0 + + def test_layer_requires_initialization(self, db_session_with_containers): + """Test that layer requires proper initialization before handling events.""" + # Arrange + layer = self._create_pause_state_persistence_layer() + # Don't initialize - graph_runtime_state should not be set + + event = GraphRunPausedEvent(reason=SchedulingPause(message="test pause")) + + # Act & Assert - Should raise AttributeError + with pytest.raises(AttributeError): + layer.on_event(event) diff --git a/api/tests/test_containers_integration_tests/test_workflow_pause_integration.py b/api/tests/test_containers_integration_tests/test_workflow_pause_integration.py new file mode 100644 index 0000000000..79da5d4d0e --- /dev/null +++ b/api/tests/test_containers_integration_tests/test_workflow_pause_integration.py @@ -0,0 +1,948 @@ +"""Comprehensive integration tests for workflow pause functionality. + +This test suite covers complete workflow pause functionality including: +- Real database interactions using containerized PostgreSQL +- Real storage operations using the test storage backend +- Complete workflow: create -> pause -> resume -> delete +- Testing with actual FileService (not mocked) +- Database transactions and rollback behavior +- Actual file upload and retrieval through storage +- Workflow status transitions in the database +- Error handling with real database constraints +- Concurrent access scenarios +- Multi-tenant isolation +- Prune functionality +- File storage integration + +These tests use TestContainers to spin up real services for integration testing, +providing more reliable and realistic test scenarios than mocks. +""" + +import json +import uuid +from dataclasses import dataclass +from datetime import timedelta + +import pytest +from sqlalchemy import delete, select +from sqlalchemy.orm import Session, selectinload, sessionmaker + +from core.workflow.entities import WorkflowExecution +from core.workflow.enums import WorkflowExecutionStatus +from extensions.ext_storage import storage +from libs.datetime_utils import naive_utc_now +from models import Account +from models import WorkflowPause as WorkflowPauseModel +from models.account import Tenant, TenantAccountJoin, TenantAccountRole +from models.model import UploadFile +from models.workflow import Workflow, WorkflowRun +from repositories.sqlalchemy_api_workflow_run_repository import ( + DifyAPISQLAlchemyWorkflowRunRepository, + _WorkflowRunError, +) + + +@dataclass +class PauseWorkflowSuccessCase: + """Test case for successful pause workflow operations.""" + + name: str + initial_status: WorkflowExecutionStatus + description: str = "" + + +@dataclass +class PauseWorkflowFailureCase: + """Test case for pause workflow failure scenarios.""" + + name: str + initial_status: WorkflowExecutionStatus + description: str = "" + + +@dataclass +class ResumeWorkflowSuccessCase: + """Test case for successful resume workflow operations.""" + + name: str + initial_status: WorkflowExecutionStatus + description: str = "" + + +@dataclass +class ResumeWorkflowFailureCase: + """Test case for resume workflow failure scenarios.""" + + name: str + initial_status: WorkflowExecutionStatus + pause_resumed: bool + set_running_status: bool = False + description: str = "" + + +@dataclass +class PrunePausesTestCase: + """Test case for prune pauses operations.""" + + name: str + pause_age: timedelta + resume_age: timedelta | None + expected_pruned_count: int + description: str = "" + + +def pause_workflow_failure_cases() -> list[PauseWorkflowFailureCase]: + """Create test cases for pause workflow failure scenarios.""" + return [ + PauseWorkflowFailureCase( + name="pause_already_paused_workflow", + initial_status=WorkflowExecutionStatus.PAUSED, + description="Should fail to pause an already paused workflow", + ), + PauseWorkflowFailureCase( + name="pause_completed_workflow", + initial_status=WorkflowExecutionStatus.SUCCEEDED, + description="Should fail to pause a completed workflow", + ), + PauseWorkflowFailureCase( + name="pause_failed_workflow", + initial_status=WorkflowExecutionStatus.FAILED, + description="Should fail to pause a failed workflow", + ), + ] + + +def resume_workflow_success_cases() -> list[ResumeWorkflowSuccessCase]: + """Create test cases for successful resume workflow operations.""" + return [ + ResumeWorkflowSuccessCase( + name="resume_paused_workflow", + initial_status=WorkflowExecutionStatus.PAUSED, + description="Should successfully resume a paused workflow", + ), + ] + + +def resume_workflow_failure_cases() -> list[ResumeWorkflowFailureCase]: + """Create test cases for resume workflow failure scenarios.""" + return [ + ResumeWorkflowFailureCase( + name="resume_already_resumed_workflow", + initial_status=WorkflowExecutionStatus.PAUSED, + pause_resumed=True, + description="Should fail to resume an already resumed workflow", + ), + ResumeWorkflowFailureCase( + name="resume_running_workflow", + initial_status=WorkflowExecutionStatus.RUNNING, + pause_resumed=False, + set_running_status=True, + description="Should fail to resume a running workflow", + ), + ] + + +def prune_pauses_test_cases() -> list[PrunePausesTestCase]: + """Create test cases for prune pauses operations.""" + return [ + PrunePausesTestCase( + name="prune_old_active_pauses", + pause_age=timedelta(days=7), + resume_age=None, + expected_pruned_count=1, + description="Should prune old active pauses", + ), + PrunePausesTestCase( + name="prune_old_resumed_pauses", + pause_age=timedelta(hours=12), # Created 12 hours ago (recent) + resume_age=timedelta(days=7), + expected_pruned_count=1, + description="Should prune old resumed pauses", + ), + PrunePausesTestCase( + name="keep_recent_active_pauses", + pause_age=timedelta(hours=1), + resume_age=None, + expected_pruned_count=0, + description="Should keep recent active pauses", + ), + PrunePausesTestCase( + name="keep_recent_resumed_pauses", + pause_age=timedelta(days=1), + resume_age=timedelta(hours=1), + expected_pruned_count=0, + description="Should keep recent resumed pauses", + ), + ] + + +class TestWorkflowPauseIntegration: + """Comprehensive integration tests for workflow pause functionality.""" + + @pytest.fixture(autouse=True) + def setup_test_data(self, db_session_with_containers): + """Set up test data for each test method using TestContainers.""" + # Create test tenant and account + + tenant = Tenant( + name="Test Tenant", + status="normal", + ) + db_session_with_containers.add(tenant) + db_session_with_containers.commit() + + account = Account( + email="test@example.com", + name="Test User", + interface_language="en-US", + status="active", + ) + db_session_with_containers.add(account) + db_session_with_containers.commit() + + # Create tenant-account join + tenant_join = TenantAccountJoin( + tenant_id=tenant.id, + account_id=account.id, + role=TenantAccountRole.OWNER, + current=True, + ) + db_session_with_containers.add(tenant_join) + db_session_with_containers.commit() + + # Set test data + self.test_tenant_id = tenant.id + self.test_user_id = account.id + self.test_app_id = str(uuid.uuid4()) + self.test_workflow_id = str(uuid.uuid4()) + + # Create test workflow + self.test_workflow = Workflow( + id=self.test_workflow_id, + tenant_id=self.test_tenant_id, + app_id=self.test_app_id, + type="workflow", + version="draft", + graph='{"nodes": [], "edges": []}', + features='{"file_upload": {"enabled": false}}', + created_by=self.test_user_id, + created_at=naive_utc_now(), + ) + + # Store session instance + self.session = db_session_with_containers + + # Save test data to database + self.session.add(self.test_workflow) + self.session.commit() + + yield + + # Cleanup + self._cleanup_test_data() + + def _cleanup_test_data(self): + """Clean up test data after each test method.""" + # Clean up workflow pauses + self.session.execute(delete(WorkflowPauseModel)) + # Clean up upload files + self.session.execute( + delete(UploadFile).where( + UploadFile.tenant_id == self.test_tenant_id, + ) + ) + # Clean up workflow runs + self.session.execute( + delete(WorkflowRun).where( + WorkflowRun.tenant_id == self.test_tenant_id, + WorkflowRun.app_id == self.test_app_id, + ) + ) + # Clean up workflows + self.session.execute( + delete(Workflow).where( + Workflow.tenant_id == self.test_tenant_id, + Workflow.app_id == self.test_app_id, + ) + ) + self.session.commit() + + def _create_test_workflow_run( + self, status: WorkflowExecutionStatus = WorkflowExecutionStatus.RUNNING + ) -> WorkflowRun: + """Create a test workflow run with specified status.""" + workflow_run = WorkflowRun( + id=str(uuid.uuid4()), + tenant_id=self.test_tenant_id, + app_id=self.test_app_id, + workflow_id=self.test_workflow_id, + type="workflow", + triggered_from="debugging", + version="draft", + status=status, + created_by=self.test_user_id, + created_by_role="account", + created_at=naive_utc_now(), + ) + self.session.add(workflow_run) + self.session.commit() + return workflow_run + + def _create_test_state(self) -> str: + """Create a test state string.""" + return json.dumps( + { + "node_id": "test-node", + "node_type": "llm", + "status": "paused", + "data": {"key": "value"}, + "timestamp": naive_utc_now().isoformat(), + } + ) + + def _get_workflow_run_repository(self): + """Get workflow run repository instance for testing.""" + # Create session factory from the test session + engine = self.session.get_bind() + session_factory = sessionmaker(bind=engine, expire_on_commit=False) + + # Create a test-specific repository that implements the missing save method + class TestWorkflowRunRepository(DifyAPISQLAlchemyWorkflowRunRepository): + """Test-specific repository that implements the missing save method.""" + + def save(self, execution: WorkflowExecution): + """Implement the missing save method for testing.""" + # For testing purposes, we don't need to implement this method + # as it's not used in the pause functionality tests + pass + + # Create and return repository instance + repository = TestWorkflowRunRepository(session_maker=session_factory) + return repository + + # ==================== Complete Pause Workflow Tests ==================== + + def test_complete_pause_resume_workflow(self): + """Test complete workflow: create -> pause -> resume -> delete.""" + # Arrange + workflow_run = self._create_test_workflow_run() + test_state = self._create_test_state() + repository = self._get_workflow_run_repository() + + # Act - Create pause state + pause_entity = repository.create_workflow_pause( + workflow_run_id=workflow_run.id, + state_owner_user_id=self.test_user_id, + state=test_state, + ) + + # Assert - Pause state created + assert pause_entity is not None + assert pause_entity.id is not None + assert pause_entity.workflow_execution_id == workflow_run.id + # Convert both to strings for comparison + retrieved_state = pause_entity.get_state() + if isinstance(retrieved_state, bytes): + retrieved_state = retrieved_state.decode() + assert retrieved_state == test_state + + # Verify database state + query = select(WorkflowPauseModel).where(WorkflowPauseModel.workflow_run_id == workflow_run.id) + pause_model = self.session.scalars(query).first() + assert pause_model is not None + assert pause_model.resumed_at is None + assert pause_model.id == pause_entity.id + + self.session.refresh(workflow_run) + assert workflow_run.status == WorkflowExecutionStatus.PAUSED + + # Act - Get pause state + retrieved_entity = repository.get_workflow_pause(workflow_run.id) + + # Assert - Pause state retrieved + assert retrieved_entity is not None + assert retrieved_entity.id == pause_entity.id + retrieved_state = retrieved_entity.get_state() + if isinstance(retrieved_state, bytes): + retrieved_state = retrieved_state.decode() + assert retrieved_state == test_state + + # Act - Resume workflow + resumed_entity = repository.resume_workflow_pause( + workflow_run_id=workflow_run.id, + pause_entity=pause_entity, + ) + + # Assert - Workflow resumed + assert resumed_entity is not None + assert resumed_entity.id == pause_entity.id + assert resumed_entity.resumed_at is not None + + # Verify database state + self.session.refresh(workflow_run) + assert workflow_run.status == WorkflowExecutionStatus.RUNNING + self.session.refresh(pause_model) + assert pause_model.resumed_at is not None + + # Act - Delete pause state + repository.delete_workflow_pause(pause_entity) + + # Assert - Pause state deleted + with Session(bind=self.session.get_bind()) as session: + deleted_pause = session.get(WorkflowPauseModel, pause_entity.id) + assert deleted_pause is None + + def test_pause_workflow_success(self): + """Test successful pause workflow scenarios.""" + workflow_run = self._create_test_workflow_run(status=WorkflowExecutionStatus.RUNNING) + test_state = self._create_test_state() + repository = self._get_workflow_run_repository() + + pause_entity = repository.create_workflow_pause( + workflow_run_id=workflow_run.id, + state_owner_user_id=self.test_user_id, + state=test_state, + ) + + assert pause_entity is not None + assert pause_entity.workflow_execution_id == workflow_run.id + + retrieved_state = pause_entity.get_state() + if isinstance(retrieved_state, bytes): + retrieved_state = retrieved_state.decode() + assert retrieved_state == test_state + + self.session.refresh(workflow_run) + assert workflow_run.status == WorkflowExecutionStatus.PAUSED + pause_query = select(WorkflowPauseModel).where(WorkflowPauseModel.workflow_run_id == workflow_run.id) + pause_model = self.session.scalars(pause_query).first() + assert pause_model is not None + assert pause_model.id == pause_entity.id + assert pause_model.resumed_at is None + + @pytest.mark.parametrize("test_case", pause_workflow_failure_cases(), ids=lambda tc: tc.name) + def test_pause_workflow_failure(self, test_case: PauseWorkflowFailureCase): + """Test pause workflow failure scenarios.""" + workflow_run = self._create_test_workflow_run(status=test_case.initial_status) + test_state = self._create_test_state() + repository = self._get_workflow_run_repository() + + with pytest.raises(_WorkflowRunError): + repository.create_workflow_pause( + workflow_run_id=workflow_run.id, + state_owner_user_id=self.test_user_id, + state=test_state, + ) + + @pytest.mark.parametrize("test_case", resume_workflow_success_cases(), ids=lambda tc: tc.name) + def test_resume_workflow_success(self, test_case: ResumeWorkflowSuccessCase): + """Test successful resume workflow scenarios.""" + workflow_run = self._create_test_workflow_run(status=test_case.initial_status) + test_state = self._create_test_state() + repository = self._get_workflow_run_repository() + + if workflow_run.status != WorkflowExecutionStatus.RUNNING: + workflow_run.status = WorkflowExecutionStatus.RUNNING + self.session.commit() + + pause_entity = repository.create_workflow_pause( + workflow_run_id=workflow_run.id, + state_owner_user_id=self.test_user_id, + state=test_state, + ) + + self.session.refresh(workflow_run) + assert workflow_run.status == WorkflowExecutionStatus.PAUSED + + resumed_entity = repository.resume_workflow_pause( + workflow_run_id=workflow_run.id, + pause_entity=pause_entity, + ) + assert resumed_entity is not None + assert resumed_entity.id == pause_entity.id + assert resumed_entity.resumed_at is not None + + self.session.refresh(workflow_run) + assert workflow_run.status == WorkflowExecutionStatus.RUNNING + pause_query = select(WorkflowPauseModel).where(WorkflowPauseModel.workflow_run_id == workflow_run.id) + pause_model = self.session.scalars(pause_query).first() + assert pause_model is not None + assert pause_model.id == pause_entity.id + assert pause_model.resumed_at is not None + + def test_resume_running_workflow(self): + """Test resume workflow failure scenarios.""" + workflow_run = self._create_test_workflow_run(status=WorkflowExecutionStatus.RUNNING) + test_state = self._create_test_state() + repository = self._get_workflow_run_repository() + + pause_entity = repository.create_workflow_pause( + workflow_run_id=workflow_run.id, + state_owner_user_id=self.test_user_id, + state=test_state, + ) + + self.session.refresh(workflow_run) + workflow_run.status = WorkflowExecutionStatus.RUNNING + self.session.add(workflow_run) + self.session.commit() + + with pytest.raises(_WorkflowRunError): + repository.resume_workflow_pause( + workflow_run_id=workflow_run.id, + pause_entity=pause_entity, + ) + + def test_resume_resumed_pause(self): + """Test resume workflow failure scenarios.""" + workflow_run = self._create_test_workflow_run(status=WorkflowExecutionStatus.RUNNING) + test_state = self._create_test_state() + repository = self._get_workflow_run_repository() + + pause_entity = repository.create_workflow_pause( + workflow_run_id=workflow_run.id, + state_owner_user_id=self.test_user_id, + state=test_state, + ) + pause_model = self.session.get(WorkflowPauseModel, pause_entity.id) + pause_model.resumed_at = naive_utc_now() + self.session.add(pause_model) + self.session.commit() + + with pytest.raises(_WorkflowRunError): + repository.resume_workflow_pause( + workflow_run_id=workflow_run.id, + pause_entity=pause_entity, + ) + + # ==================== Error Scenario Tests ==================== + + def test_pause_nonexistent_workflow_run(self): + """Test pausing a non-existent workflow run.""" + # Arrange + nonexistent_id = str(uuid.uuid4()) + test_state = self._create_test_state() + repository = self._get_workflow_run_repository() + + # Act & Assert + with pytest.raises(ValueError, match="WorkflowRun not found"): + repository.create_workflow_pause( + workflow_run_id=nonexistent_id, + state_owner_user_id=self.test_user_id, + state=test_state, + ) + + def test_resume_nonexistent_workflow_run(self): + """Test resuming a non-existent workflow run.""" + # Arrange + workflow_run = self._create_test_workflow_run() + test_state = self._create_test_state() + repository = self._get_workflow_run_repository() + + pause_entity = repository.create_workflow_pause( + workflow_run_id=workflow_run.id, + state_owner_user_id=self.test_user_id, + state=test_state, + ) + + nonexistent_id = str(uuid.uuid4()) + + # Act & Assert + with pytest.raises(ValueError, match="WorkflowRun not found"): + repository.resume_workflow_pause( + workflow_run_id=nonexistent_id, + pause_entity=pause_entity, + ) + + # ==================== Prune Functionality Tests ==================== + + @pytest.mark.parametrize("test_case", prune_pauses_test_cases(), ids=lambda tc: tc.name) + def test_prune_pauses_scenarios(self, test_case: PrunePausesTestCase): + """Test various prune pauses scenarios.""" + now = naive_utc_now() + + # Create pause state + workflow_run = self._create_test_workflow_run() + test_state = self._create_test_state() + repository = self._get_workflow_run_repository() + + pause_entity = repository.create_workflow_pause( + workflow_run_id=workflow_run.id, + state_owner_user_id=self.test_user_id, + state=test_state, + ) + + # Manually adjust timestamps for testing + pause_model = self.session.get(WorkflowPauseModel, pause_entity.id) + pause_model.created_at = now - test_case.pause_age + + if test_case.resume_age is not None: + # Resume pause and adjust resume time + repository.resume_workflow_pause( + workflow_run_id=workflow_run.id, + pause_entity=pause_entity, + ) + # Need to refresh to get the updated model + self.session.refresh(pause_model) + # Manually set the resumed_at to an older time for testing + pause_model.resumed_at = now - test_case.resume_age + self.session.commit() # Commit the resumed_at change + # Refresh again to ensure the change is persisted + self.session.refresh(pause_model) + + self.session.commit() + + # Act - Prune pauses + expiration_time = now - timedelta(days=1, seconds=1) # Expire pauses older than 1 day (plus 1 second) + resumption_time = now - timedelta( + days=7, seconds=1 + ) # Clean up pauses resumed more than 7 days ago (plus 1 second) + + # Debug: Check pause state before pruning + self.session.refresh(pause_model) + print(f"Pause created_at: {pause_model.created_at}") + print(f"Pause resumed_at: {pause_model.resumed_at}") + print(f"Expiration time: {expiration_time}") + print(f"Resumption time: {resumption_time}") + + # Force commit to ensure timestamps are saved + self.session.commit() + + # Determine if the pause should be pruned based on timestamps + should_be_pruned = False + if test_case.resume_age is not None: + # If resumed, check if resumed_at is older than resumption_time + should_be_pruned = pause_model.resumed_at < resumption_time + else: + # If not resumed, check if created_at is older than expiration_time + should_be_pruned = pause_model.created_at < expiration_time + + # Act - Prune pauses + pruned_ids = repository.prune_pauses( + expiration=expiration_time, + resumption_expiration=resumption_time, + ) + + # Assert - Check pruning results + if should_be_pruned: + assert len(pruned_ids) == test_case.expected_pruned_count + # Verify pause was actually deleted + # The pause should be in the pruned_ids list if it was pruned + assert pause_entity.id in pruned_ids + else: + assert len(pruned_ids) == 0 + + def test_prune_pauses_with_limit(self): + """Test prune pauses with limit parameter.""" + now = naive_utc_now() + + # Create multiple pause states + pause_entities = [] + repository = self._get_workflow_run_repository() + + for i in range(5): + workflow_run = self._create_test_workflow_run() + test_state = self._create_test_state() + + pause_entity = repository.create_workflow_pause( + workflow_run_id=workflow_run.id, + state_owner_user_id=self.test_user_id, + state=test_state, + ) + pause_entities.append(pause_entity) + + # Make all pauses old enough to be pruned + pause_model = self.session.get(WorkflowPauseModel, pause_entity.id) + pause_model.created_at = now - timedelta(days=7) + + self.session.commit() + + # Act - Prune with limit + expiration_time = now - timedelta(days=1) + resumption_time = now - timedelta(days=7) + + pruned_ids = repository.prune_pauses( + expiration=expiration_time, + resumption_expiration=resumption_time, + limit=3, + ) + + # Assert + assert len(pruned_ids) == 3 + + # Verify only 3 were deleted + remaining_count = ( + self.session.query(WorkflowPauseModel) + .filter(WorkflowPauseModel.id.in_([pe.id for pe in pause_entities])) + .count() + ) + assert remaining_count == 2 + + # ==================== Multi-tenant Isolation Tests ==================== + + def test_multi_tenant_pause_isolation(self): + """Test that pause states are properly isolated by tenant.""" + # Arrange - Create second tenant + + tenant2 = Tenant( + name="Test Tenant 2", + status="normal", + ) + self.session.add(tenant2) + self.session.commit() + + account2 = Account( + email="test2@example.com", + name="Test User 2", + interface_language="en-US", + status="active", + ) + self.session.add(account2) + self.session.commit() + + tenant2_join = TenantAccountJoin( + tenant_id=tenant2.id, + account_id=account2.id, + role=TenantAccountRole.OWNER, + current=True, + ) + self.session.add(tenant2_join) + self.session.commit() + + # Create workflow for tenant 2 + workflow2 = Workflow( + id=str(uuid.uuid4()), + tenant_id=tenant2.id, + app_id=str(uuid.uuid4()), + type="workflow", + version="draft", + graph='{"nodes": [], "edges": []}', + features='{"file_upload": {"enabled": false}}', + created_by=account2.id, + created_at=naive_utc_now(), + ) + self.session.add(workflow2) + self.session.commit() + + # Create workflow runs for both tenants + workflow_run1 = self._create_test_workflow_run() + workflow_run2 = WorkflowRun( + id=str(uuid.uuid4()), + tenant_id=tenant2.id, + app_id=workflow2.app_id, + workflow_id=workflow2.id, + type="workflow", + triggered_from="debugging", + version="draft", + status=WorkflowExecutionStatus.RUNNING, + created_by=account2.id, + created_by_role="account", + created_at=naive_utc_now(), + ) + self.session.add(workflow_run2) + self.session.commit() + + test_state = self._create_test_state() + repository = self._get_workflow_run_repository() + + # Act - Create pause for tenant 1 + pause_entity1 = repository.create_workflow_pause( + workflow_run_id=workflow_run1.id, + state_owner_user_id=self.test_user_id, + state=test_state, + ) + + # Try to access pause from tenant 2 using tenant 1's repository + # This should work because we're using the same repository + pause_entity2 = repository.get_workflow_pause(workflow_run2.id) + assert pause_entity2 is None # No pause for tenant 2 yet + + # Create pause for tenant 2 + pause_entity2 = repository.create_workflow_pause( + workflow_run_id=workflow_run2.id, + state_owner_user_id=account2.id, + state=test_state, + ) + + # Assert - Both pauses should exist and be separate + assert pause_entity1 is not None + assert pause_entity2 is not None + assert pause_entity1.id != pause_entity2.id + assert pause_entity1.workflow_execution_id != pause_entity2.workflow_execution_id + + def test_cross_tenant_access_restriction(self): + """Test that cross-tenant access is properly restricted.""" + # This test would require tenant-specific repositories + # For now, we test that pause entities are properly scoped by tenant_id + workflow_run = self._create_test_workflow_run() + test_state = self._create_test_state() + repository = self._get_workflow_run_repository() + + pause_entity = repository.create_workflow_pause( + workflow_run_id=workflow_run.id, + state_owner_user_id=self.test_user_id, + state=test_state, + ) + + # Verify pause is properly scoped + pause_model = self.session.get(WorkflowPauseModel, pause_entity.id) + assert pause_model.workflow_id == self.test_workflow_id + + # ==================== File Storage Integration Tests ==================== + + def test_file_storage_integration(self): + """Test that state files are properly stored and retrieved.""" + # Arrange + workflow_run = self._create_test_workflow_run() + test_state = self._create_test_state() + repository = self._get_workflow_run_repository() + + # Act - Create pause state + pause_entity = repository.create_workflow_pause( + workflow_run_id=workflow_run.id, + state_owner_user_id=self.test_user_id, + state=test_state, + ) + + # Assert - Verify file was uploaded to storage + pause_model = self.session.get(WorkflowPauseModel, pause_entity.id) + assert pause_model.state_object_key != "" + + # Verify file content in storage + + file_key = pause_model.state_object_key + storage_content = storage.load(file_key).decode() + assert storage_content == test_state + + # Verify retrieval through entity + retrieved_state = pause_entity.get_state() + if isinstance(retrieved_state, bytes): + retrieved_state = retrieved_state.decode() + assert retrieved_state == test_state + + def test_file_cleanup_on_pause_deletion(self): + """Test that files are properly handled on pause deletion.""" + # Arrange + workflow_run = self._create_test_workflow_run() + test_state = self._create_test_state() + repository = self._get_workflow_run_repository() + + pause_entity = repository.create_workflow_pause( + workflow_run_id=workflow_run.id, + state_owner_user_id=self.test_user_id, + state=test_state, + ) + + # Get file info before deletion + pause_model = self.session.get(WorkflowPauseModel, pause_entity.id) + file_key = pause_model.state_object_key + + # Act - Delete pause state + repository.delete_workflow_pause(pause_entity) + + # Assert - Pause record should be deleted + self.session.expire_all() # Clear session to ensure fresh query + deleted_pause = self.session.get(WorkflowPauseModel, pause_entity.id) + assert deleted_pause is None + + try: + content = storage.load(file_key).decode() + pytest.fail("File should be deleted from storage after pause deletion") + except FileNotFoundError: + # This is expected - file should be deleted from storage + pass + except Exception as e: + pytest.fail(f"Unexpected error when checking file deletion: {e}") + + def test_large_state_file_handling(self): + """Test handling of large state files.""" + # Arrange - Create a large state (1MB) + large_state = "x" * (1024 * 1024) # 1MB of data + large_state_json = json.dumps({"large_data": large_state}) + + workflow_run = self._create_test_workflow_run() + repository = self._get_workflow_run_repository() + + # Act + pause_entity = repository.create_workflow_pause( + workflow_run_id=workflow_run.id, + state_owner_user_id=self.test_user_id, + state=large_state_json, + ) + + # Assert + assert pause_entity is not None + retrieved_state = pause_entity.get_state() + if isinstance(retrieved_state, bytes): + retrieved_state = retrieved_state.decode() + assert retrieved_state == large_state_json + + # Verify file size in database + pause_model = self.session.get(WorkflowPauseModel, pause_entity.id) + assert pause_model.state_object_key != "" + loaded_state = storage.load(pause_model.state_object_key) + assert loaded_state.decode() == large_state_json + + def test_multiple_pause_resume_cycles(self): + """Test multiple pause/resume cycles on the same workflow run.""" + # Arrange + workflow_run = self._create_test_workflow_run() + repository = self._get_workflow_run_repository() + + # Act & Assert - Multiple cycles + for i in range(3): + state = json.dumps({"cycle": i, "data": f"state_{i}"}) + + # Reset workflow run status to RUNNING before each pause (after first cycle) + if i > 0: + self.session.refresh(workflow_run) # Refresh to get latest state from session + workflow_run.status = WorkflowExecutionStatus.RUNNING + self.session.commit() + self.session.refresh(workflow_run) # Refresh again after commit + + # Pause + pause_entity = repository.create_workflow_pause( + workflow_run_id=workflow_run.id, + state_owner_user_id=self.test_user_id, + state=state, + ) + assert pause_entity is not None + + # Verify pause + self.session.expire_all() # Clear session to ensure fresh query + self.session.refresh(workflow_run) + + # Use the test session directly to verify the pause + stmt = select(WorkflowRun).options(selectinload(WorkflowRun.pause)).where(WorkflowRun.id == workflow_run.id) + workflow_run_with_pause = self.session.scalar(stmt) + pause_model = workflow_run_with_pause.pause + + # Verify pause using test session directly + assert pause_model is not None + assert pause_model.id == pause_entity.id + assert pause_model.state_object_key != "" + + # Load file content using storage directly + file_content = storage.load(pause_model.state_object_key) + if isinstance(file_content, bytes): + file_content = file_content.decode() + assert file_content == state + + # Resume + resumed_entity = repository.resume_workflow_pause( + workflow_run_id=workflow_run.id, + pause_entity=pause_entity, + ) + assert resumed_entity is not None + assert resumed_entity.resumed_at is not None + + # Verify resume - check that pause is marked as resumed + self.session.expire_all() # Clear session to ensure fresh query + stmt = select(WorkflowPauseModel).where(WorkflowPauseModel.id == pause_entity.id) + resumed_pause_model = self.session.scalar(stmt) + assert resumed_pause_model is not None + assert resumed_pause_model.resumed_at is not None + + # Verify workflow run status + self.session.refresh(workflow_run) + assert workflow_run.status == WorkflowExecutionStatus.RUNNING diff --git a/api/tests/unit_tests/core/app/layers/test_pause_state_persist_layer.py b/api/tests/unit_tests/core/app/layers/test_pause_state_persist_layer.py new file mode 100644 index 0000000000..3bd967cbc0 --- /dev/null +++ b/api/tests/unit_tests/core/app/layers/test_pause_state_persist_layer.py @@ -0,0 +1,278 @@ +import json +from time import time +from unittest.mock import Mock + +import pytest + +from core.app.layers.pause_state_persist_layer import PauseStatePersistenceLayer +from core.variables.segments import Segment +from core.workflow.entities.pause_reason import SchedulingPause +from core.workflow.graph_engine.entities.commands import GraphEngineCommand +from core.workflow.graph_events.graph import ( + GraphRunFailedEvent, + GraphRunPausedEvent, + GraphRunStartedEvent, + GraphRunSucceededEvent, +) +from core.workflow.runtime.graph_runtime_state_protocol import ReadOnlyVariablePool +from repositories.factory import DifyAPIRepositoryFactory + + +class TestDataFactory: + """Factory helpers for constructing graph events used in tests.""" + + @staticmethod + def create_graph_run_paused_event(outputs: dict[str, object] | None = None) -> GraphRunPausedEvent: + return GraphRunPausedEvent(reason=SchedulingPause(message="test pause"), outputs=outputs or {}) + + @staticmethod + def create_graph_run_started_event() -> GraphRunStartedEvent: + return GraphRunStartedEvent() + + @staticmethod + def create_graph_run_succeeded_event(outputs: dict[str, object] | None = None) -> GraphRunSucceededEvent: + return GraphRunSucceededEvent(outputs=outputs or {}) + + @staticmethod + def create_graph_run_failed_event( + error: str = "Test error", + exceptions_count: int = 1, + ) -> GraphRunFailedEvent: + return GraphRunFailedEvent(error=error, exceptions_count=exceptions_count) + + +class MockSystemVariableReadOnlyView: + """Minimal read-only system variable view for testing.""" + + def __init__(self, workflow_execution_id: str | None = None) -> None: + self._workflow_execution_id = workflow_execution_id + + @property + def workflow_execution_id(self) -> str | None: + return self._workflow_execution_id + + +class MockReadOnlyVariablePool: + """Mock implementation of ReadOnlyVariablePool for testing.""" + + def __init__(self, variables: dict[tuple[str, str], object] | None = None): + self._variables = variables or {} + + def get(self, node_id: str, variable_key: str) -> Segment | None: + value = self._variables.get((node_id, variable_key)) + if value is None: + return None + mock_segment = Mock(spec=Segment) + mock_segment.value = value + return mock_segment + + def get_all_by_node(self, node_id: str) -> dict[str, object]: + return {key: value for (nid, key), value in self._variables.items() if nid == node_id} + + def get_by_prefix(self, prefix: str) -> dict[str, object]: + return {f"{nid}.{key}": value for (nid, key), value in self._variables.items() if nid.startswith(prefix)} + + +class MockReadOnlyGraphRuntimeState: + """Mock implementation of ReadOnlyGraphRuntimeState for testing.""" + + def __init__( + self, + start_at: float | None = None, + total_tokens: int = 0, + node_run_steps: int = 0, + ready_queue_size: int = 0, + exceptions_count: int = 0, + outputs: dict[str, object] | None = None, + variables: dict[tuple[str, str], object] | None = None, + workflow_execution_id: str | None = None, + ): + self._start_at = start_at or time() + self._total_tokens = total_tokens + self._node_run_steps = node_run_steps + self._ready_queue_size = ready_queue_size + self._exceptions_count = exceptions_count + self._outputs = outputs or {} + self._variable_pool = MockReadOnlyVariablePool(variables) + self._system_variable = MockSystemVariableReadOnlyView(workflow_execution_id) + + @property + def system_variable(self) -> MockSystemVariableReadOnlyView: + return self._system_variable + + @property + def variable_pool(self) -> ReadOnlyVariablePool: + return self._variable_pool + + @property + def start_at(self) -> float: + return self._start_at + + @property + def total_tokens(self) -> int: + return self._total_tokens + + @property + def node_run_steps(self) -> int: + return self._node_run_steps + + @property + def ready_queue_size(self) -> int: + return self._ready_queue_size + + @property + def exceptions_count(self) -> int: + return self._exceptions_count + + @property + def outputs(self) -> dict[str, object]: + return self._outputs.copy() + + @property + def llm_usage(self): + mock_usage = Mock() + mock_usage.prompt_tokens = 10 + mock_usage.completion_tokens = 20 + mock_usage.total_tokens = 30 + return mock_usage + + def get_output(self, key: str, default: object = None) -> object: + return self._outputs.get(key, default) + + def dumps(self) -> str: + return json.dumps( + { + "start_at": self._start_at, + "total_tokens": self._total_tokens, + "node_run_steps": self._node_run_steps, + "ready_queue_size": self._ready_queue_size, + "exceptions_count": self._exceptions_count, + "outputs": self._outputs, + "variables": {f"{k[0]}.{k[1]}": v for k, v in self._variable_pool._variables.items()}, + "workflow_execution_id": self._system_variable.workflow_execution_id, + } + ) + + +class MockCommandChannel: + """Mock implementation of CommandChannel for testing.""" + + def __init__(self): + self._commands: list[GraphEngineCommand] = [] + + def fetch_commands(self) -> list[GraphEngineCommand]: + return self._commands.copy() + + def send_command(self, command: GraphEngineCommand) -> None: + self._commands.append(command) + + +class TestPauseStatePersistenceLayer: + """Unit tests for PauseStatePersistenceLayer.""" + + def test_init_with_dependency_injection(self): + session_factory = Mock(name="session_factory") + state_owner_user_id = "user-123" + + layer = PauseStatePersistenceLayer( + session_factory=session_factory, + state_owner_user_id=state_owner_user_id, + ) + + assert layer._session_maker is session_factory + assert layer._state_owner_user_id == state_owner_user_id + assert not hasattr(layer, "graph_runtime_state") + assert not hasattr(layer, "command_channel") + + def test_initialize_sets_dependencies(self): + session_factory = Mock(name="session_factory") + layer = PauseStatePersistenceLayer(session_factory=session_factory, state_owner_user_id="owner") + + graph_runtime_state = MockReadOnlyGraphRuntimeState() + command_channel = MockCommandChannel() + + layer.initialize(graph_runtime_state, command_channel) + + assert layer.graph_runtime_state is graph_runtime_state + assert layer.command_channel is command_channel + + def test_on_event_with_graph_run_paused_event(self, monkeypatch: pytest.MonkeyPatch): + session_factory = Mock(name="session_factory") + layer = PauseStatePersistenceLayer(session_factory=session_factory, state_owner_user_id="owner-123") + + mock_repo = Mock() + mock_factory = Mock(return_value=mock_repo) + monkeypatch.setattr(DifyAPIRepositoryFactory, "create_api_workflow_run_repository", mock_factory) + + graph_runtime_state = MockReadOnlyGraphRuntimeState( + outputs={"result": "test_output"}, + total_tokens=100, + workflow_execution_id="run-123", + ) + command_channel = MockCommandChannel() + layer.initialize(graph_runtime_state, command_channel) + + event = TestDataFactory.create_graph_run_paused_event(outputs={"intermediate": "result"}) + expected_state = graph_runtime_state.dumps() + + layer.on_event(event) + + mock_factory.assert_called_once_with(session_factory) + mock_repo.create_workflow_pause.assert_called_once_with( + workflow_run_id="run-123", + state_owner_user_id="owner-123", + state=expected_state, + ) + + def test_on_event_ignores_non_paused_events(self, monkeypatch: pytest.MonkeyPatch): + session_factory = Mock(name="session_factory") + layer = PauseStatePersistenceLayer(session_factory=session_factory, state_owner_user_id="owner-123") + + mock_repo = Mock() + mock_factory = Mock(return_value=mock_repo) + monkeypatch.setattr(DifyAPIRepositoryFactory, "create_api_workflow_run_repository", mock_factory) + + graph_runtime_state = MockReadOnlyGraphRuntimeState() + command_channel = MockCommandChannel() + layer.initialize(graph_runtime_state, command_channel) + + events = [ + TestDataFactory.create_graph_run_started_event(), + TestDataFactory.create_graph_run_succeeded_event(), + TestDataFactory.create_graph_run_failed_event(), + ] + + for event in events: + layer.on_event(event) + + mock_factory.assert_not_called() + mock_repo.create_workflow_pause.assert_not_called() + + def test_on_event_raises_attribute_error_when_graph_runtime_state_is_none(self): + session_factory = Mock(name="session_factory") + layer = PauseStatePersistenceLayer(session_factory=session_factory, state_owner_user_id="owner-123") + + event = TestDataFactory.create_graph_run_paused_event() + + with pytest.raises(AttributeError): + layer.on_event(event) + + def test_on_event_asserts_when_workflow_execution_id_missing(self, monkeypatch: pytest.MonkeyPatch): + session_factory = Mock(name="session_factory") + layer = PauseStatePersistenceLayer(session_factory=session_factory, state_owner_user_id="owner-123") + + mock_repo = Mock() + mock_factory = Mock(return_value=mock_repo) + monkeypatch.setattr(DifyAPIRepositoryFactory, "create_api_workflow_run_repository", mock_factory) + + graph_runtime_state = MockReadOnlyGraphRuntimeState(workflow_execution_id=None) + command_channel = MockCommandChannel() + layer.initialize(graph_runtime_state, command_channel) + + event = TestDataFactory.create_graph_run_paused_event() + + with pytest.raises(AssertionError): + layer.on_event(event) + + mock_factory.assert_not_called() + mock_repo.create_workflow_pause.assert_not_called() diff --git a/api/tests/unit_tests/core/workflow/entities/test_private_workflow_pause.py b/api/tests/unit_tests/core/workflow/entities/test_private_workflow_pause.py new file mode 100644 index 0000000000..ccb2dff85a --- /dev/null +++ b/api/tests/unit_tests/core/workflow/entities/test_private_workflow_pause.py @@ -0,0 +1,171 @@ +"""Tests for _PrivateWorkflowPauseEntity implementation.""" + +from datetime import datetime +from unittest.mock import MagicMock, patch + +from models.workflow import WorkflowPause as WorkflowPauseModel +from repositories.sqlalchemy_api_workflow_run_repository import _PrivateWorkflowPauseEntity + + +class TestPrivateWorkflowPauseEntity: + """Test _PrivateWorkflowPauseEntity implementation.""" + + def test_entity_initialization(self): + """Test entity initialization with required parameters.""" + # Create mock models + mock_pause_model = MagicMock(spec=WorkflowPauseModel) + mock_pause_model.id = "pause-123" + mock_pause_model.workflow_run_id = "execution-456" + mock_pause_model.resumed_at = None + + # Create entity + entity = _PrivateWorkflowPauseEntity( + pause_model=mock_pause_model, + ) + + # Verify initialization + assert entity._pause_model is mock_pause_model + assert entity._cached_state is None + + def test_from_models_classmethod(self): + """Test from_models class method.""" + # Create mock models + mock_pause_model = MagicMock(spec=WorkflowPauseModel) + mock_pause_model.id = "pause-123" + mock_pause_model.workflow_run_id = "execution-456" + + # Create entity using from_models + entity = _PrivateWorkflowPauseEntity.from_models( + workflow_pause_model=mock_pause_model, + ) + + # Verify entity creation + assert isinstance(entity, _PrivateWorkflowPauseEntity) + assert entity._pause_model is mock_pause_model + + def test_id_property(self): + """Test id property returns pause model ID.""" + mock_pause_model = MagicMock(spec=WorkflowPauseModel) + mock_pause_model.id = "pause-123" + + entity = _PrivateWorkflowPauseEntity( + pause_model=mock_pause_model, + ) + + assert entity.id == "pause-123" + + def test_workflow_execution_id_property(self): + """Test workflow_execution_id property returns workflow run ID.""" + mock_pause_model = MagicMock(spec=WorkflowPauseModel) + mock_pause_model.workflow_run_id = "execution-456" + + entity = _PrivateWorkflowPauseEntity( + pause_model=mock_pause_model, + ) + + assert entity.workflow_execution_id == "execution-456" + + def test_resumed_at_property(self): + """Test resumed_at property returns pause model resumed_at.""" + resumed_at = datetime(2023, 12, 25, 15, 30, 45) + + mock_pause_model = MagicMock(spec=WorkflowPauseModel) + mock_pause_model.resumed_at = resumed_at + + entity = _PrivateWorkflowPauseEntity( + pause_model=mock_pause_model, + ) + + assert entity.resumed_at == resumed_at + + def test_resumed_at_property_none(self): + """Test resumed_at property returns None when not set.""" + mock_pause_model = MagicMock(spec=WorkflowPauseModel) + mock_pause_model.resumed_at = None + + entity = _PrivateWorkflowPauseEntity( + pause_model=mock_pause_model, + ) + + assert entity.resumed_at is None + + @patch("repositories.sqlalchemy_api_workflow_run_repository.storage") + def test_get_state_first_call(self, mock_storage): + """Test get_state loads from storage on first call.""" + state_data = b'{"test": "data", "step": 5}' + mock_storage.load.return_value = state_data + + mock_pause_model = MagicMock(spec=WorkflowPauseModel) + mock_pause_model.state_object_key = "test-state-key" + + entity = _PrivateWorkflowPauseEntity( + pause_model=mock_pause_model, + ) + + # First call should load from storage + result = entity.get_state() + + assert result == state_data + mock_storage.load.assert_called_once_with("test-state-key") + assert entity._cached_state == state_data + + @patch("repositories.sqlalchemy_api_workflow_run_repository.storage") + def test_get_state_cached_call(self, mock_storage): + """Test get_state returns cached data on subsequent calls.""" + state_data = b'{"test": "data", "step": 5}' + mock_storage.load.return_value = state_data + + mock_pause_model = MagicMock(spec=WorkflowPauseModel) + mock_pause_model.state_object_key = "test-state-key" + + entity = _PrivateWorkflowPauseEntity( + pause_model=mock_pause_model, + ) + + # First call + result1 = entity.get_state() + # Second call should use cache + result2 = entity.get_state() + + assert result1 == state_data + assert result2 == state_data + # Storage should only be called once + mock_storage.load.assert_called_once_with("test-state-key") + + @patch("repositories.sqlalchemy_api_workflow_run_repository.storage") + def test_get_state_with_pre_cached_data(self, mock_storage): + """Test get_state returns pre-cached data.""" + state_data = b'{"test": "data", "step": 5}' + + mock_pause_model = MagicMock(spec=WorkflowPauseModel) + + entity = _PrivateWorkflowPauseEntity( + pause_model=mock_pause_model, + ) + + # Pre-cache data + entity._cached_state = state_data + + # Should return cached data without calling storage + result = entity.get_state() + + assert result == state_data + mock_storage.load.assert_not_called() + + def test_entity_with_binary_state_data(self): + """Test entity with binary state data.""" + # Test with binary data that's not valid JSON + binary_data = b"\x00\x01\x02\x03\x04\x05\xff\xfe" + + with patch("repositories.sqlalchemy_api_workflow_run_repository.storage") as mock_storage: + mock_storage.load.return_value = binary_data + + mock_pause_model = MagicMock(spec=WorkflowPauseModel) + + entity = _PrivateWorkflowPauseEntity( + pause_model=mock_pause_model, + ) + + result = entity.get_state() + + assert result == binary_data diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_command_system.py b/api/tests/unit_tests/core/workflow/graph_engine/test_command_system.py index d451e7e608..b29baf5a9f 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_command_system.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_command_system.py @@ -3,6 +3,7 @@ import time from unittest.mock import MagicMock +from core.workflow.entities.pause_reason import SchedulingPause from core.workflow.graph import Graph from core.workflow.graph_engine import GraphEngine from core.workflow.graph_engine.command_channels import InMemoryChannel @@ -149,8 +150,8 @@ def test_pause_command(): assert any(isinstance(e, GraphRunStartedEvent) for e in events) pause_events = [e for e in events if isinstance(e, GraphRunPausedEvent)] assert len(pause_events) == 1 - assert pause_events[0].reason == "User requested pause" + assert pause_events[0].reason == SchedulingPause(message="User requested pause") graph_execution = engine.graph_runtime_state.graph_execution assert graph_execution.is_paused - assert graph_execution.pause_reason == "User requested pause" + assert graph_execution.pause_reason == SchedulingPause(message="User requested pause") diff --git a/api/tests/unit_tests/core/workflow/test_enums.py b/api/tests/unit_tests/core/workflow/test_enums.py new file mode 100644 index 0000000000..7cdb2328f2 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/test_enums.py @@ -0,0 +1,32 @@ +"""Tests for workflow pause related enums and constants.""" + +from core.workflow.enums import ( + WorkflowExecutionStatus, +) + + +class TestWorkflowExecutionStatus: + """Test WorkflowExecutionStatus enum.""" + + def test_is_ended_method(self): + """Test is_ended method for different statuses.""" + # Test ended statuses + ended_statuses = [ + WorkflowExecutionStatus.SUCCEEDED, + WorkflowExecutionStatus.FAILED, + WorkflowExecutionStatus.PARTIAL_SUCCEEDED, + WorkflowExecutionStatus.STOPPED, + ] + + for status in ended_statuses: + assert status.is_ended(), f"{status} should be considered ended" + + # Test non-ended statuses + non_ended_statuses = [ + WorkflowExecutionStatus.SCHEDULED, + WorkflowExecutionStatus.RUNNING, + WorkflowExecutionStatus.PAUSED, + ] + + for status in non_ended_statuses: + assert not status.is_ended(), f"{status} should not be considered ended" diff --git a/api/tests/unit_tests/core/workflow/test_system_variable_read_only_view.py b/api/tests/unit_tests/core/workflow/test_system_variable_read_only_view.py new file mode 100644 index 0000000000..57bc96fe71 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/test_system_variable_read_only_view.py @@ -0,0 +1,202 @@ +from typing import cast + +import pytest + +from core.file.models import File, FileTransferMethod, FileType +from core.workflow.system_variable import SystemVariable, SystemVariableReadOnlyView + + +class TestSystemVariableReadOnlyView: + """Test cases for SystemVariableReadOnlyView class.""" + + def test_read_only_property_access(self): + """Test that all properties return correct values from wrapped instance.""" + # Create test data + test_file = File( + id="file-123", + tenant_id="tenant-123", + type=FileType.IMAGE, + transfer_method=FileTransferMethod.LOCAL_FILE, + related_id="related-123", + ) + + datasource_info = {"key": "value", "nested": {"data": 42}} + + # Create SystemVariable with all fields + system_var = SystemVariable( + user_id="user-123", + app_id="app-123", + workflow_id="workflow-123", + files=[test_file], + workflow_execution_id="exec-123", + query="test query", + conversation_id="conv-123", + dialogue_count=5, + document_id="doc-123", + original_document_id="orig-doc-123", + dataset_id="dataset-123", + batch="batch-123", + datasource_type="type-123", + datasource_info=datasource_info, + invoke_from="invoke-123", + ) + + # Create read-only view + read_only_view = SystemVariableReadOnlyView(system_var) + + # Test all properties + assert read_only_view.user_id == "user-123" + assert read_only_view.app_id == "app-123" + assert read_only_view.workflow_id == "workflow-123" + assert read_only_view.workflow_execution_id == "exec-123" + assert read_only_view.query == "test query" + assert read_only_view.conversation_id == "conv-123" + assert read_only_view.dialogue_count == 5 + assert read_only_view.document_id == "doc-123" + assert read_only_view.original_document_id == "orig-doc-123" + assert read_only_view.dataset_id == "dataset-123" + assert read_only_view.batch == "batch-123" + assert read_only_view.datasource_type == "type-123" + assert read_only_view.invoke_from == "invoke-123" + + def test_defensive_copying_of_mutable_objects(self): + """Test that mutable objects are defensively copied.""" + # Create test data + test_file = File( + id="file-123", + tenant_id="tenant-123", + type=FileType.IMAGE, + transfer_method=FileTransferMethod.LOCAL_FILE, + related_id="related-123", + ) + + datasource_info = {"key": "original_value"} + + # Create SystemVariable + system_var = SystemVariable( + files=[test_file], datasource_info=datasource_info, workflow_execution_id="exec-123" + ) + + # Create read-only view + read_only_view = SystemVariableReadOnlyView(system_var) + + # Test files defensive copying + files_copy = read_only_view.files + assert isinstance(files_copy, tuple) # Should be immutable tuple + assert len(files_copy) == 1 + assert files_copy[0].id == "file-123" + + # Verify it's a copy (can't modify original through view) + assert isinstance(files_copy, tuple) + # tuples don't have append method, so they're immutable + + # Test datasource_info defensive copying + datasource_copy = read_only_view.datasource_info + assert datasource_copy is not None + assert datasource_copy["key"] == "original_value" + + datasource_copy = cast(dict, datasource_copy) + with pytest.raises(TypeError): + datasource_copy["key"] = "modified value" + + # Verify original is unchanged + assert system_var.datasource_info is not None + assert system_var.datasource_info["key"] == "original_value" + assert read_only_view.datasource_info is not None + assert read_only_view.datasource_info["key"] == "original_value" + + def test_always_accesses_latest_data(self): + """Test that properties always return the latest data from wrapped instance.""" + # Create SystemVariable + system_var = SystemVariable(user_id="original-user", workflow_execution_id="exec-123") + + # Create read-only view + read_only_view = SystemVariableReadOnlyView(system_var) + + # Verify initial value + assert read_only_view.user_id == "original-user" + + # Modify the wrapped instance + system_var.user_id = "modified-user" + + # Verify view returns the new value + assert read_only_view.user_id == "modified-user" + + def test_repr_method(self): + """Test the __repr__ method.""" + # Create SystemVariable + system_var = SystemVariable(workflow_execution_id="exec-123") + + # Create read-only view + read_only_view = SystemVariableReadOnlyView(system_var) + + # Test repr + repr_str = repr(read_only_view) + assert "SystemVariableReadOnlyView" in repr_str + assert "system_variable=" in repr_str + + def test_none_value_handling(self): + """Test that None values are properly handled.""" + # Create SystemVariable with all None values except workflow_execution_id + system_var = SystemVariable( + user_id=None, + app_id=None, + workflow_id=None, + workflow_execution_id="exec-123", + query=None, + conversation_id=None, + dialogue_count=None, + document_id=None, + original_document_id=None, + dataset_id=None, + batch=None, + datasource_type=None, + datasource_info=None, + invoke_from=None, + ) + + # Create read-only view + read_only_view = SystemVariableReadOnlyView(system_var) + + # Test all None values + assert read_only_view.user_id is None + assert read_only_view.app_id is None + assert read_only_view.workflow_id is None + assert read_only_view.query is None + assert read_only_view.conversation_id is None + assert read_only_view.dialogue_count is None + assert read_only_view.document_id is None + assert read_only_view.original_document_id is None + assert read_only_view.dataset_id is None + assert read_only_view.batch is None + assert read_only_view.datasource_type is None + assert read_only_view.datasource_info is None + assert read_only_view.invoke_from is None + + # files should be empty tuple even when default list is empty + assert read_only_view.files == () + + def test_empty_files_handling(self): + """Test that empty files list is handled correctly.""" + # Create SystemVariable with empty files + system_var = SystemVariable(files=[], workflow_execution_id="exec-123") + + # Create read-only view + read_only_view = SystemVariableReadOnlyView(system_var) + + # Test files handling + assert read_only_view.files == () + assert isinstance(read_only_view.files, tuple) + + def test_empty_datasource_info_handling(self): + """Test that empty datasource_info is handled correctly.""" + # Create SystemVariable with empty datasource_info + system_var = SystemVariable(datasource_info={}, workflow_execution_id="exec-123") + + # Create read-only view + read_only_view = SystemVariableReadOnlyView(system_var) + + # Test datasource_info handling + assert read_only_view.datasource_info == {} + # Should be a copy, not the same object + assert read_only_view.datasource_info is not system_var.datasource_info diff --git a/api/tests/unit_tests/models/test_base.py b/api/tests/unit_tests/models/test_base.py new file mode 100644 index 0000000000..e0dda3c1dd --- /dev/null +++ b/api/tests/unit_tests/models/test_base.py @@ -0,0 +1,11 @@ +from models.base import DefaultFieldsMixin + + +class FooModel(DefaultFieldsMixin): + def __init__(self, id: str): + self.id = id + + +def test_repr(): + foo_model = FooModel(id="test-id") + assert repr(foo_model) == "" diff --git a/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py b/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py new file mode 100644 index 0000000000..73b35b8e63 --- /dev/null +++ b/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py @@ -0,0 +1,370 @@ +"""Unit tests for DifyAPISQLAlchemyWorkflowRunRepository implementation.""" + +from datetime import UTC, datetime +from unittest.mock import Mock, patch + +import pytest +from sqlalchemy.orm import Session, sessionmaker + +from core.workflow.entities.workflow_pause import WorkflowPauseEntity +from core.workflow.enums import WorkflowExecutionStatus +from models.workflow import WorkflowPause as WorkflowPauseModel +from models.workflow import WorkflowRun +from repositories.sqlalchemy_api_workflow_run_repository import ( + DifyAPISQLAlchemyWorkflowRunRepository, + _PrivateWorkflowPauseEntity, + _WorkflowRunError, +) + + +class TestDifyAPISQLAlchemyWorkflowRunRepository: + """Test DifyAPISQLAlchemyWorkflowRunRepository implementation.""" + + @pytest.fixture + def mock_session(self): + """Create a mock session.""" + return Mock(spec=Session) + + @pytest.fixture + def mock_session_maker(self, mock_session): + """Create a mock sessionmaker.""" + session_maker = Mock(spec=sessionmaker) + + # Create a context manager mock + context_manager = Mock() + context_manager.__enter__ = Mock(return_value=mock_session) + context_manager.__exit__ = Mock(return_value=None) + session_maker.return_value = context_manager + + # Mock session.begin() context manager + begin_context_manager = Mock() + begin_context_manager.__enter__ = Mock(return_value=None) + begin_context_manager.__exit__ = Mock(return_value=None) + mock_session.begin = Mock(return_value=begin_context_manager) + + # Add missing session methods + mock_session.commit = Mock() + mock_session.rollback = Mock() + mock_session.add = Mock() + mock_session.delete = Mock() + mock_session.get = Mock() + mock_session.scalar = Mock() + mock_session.scalars = Mock() + + # Also support expire_on_commit parameter + def make_session(expire_on_commit=None): + cm = Mock() + cm.__enter__ = Mock(return_value=mock_session) + cm.__exit__ = Mock(return_value=None) + return cm + + session_maker.side_effect = make_session + return session_maker + + @pytest.fixture + def repository(self, mock_session_maker): + """Create repository instance with mocked dependencies.""" + + # Create a testable subclass that implements the save method + class TestableDifyAPISQLAlchemyWorkflowRunRepository(DifyAPISQLAlchemyWorkflowRunRepository): + def __init__(self, session_maker): + # Initialize without calling parent __init__ to avoid any instantiation issues + self._session_maker = session_maker + + def save(self, execution): + """Mock implementation of save method.""" + return None + + # Create repository instance + repo = TestableDifyAPISQLAlchemyWorkflowRunRepository(mock_session_maker) + + return repo + + @pytest.fixture + def sample_workflow_run(self): + """Create a sample WorkflowRun model.""" + workflow_run = Mock(spec=WorkflowRun) + workflow_run.id = "workflow-run-123" + workflow_run.tenant_id = "tenant-123" + workflow_run.app_id = "app-123" + workflow_run.workflow_id = "workflow-123" + workflow_run.status = WorkflowExecutionStatus.RUNNING + return workflow_run + + @pytest.fixture + def sample_workflow_pause(self): + """Create a sample WorkflowPauseModel.""" + pause = Mock(spec=WorkflowPauseModel) + pause.id = "pause-123" + pause.workflow_id = "workflow-123" + pause.workflow_run_id = "workflow-run-123" + pause.state_object_key = "workflow-state-123.json" + pause.resumed_at = None + pause.created_at = datetime.now(UTC) + return pause + + +class TestCreateWorkflowPause(TestDifyAPISQLAlchemyWorkflowRunRepository): + """Test create_workflow_pause method.""" + + def test_create_workflow_pause_success( + self, + repository: DifyAPISQLAlchemyWorkflowRunRepository, + mock_session: Mock, + sample_workflow_run: Mock, + ): + """Test successful workflow pause creation.""" + # Arrange + workflow_run_id = "workflow-run-123" + state_owner_user_id = "user-123" + state = '{"test": "state"}' + + mock_session.get.return_value = sample_workflow_run + + with patch("repositories.sqlalchemy_api_workflow_run_repository.uuidv7") as mock_uuidv7: + mock_uuidv7.side_effect = ["pause-123"] + with patch("repositories.sqlalchemy_api_workflow_run_repository.storage") as mock_storage: + # Act + result = repository.create_workflow_pause( + workflow_run_id=workflow_run_id, + state_owner_user_id=state_owner_user_id, + state=state, + ) + + # Assert + assert isinstance(result, _PrivateWorkflowPauseEntity) + assert result.id == "pause-123" + assert result.workflow_execution_id == workflow_run_id + + # Verify database interactions + mock_session.get.assert_called_once_with(WorkflowRun, workflow_run_id) + mock_storage.save.assert_called_once() + mock_session.add.assert_called() + # When using session.begin() context manager, commit is handled automatically + # No explicit commit call is expected + + def test_create_workflow_pause_not_found( + self, repository: DifyAPISQLAlchemyWorkflowRunRepository, mock_session: Mock + ): + """Test workflow pause creation when workflow run not found.""" + # Arrange + mock_session.get.return_value = None + + # Act & Assert + with pytest.raises(ValueError, match="WorkflowRun not found: workflow-run-123"): + repository.create_workflow_pause( + workflow_run_id="workflow-run-123", + state_owner_user_id="user-123", + state='{"test": "state"}', + ) + + mock_session.get.assert_called_once_with(WorkflowRun, "workflow-run-123") + + def test_create_workflow_pause_invalid_status( + self, repository: DifyAPISQLAlchemyWorkflowRunRepository, mock_session: Mock, sample_workflow_run: Mock + ): + """Test workflow pause creation when workflow not in RUNNING status.""" + # Arrange + sample_workflow_run.status = WorkflowExecutionStatus.PAUSED + mock_session.get.return_value = sample_workflow_run + + # Act & Assert + with pytest.raises(_WorkflowRunError, match="Only WorkflowRun with RUNNING status can be paused"): + repository.create_workflow_pause( + workflow_run_id="workflow-run-123", + state_owner_user_id="user-123", + state='{"test": "state"}', + ) + + +class TestResumeWorkflowPause(TestDifyAPISQLAlchemyWorkflowRunRepository): + """Test resume_workflow_pause method.""" + + def test_resume_workflow_pause_success( + self, + repository: DifyAPISQLAlchemyWorkflowRunRepository, + mock_session: Mock, + sample_workflow_run: Mock, + sample_workflow_pause: Mock, + ): + """Test successful workflow pause resume.""" + # Arrange + workflow_run_id = "workflow-run-123" + pause_entity = Mock(spec=WorkflowPauseEntity) + pause_entity.id = "pause-123" + + # Setup workflow run and pause + sample_workflow_run.status = WorkflowExecutionStatus.PAUSED + sample_workflow_run.pause = sample_workflow_pause + sample_workflow_pause.resumed_at = None + + mock_session.scalar.return_value = sample_workflow_run + + with patch("repositories.sqlalchemy_api_workflow_run_repository.naive_utc_now") as mock_now: + mock_now.return_value = datetime.now(UTC) + + # Act + result = repository.resume_workflow_pause( + workflow_run_id=workflow_run_id, + pause_entity=pause_entity, + ) + + # Assert + assert isinstance(result, _PrivateWorkflowPauseEntity) + assert result.id == "pause-123" + + # Verify state transitions + assert sample_workflow_pause.resumed_at is not None + assert sample_workflow_run.status == WorkflowExecutionStatus.RUNNING + + # Verify database interactions + mock_session.add.assert_called() + # When using session.begin() context manager, commit is handled automatically + # No explicit commit call is expected + + def test_resume_workflow_pause_not_paused( + self, + repository: DifyAPISQLAlchemyWorkflowRunRepository, + mock_session: Mock, + sample_workflow_run: Mock, + ): + """Test resume when workflow is not paused.""" + # Arrange + workflow_run_id = "workflow-run-123" + pause_entity = Mock(spec=WorkflowPauseEntity) + pause_entity.id = "pause-123" + + sample_workflow_run.status = WorkflowExecutionStatus.RUNNING + mock_session.scalar.return_value = sample_workflow_run + + # Act & Assert + with pytest.raises(_WorkflowRunError, match="WorkflowRun is not in PAUSED status"): + repository.resume_workflow_pause( + workflow_run_id=workflow_run_id, + pause_entity=pause_entity, + ) + + def test_resume_workflow_pause_id_mismatch( + self, + repository: DifyAPISQLAlchemyWorkflowRunRepository, + mock_session: Mock, + sample_workflow_run: Mock, + sample_workflow_pause: Mock, + ): + """Test resume when pause ID doesn't match.""" + # Arrange + workflow_run_id = "workflow-run-123" + pause_entity = Mock(spec=WorkflowPauseEntity) + pause_entity.id = "pause-456" # Different ID + + sample_workflow_run.status = WorkflowExecutionStatus.PAUSED + sample_workflow_pause.id = "pause-123" + sample_workflow_run.pause = sample_workflow_pause + mock_session.scalar.return_value = sample_workflow_run + + # Act & Assert + with pytest.raises(_WorkflowRunError, match="different id in WorkflowPause and WorkflowPauseEntity"): + repository.resume_workflow_pause( + workflow_run_id=workflow_run_id, + pause_entity=pause_entity, + ) + + +class TestDeleteWorkflowPause(TestDifyAPISQLAlchemyWorkflowRunRepository): + """Test delete_workflow_pause method.""" + + def test_delete_workflow_pause_success( + self, + repository: DifyAPISQLAlchemyWorkflowRunRepository, + mock_session: Mock, + sample_workflow_pause: Mock, + ): + """Test successful workflow pause deletion.""" + # Arrange + pause_entity = Mock(spec=WorkflowPauseEntity) + pause_entity.id = "pause-123" + + mock_session.get.return_value = sample_workflow_pause + + with patch("repositories.sqlalchemy_api_workflow_run_repository.storage") as mock_storage: + # Act + repository.delete_workflow_pause(pause_entity=pause_entity) + + # Assert + mock_storage.delete.assert_called_once_with(sample_workflow_pause.state_object_key) + mock_session.delete.assert_called_once_with(sample_workflow_pause) + # When using session.begin() context manager, commit is handled automatically + # No explicit commit call is expected + + def test_delete_workflow_pause_not_found( + self, + repository: DifyAPISQLAlchemyWorkflowRunRepository, + mock_session: Mock, + ): + """Test delete when pause not found.""" + # Arrange + pause_entity = Mock(spec=WorkflowPauseEntity) + pause_entity.id = "pause-123" + + mock_session.get.return_value = None + + # Act & Assert + with pytest.raises(_WorkflowRunError, match="WorkflowPause not found: pause-123"): + repository.delete_workflow_pause(pause_entity=pause_entity) + + +class TestPrivateWorkflowPauseEntity(TestDifyAPISQLAlchemyWorkflowRunRepository): + """Test _PrivateWorkflowPauseEntity class.""" + + def test_from_models(self, sample_workflow_pause: Mock): + """Test creating _PrivateWorkflowPauseEntity from models.""" + # Act + entity = _PrivateWorkflowPauseEntity.from_models(sample_workflow_pause) + + # Assert + assert isinstance(entity, _PrivateWorkflowPauseEntity) + assert entity._pause_model == sample_workflow_pause + + def test_properties(self, sample_workflow_pause: Mock): + """Test entity properties.""" + # Arrange + entity = _PrivateWorkflowPauseEntity.from_models(sample_workflow_pause) + + # Act & Assert + assert entity.id == sample_workflow_pause.id + assert entity.workflow_execution_id == sample_workflow_pause.workflow_run_id + assert entity.resumed_at == sample_workflow_pause.resumed_at + + def test_get_state(self, sample_workflow_pause: Mock): + """Test getting state from storage.""" + # Arrange + entity = _PrivateWorkflowPauseEntity.from_models(sample_workflow_pause) + expected_state = b'{"test": "state"}' + + with patch("repositories.sqlalchemy_api_workflow_run_repository.storage") as mock_storage: + mock_storage.load.return_value = expected_state + + # Act + result = entity.get_state() + + # Assert + assert result == expected_state + mock_storage.load.assert_called_once_with(sample_workflow_pause.state_object_key) + + def test_get_state_caching(self, sample_workflow_pause: Mock): + """Test state caching in get_state method.""" + # Arrange + entity = _PrivateWorkflowPauseEntity.from_models(sample_workflow_pause) + expected_state = b'{"test": "state"}' + + with patch("repositories.sqlalchemy_api_workflow_run_repository.storage") as mock_storage: + mock_storage.load.return_value = expected_state + + # Act + result1 = entity.get_state() + result2 = entity.get_state() # Should use cache + + # Assert + assert result1 == expected_state + assert result2 == expected_state + mock_storage.load.assert_called_once() # Only called once due to caching diff --git a/api/tests/unit_tests/services/test_workflow_run_service_pause.py b/api/tests/unit_tests/services/test_workflow_run_service_pause.py new file mode 100644 index 0000000000..a062d9444e --- /dev/null +++ b/api/tests/unit_tests/services/test_workflow_run_service_pause.py @@ -0,0 +1,200 @@ +"""Comprehensive unit tests for WorkflowRunService class. + +This test suite covers all pause state management operations including: +- Retrieving pause state for workflow runs +- Saving pause state with file uploads +- Marking paused workflows as resumed +- Error handling and edge cases +- Database transaction management +- Repository-based approach testing +""" + +from datetime import datetime +from unittest.mock import MagicMock, create_autospec, patch + +import pytest +from sqlalchemy import Engine +from sqlalchemy.orm import Session, sessionmaker + +from core.workflow.enums import WorkflowExecutionStatus +from repositories.api_workflow_run_repository import APIWorkflowRunRepository +from repositories.sqlalchemy_api_workflow_run_repository import _PrivateWorkflowPauseEntity +from services.workflow_run_service import ( + WorkflowRunService, +) + + +class TestDataFactory: + """Factory class for creating test data objects.""" + + @staticmethod + def create_workflow_run_mock( + id: str = "workflow-run-123", + tenant_id: str = "tenant-456", + app_id: str = "app-789", + workflow_id: str = "workflow-101", + status: str | WorkflowExecutionStatus = "paused", + pause_id: str | None = None, + **kwargs, + ) -> MagicMock: + """Create a mock WorkflowRun object.""" + mock_run = MagicMock() + mock_run.id = id + mock_run.tenant_id = tenant_id + mock_run.app_id = app_id + mock_run.workflow_id = workflow_id + mock_run.status = status + mock_run.pause_id = pause_id + + for key, value in kwargs.items(): + setattr(mock_run, key, value) + + return mock_run + + @staticmethod + def create_workflow_pause_mock( + id: str = "pause-123", + tenant_id: str = "tenant-456", + app_id: str = "app-789", + workflow_id: str = "workflow-101", + workflow_execution_id: str = "workflow-execution-123", + state_file_id: str = "file-456", + resumed_at: datetime | None = None, + **kwargs, + ) -> MagicMock: + """Create a mock WorkflowPauseModel object.""" + mock_pause = MagicMock() + mock_pause.id = id + mock_pause.tenant_id = tenant_id + mock_pause.app_id = app_id + mock_pause.workflow_id = workflow_id + mock_pause.workflow_execution_id = workflow_execution_id + mock_pause.state_file_id = state_file_id + mock_pause.resumed_at = resumed_at + + for key, value in kwargs.items(): + setattr(mock_pause, key, value) + + return mock_pause + + @staticmethod + def create_upload_file_mock( + id: str = "file-456", + key: str = "upload_files/test/state.json", + name: str = "state.json", + tenant_id: str = "tenant-456", + **kwargs, + ) -> MagicMock: + """Create a mock UploadFile object.""" + mock_file = MagicMock() + mock_file.id = id + mock_file.key = key + mock_file.name = name + mock_file.tenant_id = tenant_id + + for key, value in kwargs.items(): + setattr(mock_file, key, value) + + return mock_file + + @staticmethod + def create_pause_entity_mock( + pause_model: MagicMock | None = None, + upload_file: MagicMock | None = None, + ) -> _PrivateWorkflowPauseEntity: + """Create a mock _PrivateWorkflowPauseEntity object.""" + if pause_model is None: + pause_model = TestDataFactory.create_workflow_pause_mock() + if upload_file is None: + upload_file = TestDataFactory.create_upload_file_mock() + + return _PrivateWorkflowPauseEntity.from_models(pause_model, upload_file) + + +class TestWorkflowRunService: + """Comprehensive unit tests for WorkflowRunService class.""" + + @pytest.fixture + def mock_session_factory(self): + """Create a mock session factory with proper session management.""" + mock_session = create_autospec(Session) + + # Create a mock context manager for the session + mock_session_cm = MagicMock() + mock_session_cm.__enter__ = MagicMock(return_value=mock_session) + mock_session_cm.__exit__ = MagicMock(return_value=None) + + # Create a mock context manager for the transaction + mock_transaction_cm = MagicMock() + mock_transaction_cm.__enter__ = MagicMock(return_value=mock_session) + mock_transaction_cm.__exit__ = MagicMock(return_value=None) + + mock_session.begin = MagicMock(return_value=mock_transaction_cm) + + # Create mock factory that returns the context manager + mock_factory = MagicMock(spec=sessionmaker) + mock_factory.return_value = mock_session_cm + + return mock_factory, mock_session + + @pytest.fixture + def mock_workflow_run_repository(self): + """Create a mock APIWorkflowRunRepository.""" + mock_repo = create_autospec(APIWorkflowRunRepository) + return mock_repo + + @pytest.fixture + def workflow_run_service(self, mock_session_factory, mock_workflow_run_repository): + """Create WorkflowRunService instance with mocked dependencies.""" + session_factory, _ = mock_session_factory + + with patch("services.workflow_run_service.DifyAPIRepositoryFactory") as mock_factory: + mock_factory.create_api_workflow_run_repository.return_value = mock_workflow_run_repository + service = WorkflowRunService(session_factory) + return service + + @pytest.fixture + def workflow_run_service_with_engine(self, mock_session_factory, mock_workflow_run_repository): + """Create WorkflowRunService instance with Engine input.""" + mock_engine = create_autospec(Engine) + session_factory, _ = mock_session_factory + + with patch("services.workflow_run_service.DifyAPIRepositoryFactory") as mock_factory: + mock_factory.create_api_workflow_run_repository.return_value = mock_workflow_run_repository + service = WorkflowRunService(mock_engine) + return service + + # ==================== Initialization Tests ==================== + + def test_init_with_session_factory(self, mock_session_factory, mock_workflow_run_repository): + """Test WorkflowRunService initialization with session_factory.""" + session_factory, _ = mock_session_factory + + with patch("services.workflow_run_service.DifyAPIRepositoryFactory") as mock_factory: + mock_factory.create_api_workflow_run_repository.return_value = mock_workflow_run_repository + service = WorkflowRunService(session_factory) + + assert service._session_factory == session_factory + mock_factory.create_api_workflow_run_repository.assert_called_once_with(session_factory) + + def test_init_with_engine(self, mock_session_factory, mock_workflow_run_repository): + """Test WorkflowRunService initialization with Engine (should convert to sessionmaker).""" + mock_engine = create_autospec(Engine) + session_factory, _ = mock_session_factory + + with patch("services.workflow_run_service.DifyAPIRepositoryFactory") as mock_factory: + mock_factory.create_api_workflow_run_repository.return_value = mock_workflow_run_repository + with patch("services.workflow_run_service.sessionmaker", return_value=session_factory) as mock_sessionmaker: + service = WorkflowRunService(mock_engine) + + mock_sessionmaker.assert_called_once_with(bind=mock_engine, expire_on_commit=False) + assert service._session_factory == session_factory + mock_factory.create_api_workflow_run_repository.assert_called_once_with(session_factory) + + def test_init_with_default_dependencies(self, mock_session_factory): + """Test WorkflowRunService initialization with default dependencies.""" + session_factory, _ = mock_session_factory + + service = WorkflowRunService(session_factory) + + assert service._session_factory == session_factory From aa3b16a136f28cc9c2d653c9c25297a71031b342 Mon Sep 17 00:00:00 2001 From: Yeuoly Date: Thu, 30 Oct 2025 14:45:26 +0800 Subject: [PATCH 075/394] fix: migrations --- .../2025_10_27_1201-4558cfabe44e_add_workflow_trigger_logs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/migrations/versions/2025_10_27_1201-4558cfabe44e_add_workflow_trigger_logs.py b/api/migrations/versions/2025_10_27_1201-4558cfabe44e_add_workflow_trigger_logs.py index 5dbf7f947b..1fe46972c1 100644 --- a/api/migrations/versions/2025_10_27_1201-4558cfabe44e_add_workflow_trigger_logs.py +++ b/api/migrations/versions/2025_10_27_1201-4558cfabe44e_add_workflow_trigger_logs.py @@ -12,7 +12,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '4558cfabe44e' -down_revision = 'ae662b25d9bc' +down_revision = '03f8dcbc611e' branch_labels = None depends_on = None From ffc3c61d004ae8570cd77e2cbfb16c3d22ee24c7 Mon Sep 17 00:00:00 2001 From: Yeuoly Date: Thu, 30 Oct 2025 14:54:14 +0800 Subject: [PATCH 076/394] merge workflow pasuing --- api/core/app/apps/workflow/app_generator.py | 18 +++++++++--------- api/core/app/apps/workflow/app_runner.py | 4 ++-- .../{engine_layers => layers}/suspend_layer.py | 0 .../timeslice_layer.py | 0 .../trigger_post_layer.py | 12 ++++++++---- api/tasks/async_workflow_tasks.py | 8 ++++---- 6 files changed, 23 insertions(+), 19 deletions(-) rename api/core/app/{engine_layers => layers}/suspend_layer.py (100%) rename api/core/app/{engine_layers => layers}/timeslice_layer.py (100%) rename api/core/app/{engine_layers => layers}/trigger_post_layer.py (87%) diff --git a/api/core/app/apps/workflow/app_generator.py b/api/core/app/apps/workflow/app_generator.py index 9f0f788a59..46c03c061d 100644 --- a/api/core/app/apps/workflow/app_generator.py +++ b/api/core/app/apps/workflow/app_generator.py @@ -56,7 +56,7 @@ class WorkflowAppGenerator(BaseAppGenerator): call_depth: int, triggered_from: Optional[WorkflowRunTriggeredFrom] = None, root_node_id: Optional[str] = None, - layers: Optional[Sequence[GraphEngineLayer]] = None, + graph_engine_layers: Sequence[GraphEngineLayer] = (), ) -> Generator[Mapping[str, Any] | str, None, None]: ... @overload @@ -72,7 +72,7 @@ class WorkflowAppGenerator(BaseAppGenerator): call_depth: int, triggered_from: Optional[WorkflowRunTriggeredFrom] = None, root_node_id: Optional[str] = None, - layers: Optional[Sequence[GraphEngineLayer]] = None, + graph_engine_layers: Sequence[GraphEngineLayer] = (), ) -> Mapping[str, Any]: ... @overload @@ -88,7 +88,7 @@ class WorkflowAppGenerator(BaseAppGenerator): call_depth: int, triggered_from: Optional[WorkflowRunTriggeredFrom] = None, root_node_id: Optional[str] = None, - layers: Optional[Sequence[GraphEngineLayer]] = None, + graph_engine_layers: Sequence[GraphEngineLayer] = (), ) -> Union[Mapping[str, Any], Generator[Mapping[str, Any] | str, None, None]]: ... def generate( @@ -103,7 +103,7 @@ class WorkflowAppGenerator(BaseAppGenerator): call_depth: int = 0, triggered_from: Optional[WorkflowRunTriggeredFrom] = None, root_node_id: Optional[str] = None, - layers: Optional[Sequence[GraphEngineLayer]] = None, + graph_engine_layers: Sequence[GraphEngineLayer] = (), ) -> Union[Mapping[str, Any], Generator[Mapping[str, Any] | str, None, None]]: files: Sequence[Mapping[str, Any]] = args.get("files") or [] @@ -202,7 +202,7 @@ class WorkflowAppGenerator(BaseAppGenerator): workflow_node_execution_repository=workflow_node_execution_repository, streaming=streaming, root_node_id=root_node_id, - layers=layers, + graph_engine_layers=graph_engine_layers, ) def resume(self, *, workflow_run_id: str) -> None: @@ -224,7 +224,7 @@ class WorkflowAppGenerator(BaseAppGenerator): streaming: bool = True, variable_loader: VariableLoader = DUMMY_VARIABLE_LOADER, root_node_id: Optional[str] = None, - layers: Optional[Sequence[GraphEngineLayer]] = None, + graph_engine_layers: Sequence[GraphEngineLayer] = (), ) -> Union[Mapping[str, Any], Generator[str | Mapping[str, Any], None, None]]: """ Generate App response. @@ -263,7 +263,7 @@ class WorkflowAppGenerator(BaseAppGenerator): "root_node_id": root_node_id, "workflow_execution_repository": workflow_execution_repository, "workflow_node_execution_repository": workflow_node_execution_repository, - "layers": layers, + "graph_engine_layers": graph_engine_layers, }, ) @@ -458,7 +458,7 @@ class WorkflowAppGenerator(BaseAppGenerator): workflow_execution_repository: WorkflowExecutionRepository, workflow_node_execution_repository: WorkflowNodeExecutionRepository, root_node_id: Optional[str] = None, - layers: Optional[Sequence[GraphEngineLayer]] = None, + graph_engine_layers: Sequence[GraphEngineLayer] = (), ) -> None: """ Generate worker in a new thread. @@ -503,7 +503,7 @@ class WorkflowAppGenerator(BaseAppGenerator): workflow_execution_repository=workflow_execution_repository, workflow_node_execution_repository=workflow_node_execution_repository, root_node_id=root_node_id, - layers=layers, + graph_engine_layers=graph_engine_layers, ) try: diff --git a/api/core/app/apps/workflow/app_runner.py b/api/core/app/apps/workflow/app_runner.py index 47dc0cf662..707d88cb60 100644 --- a/api/core/app/apps/workflow/app_runner.py +++ b/api/core/app/apps/workflow/app_runner.py @@ -41,12 +41,13 @@ class WorkflowAppRunner(WorkflowBasedAppRunner): root_node_id: Optional[str] = None, workflow_execution_repository: WorkflowExecutionRepository, workflow_node_execution_repository: WorkflowNodeExecutionRepository, - layers: Optional[Sequence[GraphEngineLayer]] = None, + graph_engine_layers: Sequence[GraphEngineLayer] = (), ): super().__init__( queue_manager=queue_manager, variable_loader=variable_loader, app_id=application_generate_entity.app_config.app_id, + graph_engine_layers=graph_engine_layers, ) self.application_generate_entity = application_generate_entity self._workflow = workflow @@ -54,7 +55,6 @@ class WorkflowAppRunner(WorkflowBasedAppRunner): self._root_node_id = root_node_id self._workflow_execution_repository = workflow_execution_repository self._workflow_node_execution_repository = workflow_node_execution_repository - self._layers = layers or [] def run(self): """ diff --git a/api/core/app/engine_layers/suspend_layer.py b/api/core/app/layers/suspend_layer.py similarity index 100% rename from api/core/app/engine_layers/suspend_layer.py rename to api/core/app/layers/suspend_layer.py diff --git a/api/core/app/engine_layers/timeslice_layer.py b/api/core/app/layers/timeslice_layer.py similarity index 100% rename from api/core/app/engine_layers/timeslice_layer.py rename to api/core/app/layers/timeslice_layer.py diff --git a/api/core/app/engine_layers/trigger_post_layer.py b/api/core/app/layers/trigger_post_layer.py similarity index 87% rename from api/core/app/engine_layers/trigger_post_layer.py rename to api/core/app/layers/trigger_post_layer.py index 1309295b1a..fe1a46a945 100644 --- a/api/core/app/engine_layers/trigger_post_layer.py +++ b/api/core/app/layers/trigger_post_layer.py @@ -3,12 +3,11 @@ from datetime import UTC, datetime from typing import Any, ClassVar from pydantic import TypeAdapter -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker from core.workflow.graph_engine.layers.base import GraphEngineLayer from core.workflow.graph_events.base import GraphEngineEvent from core.workflow.graph_events.graph import GraphRunFailedEvent, GraphRunPausedEvent, GraphRunSucceededEvent -from models.engine import db from models.enums import WorkflowTriggerStatus from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository from tasks.workflow_cfs_scheduler.cfs_scheduler import AsyncWorkflowCFSPlanEntity @@ -32,10 +31,12 @@ class TriggerPostLayer(GraphEngineLayer): cfs_plan_scheduler_entity: AsyncWorkflowCFSPlanEntity, start_time: datetime, trigger_log_id: str, + session_maker: sessionmaker[Session], ): self.trigger_log_id = trigger_log_id self.start_time = start_time self.cfs_plan_scheduler_entity = cfs_plan_scheduler_entity + self.session_maker = session_maker def on_graph_start(self): pass @@ -45,7 +46,7 @@ class TriggerPostLayer(GraphEngineLayer): Update trigger log with success or failure. """ if isinstance(event, tuple(self._STATUS_MAP.keys())): - with Session(db.engine) as session: + with self.session_maker() as session: repo = SQLAlchemyWorkflowTriggerLogRepository(session) trigger_log = repo.get_by_id(self.trigger_log_id) if not trigger_log: @@ -62,7 +63,10 @@ class TriggerPostLayer(GraphEngineLayer): outputs = self.graph_runtime_state.outputs - workflow_run_id = outputs.get("workflow_run_id") + # BASICLY, workflow_execution_id is the same as workflow_run_id + workflow_run_id = self.graph_runtime_state.system_variable.workflow_execution_id + assert workflow_run_id, "Workflow run id is not set" + total_tokens = self.graph_runtime_state.total_tokens # Update trigger log with success diff --git a/api/tasks/async_workflow_tasks.py b/api/tasks/async_workflow_tasks.py index 07edd96bc0..a9907ac981 100644 --- a/api/tasks/async_workflow_tasks.py +++ b/api/tasks/async_workflow_tasks.py @@ -14,9 +14,9 @@ from sqlalchemy.orm import Session, sessionmaker from configs import dify_config from core.app.apps.workflow.app_generator import WorkflowAppGenerator -from core.app.engine_layers.timeslice_layer import TimeSliceLayer -from core.app.engine_layers.trigger_post_layer import TriggerPostLayer from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.layers.timeslice_layer import TimeSliceLayer +from core.app.layers.trigger_post_layer import TriggerPostLayer from extensions.ext_database import db from models.account import Account from models.enums import CreatorUserRole, WorkflowTriggerStatus @@ -145,9 +145,9 @@ def _execute_workflow_common( call_depth=0, triggered_from=trigger_data.trigger_from, root_node_id=trigger_data.root_node_id, - layers=[ + graph_engine_layers=[ TimeSliceLayer(cfs_plan_scheduler), - TriggerPostLayer(cfs_plan_scheduler_entity, start_time, trigger_log.id), + TriggerPostLayer(cfs_plan_scheduler_entity, start_time, trigger_log.id, session_factory), ], ) From 57c65ec62529c7790524c2c6afc6ef1b5cbe07b8 Mon Sep 17 00:00:00 2001 From: Yeuoly Date: Thu, 30 Oct 2025 14:58:30 +0800 Subject: [PATCH 077/394] fix: typing --- api/core/app/layers/pause_state_persist_layer.py | 4 ++-- api/repositories/factory.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/api/core/app/layers/pause_state_persist_layer.py b/api/core/app/layers/pause_state_persist_layer.py index 3dee75c082..eb5dfe9c2d 100644 --- a/api/core/app/layers/pause_state_persist_layer.py +++ b/api/core/app/layers/pause_state_persist_layer.py @@ -1,5 +1,5 @@ from sqlalchemy import Engine -from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import Session, sessionmaker from core.workflow.graph_engine.layers.base import GraphEngineLayer from core.workflow.graph_events.base import GraphEngineEvent @@ -9,7 +9,7 @@ from repositories.factory import DifyAPIRepositoryFactory class PauseStatePersistenceLayer(GraphEngineLayer): - def __init__(self, session_factory: Engine | sessionmaker, state_owner_user_id: str): + def __init__(self, session_factory: Engine | sessionmaker[Session], state_owner_user_id: str): """Create a PauseStatePersistenceLayer. The `state_owner_user_id` is used when creating state file for pause. diff --git a/api/repositories/factory.py b/api/repositories/factory.py index 96f9f886a4..8e098a7059 100644 --- a/api/repositories/factory.py +++ b/api/repositories/factory.py @@ -5,7 +5,7 @@ This factory is specifically designed for DifyAPI repositories that handle service-layer operations with dependency injection patterns. """ -from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import Session, sessionmaker from configs import dify_config from core.repositories import DifyCoreRepositoryFactory, RepositoryImportError @@ -25,7 +25,7 @@ class DifyAPIRepositoryFactory(DifyCoreRepositoryFactory): @classmethod def create_api_workflow_node_execution_repository( - cls, session_maker: sessionmaker + cls, session_maker: sessionmaker[Session] ) -> DifyAPIWorkflowNodeExecutionRepository: """ Create a DifyAPIWorkflowNodeExecutionRepository instance based on configuration. @@ -55,7 +55,7 @@ class DifyAPIRepositoryFactory(DifyCoreRepositoryFactory): ) from e @classmethod - def create_api_workflow_run_repository(cls, session_maker: sessionmaker) -> APIWorkflowRunRepository: + def create_api_workflow_run_repository(cls, session_maker: sessionmaker[Session]) -> APIWorkflowRunRepository: """ Create an APIWorkflowRunRepository instance based on configuration. From cac60a25bb6bf65c6b874997b08ec213655fd7bd Mon Sep 17 00:00:00 2001 From: Yeuoly Date: Thu, 30 Oct 2025 15:27:02 +0800 Subject: [PATCH 078/394] cleanup: migrations --- ...-4558cfabe44e_add_workflow_trigger_logs.py | 67 ----- ...5871f634954d_add_workflow_webhook_table.py | 47 ---- ...203-9ee7d347f4c1_add_app_triggers_table.py | 47 ---- ...c19938f630b6_add_workflow_schedule_plan.py | 47 ---- ..._10_27_1205-132392a2635f_plugin_trigger.py | 102 -------- ..._1752-5ed4b21dbb8d_trigger_log_metadata.py | 32 --- ..._30_1518-669ffd70119c_introduce_trigger.py | 235 ++++++++++++++++++ api/models/trigger.py | 2 +- 8 files changed, 236 insertions(+), 343 deletions(-) delete mode 100644 api/migrations/versions/2025_10_27_1201-4558cfabe44e_add_workflow_trigger_logs.py delete mode 100644 api/migrations/versions/2025_10_27_1202-5871f634954d_add_workflow_webhook_table.py delete mode 100644 api/migrations/versions/2025_10_27_1203-9ee7d347f4c1_add_app_triggers_table.py delete mode 100644 api/migrations/versions/2025_10_27_1204-c19938f630b6_add_workflow_schedule_plan.py delete mode 100644 api/migrations/versions/2025_10_27_1205-132392a2635f_plugin_trigger.py delete mode 100644 api/migrations/versions/2025_10_27_1752-5ed4b21dbb8d_trigger_log_metadata.py create mode 100644 api/migrations/versions/2025_10_30_1518-669ffd70119c_introduce_trigger.py diff --git a/api/migrations/versions/2025_10_27_1201-4558cfabe44e_add_workflow_trigger_logs.py b/api/migrations/versions/2025_10_27_1201-4558cfabe44e_add_workflow_trigger_logs.py deleted file mode 100644 index 1fe46972c1..0000000000 --- a/api/migrations/versions/2025_10_27_1201-4558cfabe44e_add_workflow_trigger_logs.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Add workflow trigger logs table - -Revision ID: 4558cfabe44e -Revises: ae662b25d9bc -Create Date: 2025-10-27 12:01:00.000000 - -""" -from alembic import op -import models as models -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '4558cfabe44e' -down_revision = '03f8dcbc611e' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('workflow_trigger_logs', - sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuidv7()'), nullable=False), - sa.Column('tenant_id', models.types.StringUUID(), nullable=False), - sa.Column('app_id', models.types.StringUUID(), nullable=False), - sa.Column('workflow_id', models.types.StringUUID(), nullable=False), - sa.Column('workflow_run_id', models.types.StringUUID(), nullable=True), - sa.Column('root_node_id', sa.String(length=255), nullable=True), - sa.Column('trigger_type', sa.String(length=50), nullable=False), - sa.Column('trigger_data', sa.Text(), nullable=False), - sa.Column('inputs', sa.Text(), nullable=False), - sa.Column('outputs', sa.Text(), nullable=True), - sa.Column('status', sa.String(length=50), nullable=False), - sa.Column('error', sa.Text(), nullable=True), - sa.Column('queue_name', sa.String(length=100), nullable=False), - sa.Column('celery_task_id', sa.String(length=255), nullable=True), - sa.Column('retry_count', sa.Integer(), nullable=False), - sa.Column('elapsed_time', sa.Float(), nullable=True), - sa.Column('total_tokens', sa.Integer(), nullable=True), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), - sa.Column('created_by_role', sa.String(length=255), nullable=False), - sa.Column('created_by', sa.String(length=255), nullable=False), - sa.Column('triggered_at', sa.DateTime(), nullable=True), - sa.Column('finished_at', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id', name='workflow_trigger_log_pkey') - ) - with op.batch_alter_table('workflow_trigger_logs', schema=None) as batch_op: - batch_op.create_index('workflow_trigger_log_created_at_idx', ['created_at'], unique=False) - batch_op.create_index('workflow_trigger_log_status_idx', ['status'], unique=False) - batch_op.create_index('workflow_trigger_log_tenant_app_idx', ['tenant_id', 'app_id'], unique=False) - batch_op.create_index('workflow_trigger_log_workflow_id_idx', ['workflow_id'], unique=False) - batch_op.create_index('workflow_trigger_log_workflow_run_idx', ['workflow_run_id'], unique=False) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('workflow_trigger_logs', schema=None) as batch_op: - batch_op.drop_index('workflow_trigger_log_workflow_run_idx') - batch_op.drop_index('workflow_trigger_log_workflow_id_idx') - batch_op.drop_index('workflow_trigger_log_tenant_app_idx') - batch_op.drop_index('workflow_trigger_log_status_idx') - batch_op.drop_index('workflow_trigger_log_created_at_idx') - - op.drop_table('workflow_trigger_logs') - # ### end Alembic commands ### diff --git a/api/migrations/versions/2025_10_27_1202-5871f634954d_add_workflow_webhook_table.py b/api/migrations/versions/2025_10_27_1202-5871f634954d_add_workflow_webhook_table.py deleted file mode 100644 index 43466a0697..0000000000 --- a/api/migrations/versions/2025_10_27_1202-5871f634954d_add_workflow_webhook_table.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Add workflow webhook table - -Revision ID: 5871f634954d -Revises: 4558cfabe44e -Create Date: 2025-10-27 12:02:00.000000 - -""" -from alembic import op -import models as models -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '5871f634954d' -down_revision = '4558cfabe44e' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('workflow_webhook_triggers', - sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuidv7()'), nullable=False), - sa.Column('app_id', models.types.StringUUID(), nullable=False), - sa.Column('node_id', sa.String(length=64), nullable=False), - sa.Column('tenant_id', models.types.StringUUID(), nullable=False), - sa.Column('webhook_id', sa.String(length=24), nullable=False), - sa.Column('created_by', models.types.StringUUID(), nullable=False), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), - sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), - sa.PrimaryKeyConstraint('id', name='workflow_webhook_trigger_pkey'), - sa.UniqueConstraint('app_id', 'node_id', name='uniq_node'), - sa.UniqueConstraint('webhook_id', name='uniq_webhook_id') - ) - with op.batch_alter_table('workflow_webhook_triggers', schema=None) as batch_op: - batch_op.create_index('workflow_webhook_trigger_tenant_idx', ['tenant_id'], unique=False) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('workflow_webhook_triggers', schema=None) as batch_op: - batch_op.drop_index('workflow_webhook_trigger_tenant_idx') - - op.drop_table('workflow_webhook_triggers') - # ### end Alembic commands ### diff --git a/api/migrations/versions/2025_10_27_1203-9ee7d347f4c1_add_app_triggers_table.py b/api/migrations/versions/2025_10_27_1203-9ee7d347f4c1_add_app_triggers_table.py deleted file mode 100644 index fe4cd24ad6..0000000000 --- a/api/migrations/versions/2025_10_27_1203-9ee7d347f4c1_add_app_triggers_table.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Add app triggers table - -Revision ID: 9ee7d347f4c1 -Revises: 5871f634954d -Create Date: 2025-10-27 12:03:00.000000 - -""" -from alembic import op -import models as models -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '9ee7d347f4c1' -down_revision = '5871f634954d' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('app_triggers', - sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuidv7()'), nullable=False), - sa.Column('tenant_id', models.types.StringUUID(), nullable=False), - sa.Column('app_id', models.types.StringUUID(), nullable=False), - sa.Column('node_id', sa.String(length=64), nullable=False), - sa.Column('trigger_type', sa.String(length=50), nullable=False), - sa.Column('title', sa.String(length=255), nullable=False), - sa.Column('provider_name', sa.String(length=255), server_default='', nullable=True), - sa.Column('status', sa.String(length=50), nullable=False), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint('id', name='app_trigger_pkey') - ) - with op.batch_alter_table('app_triggers', schema=None) as batch_op: - batch_op.create_index('app_trigger_tenant_app_idx', ['tenant_id', 'app_id'], unique=False) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('app_triggers', schema=None) as batch_op: - batch_op.drop_index('app_trigger_tenant_app_idx') - - op.drop_table('app_triggers') - # ### end Alembic commands ### diff --git a/api/migrations/versions/2025_10_27_1204-c19938f630b6_add_workflow_schedule_plan.py b/api/migrations/versions/2025_10_27_1204-c19938f630b6_add_workflow_schedule_plan.py deleted file mode 100644 index 85e7e0c735..0000000000 --- a/api/migrations/versions/2025_10_27_1204-c19938f630b6_add_workflow_schedule_plan.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Add workflow schedule plan table - -Revision ID: c19938f630b6 -Revises: 9ee7d347f4c1 -Create Date: 2025-10-27 12:04:00.000000 - -""" -from alembic import op -import models as models -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'c19938f630b6' -down_revision = '9ee7d347f4c1' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('workflow_schedule_plans', - sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuidv7()'), nullable=False), - sa.Column('app_id', models.types.StringUUID(), nullable=False), - sa.Column('node_id', sa.String(length=64), nullable=False), - sa.Column('tenant_id', models.types.StringUUID(), nullable=False), - sa.Column('cron_expression', sa.String(length=255), nullable=False), - sa.Column('timezone', sa.String(length=64), nullable=False), - sa.Column('next_run_at', sa.DateTime(), nullable=True), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), - sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), - sa.PrimaryKeyConstraint('id', name='workflow_schedule_plan_pkey'), - sa.UniqueConstraint('app_id', 'node_id', name='uniq_app_node') - ) - with op.batch_alter_table('workflow_schedule_plans', schema=None) as batch_op: - batch_op.create_index('workflow_schedule_plan_next_idx', ['next_run_at'], unique=False) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('workflow_schedule_plans', schema=None) as batch_op: - batch_op.drop_index('workflow_schedule_plan_next_idx') - - op.drop_table('workflow_schedule_plans') - # ### end Alembic commands ### diff --git a/api/migrations/versions/2025_10_27_1205-132392a2635f_plugin_trigger.py b/api/migrations/versions/2025_10_27_1205-132392a2635f_plugin_trigger.py deleted file mode 100644 index 426be1b071..0000000000 --- a/api/migrations/versions/2025_10_27_1205-132392a2635f_plugin_trigger.py +++ /dev/null @@ -1,102 +0,0 @@ -"""plugin_trigger - -Revision ID: 132392a2635f -Revises: c19938f630b6 -Create Date: 2025-10-27 12:05:00.000000 - -""" -from alembic import op -import models as models -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '132392a2635f' -down_revision = 'c19938f630b6' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('trigger_oauth_system_clients', - sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuidv7()'), nullable=False), - sa.Column('plugin_id', sa.String(length=512), nullable=False), - sa.Column('provider', sa.String(length=255), nullable=False), - sa.Column('encrypted_oauth_params', sa.Text(), nullable=False), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), - sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), - sa.PrimaryKeyConstraint('id', name='trigger_oauth_system_client_pkey'), - sa.UniqueConstraint('plugin_id', 'provider', name='trigger_oauth_system_client_plugin_id_provider_idx') - ) - op.create_table('trigger_oauth_tenant_clients', - sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuidv7()'), nullable=False), - sa.Column('tenant_id', models.types.StringUUID(), nullable=False), - sa.Column('plugin_id', sa.String(length=512), nullable=False), - sa.Column('provider', sa.String(length=255), nullable=False), - sa.Column('enabled', sa.Boolean(), server_default=sa.text('true'), nullable=False), - sa.Column('encrypted_oauth_params', sa.Text(), nullable=False), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), - sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), - sa.PrimaryKeyConstraint('id', name='trigger_oauth_tenant_client_pkey'), - sa.UniqueConstraint('tenant_id', 'plugin_id', 'provider', name='unique_trigger_oauth_tenant_client') - ) - op.create_table('trigger_subscriptions', - sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuidv7()'), nullable=False), - sa.Column('name', sa.String(length=255), nullable=False, comment='Subscription instance name'), - sa.Column('tenant_id', models.types.StringUUID(), nullable=False), - sa.Column('user_id', models.types.StringUUID(), nullable=False), - sa.Column('provider_id', sa.String(length=255), nullable=False, comment='Provider identifier (e.g., plugin_id/provider_name)'), - sa.Column('endpoint_id', sa.String(length=255), nullable=False, comment='Subscription endpoint'), - sa.Column('parameters', sa.JSON(), nullable=False, comment='Subscription parameters JSON'), - sa.Column('properties', sa.JSON(), nullable=False, comment='Subscription properties JSON'), - sa.Column('credentials', sa.JSON(), nullable=False, comment='Subscription credentials JSON'), - sa.Column('credential_type', sa.String(length=50), nullable=False, comment='oauth or api_key'), - sa.Column('credential_expires_at', sa.Integer(), nullable=False, comment='OAuth token expiration timestamp, -1 for never'), - sa.Column('expires_at', sa.Integer(), nullable=False, comment='Subscription instance expiration timestamp, -1 for never'), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), - sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), - sa.PrimaryKeyConstraint('id', name='trigger_provider_pkey'), - sa.UniqueConstraint('tenant_id', 'provider_id', 'name', name='unique_trigger_provider') - ) - with op.batch_alter_table('trigger_subscriptions', schema=None) as batch_op: - batch_op.create_index('idx_trigger_providers_endpoint', ['endpoint_id'], unique=True) - batch_op.create_index('idx_trigger_providers_tenant_endpoint', ['tenant_id', 'endpoint_id'], unique=False) - batch_op.create_index('idx_trigger_providers_tenant_provider', ['tenant_id', 'provider_id'], unique=False) - - # Create workflow_plugin_triggers table with final schema (merged from all 4 migrations) - op.create_table('workflow_plugin_triggers', - sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuidv7()'), nullable=False), - sa.Column('app_id', models.types.StringUUID(), nullable=False), - sa.Column('node_id', sa.String(length=64), nullable=False), - sa.Column('tenant_id', models.types.StringUUID(), nullable=False), - sa.Column('provider_id', sa.String(length=512), nullable=False), - sa.Column('subscription_id', sa.String(length=255), nullable=False), - sa.Column('event_name', sa.String(length=255), nullable=False), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), - sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), - sa.PrimaryKeyConstraint('id', name='workflow_plugin_trigger_pkey'), - sa.UniqueConstraint('app_id', 'node_id', name='uniq_app_node_subscription') - ) - with op.batch_alter_table('workflow_plugin_triggers', schema=None) as batch_op: - batch_op.create_index('workflow_plugin_trigger_tenant_subscription_idx', ['tenant_id', 'subscription_id', 'event_name'], unique=False) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('workflow_plugin_triggers', schema=None) as batch_op: - batch_op.drop_index('workflow_plugin_trigger_tenant_subscription_idx') - - op.drop_table('workflow_plugin_triggers') - with op.batch_alter_table('trigger_subscriptions', schema=None) as batch_op: - batch_op.drop_index('idx_trigger_providers_tenant_provider') - batch_op.drop_index('idx_trigger_providers_tenant_endpoint') - batch_op.drop_index('idx_trigger_providers_endpoint') - - op.drop_table('trigger_subscriptions') - op.drop_table('trigger_oauth_tenant_clients') - op.drop_table('trigger_oauth_system_clients') - - # ### end Alembic commands ### diff --git a/api/migrations/versions/2025_10_27_1752-5ed4b21dbb8d_trigger_log_metadata.py b/api/migrations/versions/2025_10_27_1752-5ed4b21dbb8d_trigger_log_metadata.py deleted file mode 100644 index 089246d2fa..0000000000 --- a/api/migrations/versions/2025_10_27_1752-5ed4b21dbb8d_trigger_log_metadata.py +++ /dev/null @@ -1,32 +0,0 @@ -"""trigger_log_metadata - -Revision ID: 5ed4b21dbb8d -Revises: 132392a2635f -Create Date: 2025-10-27 17:52:35.658975 - -""" -from alembic import op -import models as models -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '5ed4b21dbb8d' -down_revision = '132392a2635f' -branch_labels = None -depends_on = None - - -def upgrade(): - with op.batch_alter_table('workflow_trigger_logs', schema=None) as batch_op: - batch_op.add_column(sa.Column('trigger_metadata', sa.Text(), nullable=True)) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('workflow_trigger_logs', schema=None) as batch_op: - batch_op.drop_column('trigger_metadata') - - # ### end Alembic commands ### diff --git a/api/migrations/versions/2025_10_30_1518-669ffd70119c_introduce_trigger.py b/api/migrations/versions/2025_10_30_1518-669ffd70119c_introduce_trigger.py new file mode 100644 index 0000000000..c03d64b234 --- /dev/null +++ b/api/migrations/versions/2025_10_30_1518-669ffd70119c_introduce_trigger.py @@ -0,0 +1,235 @@ +"""introduce_trigger + +Revision ID: 669ffd70119c +Revises: 03f8dcbc611e +Create Date: 2025-10-30 15:18:49.549156 + +""" +from alembic import op +import models as models +import sqlalchemy as sa + +from models.enums import AppTriggerStatus, AppTriggerType + + +# revision identifiers, used by Alembic. +revision = '669ffd70119c' +down_revision = '03f8dcbc611e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('app_triggers', + sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuidv7()'), nullable=False), + sa.Column('tenant_id', models.types.StringUUID(), nullable=False), + sa.Column('app_id', models.types.StringUUID(), nullable=False), + sa.Column('node_id', sa.String(length=64), nullable=False), + sa.Column('trigger_type', models.types.EnumText(AppTriggerType, length=50), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('provider_name', sa.String(length=255), server_default='', nullable=True), + sa.Column('status', models.types.EnumText(AppTriggerStatus, length=50), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id', name='app_trigger_pkey') + ) + with op.batch_alter_table('app_triggers', schema=None) as batch_op: + batch_op.create_index('app_trigger_tenant_app_idx', ['tenant_id', 'app_id'], unique=False) + + op.create_table('trigger_oauth_system_clients', + sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('plugin_id', sa.String(length=512), nullable=False), + sa.Column('provider', sa.String(length=255), nullable=False), + sa.Column('encrypted_oauth_params', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id', name='trigger_oauth_system_client_pkey'), + sa.UniqueConstraint('plugin_id', 'provider', name='trigger_oauth_system_client_plugin_id_provider_idx') + ) + op.create_table('trigger_oauth_tenant_clients', + sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('tenant_id', models.types.StringUUID(), nullable=False), + sa.Column('plugin_id', sa.String(length=512), nullable=False), + sa.Column('provider', sa.String(length=255), nullable=False), + sa.Column('enabled', sa.Boolean(), server_default=sa.text('true'), nullable=False), + sa.Column('encrypted_oauth_params', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id', name='trigger_oauth_tenant_client_pkey'), + sa.UniqueConstraint('tenant_id', 'plugin_id', 'provider', name='unique_trigger_oauth_tenant_client') + ) + op.create_table('trigger_subscriptions', + sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False, comment='Subscription instance name'), + sa.Column('tenant_id', models.types.StringUUID(), nullable=False), + sa.Column('user_id', models.types.StringUUID(), nullable=False), + sa.Column('provider_id', sa.String(length=255), nullable=False, comment='Provider identifier (e.g., plugin_id/provider_name)'), + sa.Column('endpoint_id', sa.String(length=255), nullable=False, comment='Subscription endpoint'), + sa.Column('parameters', sa.JSON(), nullable=False, comment='Subscription parameters JSON'), + sa.Column('properties', sa.JSON(), nullable=False, comment='Subscription properties JSON'), + sa.Column('credentials', sa.JSON(), nullable=False, comment='Subscription credentials JSON'), + sa.Column('credential_type', sa.String(length=50), nullable=False, comment='oauth or api_key'), + sa.Column('credential_expires_at', sa.Integer(), nullable=False, comment='OAuth token expiration timestamp, -1 for never'), + sa.Column('expires_at', sa.Integer(), nullable=False, comment='Subscription instance expiration timestamp, -1 for never'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id', name='trigger_provider_pkey'), + sa.UniqueConstraint('tenant_id', 'provider_id', 'name', name='unique_trigger_provider') + ) + with op.batch_alter_table('trigger_subscriptions', schema=None) as batch_op: + batch_op.create_index('idx_trigger_providers_endpoint', ['endpoint_id'], unique=True) + batch_op.create_index('idx_trigger_providers_tenant_endpoint', ['tenant_id', 'endpoint_id'], unique=False) + batch_op.create_index('idx_trigger_providers_tenant_provider', ['tenant_id', 'provider_id'], unique=False) + + op.create_table('workflow_plugin_triggers', + sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('app_id', models.types.StringUUID(), nullable=False), + sa.Column('node_id', sa.String(length=64), nullable=False), + sa.Column('tenant_id', models.types.StringUUID(), nullable=False), + sa.Column('provider_id', sa.String(length=512), nullable=False), + sa.Column('event_name', sa.String(length=255), nullable=False), + sa.Column('subscription_id', sa.String(length=255), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id', name='workflow_plugin_trigger_pkey'), + sa.UniqueConstraint('app_id', 'node_id', name='uniq_app_node_subscription') + ) + with op.batch_alter_table('workflow_plugin_triggers', schema=None) as batch_op: + batch_op.create_index('workflow_plugin_trigger_tenant_subscription_idx', ['tenant_id', 'subscription_id', 'event_name'], unique=False) + + op.create_table('workflow_schedule_plans', + sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuidv7()'), nullable=False), + sa.Column('app_id', models.types.StringUUID(), nullable=False), + sa.Column('node_id', sa.String(length=64), nullable=False), + sa.Column('tenant_id', models.types.StringUUID(), nullable=False), + sa.Column('cron_expression', sa.String(length=255), nullable=False), + sa.Column('timezone', sa.String(length=64), nullable=False), + sa.Column('next_run_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id', name='workflow_schedule_plan_pkey'), + sa.UniqueConstraint('app_id', 'node_id', name='uniq_app_node') + ) + with op.batch_alter_table('workflow_schedule_plans', schema=None) as batch_op: + batch_op.create_index('workflow_schedule_plan_next_idx', ['next_run_at'], unique=False) + + op.create_table('workflow_trigger_logs', + sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuidv7()'), nullable=False), + sa.Column('tenant_id', models.types.StringUUID(), nullable=False), + sa.Column('app_id', models.types.StringUUID(), nullable=False), + sa.Column('workflow_id', models.types.StringUUID(), nullable=False), + sa.Column('workflow_run_id', models.types.StringUUID(), nullable=True), + sa.Column('root_node_id', sa.String(length=255), nullable=True), + sa.Column('trigger_metadata', sa.Text(), nullable=False), + sa.Column('trigger_type', models.types.EnumText(AppTriggerType, length=50), nullable=False), + sa.Column('trigger_data', sa.Text(), nullable=False), + sa.Column('inputs', sa.Text(), nullable=False), + sa.Column('outputs', sa.Text(), nullable=True), + sa.Column('status', models.types.EnumText(AppTriggerStatus, length=50), nullable=False), + sa.Column('error', sa.Text(), nullable=True), + sa.Column('queue_name', sa.String(length=100), nullable=False), + sa.Column('celery_task_id', sa.String(length=255), nullable=True), + sa.Column('retry_count', sa.Integer(), nullable=False), + sa.Column('elapsed_time', sa.Float(), nullable=True), + sa.Column('total_tokens', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('created_by_role', sa.String(length=255), nullable=False), + sa.Column('created_by', sa.String(length=255), nullable=False), + sa.Column('triggered_at', sa.DateTime(), nullable=True), + sa.Column('finished_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id', name='workflow_trigger_log_pkey') + ) + with op.batch_alter_table('workflow_trigger_logs', schema=None) as batch_op: + batch_op.create_index('workflow_trigger_log_created_at_idx', ['created_at'], unique=False) + batch_op.create_index('workflow_trigger_log_status_idx', ['status'], unique=False) + batch_op.create_index('workflow_trigger_log_tenant_app_idx', ['tenant_id', 'app_id'], unique=False) + batch_op.create_index('workflow_trigger_log_workflow_id_idx', ['workflow_id'], unique=False) + batch_op.create_index('workflow_trigger_log_workflow_run_idx', ['workflow_run_id'], unique=False) + + op.create_table('workflow_webhook_triggers', + sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuidv7()'), nullable=False), + sa.Column('app_id', models.types.StringUUID(), nullable=False), + sa.Column('node_id', sa.String(length=64), nullable=False), + sa.Column('tenant_id', models.types.StringUUID(), nullable=False), + sa.Column('webhook_id', sa.String(length=24), nullable=False), + sa.Column('created_by', models.types.StringUUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id', name='workflow_webhook_trigger_pkey'), + sa.UniqueConstraint('app_id', 'node_id', name='uniq_node'), + sa.UniqueConstraint('webhook_id', name='uniq_webhook_id') + ) + with op.batch_alter_table('workflow_webhook_triggers', schema=None) as batch_op: + batch_op.create_index('workflow_webhook_trigger_tenant_idx', ['tenant_id'], unique=False) + + with op.batch_alter_table('celery_taskmeta', schema=None) as batch_op: + batch_op.alter_column('task_id', + existing_type=sa.VARCHAR(length=155), + nullable=False) + batch_op.alter_column('status', + existing_type=sa.VARCHAR(length=50), + nullable=False) + + with op.batch_alter_table('celery_tasksetmeta', schema=None) as batch_op: + batch_op.alter_column('taskset_id', + existing_type=sa.VARCHAR(length=155), + nullable=False) + + with op.batch_alter_table('providers', schema=None) as batch_op: + batch_op.drop_column('credential_status') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('providers', schema=None) as batch_op: + batch_op.add_column(sa.Column('credential_status', sa.VARCHAR(length=20), server_default=sa.text("'active'::character varying"), autoincrement=False, nullable=True)) + + with op.batch_alter_table('celery_tasksetmeta', schema=None) as batch_op: + batch_op.alter_column('taskset_id', + existing_type=sa.VARCHAR(length=155), + nullable=True) + + with op.batch_alter_table('celery_taskmeta', schema=None) as batch_op: + batch_op.alter_column('status', + existing_type=sa.VARCHAR(length=50), + nullable=True) + batch_op.alter_column('task_id', + existing_type=sa.VARCHAR(length=155), + nullable=True) + + with op.batch_alter_table('workflow_webhook_triggers', schema=None) as batch_op: + batch_op.drop_index('workflow_webhook_trigger_tenant_idx') + + op.drop_table('workflow_webhook_triggers') + with op.batch_alter_table('workflow_trigger_logs', schema=None) as batch_op: + batch_op.drop_index('workflow_trigger_log_workflow_run_idx') + batch_op.drop_index('workflow_trigger_log_workflow_id_idx') + batch_op.drop_index('workflow_trigger_log_tenant_app_idx') + batch_op.drop_index('workflow_trigger_log_status_idx') + batch_op.drop_index('workflow_trigger_log_created_at_idx') + + op.drop_table('workflow_trigger_logs') + with op.batch_alter_table('workflow_schedule_plans', schema=None) as batch_op: + batch_op.drop_index('workflow_schedule_plan_next_idx') + + op.drop_table('workflow_schedule_plans') + with op.batch_alter_table('workflow_plugin_triggers', schema=None) as batch_op: + batch_op.drop_index('workflow_plugin_trigger_tenant_subscription_idx') + + op.drop_table('workflow_plugin_triggers') + with op.batch_alter_table('trigger_subscriptions', schema=None) as batch_op: + batch_op.drop_index('idx_trigger_providers_tenant_provider') + batch_op.drop_index('idx_trigger_providers_tenant_endpoint') + batch_op.drop_index('idx_trigger_providers_endpoint') + + op.drop_table('trigger_subscriptions') + op.drop_table('trigger_oauth_tenant_clients') + op.drop_table('trigger_oauth_system_clients') + with op.batch_alter_table('app_triggers', schema=None) as batch_op: + batch_op.drop_index('app_trigger_tenant_app_idx') + + op.drop_table('app_triggers') + # ### end Alembic commands ### diff --git a/api/models/trigger.py b/api/models/trigger.py index 5237a512e4..22bdcbca33 100644 --- a/api/models/trigger.py +++ b/api/models/trigger.py @@ -196,7 +196,7 @@ class WorkflowTriggerLog(Base): workflow_id: Mapped[str] = mapped_column(StringUUID, nullable=False) workflow_run_id: Mapped[Optional[str]] = mapped_column(StringUUID, nullable=True) root_node_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) - trigger_metadata: Mapped[Optional[str]] = mapped_column(sa.Text, nullable=True) + trigger_metadata: Mapped[str] = mapped_column(sa.Text, nullable=False) trigger_type: Mapped[str] = mapped_column(EnumText(AppTriggerType, length=50), nullable=False) trigger_data: Mapped[str] = mapped_column(sa.Text, nullable=False) # Full TriggerData as JSON inputs: Mapped[str] = mapped_column(sa.Text, nullable=False) # Just inputs for easy viewing From 1d03e0e9fc6507275b87da42540909c61d78b76a Mon Sep 17 00:00:00 2001 From: yessenia Date: Wed, 29 Oct 2025 18:51:27 +0800 Subject: [PATCH 079/394] fix(trigger): hide input params when no subscription --- .../components/plugins/readme-panel/index.tsx | 50 ++++---- .../_base/components/workflow-panel/index.tsx | 114 ++++++++++-------- .../workflow-panel/trigger-subscription.tsx | 33 +---- .../components/trigger-form/index.tsx | 6 +- .../components/trigger-form/item.tsx | 6 +- .../workflow/nodes/trigger-plugin/panel.tsx | 7 +- .../nodes/trigger-plugin/use-config.ts | 20 ++- 7 files changed, 111 insertions(+), 125 deletions(-) diff --git a/web/app/components/plugins/readme-panel/index.tsx b/web/app/components/plugins/readme-panel/index.tsx index b77d59fb0b..70d1e0db2c 100644 --- a/web/app/components/plugins/readme-panel/index.tsx +++ b/web/app/components/plugins/readme-panel/index.tsx @@ -2,7 +2,6 @@ import ActionButton from '@/app/components/base/action-button' import Loading from '@/app/components/base/loading' import { Markdown } from '@/app/components/base/markdown' -import Modal from '@/app/components/base/modal' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import { usePluginReadme } from '@/service/use-plugins' import cn from '@/utils/classnames' @@ -85,29 +84,36 @@ const ReadmePanel: FC = () => {
) - return showType === ReadmeShowType.drawer ? createPortal( -
-
- {children} + const portalContent = showType === ReadmeShowType.drawer + ? ( +
+
+ {children} +
-
, + ) + : ( +
+
{ + event.stopPropagation() + }} + > + {children} +
+
+ ) + + return createPortal( + portalContent, document.body, - ) : ( - - {children} - ) } diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx index 32b9cb2671..93589795a6 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx @@ -1,5 +1,17 @@ import { useStore as useAppStore } from '@/app/components/app/store' +import { Stop } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' import Tooltip from '@/app/components/base/tooltip' +import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' +import { + AuthCategory, + AuthorizedInDataSourceNode, + AuthorizedInNode, + PluginAuth, + PluginAuthInDataSourceNode, +} from '@/app/components/plugins/plugin-auth' +import { usePluginStore } from '@/app/components/plugins/plugin-detail-panel/store' +import type { SimpleSubscription } from '@/app/components/plugins/plugin-detail-panel/subscription-list' +import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance' import BlockIcon from '@/app/components/workflow/block-icon' import { WorkflowHistoryEvent, @@ -11,7 +23,14 @@ import { useToolIcon, useWorkflowHistory, } from '@/app/components/workflow/hooks' +import { useHooksStore } from '@/app/components/workflow/hooks-store' +import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' import Split from '@/app/components/workflow/nodes/_base/components/split' +import DataSourceBeforeRunForm from '@/app/components/workflow/nodes/data-source/before-run-form' +import type { CustomRunFormProps } from '@/app/components/workflow/nodes/data-source/types' +import { DataSourceClassification } from '@/app/components/workflow/nodes/data-source/types' +import { useLogs } from '@/app/components/workflow/run/hooks' +import SpecialResultPanel from '@/app/components/workflow/run/special-result-panel' import { useStore } from '@/app/components/workflow/store' import { BlockEnum, type Node, NodeRunningStatus } from '@/app/components/workflow/types' import { @@ -20,16 +39,18 @@ import { hasRetryNode, isSupportCustomRunForm, } from '@/app/components/workflow/utils' +import { useModalContext } from '@/context/modal-context' +import { useAllBuiltInTools } from '@/service/use-tools' import { useAllTriggerPlugins } from '@/service/use-triggers' +import { FlowType } from '@/types/common' +import { canFindTool } from '@/utils' import cn from '@/utils/classnames' import { RiCloseLine, RiPlayLargeLine, } from '@remixicon/react' -import type { - FC, - ReactNode, -} from 'react' +import { debounce } from 'lodash-es' +import type { FC, ReactNode } from 'react' import React, { cloneElement, memo, @@ -42,44 +63,18 @@ import React, { import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' import { useResizePanel } from '../../hooks/use-resize-panel' +import BeforeRunForm from '../before-run-form' +import PanelWrap from '../before-run-form/panel-wrap' import ErrorHandleOnPanel from '../error-handle/error-handle-on-panel' import HelpLink from '../help-link' import NextStep from '../next-step' import PanelOperator from '../panel-operator' import RetryOnPanel from '../retry/retry-on-panel' -import { - DescriptionInput, - TitleInput, -} from '../title-description-input' -import Tab, { TabType } from './tab' -// import AuthMethodSelector from '@/app/components/workflow/nodes/trigger-plugin/components/auth-method-selector' -import { Stop } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' -import { - AuthCategory, - AuthorizedInDataSourceNode, - AuthorizedInNode, - PluginAuth, - PluginAuthInDataSourceNode, -} from '@/app/components/plugins/plugin-auth' -import type { SimpleSubscription } from '@/app/components/plugins/plugin-detail-panel/subscription-list' -import { useHooksStore } from '@/app/components/workflow/hooks-store' -import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' -import DataSourceBeforeRunForm from '@/app/components/workflow/nodes/data-source/before-run-form' -import type { CustomRunFormProps } from '@/app/components/workflow/nodes/data-source/types' -import { DataSourceClassification } from '@/app/components/workflow/nodes/data-source/types' -import { useLogs } from '@/app/components/workflow/run/hooks' -import SpecialResultPanel from '@/app/components/workflow/run/special-result-panel' -import { useModalContext } from '@/context/modal-context' -import { FlowType } from '@/types/common' -import { canFindTool } from '@/utils' -import { debounce } from 'lodash-es' -import BeforeRunForm from '../before-run-form' -import PanelWrap from '../before-run-form/panel-wrap' +import { DescriptionInput, TitleInput } from '../title-description-input' import LastRun from './last-run' import useLastRun from './last-run/use-last-run' +import Tab, { TabType } from './tab' import { TriggerSubscription } from './trigger-subscription' -import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance' -import { useAllBuiltInTools } from '@/service/use-tools' const getCustomRunForm = (params: CustomRunFormProps): React.JSX.Element => { const nodeType = params.payload.type @@ -103,6 +98,7 @@ const BasePanel: FC = ({ children, }) => { const { t } = useTranslation() + const language = useLanguage() const { showMessageLogModal } = useAppStore(useShallow(state => ({ showMessageLogModal: state.showMessageLogModal, }))) @@ -224,6 +220,7 @@ const BasePanel: FC = ({ useEffect(() => { hasClickRunning.current = false }, [id]) + const { nodesMap, } = useNodesMetaData() @@ -278,12 +275,7 @@ const BasePanel: FC = ({ }, [pendingSingleRun, id, handleSingleRun, handleStop, setPendingSingleRun]) const logParams = useLogs() - const passedLogParams = (() => { - if ([BlockEnum.Tool, BlockEnum.Agent, BlockEnum.Iteration, BlockEnum.Loop].includes(data.type)) - return logParams - - return {} - })() + const passedLogParams = useMemo(() => [BlockEnum.Tool, BlockEnum.Agent, BlockEnum.Iteration, BlockEnum.Loop].includes(data.type) ? logParams : {}, [data.type, logParams]) const storeBuildInTools = useStore(s => s.buildInTools) const { data: buildInTools } = useAllBuiltInTools() @@ -295,16 +287,32 @@ const BasePanel: FC = ({ return data.type === BlockEnum.Tool && currToolCollection?.allow_delete }, [data.type, currToolCollection?.allow_delete]) - const { data: triggerProviders = [] } = useAllTriggerPlugins() - const currentTriggerProvider = useMemo(() => { - if (!data.provider_id || !data.provider_name) + // only fetch trigger plugins when the node is a trigger plugin + const { data: triggerPlugins = [] } = useAllTriggerPlugins(data.type === BlockEnum.TriggerPlugin) + const currentTriggerPlugin = useMemo(() => { + if (data.type !== BlockEnum.TriggerPlugin || !data.plugin_id || !triggerPlugins?.length) return undefined - return triggerProviders.find(p => p.name === data.provider_id) // todo: confirm - }, [data.type, data.provider_id, data.provider_name, triggerProviders]) + return triggerPlugins?.find(p => p.plugin_id === data.plugin_id) + }, [data.type, data.plugin_id, triggerPlugins]) + const { setDetail } = usePluginStore() - const showTriggerConfig = useMemo(() => { - return data.type === BlockEnum.TriggerPlugin && currentTriggerProvider - }, [data.type, currentTriggerProvider]) + useEffect(() => { + if (currentTriggerPlugin) { + setDetail({ + name: currentTriggerPlugin.label[language], + plugin_id: currentTriggerPlugin.plugin_id || '', + provider: currentTriggerPlugin.name, + declaration: { + tool: undefined, + // @ts-expect-error just remain the necessary fields + trigger: { + subscription_schema: currentTriggerPlugin.subscription_schema || [], + subscription_constructor: currentTriggerPlugin.subscription_constructor, + }, + }, + }) + } + }, [currentTriggerPlugin, setDetail]) const dataSourceList = useStore(s => s.dataSourceList) @@ -352,14 +360,14 @@ const BasePanel: FC = ({ pluginDetail = currentDataSource break case BlockEnum.TriggerPlugin: - pluginDetail = currentTriggerProvider + pluginDetail = currentTriggerPlugin break default: break } return !pluginDetail ? null : - }, [data.type, currToolCollection, currentDataSource, currentTriggerProvider]) + }, [data.type, currToolCollection, currentDataSource, currentTriggerPlugin]) if (logParams.showSpecialResultPanel) { return ( @@ -558,9 +566,9 @@ const BasePanel: FC = ({ ) } { - showTriggerConfig && ( + currentTriggerPlugin && ( = ({ ) } { - !needsToolAuth && !currentDataSource && !showTriggerConfig && ( + !needsToolAuth && !currentDataSource && !currentTriggerPlugin && (
void) => void children: React.ReactNode } -export const TriggerSubscription: FC = ({ data, onSubscriptionChange, children }) => { - // @ts-expect-error TODO: fix this - const { currentProvider } = useConfig(data.id as string, data) - const { setDetail } = usePluginStore() - const language = useLanguage() +export const TriggerSubscription: FC = ({ subscriptionIdSelected, onSubscriptionChange, children }) => { const { subscriptions } = useSubscriptionList() const subscriptionCount = subscriptions?.length || 0 - useEffect(() => { - if (currentProvider) { - setDetail({ - name: currentProvider.label[language], - plugin_id: currentProvider.plugin_id || '', - provider: currentProvider.name, - declaration: { - tool: undefined, - // @ts-expect-error just remain the necessary fields - trigger: { - subscription_schema: currentProvider.subscription_schema || [], - subscription_constructor: currentProvider.subscription_constructor, - }, - }, - }) - } - }, [currentProvider, setDetail]) - return
0 && 'flex items-center justify-between pr-3')}> {!subscriptionCount && } {children} {subscriptionCount > 0 && }
diff --git a/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/index.tsx b/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/index.tsx index 17c71b6b95..93bf788c34 100644 --- a/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/index.tsx +++ b/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/index.tsx @@ -14,7 +14,7 @@ type Props = { onChange: (value: PluginTriggerVarInputs) => void onOpen?: (index: number) => void inPanel?: boolean - currentTrigger?: Event + currentEvent?: Event currentProvider?: TriggerWithProvider extraParams?: Record disableVariableInsertion?: boolean @@ -27,7 +27,7 @@ const TriggerForm: FC = ({ value, onChange, inPanel, - currentTrigger, + currentEvent, currentProvider, extraParams, disableVariableInsertion = false, @@ -44,7 +44,7 @@ const TriggerForm: FC = ({ value={value} onChange={onChange} inPanel={inPanel} - currentTrigger={currentTrigger} + currentEvent={currentEvent} currentProvider={currentProvider} extraParams={extraParams} disableVariableInsertion={disableVariableInsertion} diff --git a/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx b/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx index 22331aa578..678c12f02a 100644 --- a/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx +++ b/web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx @@ -22,7 +22,7 @@ type Props = { value: PluginTriggerVarInputs onChange: (value: PluginTriggerVarInputs) => void inPanel?: boolean - currentTrigger?: Event + currentEvent?: Event currentProvider?: TriggerWithProvider extraParams?: Record disableVariableInsertion?: boolean @@ -35,7 +35,7 @@ const TriggerFormItem: FC = ({ value, onChange, inPanel, - currentTrigger, + currentEvent, currentProvider, extraParams, disableVariableInsertion = false, @@ -91,7 +91,7 @@ const TriggerFormItem: FC = ({ value={value} onChange={onChange} inPanel={inPanel} - currentTool={currentTrigger} + currentTool={currentEvent} currentProvider={currentProvider} providerType='trigger' extraParams={extraParams} diff --git a/web/app/components/workflow/nodes/trigger-plugin/panel.tsx b/web/app/components/workflow/nodes/trigger-plugin/panel.tsx index f7dc8374e7..9b4d8058b1 100644 --- a/web/app/components/workflow/nodes/trigger-plugin/panel.tsx +++ b/web/app/components/workflow/nodes/trigger-plugin/panel.tsx @@ -22,7 +22,8 @@ const Panel: FC> = ({ outputSchema, hasObjectOutput, currentProvider, - currentTrigger, + currentEvent, + subscriptionSelected, } = useConfig(id, data) const disableVariableInsertion = data.type === BlockEnum.TriggerPlugin @@ -36,7 +37,7 @@ const Panel: FC> = ({ return (
{/* Dynamic Parameters Form - Only show when authenticated */} - {triggerParameterSchema.length > 0 && ( + {triggerParameterSchema.length > 0 && subscriptionSelected && ( <>
> = ({ value={triggerParameterValue} onChange={setTriggerParameterValue} currentProvider={currentProvider} - currentTrigger={currentTrigger} + currentEvent={currentEvent} disableVariableInsertion={disableVariableInsertion} />
diff --git a/web/app/components/workflow/nodes/trigger-plugin/use-config.ts b/web/app/components/workflow/nodes/trigger-plugin/use-config.ts index 7d75aa5e00..6ea711cd31 100644 --- a/web/app/components/workflow/nodes/trigger-plugin/use-config.ts +++ b/web/app/components/workflow/nodes/trigger-plugin/use-config.ts @@ -86,6 +86,7 @@ const useConfig = (id: string, payload: PluginTriggerNodeType) => { event_name: event_name, config = {}, event_parameters: rawEventParameters = {}, + subscription_id, } = inputs const event_parameters = useMemo( @@ -97,16 +98,6 @@ const useConfig = (id: string, payload: PluginTriggerNodeType) => { [config], ) - // Construct provider for authentication check - const authProvider = useMemo(() => { - return provider_name || '' - }, [provider_id, provider_name]) - - const { data: subscriptions = [] } = useTriggerSubscriptions( - authProvider, - !!authProvider, - ) - const currentProvider = useMemo(() => { return triggerPlugins.find( provider => @@ -116,6 +107,12 @@ const useConfig = (id: string, payload: PluginTriggerNodeType) => { ) }, [triggerPlugins, provider_name, provider_id]) + const { data: subscriptions = [] } = useTriggerSubscriptions(provider_id || '') + + const subscriptionSelected = useMemo(() => { + return subscriptions?.find(s => s.id === subscription_id) + }, [subscriptions, subscription_id]) + const currentEvent = useMemo(() => { return currentProvider?.events.find( event => event.name === event_name, @@ -221,7 +218,7 @@ const useConfig = (id: string, payload: PluginTriggerNodeType) => { readOnly, inputs, currentProvider, - currentTrigger: currentEvent, + currentEvent, triggerParameterSchema, triggerParameterValue, setTriggerParameterValue, @@ -229,6 +226,7 @@ const useConfig = (id: string, payload: PluginTriggerNodeType) => { outputSchema, hasObjectOutput, subscriptions, + subscriptionSelected, } } From 6e0765fbaf6313a7f87ccf12c16c55c30bf31767 Mon Sep 17 00:00:00 2001 From: lyzno1 Date: Thu, 30 Oct 2025 14:15:30 +0800 Subject: [PATCH 080/394] feat: add install check for tools, triggers and datasources --- .../workflow/block-selector/data-sources.tsx | 1 + .../block-selector/tool/action-item.tsx | 1 + .../workflow/block-selector/tool/tool.tsx | 2 + .../workflow/block-selector/types.ts | 2 + .../hooks/use-node-plugin-installation.ts | 208 ++++++++++++++++++ .../workflow/nodes/data-source/node.tsx | 32 ++- .../workflow/nodes/data-source/types.ts | 1 + .../components/workflow/nodes/tool/node.tsx | 78 ++++--- .../components/workflow/nodes/tool/types.ts | 1 + .../workflow/nodes/trigger-plugin/node.tsx | 22 +- .../workflow/nodes/trigger-plugin/types.ts | 1 + web/app/components/workflow/types.ts | 1 + 12 files changed, 318 insertions(+), 32 deletions(-) create mode 100644 web/app/components/workflow/hooks/use-node-plugin-installation.ts diff --git a/web/app/components/workflow/block-selector/data-sources.tsx b/web/app/components/workflow/block-selector/data-sources.tsx index 3961f63dbe..b98a52dcff 100644 --- a/web/app/components/workflow/block-selector/data-sources.tsx +++ b/web/app/components/workflow/block-selector/data-sources.tsx @@ -63,6 +63,7 @@ const DataSources = ({ datasource_name: toolDefaultValue?.tool_name, datasource_label: toolDefaultValue?.tool_label, title: toolDefaultValue?.title, + plugin_unique_identifier: toolDefaultValue?.plugin_unique_identifier, } // Update defaultValue with fileExtensions if this is the local file data source if (toolDefaultValue?.provider_id === 'langgenius/file' && toolDefaultValue?.provider_name === 'file') { diff --git a/web/app/components/workflow/block-selector/tool/action-item.tsx b/web/app/components/workflow/block-selector/tool/action-item.tsx index e2c28602f8..01c319327a 100644 --- a/web/app/components/workflow/block-selector/tool/action-item.tsx +++ b/web/app/components/workflow/block-selector/tool/action-item.tsx @@ -72,6 +72,7 @@ const ToolItem: FC = ({ provider_type: provider.type, provider_name: provider.name, plugin_id: provider.plugin_id, + plugin_unique_identifier: provider.plugin_unique_identifier, provider_icon: normalizeProviderIcon(provider.icon), tool_name: payload.name, tool_label: payload.label[language], diff --git a/web/app/components/workflow/block-selector/tool/tool.tsx b/web/app/components/workflow/block-selector/tool/tool.tsx index 5ac043e933..38be8d19d6 100644 --- a/web/app/components/workflow/block-selector/tool/tool.tsx +++ b/web/app/components/workflow/block-selector/tool/tool.tsx @@ -94,6 +94,7 @@ const Tool: FC = ({ provider_type: payload.type, provider_name: payload.name, plugin_id: payload.plugin_id, + plugin_unique_identifier: payload.plugin_unique_identifier, provider_icon: normalizeProviderIcon(payload.icon), tool_name: tool.name, tool_label: tool.label[language], @@ -175,6 +176,7 @@ const Tool: FC = ({ provider_type: payload.type, provider_name: payload.name, plugin_id: payload.plugin_id, + plugin_unique_identifier: payload.plugin_unique_identifier, provider_icon: normalizeProviderIcon(payload.icon), tool_name: tool.name, tool_label: tool.label[language], diff --git a/web/app/components/workflow/block-selector/types.ts b/web/app/components/workflow/block-selector/types.ts index 512621a552..e995974b87 100644 --- a/web/app/components/workflow/block-selector/types.ts +++ b/web/app/components/workflow/block-selector/types.ts @@ -59,6 +59,7 @@ export type ToolDefaultValue = PluginCommonDefaultValue & { meta?: PluginMeta plugin_id?: string provider_icon?: Collection['icon'] + plugin_unique_identifier?: string } export type DataSourceDefaultValue = Omit & { @@ -69,6 +70,7 @@ export type DataSourceDefaultValue = Omit void + shouldDim: boolean +} + +const useToolInstallation = (data: ToolNodeType): InstallationState => { + const builtInQuery = useAllBuiltInTools() + const customQuery = useAllCustomTools() + const workflowQuery = useAllWorkflowTools() + const mcpQuery = useAllMCPTools() + const invalidateTools = useInvalidToolsByType(data.provider_type) + + const collectionInfo = useMemo(() => { + switch (data.provider_type) { + case CollectionType.builtIn: + return { + list: builtInQuery.data, + isLoading: builtInQuery.isLoading, + } + case CollectionType.custom: + return { + list: customQuery.data, + isLoading: customQuery.isLoading, + } + case CollectionType.workflow: + return { + list: workflowQuery.data, + isLoading: workflowQuery.isLoading, + } + case CollectionType.mcp: + return { + list: mcpQuery.data, + isLoading: mcpQuery.isLoading, + } + default: + return undefined + } + }, [ + builtInQuery.data, + builtInQuery.isLoading, + customQuery.data, + customQuery.isLoading, + data.provider_type, + mcpQuery.data, + mcpQuery.isLoading, + workflowQuery.data, + workflowQuery.isLoading, + ]) + + const collection = collectionInfo?.list + const isLoading = collectionInfo?.isLoading ?? false + const isResolved = !!collectionInfo && !isLoading + + const matchedCollection = useMemo(() => { + if (!collection || !collection.length) + return undefined + + return collection.find((toolWithProvider) => { + if (data.plugin_id && toolWithProvider.plugin_id === data.plugin_id) + return true + if (canFindTool(toolWithProvider.id, data.provider_id)) + return true + if (toolWithProvider.name === data.provider_name) + return true + return false + }) + }, [collection, data.plugin_id, data.provider_id, data.provider_name]) + + const uniqueIdentifier = data.plugin_unique_identifier || data.plugin_id || data.provider_id + const canInstall = Boolean(data.plugin_unique_identifier) + + const onInstallSuccess = useCallback(() => { + if (invalidateTools) + invalidateTools() + }, [invalidateTools]) + + return { + isChecking: !!collectionInfo && !isResolved, + isMissing: isResolved && !matchedCollection, + uniqueIdentifier, + canInstall, + onInstallSuccess, + } +} + +const useTriggerInstallation = (data: PluginTriggerNodeType): InstallationState => { + const triggerPluginsQuery = useAllTriggerPlugins() + const invalidateTriggers = useInvalidateAllTriggerPlugins() + + const triggerProviders = triggerPluginsQuery.data + const isLoading = triggerPluginsQuery.isLoading + + const matchedProvider = useMemo(() => { + if (!triggerProviders || !triggerProviders.length) + return undefined + + return triggerProviders.find(provider => + provider.name === data.provider_name + || provider.id === data.provider_id + || (data.plugin_id && provider.plugin_id === data.plugin_id), + ) + }, [ + data.plugin_id, + data.provider_id, + data.provider_name, + triggerProviders, + ]) + + const uniqueIdentifier = data.plugin_unique_identifier || data.plugin_id || data.provider_id + const canInstall = Boolean(data.plugin_unique_identifier) + + const onInstallSuccess = useCallback(() => { + invalidateTriggers() + }, [invalidateTriggers]) + + return { + isChecking: isLoading, + isMissing: !isLoading && !!triggerProviders && !matchedProvider, + uniqueIdentifier, + canInstall, + onInstallSuccess, + } +} + +const useDataSourceInstallation = (data: DataSourceNodeType): InstallationState => { + const dataSourceList = useStore(s => s.dataSourceList) + const invalidateDataSourceList = useInvalidDataSourceList() + + const matchedPlugin = useMemo(() => { + if (!dataSourceList || !dataSourceList.length) + return undefined + + return dataSourceList.find((item) => { + if (data.plugin_unique_identifier && item.plugin_unique_identifier === data.plugin_unique_identifier) + return true + if (data.plugin_id && item.plugin_id === data.plugin_id) + return true + if (data.provider_name && item.provider === data.provider_name) + return true + return false + }) + }, [data.plugin_id, data.plugin_unique_identifier, data.provider_name, dataSourceList]) + + const uniqueIdentifier = data.plugin_unique_identifier || data.plugin_id + const canInstall = Boolean(data.plugin_unique_identifier) + + const onInstallSuccess = useCallback(() => { + invalidateDataSourceList() + }, [invalidateDataSourceList]) + + const hasLoadedList = dataSourceList !== undefined + + return { + isChecking: !hasLoadedList, + isMissing: hasLoadedList && !matchedPlugin, + uniqueIdentifier, + canInstall, + onInstallSuccess, + } +} + +export const useNodePluginInstallation = (data: CommonNodeType): InstallationState => { + const toolInstallation = useToolInstallation(data as ToolNodeType) + const triggerInstallation = useTriggerInstallation(data as PluginTriggerNodeType) + const dataSourceInstallation = useDataSourceInstallation(data as DataSourceNodeType) + + switch (data.type as BlockEnum) { + case BlockEnum.Tool: + return toolInstallation + case BlockEnum.TriggerPlugin: + return triggerInstallation + case BlockEnum.DataSource: + return dataSourceInstallation + default: + return { + isChecking: false, + isMissing: false, + uniqueIdentifier: undefined, + canInstall: false, + onInstallSuccess: () => undefined, + } + } +} diff --git a/web/app/components/workflow/nodes/data-source/node.tsx b/web/app/components/workflow/nodes/data-source/node.tsx index f97098e52f..6e6c565dc2 100644 --- a/web/app/components/workflow/nodes/data-source/node.tsx +++ b/web/app/components/workflow/nodes/data-source/node.tsx @@ -1,10 +1,36 @@ import type { FC } from 'react' import { memo } from 'react' -import type { DataSourceNodeType } from './types' import type { NodeProps } from '@/app/components/workflow/types' -const Node: FC> = () => { +import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button' +import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation' +import type { DataSourceNodeType } from './types' + +const Node: FC> = ({ + data, +}) => { + const { + isChecking, + isMissing, + uniqueIdentifier, + canInstall, + onInstallSuccess, + } = useNodePluginInstallation(data) + + const showInstallButton = !isChecking && isMissing && canInstall && uniqueIdentifier + + if (!showInstallButton) + return null + return ( -
+
+
+ +
) } diff --git a/web/app/components/workflow/nodes/data-source/types.ts b/web/app/components/workflow/nodes/data-source/types.ts index da887244b8..cd22b305d1 100644 --- a/web/app/components/workflow/nodes/data-source/types.ts +++ b/web/app/components/workflow/nodes/data-source/types.ts @@ -30,6 +30,7 @@ export type DataSourceNodeType = CommonNodeType & { datasource_label: string datasource_parameters: ToolVarInputs datasource_configurations: Record + plugin_unique_identifier?: string } export type CustomRunFormProps = { diff --git a/web/app/components/workflow/nodes/tool/node.tsx b/web/app/components/workflow/nodes/tool/node.tsx index 8cc3ec580d..466cbb577f 100644 --- a/web/app/components/workflow/nodes/tool/node.tsx +++ b/web/app/components/workflow/nodes/tool/node.tsx @@ -1,46 +1,68 @@ import type { FC } from 'react' import React from 'react' -import type { ToolNodeType } from './types' import type { NodeProps } from '@/app/components/workflow/types' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button' +import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation' +import type { ToolNodeType } from './types' const Node: FC> = ({ data, }) => { const { tool_configurations, paramSchemas } = data const toolConfigs = Object.keys(tool_configurations || {}) + const { + isChecking, + isMissing, + uniqueIdentifier, + canInstall, + onInstallSuccess, + } = useNodePluginInstallation(data) + const showInstallButton = !isChecking && isMissing && canInstall && uniqueIdentifier - if (!toolConfigs.length) + const hasConfigs = toolConfigs.length > 0 + + if (!showInstallButton && !hasConfigs) return null return ( -
-
- {toolConfigs.map((key, index) => ( -
-
- {key} +
+ {showInstallButton && ( +
+ +
+ )} + {hasConfigs && ( +
+ {toolConfigs.map((key, index) => ( +
+
+ {key} +
+ {typeof tool_configurations[key].value === 'string' && ( +
+ {paramSchemas?.find(i => i.name === key)?.type === FormTypeEnum.secretInput ? '********' : tool_configurations[key].value} +
+ )} + {typeof tool_configurations[key].value === 'number' && ( +
+ {Number.isNaN(tool_configurations[key].value) ? '' : tool_configurations[key].value} +
+ )} + {typeof tool_configurations[key] !== 'string' && tool_configurations[key]?.type === FormTypeEnum.modelSelector && ( +
+ {tool_configurations[key].model} +
+ )}
- {typeof tool_configurations[key].value === 'string' && ( -
- {paramSchemas?.find(i => i.name === key)?.type === FormTypeEnum.secretInput ? '********' : tool_configurations[key].value} -
- )} - {typeof tool_configurations[key].value === 'number' && ( -
- {Number.isNaN(tool_configurations[key].value) ? '' : tool_configurations[key].value} -
- )} - {typeof tool_configurations[key] !== 'string' && tool_configurations[key]?.type === FormTypeEnum.modelSelector && ( -
- {tool_configurations[key].model} -
- )} -
- - ))} - -
+ ))} +
+ )}
) } diff --git a/web/app/components/workflow/nodes/tool/types.ts b/web/app/components/workflow/nodes/tool/types.ts index 12a4283cf6..6e6ef858dc 100644 --- a/web/app/components/workflow/nodes/tool/types.ts +++ b/web/app/components/workflow/nodes/tool/types.ts @@ -22,4 +22,5 @@ export type ToolNodeType = CommonNodeType & { params?: Record plugin_id?: string provider_icon?: Collection['icon'] + plugin_unique_identifier?: string } diff --git a/web/app/components/workflow/nodes/trigger-plugin/node.tsx b/web/app/components/workflow/nodes/trigger-plugin/node.tsx index 9be517e97d..bfd807f30e 100644 --- a/web/app/components/workflow/nodes/trigger-plugin/node.tsx +++ b/web/app/components/workflow/nodes/trigger-plugin/node.tsx @@ -3,6 +3,8 @@ import type { NodeProps } from '@/app/components/workflow/types' import type { FC } from 'react' import React, { useMemo } from 'react' import { useTranslation } from 'react-i18next' +import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button' +import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation' import type { PluginTriggerNodeType } from './types' import useConfig from './use-config' @@ -42,6 +44,14 @@ const Node: FC> = ({ const { subscriptions } = useConfig(id, data) const { config = {}, subscription_id } = data const configKeys = Object.keys(config) + const { + isChecking, + isMissing, + uniqueIdentifier, + canInstall, + onInstallSuccess, + } = useNodePluginInstallation(data) + const showInstallButton = !isChecking && isMissing && canInstall && uniqueIdentifier const { t } = useTranslation() @@ -50,7 +60,17 @@ const Node: FC> = ({ }, [subscription_id, subscriptions]) return ( -
+
+ {showInstallButton && ( +
+ +
+ )}
{!isValidSubscription && } {isValidSubscription && configKeys.map((key, index) => ( diff --git a/web/app/components/workflow/nodes/trigger-plugin/types.ts b/web/app/components/workflow/nodes/trigger-plugin/types.ts index 43268e9096..6dba97d795 100644 --- a/web/app/components/workflow/nodes/trigger-plugin/types.ts +++ b/web/app/components/workflow/nodes/trigger-plugin/types.ts @@ -16,6 +16,7 @@ export type PluginTriggerNodeType = CommonNodeType & { event_node_version?: string plugin_id?: string config?: Record + plugin_unique_identifier?: string } // Use base types directly diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index a4e7002960..d126daa350 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -451,6 +451,7 @@ export type MoreInfo = { export type ToolWithProvider = Collection & { tools: Tool[] meta: PluginMeta + plugin_unique_identifier?: string } export type RAGRecommendedPlugins = { From ff0f645e543cf60efccb7f4ecfd79654f8f61734 Mon Sep 17 00:00:00 2001 From: lyzno1 Date: Thu, 30 Oct 2025 14:42:52 +0800 Subject: [PATCH 081/394] Fix plugin install detection for tool nodes --- .../components/install-plugin-button.tsx | 24 +++++++++++++++---- .../workflow/nodes/data-source/node.tsx | 4 ++++ .../components/workflow/nodes/tool/node.tsx | 5 ++++ .../workflow/nodes/trigger-plugin/node.tsx | 5 ++++ 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/web/app/components/workflow/nodes/_base/components/install-plugin-button.tsx b/web/app/components/workflow/nodes/_base/components/install-plugin-button.tsx index 23119f0213..c387cb8630 100644 --- a/web/app/components/workflow/nodes/_base/components/install-plugin-button.tsx +++ b/web/app/components/workflow/nodes/_base/components/install-plugin-button.tsx @@ -7,15 +7,25 @@ import { useCheckInstalled, useInstallPackageFromMarketPlace } from '@/service/u type InstallPluginButtonProps = Omit, 'children' | 'loading'> & { uniqueIdentifier: string + extraIdentifiers?: string[] onSuccess?: () => void } export const InstallPluginButton = (props: InstallPluginButtonProps) => { - const { className, uniqueIdentifier, onSuccess, ...rest } = props + const { + className, + uniqueIdentifier, + extraIdentifiers = [], + onSuccess, + ...rest + } = props const { t } = useTranslation() + const identifiers = Array.from(new Set( + [uniqueIdentifier, ...extraIdentifiers].filter((item): item is string => Boolean(item)), + )) const manifest = useCheckInstalled({ - pluginIds: [uniqueIdentifier], - enabled: !!uniqueIdentifier, + pluginIds: identifiers, + enabled: identifiers.length > 0, }) const install = useInstallPackageFromMarketPlace() const isLoading = manifest.isLoading || install.isPending @@ -31,7 +41,13 @@ export const InstallPluginButton = (props: InstallPluginButtonProps) => { }) } if (!manifest.data) return null - if (manifest.data.plugins.some(plugin => plugin.id === uniqueIdentifier)) return null + const identifierSet = new Set(identifiers) + const isInstalled = manifest.data.plugins.some(plugin => ( + identifierSet.has(plugin.id) + || (plugin.plugin_unique_identifier && identifierSet.has(plugin.plugin_unique_identifier)) + || (plugin.plugin_id && identifierSet.has(plugin.plugin_id)) + )) + if (isInstalled) return null return
{inputs.webhook_debug_url && ( - -
{ - copy(inputs.webhook_debug_url || '') - setDebugUrlCopied(true) - setTimeout(() => setDebugUrlCopied(false), 2000) - }} +
+ -
-
-
- {t(`${i18nPrefix}.debugUrlTitle`)} -
-
- {inputs.webhook_debug_url} +
{ + copy(inputs.webhook_debug_url || '') + setDebugUrlCopied(true) + setTimeout(() => setDebugUrlCopied(false), 2000) + }} + > +
+
+
+ {t(`${i18nPrefix}.debugUrlTitle`)} +
+
+ {inputs.webhook_debug_url} +
-
- + + {isPrivateOrLocalAddress(inputs.webhook_debug_url) && ( +
+ {t(`${i18nPrefix}.debugUrlPrivateAddressWarning`)} +
+ )} +
)}
diff --git a/web/i18n/en-US/plugin-trigger.ts b/web/i18n/en-US/plugin-trigger.ts index bf1787d2c7..aedd0c6225 100644 --- a/web/i18n/en-US/plugin-trigger.ts +++ b/web/i18n/en-US/plugin-trigger.ts @@ -153,7 +153,7 @@ const translation = { description: 'This URL will receive webhook events', tooltip: 'Provide a publicly accessible endpoint that can receive callback requests from the trigger provider.', placeholder: 'Generating...', - privateAddressWarning: 'This URL appears to be an internal address, which may cause webhook requests to fail.', + privateAddressWarning: 'This URL appears to be an internal address, which may cause webhook requests to fail. You may change TRIGGER_URL to a public address.', }, }, errors: { diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index d8de0eabaf..77e71b0973 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -1144,6 +1144,7 @@ const translation = { debugUrlTitle: 'For test runs, always use this URL', debugUrlCopy: 'Click to copy', debugUrlCopied: 'Copied!', + debugUrlPrivateAddressWarning: 'This URL appears to be an internal address, which may cause webhook requests to fail. You may change TRIGGER_URL to a public address.', errorHandling: 'Error Handling', errorStrategy: 'Error Handling', responseConfiguration: 'Response', diff --git a/web/utils/urlValidation.ts b/web/utils/urlValidation.ts index abc15a1365..80b72e4e0a 100644 --- a/web/utils/urlValidation.ts +++ b/web/utils/urlValidation.ts @@ -21,3 +21,48 @@ export function validateRedirectUrl(url: string): void { throw new Error(`Invalid URL: ${url}`) } } + +/** + * Check if URL is a private/local network address or cloud debug URL + * @param url - The URL string to check + * @returns true if the URL is a private/local address or cloud debug URL + */ +export function isPrivateOrLocalAddress(url: string): boolean { + try { + const urlObj = new URL(url) + const hostname = urlObj.hostname.toLowerCase() + + // Check for Dify cloud trigger debug URLs + if (hostname === 'cloud-trigger.dify.dev') + return true + + // Check for localhost + if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') + return true + + // Check for private IP ranges + const ipv4Regex = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/ + const ipv4Match = hostname.match(ipv4Regex) + if (ipv4Match) { + const [, a, b] = ipv4Match.map(Number) + // 10.0.0.0/8 + if (a === 10) + return true + // 172.16.0.0/12 + if (a === 172 && b >= 16 && b <= 31) + return true + // 192.168.0.0/16 + if (a === 192 && b === 168) + return true + // 169.254.0.0/16 (link-local) + if (a === 169 && b === 254) + return true + } + + // Check for .local domains + return hostname.endsWith('.local') + } + catch { + return false + } +} From 9f59baed1049891b679a1ea19dc7f02827d4cc21 Mon Sep 17 00:00:00 2001 From: zhsama Date: Tue, 4 Nov 2025 18:34:41 +0800 Subject: [PATCH 193/394] fix(urlValidation): remove specific check for Dify cloud trigger debug URLs --- web/utils/urlValidation.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/web/utils/urlValidation.ts b/web/utils/urlValidation.ts index 80b72e4e0a..db6de5275a 100644 --- a/web/utils/urlValidation.ts +++ b/web/utils/urlValidation.ts @@ -32,10 +32,6 @@ export function isPrivateOrLocalAddress(url: string): boolean { const urlObj = new URL(url) const hostname = urlObj.hostname.toLowerCase() - // Check for Dify cloud trigger debug URLs - if (hostname === 'cloud-trigger.dify.dev') - return true - // Check for localhost if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return true From e9738b891fe316b2b44abefbe5642aad412a8db1 Mon Sep 17 00:00:00 2001 From: aka James4u Date: Tue, 4 Nov 2025 05:06:44 -0800 Subject: [PATCH 194/394] test: adding some web tests (#27792) --- web/__tests__/check-i18n.test.ts | 100 ++++++++ web/__tests__/navigation-utils.test.ts | 112 +++++++++ web/service/_tools_util.spec.ts | 36 +++ web/utils/clipboard.spec.ts | 109 +++++++++ web/utils/emoji.spec.ts | 77 +++++++ web/utils/format.spec.ts | 94 +++++++- web/utils/index.spec.ts | 305 +++++++++++++++++++++++++ web/utils/urlValidation.spec.ts | 49 ++++ web/utils/validators.spec.ts | 139 +++++++++++ web/utils/var.spec.ts | 236 +++++++++++++++++++ 10 files changed, 1256 insertions(+), 1 deletion(-) create mode 100644 web/utils/clipboard.spec.ts create mode 100644 web/utils/emoji.spec.ts create mode 100644 web/utils/urlValidation.spec.ts create mode 100644 web/utils/validators.spec.ts create mode 100644 web/utils/var.spec.ts diff --git a/web/__tests__/check-i18n.test.ts b/web/__tests__/check-i18n.test.ts index b579f22d4b..7773edcdbb 100644 --- a/web/__tests__/check-i18n.test.ts +++ b/web/__tests__/check-i18n.test.ts @@ -759,4 +759,104 @@ export default translation` expect(result).not.toContain('Zbuduj inteligentnego agenta') }) }) + + describe('Performance and Scalability', () => { + it('should handle large translation files efficiently', async () => { + // Create a large translation file with 1000 keys + const largeContent = `const translation = { +${Array.from({ length: 1000 }, (_, i) => ` key${i}: 'value${i}',`).join('\n')} +} + +export default translation` + + fs.writeFileSync(path.join(testEnDir, 'large.ts'), largeContent) + + const startTime = Date.now() + const keys = await getKeysFromLanguage('en-US') + const endTime = Date.now() + + expect(keys.length).toBe(1000) + expect(endTime - startTime).toBeLessThan(1000) // Should complete in under 1 second + }) + + it('should handle multiple translation files concurrently', async () => { + // Create multiple files + for (let i = 0; i < 10; i++) { + const content = `const translation = { + key${i}: 'value${i}', + nested${i}: { + subkey: 'subvalue' + } +} + +export default translation` + fs.writeFileSync(path.join(testEnDir, `file${i}.ts`), content) + } + + const startTime = Date.now() + const keys = await getKeysFromLanguage('en-US') + const endTime = Date.now() + + expect(keys.length).toBe(20) // 10 files * 2 keys each + expect(endTime - startTime).toBeLessThan(500) + }) + }) + + describe('Unicode and Internationalization', () => { + it('should handle Unicode characters in keys and values', async () => { + const unicodeContent = `const translation = { + '中文键': '中文值', + 'العربية': 'قيمة', + 'emoji_😀': 'value with emoji 🎉', + 'mixed_中文_English': 'mixed value' +} + +export default translation` + + fs.writeFileSync(path.join(testEnDir, 'unicode.ts'), unicodeContent) + + const keys = await getKeysFromLanguage('en-US') + + expect(keys).toContain('unicode.中文键') + expect(keys).toContain('unicode.العربية') + expect(keys).toContain('unicode.emoji_😀') + expect(keys).toContain('unicode.mixed_中文_English') + }) + + it('should handle RTL language files', async () => { + const rtlContent = `const translation = { + مرحبا: 'Hello', + العالم: 'World', + nested: { + مفتاح: 'key' + } +} + +export default translation` + + fs.writeFileSync(path.join(testEnDir, 'rtl.ts'), rtlContent) + + const keys = await getKeysFromLanguage('en-US') + + expect(keys).toContain('rtl.مرحبا') + expect(keys).toContain('rtl.العالم') + expect(keys).toContain('rtl.nested.مفتاح') + }) + }) + + describe('Error Recovery', () => { + it('should handle syntax errors in translation files gracefully', async () => { + const invalidContent = `const translation = { + validKey: 'valid value', + invalidKey: 'missing quote, + anotherKey: 'another value' +} + +export default translation` + + fs.writeFileSync(path.join(testEnDir, 'invalid.ts'), invalidContent) + + await expect(getKeysFromLanguage('en-US')).rejects.toThrow() + }) + }) }) diff --git a/web/__tests__/navigation-utils.test.ts b/web/__tests__/navigation-utils.test.ts index fa4986e63d..3eeba52943 100644 --- a/web/__tests__/navigation-utils.test.ts +++ b/web/__tests__/navigation-utils.test.ts @@ -286,4 +286,116 @@ describe('Navigation Utilities', () => { expect(mockPush).toHaveBeenCalledWith('/datasets/filtered-set/documents?page=1&limit=50&status=active&type=pdf&sort=created_at&order=desc') }) }) + + describe('Edge Cases and Error Handling', () => { + test('handles special characters in query parameters', () => { + Object.defineProperty(window, 'location', { + value: { search: '?keyword=hello%20world&filter=type%3Apdf&tag=%E4%B8%AD%E6%96%87' }, + writable: true, + }) + + const path = createNavigationPath('/datasets/123/documents') + expect(path).toContain('hello+world') + expect(path).toContain('type%3Apdf') + expect(path).toContain('%E4%B8%AD%E6%96%87') + }) + + test('handles duplicate query parameters', () => { + Object.defineProperty(window, 'location', { + value: { search: '?tag=tag1&tag=tag2&tag=tag3' }, + writable: true, + }) + + const params = extractQueryParams(['tag']) + // URLSearchParams.get() returns the first value + expect(params.tag).toBe('tag1') + }) + + test('handles very long query strings', () => { + const longValue = 'a'.repeat(1000) + Object.defineProperty(window, 'location', { + value: { search: `?data=${longValue}` }, + writable: true, + }) + + const path = createNavigationPath('/datasets/123/documents') + expect(path).toContain(longValue) + expect(path.length).toBeGreaterThan(1000) + }) + + test('handles empty string values in query parameters', () => { + const path = createNavigationPathWithParams('/datasets/123/documents', { + page: 1, + keyword: '', + filter: '', + sort: 'name', + }) + + expect(path).toBe('/datasets/123/documents?page=1&sort=name') + expect(path).not.toContain('keyword=') + expect(path).not.toContain('filter=') + }) + + test('handles null and undefined values in mergeQueryParams', () => { + Object.defineProperty(window, 'location', { + value: { search: '?page=1&limit=10&keyword=test' }, + writable: true, + }) + + const merged = mergeQueryParams({ + keyword: null, + filter: undefined, + sort: 'name', + }) + const result = merged.toString() + + expect(result).toContain('page=1') + expect(result).toContain('limit=10') + expect(result).not.toContain('keyword') + expect(result).toContain('sort=name') + }) + + test('handles navigation with hash fragments', () => { + Object.defineProperty(window, 'location', { + value: { search: '?page=1', hash: '#section-2' }, + writable: true, + }) + + const path = createNavigationPath('/datasets/123/documents') + // Should preserve query params but not hash + expect(path).toBe('/datasets/123/documents?page=1') + }) + + test('handles malformed query strings gracefully', () => { + Object.defineProperty(window, 'location', { + value: { search: '?page=1&invalid&limit=10&=value&key=' }, + writable: true, + }) + + const params = extractQueryParams(['page', 'limit', 'invalid', 'key']) + expect(params.page).toBe('1') + expect(params.limit).toBe('10') + // Malformed params should be handled by URLSearchParams + expect(params.invalid).toBe('') // for `&invalid` + expect(params.key).toBe('') // for `&key=` + }) + }) + + describe('Performance Tests', () => { + test('handles large number of query parameters efficiently', () => { + const manyParams = Array.from({ length: 50 }, (_, i) => `param${i}=value${i}`).join('&') + Object.defineProperty(window, 'location', { + value: { search: `?${manyParams}` }, + writable: true, + }) + + const startTime = Date.now() + const path = createNavigationPath('/datasets/123/documents') + const endTime = Date.now() + + expect(endTime - startTime).toBeLessThan(50) // Should be fast + expect(path).toContain('param0=value0') + expect(path).toContain('param49=value49') + }) + }) }) diff --git a/web/service/_tools_util.spec.ts b/web/service/_tools_util.spec.ts index f06e5a1e34..658c276df1 100644 --- a/web/service/_tools_util.spec.ts +++ b/web/service/_tools_util.spec.ts @@ -14,3 +14,39 @@ describe('makeProviderQuery', () => { expect(buildProviderQuery('ABC?DEF')).toBe('provider=ABC%3FDEF') }) }) + +describe('Tools Utilities', () => { + describe('buildProviderQuery', () => { + it('should build query string with provider parameter', () => { + const result = buildProviderQuery('openai') + expect(result).toBe('provider=openai') + }) + + it('should handle provider names with special characters', () => { + const result = buildProviderQuery('provider-name') + expect(result).toBe('provider=provider-name') + }) + + it('should handle empty string', () => { + const result = buildProviderQuery('') + expect(result).toBe('provider=') + }) + + it('should URL encode special characters', () => { + const result = buildProviderQuery('provider name') + expect(result).toBe('provider=provider+name') + }) + + it('should handle Unicode characters', () => { + const result = buildProviderQuery('提供者') + expect(result).toContain('provider=') + expect(decodeURIComponent(result)).toBe('provider=提供者') + }) + + it('should handle provider names with slashes', () => { + const result = buildProviderQuery('langgenius/openai/gpt-4') + expect(result).toContain('provider=') + expect(decodeURIComponent(result)).toBe('provider=langgenius/openai/gpt-4') + }) + }) +}) diff --git a/web/utils/clipboard.spec.ts b/web/utils/clipboard.spec.ts new file mode 100644 index 0000000000..ccdafe83f4 --- /dev/null +++ b/web/utils/clipboard.spec.ts @@ -0,0 +1,109 @@ +import { writeTextToClipboard } from './clipboard' + +describe('Clipboard Utilities', () => { + describe('writeTextToClipboard', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it('should use navigator.clipboard.writeText when available', async () => { + const mockWriteText = jest.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: mockWriteText }, + writable: true, + configurable: true, + }) + + await writeTextToClipboard('test text') + expect(mockWriteText).toHaveBeenCalledWith('test text') + }) + + it('should fallback to execCommand when clipboard API not available', async () => { + Object.defineProperty(navigator, 'clipboard', { + value: undefined, + writable: true, + configurable: true, + }) + + const mockExecCommand = jest.fn().mockReturnValue(true) + document.execCommand = mockExecCommand + + const appendChildSpy = jest.spyOn(document.body, 'appendChild') + const removeChildSpy = jest.spyOn(document.body, 'removeChild') + + await writeTextToClipboard('fallback text') + + expect(appendChildSpy).toHaveBeenCalled() + expect(mockExecCommand).toHaveBeenCalledWith('copy') + expect(removeChildSpy).toHaveBeenCalled() + }) + + it('should handle execCommand failure', async () => { + Object.defineProperty(navigator, 'clipboard', { + value: undefined, + writable: true, + configurable: true, + }) + + const mockExecCommand = jest.fn().mockReturnValue(false) + document.execCommand = mockExecCommand + + await expect(writeTextToClipboard('fail text')).rejects.toThrow() + }) + + it('should handle execCommand exception', async () => { + Object.defineProperty(navigator, 'clipboard', { + value: undefined, + writable: true, + configurable: true, + }) + + const mockExecCommand = jest.fn().mockImplementation(() => { + throw new Error('execCommand error') + }) + document.execCommand = mockExecCommand + + await expect(writeTextToClipboard('error text')).rejects.toThrow('execCommand error') + }) + + it('should clean up textarea after fallback', async () => { + Object.defineProperty(navigator, 'clipboard', { + value: undefined, + writable: true, + configurable: true, + }) + + document.execCommand = jest.fn().mockReturnValue(true) + const removeChildSpy = jest.spyOn(document.body, 'removeChild') + + await writeTextToClipboard('cleanup test') + + expect(removeChildSpy).toHaveBeenCalled() + }) + + it('should handle empty string', async () => { + const mockWriteText = jest.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: mockWriteText }, + writable: true, + configurable: true, + }) + + await writeTextToClipboard('') + expect(mockWriteText).toHaveBeenCalledWith('') + }) + + it('should handle special characters', async () => { + const mockWriteText = jest.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: mockWriteText }, + writable: true, + configurable: true, + }) + + const specialText = 'Test\n\t"quotes"\n中文\n😀' + await writeTextToClipboard(specialText) + expect(mockWriteText).toHaveBeenCalledWith(specialText) + }) + }) +}) diff --git a/web/utils/emoji.spec.ts b/web/utils/emoji.spec.ts new file mode 100644 index 0000000000..df9520234a --- /dev/null +++ b/web/utils/emoji.spec.ts @@ -0,0 +1,77 @@ +import { searchEmoji } from './emoji' +import { SearchIndex } from 'emoji-mart' + +jest.mock('emoji-mart', () => ({ + SearchIndex: { + search: jest.fn(), + }, +})) + +describe('Emoji Utilities', () => { + describe('searchEmoji', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should return emoji natives for search results', async () => { + const mockEmojis = [ + { skins: [{ native: '😀' }] }, + { skins: [{ native: '😃' }] }, + { skins: [{ native: '😄' }] }, + ] + ;(SearchIndex.search as jest.Mock).mockResolvedValue(mockEmojis) + + const result = await searchEmoji('smile') + expect(result).toEqual(['😀', '😃', '😄']) + }) + + it('should return empty array when no results', async () => { + ;(SearchIndex.search as jest.Mock).mockResolvedValue([]) + + const result = await searchEmoji('nonexistent') + expect(result).toEqual([]) + }) + + it('should return empty array when search returns null', async () => { + ;(SearchIndex.search as jest.Mock).mockResolvedValue(null) + + const result = await searchEmoji('test') + expect(result).toEqual([]) + }) + + it('should handle search with empty string', async () => { + ;(SearchIndex.search as jest.Mock).mockResolvedValue([]) + + const result = await searchEmoji('') + expect(result).toEqual([]) + expect(SearchIndex.search).toHaveBeenCalledWith('') + }) + + it('should extract native from first skin', async () => { + const mockEmojis = [ + { + skins: [ + { native: '👍' }, + { native: '👍🏻' }, + { native: '👍🏼' }, + ], + }, + ] + ;(SearchIndex.search as jest.Mock).mockResolvedValue(mockEmojis) + + const result = await searchEmoji('thumbs') + expect(result).toEqual(['👍']) + }) + + it('should handle multiple search terms', async () => { + const mockEmojis = [ + { skins: [{ native: '❤️' }] }, + { skins: [{ native: '💙' }] }, + ] + ;(SearchIndex.search as jest.Mock).mockResolvedValue(mockEmojis) + + const result = await searchEmoji('heart love') + expect(result).toEqual(['❤️', '💙']) + }) + }) +}) diff --git a/web/utils/format.spec.ts b/web/utils/format.spec.ts index c94495d597..20e54fe1a4 100644 --- a/web/utils/format.spec.ts +++ b/web/utils/format.spec.ts @@ -1,4 +1,4 @@ -import { downloadFile, formatFileSize, formatNumber, formatTime } from './format' +import { downloadFile, formatFileSize, formatNumber, formatNumberAbbreviated, formatTime } from './format' describe('formatNumber', () => { test('should correctly format integers', () => { @@ -102,3 +102,95 @@ describe('downloadFile', () => { jest.restoreAllMocks() }) }) + +describe('formatNumberAbbreviated', () => { + it('should return number as string when less than 1000', () => { + expect(formatNumberAbbreviated(0)).toBe('0') + expect(formatNumberAbbreviated(1)).toBe('1') + expect(formatNumberAbbreviated(999)).toBe('999') + }) + + it('should format thousands with k suffix', () => { + expect(formatNumberAbbreviated(1000)).toBe('1k') + expect(formatNumberAbbreviated(1200)).toBe('1.2k') + expect(formatNumberAbbreviated(1500)).toBe('1.5k') + expect(formatNumberAbbreviated(9999)).toBe('10k') + }) + + it('should format millions with M suffix', () => { + expect(formatNumberAbbreviated(1000000)).toBe('1M') + expect(formatNumberAbbreviated(1500000)).toBe('1.5M') + expect(formatNumberAbbreviated(2300000)).toBe('2.3M') + expect(formatNumberAbbreviated(999999999)).toBe('1000M') + }) + + it('should format billions with B suffix', () => { + expect(formatNumberAbbreviated(1000000000)).toBe('1B') + expect(formatNumberAbbreviated(1500000000)).toBe('1.5B') + expect(formatNumberAbbreviated(2300000000)).toBe('2.3B') + }) + + it('should remove .0 from whole numbers', () => { + expect(formatNumberAbbreviated(1000)).toBe('1k') + expect(formatNumberAbbreviated(2000000)).toBe('2M') + expect(formatNumberAbbreviated(3000000000)).toBe('3B') + }) + + it('should keep decimal for non-whole numbers', () => { + expect(formatNumberAbbreviated(1100)).toBe('1.1k') + expect(formatNumberAbbreviated(1500000)).toBe('1.5M') + expect(formatNumberAbbreviated(2700000000)).toBe('2.7B') + }) + + it('should handle edge cases', () => { + expect(formatNumberAbbreviated(950)).toBe('950') + expect(formatNumberAbbreviated(1001)).toBe('1k') + expect(formatNumberAbbreviated(999999)).toBe('1000k') + }) +}) + +describe('formatNumber edge cases', () => { + it('should handle very large numbers', () => { + expect(formatNumber(1234567890123)).toBe('1,234,567,890,123') + }) + + it('should handle numbers with many decimal places', () => { + expect(formatNumber(1234.56789)).toBe('1,234.56789') + }) + + it('should handle negative decimals', () => { + expect(formatNumber(-1234.56)).toBe('-1,234.56') + }) + + it('should handle string with decimals', () => { + expect(formatNumber('9876543.21')).toBe('9,876,543.21') + }) +}) + +describe('formatFileSize edge cases', () => { + it('should handle exactly 1024 bytes', () => { + expect(formatFileSize(1024)).toBe('1.00 KB') + }) + + it('should handle fractional bytes', () => { + expect(formatFileSize(512.5)).toBe('512.50 bytes') + }) +}) + +describe('formatTime edge cases', () => { + it('should handle exactly 60 seconds', () => { + expect(formatTime(60)).toBe('1.00 min') + }) + + it('should handle exactly 3600 seconds', () => { + expect(formatTime(3600)).toBe('1.00 h') + }) + + it('should handle fractional seconds', () => { + expect(formatTime(45.5)).toBe('45.50 sec') + }) + + it('should handle very large durations', () => { + expect(formatTime(86400)).toBe('24.00 h') // 24 hours + }) +}) diff --git a/web/utils/index.spec.ts b/web/utils/index.spec.ts index 21a0d80dd0..beda974e5c 100644 --- a/web/utils/index.spec.ts +++ b/web/utils/index.spec.ts @@ -293,3 +293,308 @@ describe('removeSpecificQueryParam', () => { expect(replaceStateCall[2]).toMatch(/param3=value3/) }) }) + +describe('sleep', () => { + it('should resolve after specified milliseconds', async () => { + const start = Date.now() + await sleep(100) + const end = Date.now() + expect(end - start).toBeGreaterThanOrEqual(90) // Allow some tolerance + }) + + it('should handle zero milliseconds', async () => { + await expect(sleep(0)).resolves.toBeUndefined() + }) +}) + +describe('asyncRunSafe extended', () => { + it('should handle promise that resolves with null', async () => { + const [error, result] = await asyncRunSafe(Promise.resolve(null)) + expect(error).toBeNull() + expect(result).toBeNull() + }) + + it('should handle promise that resolves with undefined', async () => { + const [error, result] = await asyncRunSafe(Promise.resolve(undefined)) + expect(error).toBeNull() + expect(result).toBeUndefined() + }) + + it('should handle promise that resolves with false', async () => { + const [error, result] = await asyncRunSafe(Promise.resolve(false)) + expect(error).toBeNull() + expect(result).toBe(false) + }) + + it('should handle promise that resolves with 0', async () => { + const [error, result] = await asyncRunSafe(Promise.resolve(0)) + expect(error).toBeNull() + expect(result).toBe(0) + }) + + // TODO: pre-commit blocks this test case + // Error msg: "Expected the Promise rejection reason to be an Error" + + // it('should handle promise that rejects with null', async () => { + // const [error] = await asyncRunSafe(Promise.reject(null)) + // expect(error).toBeInstanceOf(Error) + // expect(error?.message).toBe('unknown error') + // }) +}) + +describe('getTextWidthWithCanvas', () => { + it('should return 0 when canvas context is not available', () => { + const mockGetContext = jest.fn().mockReturnValue(null) + jest.spyOn(document, 'createElement').mockReturnValue({ + getContext: mockGetContext, + } as any) + + const width = getTextWidthWithCanvas('test') + expect(width).toBe(0) + + jest.restoreAllMocks() + }) + + it('should measure text width with custom font', () => { + const mockMeasureText = jest.fn().mockReturnValue({ width: 123.456 }) + const mockContext = { + font: '', + measureText: mockMeasureText, + } + jest.spyOn(document, 'createElement').mockReturnValue({ + getContext: jest.fn().mockReturnValue(mockContext), + } as any) + + const width = getTextWidthWithCanvas('test', '16px Arial') + expect(mockContext.font).toBe('16px Arial') + expect(width).toBe(123.46) + + jest.restoreAllMocks() + }) + + it('should handle empty string', () => { + const mockMeasureText = jest.fn().mockReturnValue({ width: 0 }) + jest.spyOn(document, 'createElement').mockReturnValue({ + getContext: jest.fn().mockReturnValue({ + font: '', + measureText: mockMeasureText, + }), + } as any) + + const width = getTextWidthWithCanvas('') + expect(width).toBe(0) + + jest.restoreAllMocks() + }) +}) + +describe('randomString extended', () => { + it('should generate string of exact length', () => { + expect(randomString(10).length).toBe(10) + expect(randomString(50).length).toBe(50) + expect(randomString(100).length).toBe(100) + }) + + it('should generate different strings on multiple calls', () => { + const str1 = randomString(20) + const str2 = randomString(20) + const str3 = randomString(20) + expect(str1).not.toBe(str2) + expect(str2).not.toBe(str3) + expect(str1).not.toBe(str3) + }) + + it('should only contain valid characters', () => { + const validChars = /^[0-9a-zA-Z_-]+$/ + const str = randomString(100) + expect(validChars.test(str)).toBe(true) + }) + + it('should handle length of 1', () => { + const str = randomString(1) + expect(str.length).toBe(1) + }) + + it('should handle length of 0', () => { + const str = randomString(0) + expect(str).toBe('') + }) +}) + +describe('getPurifyHref extended', () => { + it('should escape HTML entities', () => { + expect(getPurifyHref('')).not.toContain('')).toThrow('Authorization URL must be HTTP or HTTPS') + }) + + it('should reject file: protocol', () => { + expect(() => validateRedirectUrl('file:///etc/passwd')).toThrow('Authorization URL must be HTTP or HTTPS') + }) + + it('should reject ftp: protocol', () => { + expect(() => validateRedirectUrl('ftp://example.com')).toThrow('Authorization URL must be HTTP or HTTPS') + }) + + it('should reject vbscript: protocol', () => { + expect(() => validateRedirectUrl('vbscript:msgbox(1)')).toThrow('Authorization URL must be HTTP or HTTPS') + }) + + it('should reject malformed URLs', () => { + expect(() => validateRedirectUrl('not a url')).toThrow('Invalid URL') + expect(() => validateRedirectUrl('://example.com')).toThrow('Invalid URL') + expect(() => validateRedirectUrl('')).toThrow('Invalid URL') + }) + + it('should handle URLs with query parameters', () => { + expect(() => validateRedirectUrl('https://example.com?param=value')).not.toThrow() + expect(() => validateRedirectUrl('https://example.com?redirect=http://evil.com')).not.toThrow() + }) + + it('should handle URLs with fragments', () => { + expect(() => validateRedirectUrl('https://example.com#section')).not.toThrow() + expect(() => validateRedirectUrl('https://example.com/path#fragment')).not.toThrow() + }) + + it('should handle URLs with authentication', () => { + expect(() => validateRedirectUrl('https://user:pass@example.com')).not.toThrow() + }) + + it('should handle international domain names', () => { + expect(() => validateRedirectUrl('https://例え.jp')).not.toThrow() + }) + + it('should reject protocol-relative URLs', () => { + expect(() => validateRedirectUrl('//example.com')).toThrow('Invalid URL') + }) + }) +}) diff --git a/web/utils/validators.spec.ts b/web/utils/validators.spec.ts new file mode 100644 index 0000000000..b09955d12e --- /dev/null +++ b/web/utils/validators.spec.ts @@ -0,0 +1,139 @@ +import { draft07Validator, forbidBooleanProperties } from './validators' + +describe('Validators', () => { + describe('draft07Validator', () => { + it('should validate a valid JSON schema', () => { + const validSchema = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + } + const result = draft07Validator(validSchema) + expect(result.valid).toBe(true) + expect(result.errors).toHaveLength(0) + }) + + it('should invalidate schema with unknown type', () => { + const invalidSchema = { + type: 'invalid_type', + } + const result = draft07Validator(invalidSchema) + expect(result.valid).toBe(false) + expect(result.errors.length).toBeGreaterThan(0) + }) + + it('should validate nested schemas', () => { + const nestedSchema = { + type: 'object', + properties: { + user: { + type: 'object', + properties: { + name: { type: 'string' }, + address: { + type: 'object', + properties: { + street: { type: 'string' }, + city: { type: 'string' }, + }, + }, + }, + }, + }, + } + const result = draft07Validator(nestedSchema) + expect(result.valid).toBe(true) + }) + + it('should validate array schemas', () => { + const arraySchema = { + type: 'array', + items: { type: 'string' }, + } + const result = draft07Validator(arraySchema) + expect(result.valid).toBe(true) + }) + }) + + describe('forbidBooleanProperties', () => { + it('should return empty array for schema without boolean properties', () => { + const schema = { + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + } + const errors = forbidBooleanProperties(schema) + expect(errors).toHaveLength(0) + }) + + it('should detect boolean property at root level', () => { + const schema = { + properties: { + name: true, + age: { type: 'number' }, + }, + } + const errors = forbidBooleanProperties(schema) + expect(errors).toHaveLength(1) + expect(errors[0]).toContain('name') + }) + + it('should detect boolean properties in nested objects', () => { + const schema = { + properties: { + user: { + properties: { + name: true, + profile: { + properties: { + bio: false, + }, + }, + }, + }, + }, + } + const errors = forbidBooleanProperties(schema) + expect(errors).toHaveLength(2) + expect(errors.some(e => e.includes('user.name'))).toBe(true) + expect(errors.some(e => e.includes('user.profile.bio'))).toBe(true) + }) + + it('should handle schema without properties', () => { + const schema = { type: 'string' } + const errors = forbidBooleanProperties(schema) + expect(errors).toHaveLength(0) + }) + + it('should handle null schema', () => { + const errors = forbidBooleanProperties(null) + expect(errors).toHaveLength(0) + }) + + it('should handle empty schema', () => { + const errors = forbidBooleanProperties({}) + expect(errors).toHaveLength(0) + }) + + it('should provide correct path in error messages', () => { + const schema = { + properties: { + level1: { + properties: { + level2: { + properties: { + level3: true, + }, + }, + }, + }, + }, + } + const errors = forbidBooleanProperties(schema) + expect(errors[0]).toContain('level1.level2.level3') + }) + }) +}) diff --git a/web/utils/var.spec.ts b/web/utils/var.spec.ts new file mode 100644 index 0000000000..6f55df0d34 --- /dev/null +++ b/web/utils/var.spec.ts @@ -0,0 +1,236 @@ +import { + checkKey, + checkKeys, + getMarketplaceUrl, + getNewVar, + getNewVarInWorkflow, + getVars, + hasDuplicateStr, + replaceSpaceWithUnderscoreInVarNameInput, +} from './var' +import { InputVarType } from '@/app/components/workflow/types' + +describe('Variable Utilities', () => { + describe('checkKey', () => { + it('should return error for empty key when canBeEmpty is false', () => { + expect(checkKey('', false)).toBe('canNoBeEmpty') + }) + + it('should return true for empty key when canBeEmpty is true', () => { + expect(checkKey('', true)).toBe(true) + }) + + it('should return error for key that is too long', () => { + const longKey = 'a'.repeat(101) // Assuming MAX_VAR_KEY_LENGTH is 100 + expect(checkKey(longKey)).toBe('tooLong') + }) + + it('should return error for key starting with number', () => { + expect(checkKey('1variable')).toBe('notStartWithNumber') + }) + + it('should return true for valid key', () => { + expect(checkKey('valid_variable_name')).toBe(true) + expect(checkKey('validVariableName')).toBe(true) + expect(checkKey('valid123')).toBe(true) + }) + + it('should return error for invalid characters', () => { + expect(checkKey('invalid-key')).toBe('notValid') + expect(checkKey('invalid key')).toBe('notValid') + expect(checkKey('invalid.key')).toBe('notValid') + expect(checkKey('invalid@key')).toBe('notValid') + }) + + it('should handle underscore correctly', () => { + expect(checkKey('_valid')).toBe(true) + expect(checkKey('valid_name')).toBe(true) + expect(checkKey('valid_name_123')).toBe(true) + }) + }) + + describe('checkKeys', () => { + it('should return valid for all valid keys', () => { + const result = checkKeys(['key1', 'key2', 'validKey']) + expect(result.isValid).toBe(true) + expect(result.errorKey).toBe('') + expect(result.errorMessageKey).toBe('') + }) + + it('should return error for first invalid key', () => { + const result = checkKeys(['validKey', '1invalid', 'anotherValid']) + expect(result.isValid).toBe(false) + expect(result.errorKey).toBe('1invalid') + expect(result.errorMessageKey).toBe('notStartWithNumber') + }) + + it('should handle empty array', () => { + const result = checkKeys([]) + expect(result.isValid).toBe(true) + }) + + it('should stop checking after first error', () => { + const result = checkKeys(['valid', 'invalid-key', '1invalid']) + expect(result.isValid).toBe(false) + expect(result.errorKey).toBe('invalid-key') + expect(result.errorMessageKey).toBe('notValid') + }) + }) + + describe('hasDuplicateStr', () => { + it('should return false for unique strings', () => { + expect(hasDuplicateStr(['a', 'b', 'c'])).toBe(false) + }) + + it('should return true for duplicate strings', () => { + expect(hasDuplicateStr(['a', 'b', 'a'])).toBe(true) + expect(hasDuplicateStr(['test', 'test'])).toBe(true) + }) + + it('should handle empty array', () => { + expect(hasDuplicateStr([])).toBe(false) + }) + + it('should handle single element', () => { + expect(hasDuplicateStr(['single'])).toBe(false) + }) + + it('should handle multiple duplicates', () => { + expect(hasDuplicateStr(['a', 'b', 'a', 'b', 'c'])).toBe(true) + }) + }) + + describe('getVars', () => { + it('should extract variables from template string', () => { + const result = getVars('Hello {{name}}, your age is {{age}}') + expect(result).toEqual(['name', 'age']) + }) + + it('should handle empty string', () => { + expect(getVars('')).toEqual([]) + }) + + it('should handle string without variables', () => { + expect(getVars('Hello world')).toEqual([]) + }) + + it('should remove duplicate variables', () => { + const result = getVars('{{name}} and {{name}} again') + expect(result).toEqual(['name']) + }) + + it('should filter out placeholder variables', () => { + const result = getVars('{{#context#}} {{name}} {{#histories#}}') + expect(result).toEqual(['name']) + }) + + it('should handle variables with underscores', () => { + const result = getVars('{{user_name}} {{user_age}}') + expect(result).toEqual(['user_name', 'user_age']) + }) + + it('should handle variables with numbers', () => { + const result = getVars('{{var1}} {{var2}} {{var123}}') + expect(result).toEqual(['var1', 'var2', 'var123']) + }) + + it('should ignore invalid variable names', () => { + const result = getVars('{{1invalid}} {{valid}} {{-invalid}}') + expect(result).toEqual(['valid']) + }) + + it('should filter out variables that are too long', () => { + const longVar = 'a'.repeat(101) + const result = getVars(`{{${longVar}}} {{valid}}`) + expect(result).toEqual(['valid']) + }) + }) + + describe('getNewVar', () => { + it('should create new string variable', () => { + const result = getNewVar('testKey', 'string') + expect(result.key).toBe('testKey') + expect(result.type).toBe('string') + expect(result.name).toBe('testKey') + }) + + it('should create new number variable', () => { + const result = getNewVar('numKey', 'number') + expect(result.key).toBe('numKey') + expect(result.type).toBe('number') + }) + + it('should truncate long names', () => { + const longKey = 'a'.repeat(100) + const result = getNewVar(longKey, 'string') + expect(result.name.length).toBeLessThanOrEqual(result.key.length) + }) + }) + + describe('getNewVarInWorkflow', () => { + it('should create text input variable by default', () => { + const result = getNewVarInWorkflow('testVar') + expect(result.variable).toBe('testVar') + expect(result.type).toBe(InputVarType.textInput) + expect(result.label).toBe('testVar') + }) + + it('should create select variable', () => { + const result = getNewVarInWorkflow('selectVar', InputVarType.select) + expect(result.variable).toBe('selectVar') + expect(result.type).toBe(InputVarType.select) + }) + + it('should create number variable', () => { + const result = getNewVarInWorkflow('numVar', InputVarType.number) + expect(result.variable).toBe('numVar') + expect(result.type).toBe(InputVarType.number) + }) + }) + + describe('getMarketplaceUrl', () => { + beforeEach(() => { + Object.defineProperty(window, 'location', { + value: { origin: 'https://example.com' }, + writable: true, + }) + }) + + it('should add additional parameters', () => { + const url = getMarketplaceUrl('/plugins', { category: 'ai', version: '1.0' }) + expect(url).toContain('category=ai') + expect(url).toContain('version=1.0') + }) + + it('should skip undefined parameters', () => { + const url = getMarketplaceUrl('/plugins', { category: 'ai', version: undefined }) + expect(url).toContain('category=ai') + expect(url).not.toContain('version=') + }) + }) + + describe('replaceSpaceWithUnderscoreInVarNameInput', () => { + it('should replace spaces with underscores', () => { + const input = document.createElement('input') + input.value = 'test variable name' + replaceSpaceWithUnderscoreInVarNameInput(input) + expect(input.value).toBe('test_variable_name') + }) + + it('should preserve cursor position', () => { + const input = document.createElement('input') + input.value = 'test name' + input.setSelectionRange(5, 5) + replaceSpaceWithUnderscoreInVarNameInput(input) + expect(input.selectionStart).toBe(5) + expect(input.selectionEnd).toBe(5) + }) + + it('should handle multiple spaces', () => { + const input = document.createElement('input') + input.value = 'test multiple spaces' + replaceSpaceWithUnderscoreInVarNameInput(input) + expect(input.value).toBe('test__multiple___spaces') + }) + }) +}) From 34be16874f528f2a6591f5b497d5f20b6127895f Mon Sep 17 00:00:00 2001 From: Novice Date: Wed, 5 Nov 2025 09:28:49 +0800 Subject: [PATCH 195/394] feat: add validation to prevent saving empty opening statement in conversation opener modal (#27843) --- .../new-feature-panel/conversation-opener/modal.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx index f0af893f0d..8ab007e66b 100644 --- a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx +++ b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useBoolean } from 'ahooks' import { produce } from 'immer' @@ -45,7 +45,13 @@ const OpeningSettingModal = ({ const [isShowConfirmAddVar, { setTrue: showConfirmAddVar, setFalse: hideConfirmAddVar }] = useBoolean(false) const [notIncludeKeys, setNotIncludeKeys] = useState([]) + const isSaveDisabled = useMemo(() => !tempValue.trim(), [tempValue]) + const handleSave = useCallback((ignoreVariablesCheck?: boolean) => { + // Prevent saving if opening statement is empty + if (isSaveDisabled) + return + if (!ignoreVariablesCheck) { const keys = getInputKeys(tempValue) const promptKeys = promptVariables.map(item => item.key) @@ -75,7 +81,7 @@ const OpeningSettingModal = ({ } }) onSave(newOpening) - }, [data, onSave, promptVariables, workflowVariables, showConfirmAddVar, tempSuggestedQuestions, tempValue]) + }, [data, onSave, promptVariables, workflowVariables, showConfirmAddVar, tempSuggestedQuestions, tempValue, isSaveDisabled]) const cancelAutoAddVar = useCallback(() => { hideConfirmAddVar() @@ -217,6 +223,7 @@ const OpeningSettingModal = ({ From f31b821cc082f8324f36a25285d7c2a4600f10cc Mon Sep 17 00:00:00 2001 From: yangzheli <43645580+yangzheli@users.noreply.github.com> Date: Wed, 5 Nov 2025 09:29:13 +0800 Subject: [PATCH 196/394] fix(web): improve the consistency of the inputs-form UI (#27837) --- .../chat/chat-with-history/inputs-form/content.tsx | 2 +- .../chat/embedded-chatbot/inputs-form/content.tsx | 2 +- .../share/text-generation/run-once/index.tsx | 11 +++++++---- .../_base/components/before-run-form/form-item.tsx | 6 +++--- web/i18n/de-DE/app-debug.ts | 1 - web/i18n/en-US/app-debug.ts | 1 - web/i18n/es-ES/app-debug.ts | 1 - web/i18n/fa-IR/app-debug.ts | 1 - web/i18n/fr-FR/app-debug.ts | 1 - web/i18n/hi-IN/app-debug.ts | 1 - web/i18n/id-ID/app-debug.ts | 1 - web/i18n/it-IT/app-debug.ts | 1 - web/i18n/ja-JP/app-debug.ts | 1 - web/i18n/ko-KR/app-debug.ts | 1 - web/i18n/pl-PL/app-debug.ts | 1 - web/i18n/pt-BR/app-debug.ts | 1 - web/i18n/ro-RO/app-debug.ts | 1 - web/i18n/ru-RU/app-debug.ts | 1 - web/i18n/sl-SI/app-debug.ts | 1 - web/i18n/th-TH/app-debug.ts | 1 - web/i18n/tr-TR/app-debug.ts | 1 - web/i18n/uk-UA/app-debug.ts | 1 - web/i18n/vi-VN/app-debug.ts | 1 - web/i18n/zh-Hans/app-debug.ts | 1 - web/i18n/zh-Hant/app-debug.ts | 1 - 25 files changed, 12 insertions(+), 30 deletions(-) diff --git a/web/app/components/base/chat/chat-with-history/inputs-form/content.tsx b/web/app/components/base/chat/chat-with-history/inputs-form/content.tsx index 392bdf2b77..c7785ebd89 100644 --- a/web/app/components/base/chat/chat-with-history/inputs-form/content.tsx +++ b/web/app/components/base/chat/chat-with-history/inputs-form/content.tsx @@ -49,7 +49,7 @@ const InputsFormContent = ({ showTip }: Props) => {
{form.label}
{!form.required && ( -
{t('appDebug.variableTable.optional')}
+
{t('workflow.panel.optional')}
)}
)} diff --git a/web/app/components/base/chat/embedded-chatbot/inputs-form/content.tsx b/web/app/components/base/chat/embedded-chatbot/inputs-form/content.tsx index dd65f0ce72..caf4e363ff 100644 --- a/web/app/components/base/chat/embedded-chatbot/inputs-form/content.tsx +++ b/web/app/components/base/chat/embedded-chatbot/inputs-form/content.tsx @@ -49,7 +49,7 @@ const InputsFormContent = ({ showTip }: Props) => {
{form.label}
{!form.required && ( -
{t('appDebug.variableTable.optional')}
+
{t('workflow.panel.optional')}
)}
)} diff --git a/web/app/components/share/text-generation/run-once/index.tsx b/web/app/components/share/text-generation/run-once/index.tsx index d24428f32a..112f08a1d7 100644 --- a/web/app/components/share/text-generation/run-once/index.tsx +++ b/web/app/components/share/text-generation/run-once/index.tsx @@ -100,7 +100,10 @@ const RunOnce: FC = ({ : promptConfig.prompt_variables.map(item => (
{item.type !== 'checkbox' && ( - +
+
{item.name}
+ {!item.required && {t('workflow.panel.optional')}} +
)}
{item.type === 'select' && ( @@ -115,7 +118,7 @@ const RunOnce: FC = ({ {item.type === 'string' && ( ) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }} maxLength={item.max_length || DEFAULT_VALUE_MAX_LEN} @@ -124,7 +127,7 @@ const RunOnce: FC = ({ {item.type === 'paragraph' && (